@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.
- package/README.md +30 -3
- package/bin/expo-kit.js +749 -288
- package/bin/lib.mjs +310 -3
- package/package.json +1 -1
- package/registry/_docs/action-sheet.json +1 -1
- package/registry/_docs/alert.json +6 -0
- package/registry/_docs/bottom-sheet.json +6 -0
- package/registry/_docs/bullet.json +6 -0
- package/registry/_docs/dialog.json +1 -1
- package/registry/_docs/drawer.json +3 -3
- package/registry/_docs/glass-search-bar.json +1 -1
- package/registry/_docs/grain.json +6 -1
- package/registry/_docs/isometric-bar.json +6 -0
- package/registry/_docs/liquidglass-tab.json +1 -1
- package/registry/_docs/password-input.json +1 -1
- package/registry/_docs/pixel-bar.json +6 -0
- package/registry/_docs/polar-area.json +6 -0
- package/registry/_docs/qr-code-button.json +9 -3
- package/registry/_docs/scratch-card.json +7 -1
- package/registry/_docs/search-bar.json +1 -1
- package/registry/_docs/select.json +2 -2
- package/registry/_docs/slide-to-confirm.json +2 -2
- package/registry/_docs/spinner.json +1 -1
- package/registry/_docs/stateful-button.json +2 -2
- package/registry/_docs/swipe-deck.json +1 -1
- package/registry/_docs/time-picker.json +2 -2
- package/registry/_docs/timeline-chart.json +6 -0
- package/registry/_docs/toast.json +15 -3
- package/registry/_docs/treemap.json +6 -0
- package/registry/_docs/waterfall.json +6 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
- package/registry/accordion/files/src/lib/kit.tsx +363 -11
- package/registry/accordion.json +1 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +43 -48
- package/registry/action-sheet/files/src/lib/kit.tsx +363 -11
- package/registry/action-sheet.json +1 -0
- package/registry/alert/files/src/components/ui/alert.tsx +19 -12
- package/registry/alert/files/src/lib/kit.tsx +363 -11
- package/registry/alert.json +3 -4
- package/registry/assistant-app.json +1 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +84 -66
- package/registry/avatar/files/src/lib/kit.tsx +363 -11
- package/registry/avatar.json +1 -0
- package/registry/badge/files/src/components/ui/badge.tsx +34 -34
- package/registry/badge/files/src/lib/kit.tsx +363 -11
- package/registry/badge.json +1 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +28 -27
- package/registry/bottom-nav/files/src/lib/kit.tsx +363 -11
- package/registry/bottom-nav.json +5 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +353 -289
- package/registry/bottom-sheet/files/src/lib/kit.tsx +363 -11
- package/registry/bottom-sheet.json +5 -1
- package/registry/bullet.json +1 -0
- package/registry/button/files/src/components/ui/button.tsx +18 -16
- package/registry/button/files/src/lib/kit.tsx +363 -11
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +2 -2
- package/registry/button-3d/files/src/lib/kit.tsx +363 -11
- package/registry/button-3d.json +1 -0
- package/registry/button.json +1 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +74 -76
- package/registry/calendar/files/src/lib/kit.tsx +363 -11
- package/registry/calendar.json +1 -0
- package/registry/card/files/src/components/ui/card.tsx +3 -3
- package/registry/card/files/src/lib/kit.tsx +363 -11
- package/registry/card-stack.json +1 -0
- package/registry/card.json +1 -0
- package/registry/carousel.json +1 -0
- package/registry/chat.json +7 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
- package/registry/checkbox/files/src/lib/kit.tsx +363 -11
- package/registry/checkbox.json +1 -0
- package/registry/checkout.json +1 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +22 -22
- package/registry/chip-group/files/src/lib/kit.tsx +363 -11
- package/registry/chip-group.json +1 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
- package/registry/confetti.json +3 -6
- package/registry/coupon/files/src/components/ui/coupon.tsx +42 -42
- package/registry/coupon/files/src/components/ui/flip-card.tsx +6 -5
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +76 -91
- package/registry/coupon/files/src/lib/kit.tsx +363 -11
- package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
- package/registry/coupon.json +1 -0
- package/registry/cube-pager.json +1 -0
- package/registry/dashboard.json +1 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +74 -76
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +55 -56
- package/registry/date-picker/files/src/lib/kit.tsx +363 -11
- package/registry/date-picker.json +1 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +60 -56
- package/registry/dialog/files/src/lib/kit.tsx +363 -11
- package/registry/dialog.json +1 -0
- package/registry/donut-shop.json +1 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +73 -69
- package/registry/drawer/files/src/lib/kit.tsx +363 -11
- package/registry/drawer.json +1 -0
- package/registry/fab.json +1 -0
- package/registry/fintech-app.json +1 -0
- package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +92 -86
- package/registry/flip-calendar/files/src/lib/kit.tsx +363 -11
- package/registry/flip-calendar.json +7 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +6 -5
- package/registry/flip-card/files/src/lib/kit.tsx +363 -11
- package/registry/flip-card.json +1 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
- package/registry/floating-input/files/src/lib/kit.tsx +363 -11
- package/registry/floating-input.json +1 -0
- package/registry/food-app.json +1 -0
- package/registry/gift-card.json +1 -0
- package/registry/glass-button.json +5 -0
- package/registry/glass-nav-bar.json +1 -0
- package/registry/glass-search-bar.json +1 -0
- package/registry/glass-segmented.json +1 -0
- package/registry/glass-sheet.json +1 -0
- package/registry/glass-tab-bar.json +5 -0
- package/registry/glass.json +1 -0
- package/registry/grain/files/src/components/ui/grain.tsx +16 -14
- package/registry/grain.json +8 -6
- package/registry/inbox.json +1 -0
- package/registry/isometric-bar.json +1 -0
- package/registry/kit/files/src/lib/kit.tsx +363 -11
- package/registry/kit.json +1 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +23 -9
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +2 -2
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
- package/registry/liquidglass-tab.json +1 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +42 -42
- package/registry/list-item/files/src/lib/kit.tsx +363 -11
- package/registry/list-item.json +1 -0
- package/registry/loyalty-card.json +1 -0
- package/registry/morphing-button-group.json +1 -0
- package/registry/number-ticker.json +1 -0
- package/registry/offers.json +1 -0
- package/registry/onboarding.json +1 -0
- package/registry/orb-reveal.json +1 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +48 -29
- package/registry/otp-input/files/src/lib/kit.tsx +363 -11
- package/registry/otp-input.json +1 -0
- package/registry/parallax-layers.json +1 -0
- package/registry/parallax-scroll.json +1 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
- package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
- package/registry/password-input/files/src/lib/kit.tsx +363 -11
- package/registry/password-input.json +1 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +6 -5
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +17 -17
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +76 -91
- package/registry/payment-card/files/src/lib/kit.tsx +363 -11
- package/registry/payment-card.json +1 -0
- package/registry/paywall.json +1 -0
- package/registry/pixel-bar.json +1 -0
- package/registry/polar-area.json +1 -0
- package/registry/profile.json +1 -0
- package/registry/progress/files/src/components/ui/progress.tsx +29 -29
- package/registry/progress/files/src/lib/kit.tsx +363 -11
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +20 -20
- package/registry/progress-steps/files/src/lib/kit.tsx +363 -11
- package/registry/progress-steps.json +1 -0
- package/registry/progress.json +1 -0
- package/registry/qr-code-button.json +1 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
- package/registry/radio-group/files/src/lib/kit.tsx +363 -11
- package/registry/radio-group.json +1 -0
- package/registry/rating.json +1 -0
- package/registry/ring-carousel.json +1 -0
- package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +98 -82
- package/registry/ruler-time-picker/files/src/lib/kit.tsx +363 -11
- package/registry/ruler-time-picker.json +7 -0
- package/registry/scratch-card.json +1 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +25 -21
- package/registry/search-bar/files/src/lib/kit.tsx +363 -11
- package/registry/search-bar.json +1 -0
- package/registry/search-results.json +4 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +8 -7
- package/registry/segmented-control/files/src/lib/kit.tsx +363 -11
- package/registry/segmented-control.json +1 -0
- package/registry/select/files/src/components/ui/select.tsx +93 -88
- package/registry/select/files/src/lib/kit.tsx +363 -11
- package/registry/select.json +6 -0
- package/registry/settings.json +1 -0
- package/registry/sign-in.json +1 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
- package/registry/skeleton/files/src/lib/kit.tsx +363 -11
- package/registry/skeleton.json +1 -0
- package/registry/slide-to-confirm.json +1 -0
- package/registry/slider/files/src/components/ui/slider.tsx +103 -96
- package/registry/slider/files/src/lib/kit.tsx +363 -11
- package/registry/slider.json +5 -0
- package/registry/spin-wheel.json +1 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
- package/registry/spinner/files/src/lib/kit.tsx +363 -11
- package/registry/spinner.json +6 -0
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
- package/registry/stateful-button/files/src/lib/kit.tsx +363 -11
- package/registry/stateful-button.json +5 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +38 -36
- package/registry/stepper/files/src/lib/kit.tsx +363 -11
- package/registry/stepper.json +1 -0
- package/registry/swipe-deck.json +1 -0
- package/registry/swipeable-row.json +4 -0
- package/registry/switch/files/src/components/ui/switch.tsx +39 -42
- package/registry/switch/files/src/lib/kit.tsx +363 -11
- package/registry/switch.json +1 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +59 -62
- package/registry/tabs/files/src/lib/kit.tsx +363 -11
- package/registry/tabs.json +1 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
- package/registry/text-animation/files/src/lib/kit.tsx +363 -11
- package/registry/text-animation.json +7 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
- package/registry/textarea/files/src/lib/kit.tsx +363 -11
- package/registry/textarea.json +1 -0
- package/registry/ticket-card.json +1 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +363 -11
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
- package/registry/ticket-shape.json +1 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +76 -91
- package/registry/tilt-card/files/src/lib/kit.tsx +363 -11
- package/registry/tilt-card.json +1 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +122 -119
- package/registry/time-picker/files/src/lib/kit.tsx +363 -11
- package/registry/time-picker.json +1 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +84 -66
- package/registry/timeline/files/src/components/ui/timeline.tsx +44 -41
- package/registry/timeline/files/src/lib/kit.tsx +363 -11
- package/registry/timeline-chart.json +1 -0
- package/registry/timeline.json +1 -0
- package/registry/toast/files/src/components/ui/toast.tsx +541 -463
- package/registry/toast/files/src/lib/kit.tsx +363 -11
- package/registry/toast.json +7 -5
- package/registry/treemap.json +1 -0
- package/registry/waterfall.json +1 -0
- package/registry/wheel-list.json +1 -0
- package/schema/components.schema.json +1 -0
- package/registry/alert/files/src/components/ui/button.tsx +0 -290
- package/registry/confetti/files/src/lib/kit.tsx +0 -320
- package/registry/grain/files/src/lib/kit.tsx +0 -320
- 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 (
|
|
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 === '--
|
|
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
|
|
275
|
+
aliases,
|
|
230
276
|
paths,
|
|
231
|
-
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
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
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
|
|
317
|
+
return dest[folder] ?? `src/${folder}`;
|
|
271
318
|
}
|
|
272
319
|
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
log(` would write ${rel}`);
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
331
|
+
let aliasIndex = null;
|
|
288
332
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
748
|
+
return files;
|
|
679
749
|
}
|
|
680
750
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
|
899
|
+
return { files, problems };
|
|
707
900
|
}
|
|
708
901
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
|
1302
|
+
// offline, slow or blocked: the count just misses these
|
|
963
1303
|
}
|
|
964
1304
|
}
|
|
965
1305
|
|
|
966
|
-
/**
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
983
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1025
|
-
log(
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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,
|
|
1046
|
-
ensureExpoRouterMain(projectRoot,
|
|
1408
|
+
syncTabRoutes(projectRoot, item.target, flags);
|
|
1409
|
+
ensureExpoRouterMain(projectRoot, flags.dryRun);
|
|
1047
1410
|
}
|
|
1048
|
-
|
|
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
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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(
|
|
1064
|
-
|
|
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 (
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
)
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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 (!
|
|
1112
|
-
|
|
1478
|
+
if (!names.length) {
|
|
1479
|
+
listRegistry();
|
|
1480
|
+
return;
|
|
1113
1481
|
}
|
|
1114
1482
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1491
|
+
await runBatch(names, projectRoot, loadConfig(projectRoot), flags, { report: true });
|
|
1492
|
+
}
|
|
1125
1493
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
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(
|
|
1152
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
\`
|
|
1333
|
-
|
|
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)
|
|
1401
|
-
|
|
1861
|
+
if (e instanceof CliError) die(e.message);
|
|
1862
|
+
die(`Unexpected error: ${e?.stack ?? e?.message ?? e}`);
|
|
1402
1863
|
}
|