@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.
- package/README.md +8 -4
- package/bin/expo-kit.js +137 -24
- package/bin/lib.mjs +110 -1
- package/bin/mcp.mjs +40 -18
- package/package.json +2 -2
- package/registry/_docs/assistant-app.json +47 -0
- package/registry/_docs/avatar.json +1 -1
- package/registry/_docs/badge.json +1 -1
- package/registry/_docs/bottom-nav.json +6 -6
- package/registry/_docs/button-3d.json +2 -2
- package/registry/_docs/calendar.json +5 -5
- package/registry/_docs/checkout.json +7 -1
- package/registry/_docs/confetti.json +3 -3
- package/registry/_docs/date-picker.json +4 -4
- package/registry/_docs/donut-shop.json +88 -0
- package/registry/_docs/drawer.json +4 -4
- package/registry/_docs/fintech-app.json +49 -0
- package/registry/_docs/floating-input.json +5 -5
- package/registry/_docs/food-app.json +47 -0
- package/registry/_docs/glass.json +1 -1
- package/registry/_docs/grain.json +80 -0
- package/registry/_docs/morphing-button-group.json +96 -0
- package/registry/_docs/orb-reveal.json +132 -0
- package/registry/_docs/otp-input.json +4 -4
- package/registry/_docs/password-input.json +3 -3
- package/registry/_docs/qr-code-button.json +122 -0
- package/registry/_docs/search-bar.json +3 -3
- package/registry/_docs/select.json +7 -7
- package/registry/_docs/settings.json +6 -0
- package/registry/_docs/skeleton.json +2 -2
- package/registry/_docs/slider.json +6 -6
- package/registry/_docs/spinner.json +1 -1
- package/registry/_docs/stepper.json +7 -7
- package/registry/_docs/textarea.json +2 -2
- package/registry/_docs/ticket-shape.json +1 -1
- package/registry/_docs/tilt-card.json +4 -4
- package/registry/_docs/time-picker.json +6 -6
- package/registry/_docs/timeline.json +2 -2
- package/registry/_docs/toast.json +8 -8
- package/registry/accordion/files/src/components/ui/accordion.tsx +5 -4
- package/registry/accordion/files/src/lib/kit.tsx +8 -5
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +9 -8
- package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
- package/registry/alert/files/src/components/ui/alert.tsx +7 -4
- package/registry/alert/files/src/components/ui/button.tsx +4 -3
- package/registry/alert/files/src/lib/kit.tsx +8 -5
- package/registry/assistant-app.json +49 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
- package/registry/avatar/files/src/lib/kit.tsx +8 -5
- package/registry/badge/files/src/components/ui/badge.tsx +21 -7
- package/registry/badge/files/src/lib/kit.tsx +8 -5
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +14 -7
- package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
- package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
- package/registry/button/files/src/components/ui/button.tsx +4 -3
- package/registry/button/files/src/lib/kit.tsx +8 -5
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +3 -2
- package/registry/button-3d/files/src/lib/kit.tsx +8 -5
- package/registry/calendar/files/src/components/ui/calendar.tsx +9 -5
- package/registry/calendar/files/src/lib/kit.tsx +8 -5
- package/registry/card/files/src/components/ui/card.tsx +8 -6
- package/registry/card/files/src/lib/kit.tsx +8 -5
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +6 -5
- package/registry/checkbox/files/src/lib/kit.tsx +8 -5
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +4 -2
- package/registry/chip-group/files/src/lib/kit.tsx +8 -5
- package/registry/confetti/files/src/lib/kit.tsx +8 -5
- package/registry/coupon/files/src/components/ui/coupon.tsx +23 -20
- package/registry/coupon/files/src/components/ui/flip-card.tsx +1 -1
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +2 -2
- package/registry/coupon/files/src/lib/kit.tsx +8 -5
- package/registry/date-picker/files/src/components/ui/calendar.tsx +9 -5
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +2 -2
- package/registry/date-picker/files/src/lib/kit.tsx +8 -5
- package/registry/dialog/files/src/components/ui/dialog.tsx +12 -6
- package/registry/dialog/files/src/lib/kit.tsx +8 -5
- package/registry/donut-shop.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +25 -19
- package/registry/drawer/files/src/lib/kit.tsx +8 -5
- package/registry/fintech-app.json +57 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +1 -1
- package/registry/flip-card/files/src/lib/kit.tsx +8 -5
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +16 -6
- package/registry/floating-input/files/src/lib/kit.tsx +8 -5
- package/registry/food-app.json +55 -0
- package/registry/grain/files/src/components/ui/grain.tsx +113 -0
- package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
- package/registry/grain.json +17 -0
- package/registry/kit/files/src/lib/kit.tsx +8 -5
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
- package/registry/list-item/files/src/components/ui/list-item.tsx +20 -16
- package/registry/list-item/files/src/lib/kit.tsx +8 -5
- package/registry/morphing-button-group.json +18 -0
- package/registry/orb-reveal.json +20 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +8 -6
- package/registry/otp-input/files/src/lib/kit.tsx +8 -5
- package/registry/password-input/files/src/components/ui/floating-input.tsx +16 -6
- package/registry/password-input/files/src/components/ui/password-input.tsx +2 -2
- package/registry/password-input/files/src/lib/kit.tsx +8 -5
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +1 -1
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +27 -14
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +2 -2
- package/registry/payment-card/files/src/lib/kit.tsx +8 -5
- package/registry/progress/files/src/components/ui/progress.tsx +8 -5
- package/registry/progress/files/src/lib/kit.tsx +8 -5
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +15 -11
- package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
- package/registry/qr-code-button.json +18 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +10 -9
- package/registry/radio-group/files/src/lib/kit.tsx +8 -5
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +13 -11
- package/registry/search-bar/files/src/lib/kit.tsx +8 -5
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +4 -2
- package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
- package/registry/select/files/src/components/ui/select.tsx +3 -3
- package/registry/select/files/src/lib/kit.tsx +8 -5
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
- package/registry/skeleton/files/src/lib/kit.tsx +8 -5
- package/registry/slider/files/src/components/ui/slider.tsx +5 -4
- package/registry/slider/files/src/lib/kit.tsx +8 -5
- package/registry/spinner/files/src/components/ui/spinner.tsx +17 -12
- package/registry/spinner/files/src/lib/kit.tsx +8 -5
- package/registry/stateful-button/files/src/lib/kit.tsx +8 -5
- package/registry/stepper/files/src/components/ui/stepper.tsx +8 -4
- package/registry/stepper/files/src/lib/kit.tsx +8 -5
- package/registry/switch/files/src/components/ui/switch.tsx +4 -3
- package/registry/switch/files/src/lib/kit.tsx +8 -5
- package/registry/tabs/files/src/components/ui/tabs.tsx +10 -8
- package/registry/tabs/files/src/lib/kit.tsx +8 -5
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +4 -4
- package/registry/text-animation/files/src/lib/kit.tsx +8 -5
- package/registry/textarea/files/src/components/ui/textarea.tsx +11 -9
- package/registry/textarea/files/src/lib/kit.tsx +8 -5
- package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +2 -2
- package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +2 -2
- package/registry/time-picker/files/src/lib/kit.tsx +8 -5
- package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
- package/registry/timeline/files/src/components/ui/timeline.tsx +12 -10
- package/registry/timeline/files/src/lib/kit.tsx +8 -5
- package/registry/toast/files/src/components/ui/button.tsx +4 -3
- package/registry/toast/files/src/components/ui/toast.tsx +13 -11
- package/registry/toast/files/src/lib/kit.tsx +8 -5
- package/schema/components.schema.json +1 -1
- package/registry/_docs/color-swatch.json +0 -82
- package/registry/_docs/kpi-card.json +0 -14
- package/registry/_docs/like-button.json +0 -92
- package/registry/_docs/morph-button.json +0 -128
- package/registry/_docs/pagination-dots.json +0 -108
- package/registry/_docs/shimmer-button.json +0 -98
- package/registry/_docs/tag-input.json +0 -148
- package/registry/color-swatch.json +0 -17
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
- package/registry/kpi-card.json +0 -14
- package/registry/like-button.json +0 -18
- package/registry/morph-button.json +0 -18
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
- package/registry/pagination-dots/files/src/lib/kit.tsx +0 -219
- package/registry/pagination-dots.json +0 -15
- package/registry/shimmer-button.json +0 -17
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
- package/registry/tag-input.json +0 -17
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|

|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
The CLI for **[ExpoKit](https://expokit.
|
|
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.
|
|
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.
|
|
41
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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))
|
|
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})
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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(
|
|
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(
|
|
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})
|
|
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.
|
|
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
|
|
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
|
|
1203
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 ||
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
+
"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.
|
|
30
|
+
"homepage": "https://expokit.in",
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
33
|
"url": "git+https://github.com/Anshkaran7/expokit.git",
|