@expokit/cli 0.3.0 → 0.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.
Files changed (165) hide show
  1. package/README.md +8 -4
  2. package/bin/expo-kit.js +137 -24
  3. package/bin/lib.mjs +110 -1
  4. package/bin/mcp.mjs +40 -18
  5. package/package.json +2 -2
  6. package/registry/_docs/assistant-app.json +47 -0
  7. package/registry/_docs/avatar.json +1 -1
  8. package/registry/_docs/badge.json +1 -1
  9. package/registry/_docs/bottom-nav.json +6 -6
  10. package/registry/_docs/button-3d.json +2 -2
  11. package/registry/_docs/calendar.json +5 -5
  12. package/registry/_docs/checkout.json +7 -1
  13. package/registry/_docs/confetti.json +3 -3
  14. package/registry/_docs/date-picker.json +4 -4
  15. package/registry/_docs/donut-shop.json +88 -0
  16. package/registry/_docs/drawer.json +4 -4
  17. package/registry/_docs/fintech-app.json +49 -0
  18. package/registry/_docs/floating-input.json +5 -5
  19. package/registry/_docs/food-app.json +47 -0
  20. package/registry/_docs/glass.json +1 -1
  21. package/registry/_docs/grain.json +80 -0
  22. package/registry/_docs/morphing-button-group.json +96 -0
  23. package/registry/_docs/orb-reveal.json +132 -0
  24. package/registry/_docs/otp-input.json +4 -4
  25. package/registry/_docs/password-input.json +3 -3
  26. package/registry/_docs/qr-code-button.json +122 -0
  27. package/registry/_docs/search-bar.json +3 -3
  28. package/registry/_docs/select.json +7 -7
  29. package/registry/_docs/settings.json +6 -0
  30. package/registry/_docs/skeleton.json +2 -2
  31. package/registry/_docs/slider.json +6 -6
  32. package/registry/_docs/spinner.json +1 -1
  33. package/registry/_docs/stepper.json +7 -7
  34. package/registry/_docs/textarea.json +2 -2
  35. package/registry/_docs/ticket-shape.json +1 -1
  36. package/registry/_docs/tilt-card.json +4 -4
  37. package/registry/_docs/time-picker.json +6 -6
  38. package/registry/_docs/timeline.json +2 -2
  39. package/registry/_docs/toast.json +8 -8
  40. package/registry/accordion/files/src/components/ui/accordion.tsx +5 -4
  41. package/registry/accordion/files/src/lib/kit.tsx +8 -5
  42. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +9 -8
  43. package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
  44. package/registry/alert/files/src/components/ui/alert.tsx +7 -4
  45. package/registry/alert/files/src/components/ui/button.tsx +4 -3
  46. package/registry/alert/files/src/lib/kit.tsx +8 -5
  47. package/registry/assistant-app.json +49 -0
  48. package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
  49. package/registry/avatar/files/src/lib/kit.tsx +8 -5
  50. package/registry/badge/files/src/components/ui/badge.tsx +21 -7
  51. package/registry/badge/files/src/lib/kit.tsx +8 -5
  52. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +14 -7
  53. package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
  54. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
  55. package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
  56. package/registry/button/files/src/components/ui/button.tsx +4 -3
  57. package/registry/button/files/src/lib/kit.tsx +8 -5
  58. package/registry/button-3d/files/src/components/ui/button-3d.tsx +3 -2
  59. package/registry/button-3d/files/src/lib/kit.tsx +8 -5
  60. package/registry/calendar/files/src/components/ui/calendar.tsx +9 -5
  61. package/registry/calendar/files/src/lib/kit.tsx +8 -5
  62. package/registry/card/files/src/components/ui/card.tsx +8 -6
  63. package/registry/card/files/src/lib/kit.tsx +8 -5
  64. package/registry/checkbox/files/src/components/ui/checkbox.tsx +6 -5
  65. package/registry/checkbox/files/src/lib/kit.tsx +8 -5
  66. package/registry/chip-group/files/src/components/ui/chip-group.tsx +4 -2
  67. package/registry/chip-group/files/src/lib/kit.tsx +8 -5
  68. package/registry/confetti/files/src/lib/kit.tsx +8 -5
  69. package/registry/coupon/files/src/components/ui/coupon.tsx +23 -20
  70. package/registry/coupon/files/src/components/ui/flip-card.tsx +1 -1
  71. package/registry/coupon/files/src/components/ui/tilt-card.tsx +2 -2
  72. package/registry/coupon/files/src/lib/kit.tsx +8 -5
  73. package/registry/date-picker/files/src/components/ui/calendar.tsx +9 -5
  74. package/registry/date-picker/files/src/components/ui/date-picker.tsx +2 -2
  75. package/registry/date-picker/files/src/lib/kit.tsx +8 -5
  76. package/registry/dialog/files/src/components/ui/dialog.tsx +12 -6
  77. package/registry/dialog/files/src/lib/kit.tsx +8 -5
  78. package/registry/donut-shop.json +18 -0
  79. package/registry/drawer/files/src/components/ui/drawer.tsx +25 -19
  80. package/registry/drawer/files/src/lib/kit.tsx +8 -5
  81. package/registry/fintech-app.json +57 -0
  82. package/registry/flip-card/files/src/components/ui/flip-card.tsx +1 -1
  83. package/registry/flip-card/files/src/lib/kit.tsx +8 -5
  84. package/registry/floating-input/files/src/components/ui/floating-input.tsx +16 -6
  85. package/registry/floating-input/files/src/lib/kit.tsx +8 -5
  86. package/registry/food-app.json +55 -0
  87. package/registry/grain/files/src/components/ui/grain.tsx +113 -0
  88. package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
  89. package/registry/grain.json +17 -0
  90. package/registry/kit/files/src/lib/kit.tsx +8 -5
  91. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
  92. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
  93. package/registry/list-item/files/src/components/ui/list-item.tsx +20 -16
  94. package/registry/list-item/files/src/lib/kit.tsx +8 -5
  95. package/registry/morphing-button-group.json +18 -0
  96. package/registry/orb-reveal.json +20 -0
  97. package/registry/otp-input/files/src/components/ui/otp-input.tsx +8 -6
  98. package/registry/otp-input/files/src/lib/kit.tsx +8 -5
  99. package/registry/password-input/files/src/components/ui/floating-input.tsx +16 -6
  100. package/registry/password-input/files/src/components/ui/password-input.tsx +2 -2
  101. package/registry/password-input/files/src/lib/kit.tsx +8 -5
  102. package/registry/payment-card/files/src/components/ui/flip-card.tsx +1 -1
  103. package/registry/payment-card/files/src/components/ui/payment-card.tsx +27 -14
  104. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +2 -2
  105. package/registry/payment-card/files/src/lib/kit.tsx +8 -5
  106. package/registry/progress/files/src/components/ui/progress.tsx +8 -5
  107. package/registry/progress/files/src/lib/kit.tsx +8 -5
  108. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +15 -11
  109. package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
  110. package/registry/qr-code-button.json +18 -0
  111. package/registry/radio-group/files/src/components/ui/radio-group.tsx +10 -9
  112. package/registry/radio-group/files/src/lib/kit.tsx +8 -5
  113. package/registry/search-bar/files/src/components/ui/search-bar.tsx +13 -11
  114. package/registry/search-bar/files/src/lib/kit.tsx +8 -5
  115. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +4 -2
  116. package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
  117. package/registry/select/files/src/components/ui/select.tsx +3 -3
  118. package/registry/select/files/src/lib/kit.tsx +8 -5
  119. package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
  120. package/registry/skeleton/files/src/lib/kit.tsx +8 -5
  121. package/registry/slider/files/src/components/ui/slider.tsx +5 -4
  122. package/registry/slider/files/src/lib/kit.tsx +8 -5
  123. package/registry/spinner/files/src/components/ui/spinner.tsx +17 -12
  124. package/registry/spinner/files/src/lib/kit.tsx +8 -5
  125. package/registry/stateful-button/files/src/lib/kit.tsx +8 -5
  126. package/registry/stepper/files/src/components/ui/stepper.tsx +8 -4
  127. package/registry/stepper/files/src/lib/kit.tsx +8 -5
  128. package/registry/switch/files/src/components/ui/switch.tsx +4 -3
  129. package/registry/switch/files/src/lib/kit.tsx +8 -5
  130. package/registry/tabs/files/src/components/ui/tabs.tsx +10 -8
  131. package/registry/tabs/files/src/lib/kit.tsx +8 -5
  132. package/registry/text-animation/files/src/components/ui/text-animation.tsx +4 -4
  133. package/registry/text-animation/files/src/lib/kit.tsx +8 -5
  134. package/registry/textarea/files/src/components/ui/textarea.tsx +11 -9
  135. package/registry/textarea/files/src/lib/kit.tsx +8 -5
  136. package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
  137. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +2 -2
  138. package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
  139. package/registry/time-picker/files/src/components/ui/time-picker.tsx +2 -2
  140. package/registry/time-picker/files/src/lib/kit.tsx +8 -5
  141. package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
  142. package/registry/timeline/files/src/components/ui/timeline.tsx +12 -10
  143. package/registry/timeline/files/src/lib/kit.tsx +8 -5
  144. package/registry/toast/files/src/components/ui/button.tsx +4 -3
  145. package/registry/toast/files/src/components/ui/toast.tsx +13 -11
  146. package/registry/toast/files/src/lib/kit.tsx +8 -5
  147. package/schema/components.schema.json +1 -1
  148. package/registry/_docs/color-swatch.json +0 -82
  149. package/registry/_docs/kpi-card.json +0 -14
  150. package/registry/_docs/like-button.json +0 -92
  151. package/registry/_docs/morph-button.json +0 -128
  152. package/registry/_docs/pagination-dots.json +0 -108
  153. package/registry/_docs/shimmer-button.json +0 -98
  154. package/registry/_docs/tag-input.json +0 -148
  155. package/registry/color-swatch.json +0 -17
  156. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
  157. package/registry/kpi-card.json +0 -14
  158. package/registry/like-button.json +0 -18
  159. package/registry/morph-button.json +0 -18
  160. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
  161. package/registry/pagination-dots/files/src/lib/kit.tsx +0 -219
  162. package/registry/pagination-dots.json +0 -15
  163. package/registry/shimmer-button.json +0 -17
  164. package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
  165. package/registry/tag-input.json +0 -17
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
7
7
  ![License](https://img.shields.io/badge/license-MIT-green)
8
8
 
9
- The CLI for **[ExpoKit](https://expokit.karandev.in)** — animated UI components for Expo and React Native. It copies
9
+ The CLI for **[ExpoKit](https://expokit.in)** — animated UI components for Expo and React Native. It copies
10
10
  the component source into your app (like shadcn/ui), so you own the code.
11
11
 
12
12
  ## Usage
@@ -29,16 +29,20 @@ See every component: `npx @expokit/cli@latest add --list`
29
29
  ## Docs
30
30
 
31
31
  Everything else — live previews, props, installation, theming, Pro, AI / MCP setup and help — is on
32
- **[expokit.karandev.in](https://expokit.karandev.in/docs)**.
32
+ **[expokit.in](https://expokit.in/docs)**.
33
33
 
34
34
  ## Security
35
35
 
36
36
  - **No dependencies, no install scripts, no telemetry.**
37
37
  - **Writes only inside your app** — `components.json`, the `@/*` alias in `tsconfig.json` and the component files.
38
38
  `--dry-run` shows every change first; `add` never overwrites a file you changed unless you pass `--force`.
39
+ `paths` in `components.json` that point outside the app are refused. Symlinks inside the app are followed, not
40
+ checked, so a cloned repo that symlinks a folder elsewhere can still send writes there.
39
41
  - **Free components need no network** — they ship inside this package. Pro components are downloaded over HTTPS from
40
- expokit.karandev.in; your token is stored readable only by you, sent only to that site and never through a redirect.
41
- - **Runs one command:** `npx expo install` for the packages a component needs.
42
+ expokit.in; your token is stored readable only by you, sent only to that site and never through a redirect.
43
+ In scripts, pipe it to `login` or set `EXPO_KIT_TOKEN` (`--token` leaves it in your shell history).
44
+ - **Runs one command:** `npx expo install` for the packages a component needs — without `EXPO_KIT_TOKEN` in its
45
+ environment, so install scripts never see your token.
42
46
 
43
47
  Report a vulnerability privately: [SECURITY.md](https://github.com/Anshkaran7/expokit/blob/main/SECURITY.md).
44
48
 
package/bin/expo-kit.js CHANGED
@@ -14,20 +14,28 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import readline from 'node:readline';
16
16
  import { Writable } from 'node:stream';
17
+ import { setTimeout as sleep } from 'node:timers/promises';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import {
19
20
  CliError,
20
21
  DEFAULT_SITE_URL,
21
22
  addTsconfigPathAlias,
22
23
  canSendToken,
24
+ envWithoutToken,
23
25
  hasJsoncExtras,
26
+ isInsideDir,
24
27
  isSafePackageSpec,
28
+ isSafeTabName,
25
29
  isValidItemName,
26
30
  kitVersion,
27
31
  mergeDependencies,
28
32
  parseJsonc,
29
33
  parseSiteUrl,
34
+ retryDelayMs,
35
+ retryHint,
30
36
  tabComponentName,
37
+ needsDevBuild,
38
+ pickMainExport,
31
39
  } from './lib.mjs';
32
40
 
33
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,6 +48,8 @@ const CREDENTIALS_DIR = path.join(os.homedir(), '.expo-kit');
40
48
  /** How users run the CLI. Bare `expo-kit` only works once installed, and `npx expo-kit` would fetch an unrelated npm package. */
41
49
  const RUN = 'npx @expokit/cli@latest';
42
50
  const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
51
+ /** Env for `npx expo install` / create-expo-app: they run third-party install scripts that never need the Pro token. */
52
+ const childEnv = envWithoutToken(process.env);
43
53
 
44
54
  const DEFAULT_ALIASES = {
45
55
  components: '@/components',
@@ -179,7 +189,7 @@ function detectProjectLayout(projectRoot) {
179
189
  function buildDefaultConfig(projectRoot) {
180
190
  const { paths } = detectProjectLayout(projectRoot);
181
191
  return {
182
- $schema: 'https://expokit.karandev.in/schema.json',
192
+ $schema: 'https://expokit.in/schema.json',
183
193
  aliases: { ...DEFAULT_ALIASES },
184
194
  paths,
185
195
  };
@@ -204,11 +214,19 @@ function loadConfig(projectRoot) {
204
214
  return { ...defaults, configPath };
205
215
  }
206
216
  const user = readJsonFile(configPath, 'components.json');
217
+ const paths = { ...defaults.paths, ...user.paths };
218
+ // Every write goes to path.join(projectRoot, paths.*): a "../" from a cloned repo would land outside the app.
219
+ for (const [key, value] of Object.entries(paths)) {
220
+ if (value === null || value === undefined) continue; // falls back to the default folder
221
+ if (typeof value !== 'string' || !isInsideDir(projectRoot, value)) {
222
+ throw new CliError(`components.json paths.${key} (${JSON.stringify(value)}) must be a folder inside the project. Fix it and re-run.`);
223
+ }
224
+ }
207
225
  return {
208
226
  ...defaults,
209
227
  ...user,
210
228
  aliases: { ...defaults.aliases, ...user.aliases },
211
- paths: { ...defaults.paths, ...user.paths },
229
+ paths,
212
230
  installed: { ...user.installed },
213
231
  configPath,
214
232
  };
@@ -357,12 +375,9 @@ function writeCredentials(data) {
357
375
  fs.chmodSync(CREDENTIALS_PATH, 0o600); // also when the file already existed
358
376
  }
359
377
 
360
- async function apiGet(pathname, token) {
361
- parseSiteUrl(SITE_URL); // https only (http just for localhost) — throws CliError
362
- const url = `${SITE_URL}${pathname}`;
363
- let res;
378
+ async function siteFetch(url, token) {
364
379
  try {
365
- res = await fetch(url, {
380
+ return await fetch(url, {
366
381
  headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
367
382
  // A request that carries the token never follows a redirect: the token goes to SITE_URL's origin only.
368
383
  redirect: token ? 'manual' : 'follow',
@@ -374,6 +389,26 @@ async function apiGet(pathname, token) {
374
389
  'Check your connection (or EXPO_KIT_URL) and try again.'
375
390
  );
376
391
  }
392
+ }
393
+
394
+ /** Rate limited (429) or briefly unavailable (503): worth one polite retry. */
395
+ const BUSY_STATUSES = new Set([429, 503]);
396
+
397
+ async function apiGet(pathname, token) {
398
+ parseSiteUrl(SITE_URL); // https only (http just for localhost) — throws CliError
399
+ const url = `${SITE_URL}${pathname}`;
400
+ let res = await siteFetch(url, token);
401
+ if (BUSY_STATUSES.has(res.status)) {
402
+ // Wait what the server asks (plus jitter so a crowd of CLIs doesn't come back in lockstep), then try once more.
403
+ // When it asks for more than 10 s, don't knock early: skip the retry and just say when to try again.
404
+ const wait = retryDelayMs(res.headers.get('retry-after'));
405
+ if (wait !== null) {
406
+ await res.arrayBuffer().catch(() => {});
407
+ log(`${new URL(SITE_URL).host} is busy (HTTP ${res.status}) — trying again in ${Math.max(1, Math.ceil(wait / 1000))} s…`);
408
+ await sleep(wait + Math.floor(Math.random() * 500));
409
+ res = await siteFetch(url, token);
410
+ }
411
+ }
377
412
  if (token && res.status >= 300 && res.status < 400) {
378
413
  const location = res.headers.get('location');
379
414
  fail(
@@ -388,6 +423,11 @@ async function apiGet(pathname, token) {
388
423
  } catch {
389
424
  // non-JSON (proxy error page, …)
390
425
  }
426
+ if (BUSY_STATUSES.has(res.status)) {
427
+ const why = res.status === 429 ? 'is limiting requests (HTTP 429).' : "is temporarily unavailable (HTTP 503) — this isn't a problem with your token.";
428
+ const hint = typeof body?.message === 'string' && body.message ? body.message : `Try again ${retryHint(res.headers.get('retry-after'))}.`;
429
+ fail(`${SITE_URL} ${why} ${hint}`);
430
+ }
391
431
  return { status: res.status, ok: res.ok, body };
392
432
  }
393
433
 
@@ -395,12 +435,33 @@ function failServerError(status, what) {
395
435
  fail(`Server error from ${SITE_URL} (HTTP ${status}) while ${what}. This isn't a problem with your token — try again in a moment.`);
396
436
  }
397
437
 
398
- /** Downloads one Pro item (own files only). Exits with a clear message on 401 / 403 / 5xx. */
438
+ /** Pro items already downloaded in this run, so `add a b c` and `update --force` fetch a shared dep (e.g. glass) once. */
439
+ const remoteItemCache = new Map();
440
+
441
+ /** The 401 message for a saved or given token: expired (the site says `token_expired`) or invalid / revoked. */
442
+ function rejectedToken(body, which) {
443
+ return body?.error === 'token_expired'
444
+ ? `${which} has expired. Create a new one at ${SITE_URL}/account and run \`${RUN} login\`.`
445
+ : `${which} is invalid or was revoked. Create a new one at ${SITE_URL}/account and run \`${RUN} login\`.`;
446
+ }
447
+
448
+ /** " · token expires Jan 5, 2027" when the site sent an expiry, else "". */
449
+ function expiryNote(body) {
450
+ const at = body?.expiresAt ? new Date(body.expiresAt) : null;
451
+ if (!at || Number.isNaN(at.getTime())) return '';
452
+ return ` · token expires ${at.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
453
+ }
454
+
455
+ /** Downloads one Pro item (own files only). Exits with a clear message on 401 / 403 / 429 / 5xx. */
399
456
  async function fetchRemoteItem(name, { unknownLocally = false } = {}) {
457
+ if (remoteItemCache.has(name)) return remoteItemCache.get(name);
400
458
  const creds = readCredentials();
401
459
  assertTokenTarget(creds);
402
460
  const { status, ok, body } = await apiGet(`/api/registry/${encodeURIComponent(name)}`, creds?.token);
403
- if (ok && body && Array.isArray(body.files)) return body;
461
+ if (ok && body && Array.isArray(body.files)) {
462
+ remoteItemCache.set(name, body);
463
+ return body;
464
+ }
404
465
  if (status === 404 && unknownLocally) fail(`Unknown item "${name}". Run: ${RUN} add --list`);
405
466
  if (status >= 500) failServerError(status, `downloading "${name}"`);
406
467
  if (status === 401 && !creds) {
@@ -411,7 +472,7 @@ async function fetchRemoteItem(name, { unknownLocally = false } = {}) {
411
472
  );
412
473
  }
413
474
  if (status === 401) {
414
- fail(`Your token (${creds.source}) is invalid or was revoked. Create a new one at ${SITE_URL}/account and run \`${RUN} login\`.`);
475
+ fail(rejectedToken(body, `Your token (${creds.source})`));
415
476
  }
416
477
  if (status === 403) {
417
478
  fail(`"${name}" needs ExpoKit Pro, and your account doesn't have it yet. Upgrade at ${SITE_URL}/pricing.`);
@@ -516,13 +577,37 @@ function promptSecret(question) {
516
577
  });
517
578
  }
518
579
 
580
+ /**
581
+ * First non-blank line of a non-TTY stdin (`printf %s "$TOKEN" | … login`, or typed into a console without a TTY):
582
+ * the token never lands in argv, shell history or `ps`.
583
+ */
584
+ async function readTokenFromStdin() {
585
+ let data = '';
586
+ process.stdin.setEncoding('utf8');
587
+ for await (const chunk of process.stdin) {
588
+ data += chunk;
589
+ // Stop at the first non-blank line: a pipe left open (Git Bash/mintty, IDE consoles) never sends EOF after Enter.
590
+ if (/\S.*\n/.test(data)) break;
591
+ if (data.length > 4096) break; // a token is ~50 chars; don't slurp a whole file
592
+ }
593
+ return data.trim().split(/\r?\n/)[0].trim();
594
+ }
595
+
519
596
  async function cmdLogin(args) {
520
597
  const i = args.findIndex((a) => a === '--token' || a.startsWith('--token='));
521
598
  let token = i === -1 ? '' : args[i].includes('=') ? args[i].slice('--token='.length) : args[i + 1] ?? '';
599
+ if (i !== -1) warn('--token leaves the token in your shell history. Prefer the prompt, piping it in, or EXPO_KIT_TOKEN.');
522
600
  if (!token) {
523
- log(`Create a CLI token at ${SITE_URL}/account (signed in), then paste it here (it won't be shown).`);
524
- if (!process.stdin.isTTY) fail(`No token given. Use: ${RUN} login --token <token>`);
525
- token = await promptSecret('Token: ');
601
+ if (process.stdin.isTTY) {
602
+ log(`Create a CLI token at ${SITE_URL}/account (signed in), then paste it here (it won't be shown).`);
603
+ token = await promptSecret('Token: ');
604
+ } else {
605
+ log(`Paste your token from ${SITE_URL}/account and press Enter (or pipe it in)…`);
606
+ token = await readTokenFromStdin();
607
+ if (!token) {
608
+ fail(`No token given. Pipe it in (printf %s "$TOKEN" | ${RUN} login) or set EXPO_KIT_TOKEN. Tokens: ${SITE_URL}/account`);
609
+ }
610
+ }
526
611
  }
527
612
  token = token.trim();
528
613
  if (!token) fail('No token given.');
@@ -531,14 +616,17 @@ async function cmdLogin(args) {
531
616
  log(`Verifying the token with ${site.host} — it will only be sent to ${site.origin}.`);
532
617
  if (site.origin !== new URL(DEFAULT_SITE_URL).origin) warn(`${site.origin} is not the default site (EXPO_KIT_URL is set).`);
533
618
  const { status, ok, body } = await apiGet('/api/cli/whoami', token);
534
- if (status === 401) fail(`That token isn't valid (or was revoked). Create a new one at ${SITE_URL}/account.`);
619
+ if (status === 401) fail(rejectedToken(body, 'That token'));
535
620
  if (status >= 500) failServerError(status, 'verifying the token');
536
621
  if (!ok) fail(`Couldn't verify the token (HTTP ${status}). Check your network and ${SITE_URL}, then try again.`);
537
622
 
538
623
  writeCredentials({ token, url: site.origin, savedAt: new Date().toISOString() });
539
624
  log(`Saved to ${CREDENTIALS_PATH} (readable only by you).`);
540
625
  if (ok && body) {
541
- log(body.pro ? `Logged in — Pro is unlocked (${body.plan} plan).` : `Logged in on the ${body.plan} plan. Pro items need Pro: ${SITE_URL}/pricing`);
626
+ log(
627
+ (body.pro ? `Logged in — Pro is unlocked (${body.plan} plan).` : `Logged in on the ${body.plan} plan. Pro items need Pro: ${SITE_URL}/pricing`) +
628
+ expiryNote(body)
629
+ );
542
630
  }
543
631
  }
544
632
 
@@ -557,10 +645,10 @@ async function cmdWhoami() {
557
645
  if (!creds) fail(`Not logged in. Run: ${RUN} login (tokens: ${SITE_URL}/account)`);
558
646
  assertTokenTarget(creds);
559
647
  const { status, ok, body } = await apiGet('/api/cli/whoami', creds.token);
560
- if (status === 401) fail(`Your token (${creds.source}) is invalid or was revoked. Create a new one at ${SITE_URL}/account.`);
648
+ if (status === 401) fail(rejectedToken(body, `Your token (${creds.source})`));
561
649
  if (status >= 500) failServerError(status, 'checking your token');
562
650
  if (!ok || !body) fail(`Couldn't check your token (HTTP ${status}).`);
563
- log(`User ${body.userId} · ${body.plan} plan · Pro ${body.pro ? 'unlocked' : 'locked'} · ${SITE_URL}`);
651
+ log(`User ${body.userId} · ${body.plan} plan · Pro ${body.pro ? 'unlocked' : 'locked'}${expiryNote(body)} · ${SITE_URL}`);
564
652
  }
565
653
 
566
654
  function collectFiles(src, dest, files = []) {
@@ -726,6 +814,11 @@ function syncTabRoutes(projectRoot, config, options) {
726
814
  }
727
815
 
728
816
  for (const tabName of tabNames) {
817
+ // Names come from the user's tabs.ts and become file names: never let one be a path.
818
+ if (!isSafeTabName(tabName)) {
819
+ warn(`Skipping tab ${JSON.stringify(tabName)} in tabs.ts: route names may only use letters, numbers and - _ . ( ) [ ]`);
820
+ continue;
821
+ }
729
822
  const fileName = tabName === 'index' ? 'index.tsx' : `${tabName}.tsx`;
730
823
  const destPath = path.join(tabsDir, fileName);
731
824
  const rel = path.relative(projectRoot, destPath);
@@ -751,6 +844,7 @@ function runExpoInstall(projectRoot, packages, dryRun) {
751
844
  log(`Installing: ${packages.join(', ')}`);
752
845
  const result = spawnSync('npx', ['expo', 'install', ...packages], {
753
846
  cwd: projectRoot,
847
+ env: childEnv,
754
848
  stdio: 'inherit',
755
849
  shell: process.platform === 'win32',
756
850
  });
@@ -762,6 +856,17 @@ function runExpoInstall(projectRoot, packages, dryRun) {
762
856
  return true;
763
857
  }
764
858
 
859
+ /** After an add: can the item run in Expo Go, or does it need a development build? */
860
+ function printRunHint(deps) {
861
+ const native = needsDevBuild(deps);
862
+ if (!native.length) {
863
+ log('Runs in Expo Go: npx expo start, then scan the QR code with your phone.');
864
+ return;
865
+ }
866
+ log(`Needs a development build (not in Expo Go): ${native.join(', ')}`);
867
+ console.log(' • Build and run it: npx expo run:ios or npx expo run:android');
868
+ }
869
+
765
870
  /** Folder names `init <dir>` will create: no paths, no spaces, nothing a shell could misread. */
766
871
  const SAFE_APP_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
767
872
 
@@ -778,6 +883,7 @@ function createExpoApp(cwd, dir, flags) {
778
883
  log(`Creating a new Expo app in ./${dir}…`);
779
884
  const result = spawnSync('npx', ['create-expo-app@latest', dir, '--yes'], {
780
885
  cwd,
886
+ env: childEnv,
781
887
  stdio: 'inherit',
782
888
  shell: process.platform === 'win32',
783
889
  });
@@ -808,7 +914,7 @@ function cmdInit(cwd, flags, dir) {
808
914
 
809
915
  const { paths, aliasTarget, layout } = detectProjectLayout(projectRoot);
810
916
  const config = {
811
- $schema: 'https://expokit.karandev.in/schema.json',
917
+ $schema: 'https://expokit.in/schema.json',
812
918
  aliases: { ...DEFAULT_ALIASES },
813
919
  paths,
814
920
  };
@@ -918,6 +1024,7 @@ async function cmdAdd(args, cwd) {
918
1024
  ensureExpoRouterMain(projectRoot, true);
919
1025
  }
920
1026
  runExpoInstall(projectRoot, meta.dependencies ?? [], true);
1027
+ if (resolved !== 'liquidglass-tab') printRunHint(meta.dependencies);
921
1028
  return;
922
1029
  }
923
1030
 
@@ -956,10 +1063,9 @@ async function cmdAdd(args, cwd) {
956
1063
  fs.existsSync(path.join(filesRoot, 'src', 'components', f, `${resolved}.tsx`))
957
1064
  );
958
1065
  const main = folder ? path.join(filesRoot, 'src', 'components', folder, `${resolved}.tsx`) : null;
959
- const exported = main
960
- ? fs.readFileSync(main, 'utf8').match(/export (?:function|const) ([A-Z]\w*)/)?.[1]
961
- : null;
1066
+ const exported = main ? pickMainExport(fs.readFileSync(main, 'utf8'), [meta.title, resolved]) : null;
962
1067
  if (exported) log(`Import: import { ${exported} } from "@/components/${folder}/${resolved}";`);
1068
+ printRunHint(meta.dependencies);
963
1069
  }
964
1070
  }
965
1071
 
@@ -1070,6 +1176,11 @@ function cmdDoctor(cwd) {
1070
1176
  const tabsDir = path.join(projectRoot, config.paths.app ?? 'src/app', '(tabs)');
1071
1177
 
1072
1178
  for (const tabName of tabNames) {
1179
+ if (!isSafeTabName(tabName)) {
1180
+ warn(`Tab ${JSON.stringify(tabName)} in tabs.ts is not a valid route name (letters, numbers and - _ . ( ) [ ] only)`);
1181
+ issues += 1;
1182
+ continue;
1183
+ }
1073
1184
  const fileName = tabName === 'index' ? 'index.tsx' : `${tabName}.tsx`;
1074
1185
  const routePath = path.join(tabsDir, fileName);
1075
1186
  if (!fs.existsSync(routePath)) {
@@ -1178,7 +1289,7 @@ Usage:
1178
1289
  npx @expokit/cli@latest update [name] [--dry-run] [--force]
1179
1290
  npx @expokit/cli@latest doctor
1180
1291
  npx @expokit/cli@latest add --list
1181
- npx @expokit/cli@latest login [--token <token>]
1292
+ npx @expokit/cli@latest login
1182
1293
  npx @expokit/cli@latest logout
1183
1294
  npx @expokit/cli@latest whoami
1184
1295
  npx @expokit/cli@latest mcp
@@ -1199,8 +1310,10 @@ auto-sync — run \`update\` after upgrading the package.
1199
1310
  files; tabs.ts kept). Other items: use \`add <name> --force\` to overwrite.
1200
1311
 
1201
1312
  Pro items (marked PRO in \`add --list\`): create a token at ${SITE_URL}/account,
1202
- run \`login\`, then \`add\` them like any other item. Credentials live in
1203
- ~/.expo-kit/credentials.json (or set EXPO_KIT_TOKEN). EXPO_KIT_URL overrides the
1313
+ run \`login\` and paste it, then \`add\` them like any other item. In scripts, pipe
1314
+ it in (printf %s "$TOKEN" | npx @expokit/cli@latest login) or set EXPO_KIT_TOKEN;
1315
+ \`login --token <token>\` still works but leaves the token in your shell history.
1316
+ Credentials live in ~/.expo-kit/credentials.json. EXPO_KIT_URL overrides the
1204
1317
  site (https only; http just for localhost). A saved token is only sent to the site
1205
1318
  it was created for (or ${DEFAULT_SITE_URL}).
1206
1319
 
package/bin/lib.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  * Pure helpers for the expo-kit CLI (no I/O, no dependencies) — kept separate so they can be unit-tested.
3
3
  */
4
4
 
5
+ import path from 'node:path';
6
+
5
7
  /** Thrown for user-facing errors; the CLI entry point prints `message` and exits 1. */
6
8
  export class CliError extends Error {}
7
9
 
@@ -269,6 +271,27 @@ export function isValidItemName(name) {
269
271
  return typeof name === 'string' && /^[a-z0-9][a-z0-9-]{0,99}$/.test(name);
270
272
  }
271
273
 
274
+ /**
275
+ * Tab names from the user's tabs.ts become route files in app/(tabs)/: letters, digits and route punctuation
276
+ * (`-_.()[]`) only, so a name can never be a path like `../../x`.
277
+ */
278
+ export function isSafeTabName(name) {
279
+ return typeof name === 'string' && name.length <= 100 && /^[A-Za-z0-9_.()[\]-]+$/.test(name) && !/^\.+$/.test(name);
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Project paths
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Whether `rel` (a components.json `paths` value) stays inside `root` once joined to it the way the CLI
288
+ * writes files: `../x` escapes, while `/src/lib` is re-rooted by path.join and stays inside.
289
+ */
290
+ export function isInsideDir(root, rel) {
291
+ const r = path.relative(root, path.join(root, rel));
292
+ return r === '' || (r !== '..' && !r.startsWith(`..${path.sep}`) && !path.isAbsolute(r));
293
+ }
294
+
272
295
  /**
273
296
  * npm package name, optionally with a simple version (`@1.2.3`, `@~14.0.0`, `@^2`). It reaches
274
297
  * `npx expo install` through a shell on Windows, so no `< > | =` or spaces — ranges like `>=1` are out.
@@ -288,6 +311,46 @@ export function kitVersion(text) {
288
311
  return /export function KitProvider\b/.test(text) ? 1 : null;
289
312
  }
290
313
 
314
+ /**
315
+ * Packages a registry item may depend on that work in the Expo Go app (SDK 58): native modules Expo Go
316
+ * ships, plus pure-JS packages. Anything else needs a development build (`npx expo run:ios|android`).
317
+ * Keep in sync with the Expo Go module list when the SDK moves.
318
+ */
319
+ export const EXPO_GO_PACKAGES = new Set([
320
+ '@expo/vector-icons',
321
+ '@shopify/react-native-skia',
322
+ 'expo-blur',
323
+ 'expo-charts',
324
+ 'expo-glass-effect',
325
+ 'expo-haptics',
326
+ 'expo-router',
327
+ 'expo-splash-screen',
328
+ 'react-native-gesture-handler',
329
+ 'react-native-reanimated',
330
+ 'react-native-safe-area-context',
331
+ 'react-native-screens',
332
+ 'react-native-svg',
333
+ 'react-native-worklets',
334
+ ]);
335
+
336
+ /** Which of `deps` keep an item out of Expo Go (bare names; `pkg@1.2` is read as `pkg`). Empty: runs in Expo Go. */
337
+ export function needsDevBuild(deps) {
338
+ return (deps ?? [])
339
+ .map((spec) => spec.replace(/^(@?[^@]+)@.*$/, '$1'))
340
+ .filter((name) => !EXPO_GO_PACKAGES.has(name));
341
+ }
342
+
343
+ /**
344
+ * The component a file is named for: the PascalCase export matching one of `names` ("Grain Gradient" or
345
+ * "grain-gradient" → GrainGradient), else the first PascalCase export (never a CONSTANT).
346
+ */
347
+ export function pickMainExport(source, names = []) {
348
+ const exports = [...source.matchAll(/export (?:function|const) ([A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*)\b/g)].map((m) => m[1]);
349
+ const pascal = (s) => String(s ?? '').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
350
+ const wanted = names.map(pascal).map((n) => n.toLowerCase());
351
+ return exports.find((e) => wanted.includes(e.toLowerCase())) ?? exports[0] ?? null;
352
+ }
353
+
291
354
  /** Union of dependency lists, order preserved. */
292
355
  export function mergeDependencies(...lists) {
293
356
  const out = [];
@@ -301,7 +364,7 @@ export function mergeDependencies(...lists) {
301
364
  // Site URL + token guard
302
365
  // ---------------------------------------------------------------------------
303
366
 
304
- export const DEFAULT_SITE_URL = 'https://expokit.karandev.in';
367
+ export const DEFAULT_SITE_URL = 'https://expokit.in';
305
368
 
306
369
  export function isLoopbackHost(hostname) {
307
370
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
@@ -341,3 +404,49 @@ export function canSendToken({ siteUrl, credsUrl, fromEnv = false, defaultUrl =
341
404
  if (fromEnv) return isLoopbackHost(new URL(siteUrl).hostname);
342
405
  return Boolean(credsUrl) && target === originOf(credsUrl);
343
406
  }
407
+
408
+ /**
409
+ * `env` without EXPO_KIT_TOKEN, for child processes: `npx expo install` runs third-party install scripts,
410
+ * and none of them needs the Pro token. Case-insensitive, as Windows env names are.
411
+ */
412
+ export function envWithoutToken(env) {
413
+ return Object.fromEntries(Object.entries(env).filter(([key]) => key.toUpperCase() !== 'EXPO_KIT_TOKEN'));
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // 429 / 503: wait once, then give up
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /** The longest the CLI waits before its one retry of a 429 / 503. */
421
+ export const RETRY_MAX_WAIT_MS = 10_000;
422
+
423
+ /** A Retry-After header (delta-seconds or an HTTP date) in whole seconds from `now`; null when missing or unreadable. */
424
+ export function parseRetryAfter(value, now = Date.now()) {
425
+ if (value === null || value === undefined) return null;
426
+ const text = String(value).trim();
427
+ if (/^\d+$/.test(text)) return Number(text);
428
+ if (!/[A-Za-z]/.test(text)) return null; // an HTTP date always names its day / month
429
+ const at = Date.parse(text);
430
+ return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000));
431
+ }
432
+
433
+ /**
434
+ * Milliseconds to wait before retrying a 429 / 503 once: what the server asked (3 s when it didn't say).
435
+ * null when it asked for longer than RETRY_MAX_WAIT_MS: retrying sooner than that just earns another 429 / 503.
436
+ */
437
+ export function retryDelayMs(retryAfter, now = Date.now()) {
438
+ const secs = parseRetryAfter(retryAfter, now);
439
+ if (secs === null) return 3000;
440
+ if (secs * 1000 > RETRY_MAX_WAIT_MS) return null;
441
+ return secs * 1000;
442
+ }
443
+
444
+ /** "in 30 s" / "in 5 min" / "in about 2 h" for a "try again …" message; "in a minute" when the server didn't say. */
445
+ export function retryHint(retryAfter, now = Date.now()) {
446
+ const secs = parseRetryAfter(retryAfter, now);
447
+ if (secs === null) return 'in a minute';
448
+ if (secs < 90) return `in ${Math.max(1, secs)} s`;
449
+ if (secs < 90 * 60) return `in ${Math.round(secs / 60)} min`;
450
+ if (secs < 48 * 3600) return `in about ${Math.round(secs / 3600)} h`;
451
+ return 'later';
452
+ }
package/bin/mcp.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
 
13
+ import { needsDevBuild } from './lib.mjs';
14
+
13
15
  /** Newest first; we answer with the client's version when we know it, else our newest. */
14
16
  export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
15
17
 
@@ -21,7 +23,7 @@ Items are copied into the user's app as source files by the expo-kit CLI — the
21
23
  Workflow: search_items or list_items → view_item (props, import line, examples) → get_add_command, then run that command in the app's root folder.
22
24
  The CLI creates components.json, adds the @/* tsconfig alias, copies files under src/ (or the root app layout) and installs npm deps with \`npx expo install\`.
23
25
  Import components from '@/components/...' and wrap the app in <KitProvider> from '@/lib/kit' (added with the first item).
24
- Pro items need an ExpoKit Pro plan: the user runs \`npx ${PACKAGE}@latest login\` once (token from https://expokit.karandev.in/account). Never ask the user to paste a token into chat.`;
26
+ Pro items need an ExpoKit Pro plan: the user runs \`npx ${PACKAGE}@latest login\` once (token from https://expokit.in/account). Never ask the user to paste a token into chat.`;
25
27
 
26
28
  // ---------------------------------------------------------------------------
27
29
  // Registry access (bundled JSON)
@@ -187,6 +189,8 @@ function viewItem(catalog, registryDir, { name, include_source: includeSource =
187
189
  if (imp) out.push(`Import: \`${imp}\``);
188
190
  if (doc?.files?.length) out.push(`Files it adds: ${doc.files.join(', ')}`);
189
191
  if (it.dependencies.length) out.push(`npm deps (installed by the CLI): ${it.dependencies.join(', ')}`);
192
+ const native = needsDevBuild(it.dependencies);
193
+ out.push(native.length ? `Runs in Expo Go: no, needs a development build (${native.join(', ')})` : 'Runs in Expo Go: yes');
190
194
  if (it.registryDependencies.length) out.push(`Also adds: ${it.registryDependencies.join(', ')}`);
191
195
 
192
196
  if (doc?.props?.length) {
@@ -206,7 +210,7 @@ function viewItem(catalog, registryDir, { name, include_source: includeSource =
206
210
  out.push('', '## Examples');
207
211
  for (const ex of doc.examples) out.push('', `### ${ex.title}`, '', '```tsx', ex.code.trimEnd(), '```');
208
212
  } else if (it.tier === 'pro') {
209
- out.push('', 'Usage examples and source for Pro items are on https://expokit.karandev.in after signing in, and in the files the add command copies.');
213
+ out.push('', 'Usage examples and source for Pro items are on https://expokit.in after signing in, and in the files the add command copies.');
210
214
  }
211
215
 
212
216
  if (includeSource) {
@@ -240,7 +244,7 @@ function getAddCommand(catalog, { names }) {
240
244
  ];
241
245
  if (pro.length)
242
246
  lines.push(
243
- `- PRO: ${pro.join(', ')} ${pro.length === 1 ? 'needs' : 'need'} an ExpoKit Pro plan. If the command reports "not logged in", ask the user to run \`npx ${PACKAGE}@latest login\` themselves (token from https://expokit.karandev.in/account). Never ask for the token in chat.`
247
+ `- PRO: ${pro.join(', ')} ${pro.length === 1 ? 'needs' : 'need'} an ExpoKit Pro plan. If the command reports "not logged in", ask the user to run \`npx ${PACKAGE}@latest login\` themselves (token from https://expokit.in/account). Never ask for the token in chat.`
244
248
  );
245
249
  return lines.join('\n');
246
250
  }
@@ -265,15 +269,19 @@ export function callTool(catalog, registryDir, name, args = {}) {
265
269
  // ---------------------------------------------------------------------------
266
270
 
267
271
  const rpcError = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
272
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
268
273
 
269
274
  /** One incoming JSON-RPC message → the response object, or null for notifications. */
270
275
  export function handleMessage(msg, ctx) {
271
- if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
272
- return msg && 'id' in msg ? rpcError(msg.id ?? null, -32600, 'Invalid request') : null;
276
+ if (!isObject(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
277
+ // A stray `5`, "abc" or nested array has no id to answer; an object with one gets an error back.
278
+ return isObject(msg) && 'id' in msg ? rpcError(msg.id ?? null, -32600, 'Invalid request') : null;
273
279
  }
274
280
  const isRequest = 'id' in msg && msg.id !== null;
275
281
  if (!isRequest) return null; // notifications/initialized, notifications/cancelled, …
276
- const { id, method, params = {} } = msg;
282
+ const { id, method } = msg;
283
+ // `params: null` skips a destructuring default, and params.name would then throw.
284
+ const params = isObject(msg.params) ? msg.params : {};
277
285
  const ok = (result) => ({ jsonrpc: '2.0', id, result });
278
286
 
279
287
  switch (method) {
@@ -313,6 +321,30 @@ export function handleMessage(msg, ctx) {
313
321
  }
314
322
  }
315
323
 
324
+ /**
325
+ * One stdin line → what to write back: a reply, an array of replies for a batch, or null. Never throws —
326
+ * it runs inside the stdin listener, where one bad line would otherwise kill the server for the session.
327
+ */
328
+ export function handleLine(line, ctx) {
329
+ let msg;
330
+ try {
331
+ msg = JSON.parse(line);
332
+ } catch {
333
+ return rpcError(null, -32700, 'Parse error');
334
+ }
335
+ const reply = (m) => {
336
+ try {
337
+ return handleMessage(m, ctx);
338
+ } catch {
339
+ return isObject(m) && 'id' in m && m.id !== null ? rpcError(m.id, -32603, 'Internal error') : null;
340
+ }
341
+ };
342
+ if (!Array.isArray(msg)) return reply(msg);
343
+ // Batches came and went in 2025-03-26; answer them anyway.
344
+ const replies = msg.map(reply).filter(Boolean);
345
+ return replies.length ? replies : null;
346
+ }
347
+
316
348
  /** Serves MCP on stdin/stdout until stdin closes. */
317
349
  export function startMcpServer({ registryDir, version, aliases }) {
318
350
  const ctx = { registryDir, version, catalog: loadCatalog(registryDir, aliases) };
@@ -326,18 +358,8 @@ export function startMcpServer({ registryDir, version, aliases }) {
326
358
  const line = buffer.slice(0, nl).trim();
327
359
  buffer = buffer.slice(nl + 1);
328
360
  if (!line) continue;
329
- let msg;
330
- try {
331
- msg = JSON.parse(line);
332
- } catch {
333
- send(rpcError(null, -32700, 'Parse error'));
334
- continue;
335
- }
336
- // Batches came and went in 2025-03-26; answer them anyway.
337
- const replies = (Array.isArray(msg) ? msg : [msg]).map((m) => handleMessage(m, ctx)).filter(Boolean);
338
- if (Array.isArray(msg)) {
339
- if (replies.length) send(replies);
340
- } else if (replies[0]) send(replies[0]);
361
+ const reply = handleLine(line, ctx);
362
+ if (reply) send(reply);
341
363
  }
342
364
  });
343
365
  process.stdin.on('end', () => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expokit/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "shadcn-style CLI for Expo — add native UI blocks and components to your app",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "engines": {
28
28
  "node": ">=18"
29
29
  },
30
- "homepage": "https://expokit.karandev.in",
30
+ "homepage": "https://expokit.in",
31
31
  "repository": {
32
32
  "type": "git",
33
33
  "url": "git+https://github.com/Anshkaran7/expokit.git",