get-ui-cli 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/core/css.d.ts +25 -0
- package/dist/core/css.js +129 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +6 -0
- package/dist/core/resolve.d.ts +58 -0
- package/dist/core/resolve.js +390 -0
- package/dist/core/search.d.ts +17 -0
- package/dist/core/search.js +130 -0
- package/dist/core/shadcn-theme.d.ts +8 -0
- package/dist/core/shadcn-theme.js +52 -0
- package/dist/core/transform.d.ts +33 -0
- package/dist/core/transform.js +148 -0
- package/dist/core/versions.d.ts +3 -0
- package/dist/core/versions.js +44 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +390 -0
- package/dist/node/install.d.ts +43 -0
- package/dist/node/install.js +410 -0
- package/dist/node/project.d.ts +34 -0
- package/dist/node/project.js +227 -0
- package/dist/node/registry.d.ts +10 -0
- package/dist/node/registry.js +79 -0
- package/dist/registry-types.d.ts +48 -0
- package/dist/registry-types.js +41 -0
- package/package.json +68 -4
- package/registry/index.json +1 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// Plan and write resolved registry files into a user's project.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mergeCss } from '../core/css.js';
|
|
6
|
+
import { packageName } from '../core/resolve.js';
|
|
7
|
+
import { importJsxNamespace, listImports, rewriteImports, transformIconPlaceholders } from '../core/transform.js';
|
|
8
|
+
import { withKnownMajor } from '../core/versions.js';
|
|
9
|
+
import { SHADCN_BASE_CSS, SHADCN_BASE_THEME, hasShadcnTheme, usesShadcnTokens } from '../core/shadcn-theme.js';
|
|
10
|
+
export const UTILS_SOURCE = `import { clsx, type ClassValue } from "clsx"
|
|
11
|
+
import { twMerge } from "tailwind-merge"
|
|
12
|
+
|
|
13
|
+
export function cn(...inputs: ClassValue[]) {
|
|
14
|
+
return twMerge(clsx(inputs))
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
/** Packages a React project always has — never auto-added. */
|
|
18
|
+
const IMPLICIT_PACKAGES = new Set(['react', 'react-dom', 'next', 'cn']);
|
|
19
|
+
const CODE_EXT = /\.(tsx?|jsx?|mjs|cjs)$/;
|
|
20
|
+
function stripExt(p) {
|
|
21
|
+
return p.replace(CODE_EXT, '');
|
|
22
|
+
}
|
|
23
|
+
function toPosix(p) {
|
|
24
|
+
return p.split(path.sep).join('/');
|
|
25
|
+
}
|
|
26
|
+
function within(dir, file) {
|
|
27
|
+
const rel = path.relative(dir, file);
|
|
28
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
|
|
29
|
+
return null;
|
|
30
|
+
return toPosix(rel);
|
|
31
|
+
}
|
|
32
|
+
function itemName(label) {
|
|
33
|
+
return label.split('/').pop() ?? label;
|
|
34
|
+
}
|
|
35
|
+
/** Map a target like `components/kokonutui/x.tsx` onto the project's directories. */
|
|
36
|
+
function mapTarget(target, project) {
|
|
37
|
+
if (target.startsWith('~/'))
|
|
38
|
+
return path.join(project.cwd, target.slice(2));
|
|
39
|
+
const t = target.replace(/^\.?\/+/, '').replace(/^src\//, '');
|
|
40
|
+
const seg = (prefix) => t.startsWith(prefix) ? t.slice(prefix.length) : null;
|
|
41
|
+
let rest;
|
|
42
|
+
if ((rest = seg('components/ui/')) !== null)
|
|
43
|
+
return path.join(project.dirs.ui, rest);
|
|
44
|
+
if ((rest = seg('components/')) !== null)
|
|
45
|
+
return path.join(project.dirs.components, rest);
|
|
46
|
+
if ((rest = seg('hooks/')) !== null)
|
|
47
|
+
return path.join(project.dirs.hooks, rest);
|
|
48
|
+
if ((rest = seg('lib/')) !== null)
|
|
49
|
+
return path.join(project.dirs.lib, rest);
|
|
50
|
+
if (/^(app|pages|styles)\//.test(t))
|
|
51
|
+
return path.join(project.baseDir, t);
|
|
52
|
+
return path.join(project.cwd, target.replace(/^\.?\/+/, ''));
|
|
53
|
+
}
|
|
54
|
+
export function destinationFor(file, project) {
|
|
55
|
+
const base = path.posix.basename(file.path);
|
|
56
|
+
if (file.target)
|
|
57
|
+
return mapTarget(file.target, project);
|
|
58
|
+
switch (file.type) {
|
|
59
|
+
case 'registry:ui':
|
|
60
|
+
return path.join(project.dirs.ui, base);
|
|
61
|
+
case 'registry:hook':
|
|
62
|
+
return path.join(project.dirs.hooks, base);
|
|
63
|
+
case 'registry:lib':
|
|
64
|
+
return path.join(project.dirs.lib, base);
|
|
65
|
+
case 'registry:block':
|
|
66
|
+
return path.join(project.dirs.components, 'blocks', itemName(file.item), base);
|
|
67
|
+
case 'registry:theme':
|
|
68
|
+
case 'registry:style':
|
|
69
|
+
case 'registry:file':
|
|
70
|
+
case 'registry:page': {
|
|
71
|
+
// No target given: keep the path below a `registry/<style>/` prefix, if any.
|
|
72
|
+
const p = file.path.replace(/^\/+/, '').replace(/^registry\/[^/]+\//, '');
|
|
73
|
+
return path.join(project.dirs.components, p);
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
return path.join(project.dirs.components, base);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Import specifier (without extension) that resolves to `abs`. */
|
|
80
|
+
function specifierFor(abs, project) {
|
|
81
|
+
const noExt = stripExt(abs);
|
|
82
|
+
const order = [
|
|
83
|
+
[project.dirs.ui, project.aliases.ui],
|
|
84
|
+
[project.dirs.components, project.aliases.components],
|
|
85
|
+
[project.dirs.hooks, project.aliases.hooks],
|
|
86
|
+
[project.dirs.lib, project.aliases.lib],
|
|
87
|
+
];
|
|
88
|
+
for (const [dir, alias] of order) {
|
|
89
|
+
const rel = within(dir, noExt);
|
|
90
|
+
if (rel)
|
|
91
|
+
return `${alias}/${rel}`;
|
|
92
|
+
}
|
|
93
|
+
const rel = within(project.baseDir, noExt) ?? within(project.cwd, noExt);
|
|
94
|
+
return `${project.aliasPrefix ?? '@/'}${rel ?? toPosix(noExt)}`;
|
|
95
|
+
}
|
|
96
|
+
/** When the project has no path alias, turn `@/x` specifiers into relative ones. */
|
|
97
|
+
function relativize(code, fileAbs, project) {
|
|
98
|
+
if (project.aliasPrefix)
|
|
99
|
+
return code;
|
|
100
|
+
return code.replace(/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"])@\/([^'"\n]+)\2/g, (_m, lead, q, rest) => {
|
|
101
|
+
let rel = toPosix(path.relative(path.dirname(fileAbs), path.join(project.baseDir, rest)));
|
|
102
|
+
if (!rel.startsWith('.'))
|
|
103
|
+
rel = `./${rel}`;
|
|
104
|
+
return `${lead}${q}${rel}${q}`;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** shadcn-style primitives come in Radix and Base UI flavours that can't stand in for each other. */
|
|
108
|
+
function flavour(code) {
|
|
109
|
+
if (/from\s*['"]@base-ui(?:-components)?\/react/.test(code))
|
|
110
|
+
return 'base-ui';
|
|
111
|
+
if (/from\s*['"](?:radix-ui|@radix-ui\/react-[\w-]+)['"]/.test(code))
|
|
112
|
+
return 'radix';
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** Share of identifiers two files have in common (0–1). A user's edits keep it near 1. */
|
|
116
|
+
function similarity(a, b) {
|
|
117
|
+
const words = (code) => new Set(code.match(/[A-Za-z_$][\w$]{2,}/g) ?? []);
|
|
118
|
+
const A = words(a);
|
|
119
|
+
const B = words(b);
|
|
120
|
+
let shared = 0;
|
|
121
|
+
for (const w of A)
|
|
122
|
+
if (B.has(w))
|
|
123
|
+
shared++;
|
|
124
|
+
return shared / Math.max(1, A.size + B.size - shared);
|
|
125
|
+
}
|
|
126
|
+
// Measured: same-name components from different libraries score 0.3–0.4; versions
|
|
127
|
+
// of the same component (another shadcn style, or the user's edits) score 0.55–1.
|
|
128
|
+
const DIFFERENT_COMPONENT = 0.5;
|
|
129
|
+
/** What callers can rely on: exports, component props, and cva variant options. */
|
|
130
|
+
function api(code) {
|
|
131
|
+
const out = new Set();
|
|
132
|
+
for (const m of code.matchAll(/export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/g))
|
|
133
|
+
out.add(`export:${m[1]}`);
|
|
134
|
+
for (const m of code.matchAll(/export\s*\{([^}]*)\}/g))
|
|
135
|
+
for (const name of m[1].split(',')) {
|
|
136
|
+
const n = name.trim().split(/\s+as\s+/).pop();
|
|
137
|
+
if (n && n !== 'type')
|
|
138
|
+
out.add(`export:${n.replace(/^type\s+/, '')}`);
|
|
139
|
+
}
|
|
140
|
+
// Props destructured by components: function Card({ className, size = "default", ...props })
|
|
141
|
+
for (const m of code.matchAll(/(?:function\s+[A-Z][\w$]*|const\s+[A-Z][\w$]*\s*=\s*(?:React\.)?(?:forwardRef(?:<[^>]*>)?\()?)\s*\(\s*\{([^}]*)\}/g))
|
|
142
|
+
for (const part of m[1].split(',')) {
|
|
143
|
+
const p = part.trim().split(/[=:\s]/)[0];
|
|
144
|
+
if (p && !p.startsWith('...'))
|
|
145
|
+
out.add(`prop:${p}`);
|
|
146
|
+
}
|
|
147
|
+
// cva variant options: variants: { variant: { default: "…", success: "…" }, size: { sm: "…" } }
|
|
148
|
+
for (const m of code.matchAll(/\bvariants\s*:\s*\{([\s\S]*?)\n\s{0,4}\}/g))
|
|
149
|
+
for (const k of m[1].matchAll(/^\s*["']?([\w-]+)["']?\s*:/gm))
|
|
150
|
+
out.add(`variant:${k[1]}`);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* An existing file at the destination that this file can't replace or reuse:
|
|
155
|
+
* - the same primitive in the other flavour (Radix vs Base UI `button.tsx`);
|
|
156
|
+
* - a different component with the same name (two libraries' `carousel.tsx`);
|
|
157
|
+
* - a version missing something the incoming one offers (a `size` prop, a badge
|
|
158
|
+
* `success` variant) that the components being installed are likely to use.
|
|
159
|
+
* A user's styling edits keep the API, so their file is reused as before.
|
|
160
|
+
*/
|
|
161
|
+
export function clashes(existing, incoming) {
|
|
162
|
+
const a = flavour(existing);
|
|
163
|
+
const b = flavour(incoming);
|
|
164
|
+
if (a && b && a !== b)
|
|
165
|
+
return true;
|
|
166
|
+
if (similarity(existing, incoming) < DIFFERENT_COMPONENT)
|
|
167
|
+
return true;
|
|
168
|
+
const have = api(existing);
|
|
169
|
+
for (const x of api(incoming))
|
|
170
|
+
if (!have.has(x))
|
|
171
|
+
return true;
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
/** Folder for a clashing file: its flavour (`ui/base-ui/button.tsx`) or else its library (`ui/magicui/meteors.tsx`). */
|
|
175
|
+
function clashFolder(existing, incoming, library) {
|
|
176
|
+
const a = flavour(existing);
|
|
177
|
+
const b = flavour(incoming);
|
|
178
|
+
return a && b && a !== b ? b : library;
|
|
179
|
+
}
|
|
180
|
+
function status(abs, content) {
|
|
181
|
+
if (!fs.existsSync(abs))
|
|
182
|
+
return 'new';
|
|
183
|
+
return fs.readFileSync(abs, 'utf8') === content ? 'identical' : 'modified';
|
|
184
|
+
}
|
|
185
|
+
function rel(project, abs) {
|
|
186
|
+
return toPosix(path.relative(project.cwd, abs));
|
|
187
|
+
}
|
|
188
|
+
/** Compute everything that would be written / installed, without touching disk. */
|
|
189
|
+
export function planInstall(results, project) {
|
|
190
|
+
const warnings = [];
|
|
191
|
+
const allFiles = [];
|
|
192
|
+
const deps = new Set();
|
|
193
|
+
const devDeps = new Set();
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
for (const r of results) {
|
|
196
|
+
warnings.push(...r.warnings);
|
|
197
|
+
for (const d of r.dependencies)
|
|
198
|
+
deps.add(d);
|
|
199
|
+
for (const d of r.devDependencies)
|
|
200
|
+
devDeps.add(d);
|
|
201
|
+
for (const f of r.files)
|
|
202
|
+
allFiles.push(f);
|
|
203
|
+
}
|
|
204
|
+
// Destinations + specifier map for internal imports.
|
|
205
|
+
const dest = new Map();
|
|
206
|
+
const fileMap = {};
|
|
207
|
+
const relocated = [];
|
|
208
|
+
for (const f of allFiles) {
|
|
209
|
+
let abs = destinationFor(f, project);
|
|
210
|
+
// Install a clashing file beside the existing one and point this install's
|
|
211
|
+
// imports at it; the project's own file stays as it is.
|
|
212
|
+
if (CODE_EXT.test(abs) && f.library !== 'external' && fs.existsSync(abs)) {
|
|
213
|
+
const existing = fs.readFileSync(abs, 'utf8');
|
|
214
|
+
if (clashes(existing, f.content)) {
|
|
215
|
+
const moved = path.join(path.dirname(abs), clashFolder(existing, f.content, f.library), path.basename(abs));
|
|
216
|
+
warnings.push(`${rel(project, abs)} already exists and is a different version; installed ${f.item}'s copy at ${rel(project, moved)}.`);
|
|
217
|
+
relocated.push([abs, moved]);
|
|
218
|
+
abs = moved;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
dest.set(f, abs);
|
|
222
|
+
if (!CODE_EXT.test(abs))
|
|
223
|
+
continue;
|
|
224
|
+
const spec = specifierFor(abs, project);
|
|
225
|
+
for (const src of [f.path, f.target]) {
|
|
226
|
+
if (!src)
|
|
227
|
+
continue;
|
|
228
|
+
const clean = stripExt(src.replace(/^~?\/+/, '').replace(/^src\//, ''));
|
|
229
|
+
fileMap[`@/${clean}`] ??= spec;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Components import shared files by their usual path (`@/components/ui/button`);
|
|
233
|
+
// send those imports to the relocated copies.
|
|
234
|
+
for (const [from, to] of relocated) {
|
|
235
|
+
const target = specifierFor(to, project);
|
|
236
|
+
fileMap[specifierFor(from, project)] = target;
|
|
237
|
+
const noExt = stripExt(from);
|
|
238
|
+
for (const [dir, canonical] of [[project.dirs.ui, '@/components/ui'], [project.dirs.components, '@/components'], [project.dirs.hooks, '@/hooks'], [project.dirs.lib, '@/lib']]) {
|
|
239
|
+
const r = within(dir, noExt);
|
|
240
|
+
if (r) {
|
|
241
|
+
fileMap[`${canonical}/${r}`] = target;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// framer-motion was renamed to motion; if the project already uses motion, don't install both.
|
|
247
|
+
const preferMotion = project.installed.has('motion') && !project.installed.has('framer-motion');
|
|
248
|
+
let swappedMotion = false;
|
|
249
|
+
const files = [];
|
|
250
|
+
let usesUtils = false;
|
|
251
|
+
const undeclared = new Set();
|
|
252
|
+
for (const f of allFiles) {
|
|
253
|
+
const abs = dest.get(f);
|
|
254
|
+
if (seen.has(abs))
|
|
255
|
+
continue;
|
|
256
|
+
seen.add(abs);
|
|
257
|
+
let content = f.content;
|
|
258
|
+
if (CODE_EXT.test(abs)) {
|
|
259
|
+
content = transformIconPlaceholders(content);
|
|
260
|
+
if (/\.tsx?$/.test(abs))
|
|
261
|
+
content = importJsxNamespace(content);
|
|
262
|
+
content = rewriteImports(content, project.aliases, fileMap);
|
|
263
|
+
content = relativize(content, abs, project);
|
|
264
|
+
if (preferMotion) {
|
|
265
|
+
const swapped = content.replace(/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])framer-motion\2/g, '$1$2motion/react$2');
|
|
266
|
+
if (swapped !== content)
|
|
267
|
+
swappedMotion = true;
|
|
268
|
+
content = swapped;
|
|
269
|
+
}
|
|
270
|
+
if (!project.rsc)
|
|
271
|
+
content = content.replace(/^\s*(['"])use client\1;?\s*\n/, '');
|
|
272
|
+
if (content.includes(project.aliases.utils) || /from\s+['"][./]+[^'"]*lib\/utils['"]/.test(content))
|
|
273
|
+
usesUtils = true;
|
|
274
|
+
// Safety net: packages imported by the code but not declared by the registry item.
|
|
275
|
+
for (const spec of listImports(content)) {
|
|
276
|
+
if (/^(\.|\/|node:)/.test(spec) || spec.startsWith('@/') || (project.aliasPrefix && spec.startsWith(project.aliasPrefix)))
|
|
277
|
+
continue;
|
|
278
|
+
const pkg = packageName(spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]);
|
|
279
|
+
if (!pkg || IMPLICIT_PACKAGES.has(pkg) || project.installed.has(pkg) || /^[A-Z~#]/.test(pkg))
|
|
280
|
+
continue;
|
|
281
|
+
if (![...deps].some((d) => packageName(d) === pkg)) {
|
|
282
|
+
deps.add(pkg);
|
|
283
|
+
undeclared.add(pkg);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
files.push({ abs, rel: rel(project, abs), content, status: status(abs, content), item: f.item, kind: 'file' });
|
|
288
|
+
}
|
|
289
|
+
if (preferMotion) {
|
|
290
|
+
for (const d of [...deps])
|
|
291
|
+
if (packageName(d) === 'framer-motion')
|
|
292
|
+
deps.delete(d);
|
|
293
|
+
if (swappedMotion)
|
|
294
|
+
warnings.push('Imported from motion/react instead of framer-motion (your project already uses motion).');
|
|
295
|
+
}
|
|
296
|
+
if (undeclared.size)
|
|
297
|
+
warnings.push(`Added undeclared imports to packages: ${[...undeclared].sort().join(', ')}`);
|
|
298
|
+
// lib/utils
|
|
299
|
+
if (usesUtils && !fs.existsSync(project.utilsFile) && !seen.has(project.utilsFile)) {
|
|
300
|
+
files.unshift({ abs: project.utilsFile, rel: rel(project, project.utilsFile), content: UTILS_SOURCE, status: 'new', item: 'get-ui', kind: 'file', note: 'cn() helper' });
|
|
301
|
+
deps.add('clsx');
|
|
302
|
+
deps.add('tailwind-merge');
|
|
303
|
+
}
|
|
304
|
+
else if (usesUtils) {
|
|
305
|
+
deps.add('clsx');
|
|
306
|
+
deps.add('tailwind-merge');
|
|
307
|
+
}
|
|
308
|
+
// Global CSS
|
|
309
|
+
const cssVars = { theme: {}, light: {}, dark: {} };
|
|
310
|
+
const css = {};
|
|
311
|
+
for (const r of results) {
|
|
312
|
+
for (const k of ['theme', 'light', 'dark'])
|
|
313
|
+
Object.assign(cssVars[k], { ...r.cssVars[k], ...cssVars[k] });
|
|
314
|
+
for (const [k, v] of Object.entries(r.css))
|
|
315
|
+
if (!(k in css))
|
|
316
|
+
css[k] = v;
|
|
317
|
+
}
|
|
318
|
+
// shadcn-style components need shadcn's base tokens, which `shadcn init` normally adds.
|
|
319
|
+
// If the project never ran it, add them now (the project's own values always win).
|
|
320
|
+
if (project.tailwind.css && files.some((f) => CODE_EXT.test(f.abs) && usesShadcnTokens(f.content))) {
|
|
321
|
+
const sheet = fs.readFileSync(project.tailwind.css, 'utf8');
|
|
322
|
+
if (!hasShadcnTheme(sheet)) {
|
|
323
|
+
const v3 = project.tailwind.version === 3;
|
|
324
|
+
for (const k of ['light', 'dark'])
|
|
325
|
+
cssVars[k] = { ...SHADCN_BASE_THEME[k], ...cssVars[k] };
|
|
326
|
+
if (v3)
|
|
327
|
+
warnings.push('Tailwind v3: map the shadcn colors in tailwind.config (https://ui.shadcn.com/docs/installation/manual).');
|
|
328
|
+
else
|
|
329
|
+
cssVars.theme = { ...SHADCN_BASE_THEME.theme, ...cssVars.theme };
|
|
330
|
+
if (!v3 && !/@custom-variant\s+dark\b/.test(sheet))
|
|
331
|
+
Object.assign(css, SHADCN_BASE_CSS);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const hasCss = Object.values(cssVars).some((v) => Object.keys(v).length) || Object.keys(css).length;
|
|
335
|
+
if (hasCss) {
|
|
336
|
+
if (project.tailwind.css) {
|
|
337
|
+
const before = fs.readFileSync(project.tailwind.css, 'utf8');
|
|
338
|
+
const merged = mergeCss(before, { cssVars, css, tailwindVersion: project.tailwind.version });
|
|
339
|
+
warnings.push(...merged.warnings);
|
|
340
|
+
if (merged.changed)
|
|
341
|
+
files.push({
|
|
342
|
+
abs: project.tailwind.css,
|
|
343
|
+
rel: rel(project, project.tailwind.css),
|
|
344
|
+
content: merged.css,
|
|
345
|
+
status: 'modified',
|
|
346
|
+
item: 'css',
|
|
347
|
+
kind: 'css',
|
|
348
|
+
note: merged.added.join('; '),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
warnings.push('No global CSS file found — add the component CSS variables / keyframes manually (see `get-ui view`).');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Local imports that nothing provides (upstream registry bugs, e.g. a missing data file).
|
|
356
|
+
const planned = new Set(files.map((f) => f.abs));
|
|
357
|
+
const exists = (base) => ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx', '/index.js'].some((e) => planned.has(base + e) || fs.existsSync(base + e));
|
|
358
|
+
for (const f of files) {
|
|
359
|
+
if (!CODE_EXT.test(f.abs))
|
|
360
|
+
continue;
|
|
361
|
+
for (const spec of listImports(f.content)) {
|
|
362
|
+
let base = null;
|
|
363
|
+
if (spec.startsWith('.'))
|
|
364
|
+
base = path.resolve(path.dirname(f.abs), spec);
|
|
365
|
+
else if (project.aliasPrefix && spec.startsWith(project.aliasPrefix))
|
|
366
|
+
base = path.join(project.baseDir, spec.slice(project.aliasPrefix.length));
|
|
367
|
+
if (base && !exists(base))
|
|
368
|
+
warnings.push(`${f.rel} imports "${spec}", which the registry doesn't provide — create it or adjust the import.`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const missing = (list) => [...list].filter((d) => !project.installed.has(packageName(d))).sort();
|
|
372
|
+
const dependencies = missing(deps).map(withKnownMajor);
|
|
373
|
+
const devDependencies = missing(devDeps).filter((d) => !deps.has(d)).map(withKnownMajor);
|
|
374
|
+
return { files, dependencies, devDependencies, warnings: [...new Set(warnings)] };
|
|
375
|
+
}
|
|
376
|
+
export async function writePlan(plan, onConflict) {
|
|
377
|
+
const result = { written: [], skipped: [], unchanged: [] };
|
|
378
|
+
for (const f of plan.files) {
|
|
379
|
+
if (f.status === 'identical') {
|
|
380
|
+
result.unchanged.push(f);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (f.status === 'modified' && f.kind === 'file') {
|
|
384
|
+
const decision = await onConflict(f);
|
|
385
|
+
if (decision === 'skip') {
|
|
386
|
+
result.skipped.push(f);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
fs.mkdirSync(path.dirname(f.abs), { recursive: true });
|
|
391
|
+
fs.writeFileSync(f.abs, f.content);
|
|
392
|
+
result.written.push(f);
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
export function installCommand(pm, deps, dev = false) {
|
|
397
|
+
const verb = pm === 'npm' ? 'install' : 'add';
|
|
398
|
+
const devFlag = dev ? (pm === 'npm' ? '--save-dev' : '-D') : [];
|
|
399
|
+
return [pm, verb, ...[].concat(devFlag), ...deps];
|
|
400
|
+
}
|
|
401
|
+
export function runCommand(cmd, cwd) {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const child = spawn(cmd[0], cmd.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
|
|
404
|
+
let stderr = '';
|
|
405
|
+
child.stderr?.on('data', (d) => (stderr += d));
|
|
406
|
+
child.stdout?.on('data', () => { });
|
|
407
|
+
child.on('error', reject);
|
|
408
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd.join(' ')} exited with ${code}\n${stderr.slice(-2000)}`))));
|
|
409
|
+
});
|
|
410
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Aliases } from '../core/transform.js';
|
|
2
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
|
3
|
+
export interface ProjectConfig {
|
|
4
|
+
cwd: string;
|
|
5
|
+
/** `cwd/src` when the project uses a src directory, else `cwd`. */
|
|
6
|
+
baseDir: string;
|
|
7
|
+
hasSrc: boolean;
|
|
8
|
+
/** Import specifiers used in written code. */
|
|
9
|
+
aliases: Aliases;
|
|
10
|
+
/** Absolute directories the aliases point to. */
|
|
11
|
+
dirs: {
|
|
12
|
+
components: string;
|
|
13
|
+
ui: string;
|
|
14
|
+
lib: string;
|
|
15
|
+
hooks: string;
|
|
16
|
+
};
|
|
17
|
+
utilsFile: string;
|
|
18
|
+
/** Prefix (`@/`) mapped to baseDir, or null when imports must be relative. */
|
|
19
|
+
aliasPrefix: string | null;
|
|
20
|
+
tailwind: {
|
|
21
|
+
version: 3 | 4 | null;
|
|
22
|
+
css: string | null;
|
|
23
|
+
};
|
|
24
|
+
packageManager: PackageManager;
|
|
25
|
+
rsc: boolean;
|
|
26
|
+
/** Packages already declared in package.json. */
|
|
27
|
+
installed: Set<string>;
|
|
28
|
+
hasPackageJson: boolean;
|
|
29
|
+
hasComponentsJson: boolean;
|
|
30
|
+
typescript: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Parse JSON with comments and trailing commas (tsconfig / jsconfig). */
|
|
33
|
+
export declare function parseJsonc(text: string): any;
|
|
34
|
+
export declare function detectProject(cwd: string): ProjectConfig;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Detect the user's project layout: aliases, directories, Tailwind, package manager.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_ALIASES } from '../core/transform.js';
|
|
5
|
+
/** Parse JSON with comments and trailing commas (tsconfig / jsconfig). */
|
|
6
|
+
export function parseJsonc(text) {
|
|
7
|
+
let out = '';
|
|
8
|
+
let inStr = false;
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
const ch = text[i];
|
|
11
|
+
if (inStr) {
|
|
12
|
+
out += ch;
|
|
13
|
+
if (ch === '\\') {
|
|
14
|
+
out += text[++i] ?? '';
|
|
15
|
+
}
|
|
16
|
+
else if (ch === '"')
|
|
17
|
+
inStr = false;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (ch === '"') {
|
|
21
|
+
inStr = true;
|
|
22
|
+
out += ch;
|
|
23
|
+
}
|
|
24
|
+
else if (ch === '/' && text[i + 1] === '/') {
|
|
25
|
+
while (i < text.length && text[i] !== '\n')
|
|
26
|
+
i++;
|
|
27
|
+
out += '\n';
|
|
28
|
+
}
|
|
29
|
+
else if (ch === '/' && text[i + 1] === '*') {
|
|
30
|
+
const end = text.indexOf('*/', i + 2);
|
|
31
|
+
i = end === -1 ? text.length : end + 1;
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
out += ch;
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
37
|
+
}
|
|
38
|
+
function readJsonSafe(file, jsonc = false) {
|
|
39
|
+
try {
|
|
40
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
41
|
+
return jsonc ? parseJsonc(text) : JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function readTsPaths(cwd) {
|
|
48
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
49
|
+
let file = path.join(cwd, name);
|
|
50
|
+
// Follow up to 3 levels of relative `extends`.
|
|
51
|
+
for (let depth = 0; depth < 4 && fs.existsSync(file); depth++) {
|
|
52
|
+
const cfg = readJsonSafe(file, true);
|
|
53
|
+
if (!cfg)
|
|
54
|
+
break;
|
|
55
|
+
const co = cfg.compilerOptions ?? {};
|
|
56
|
+
if (co.paths)
|
|
57
|
+
return { baseUrl: path.resolve(path.dirname(file), co.baseUrl ?? '.'), paths: co.paths };
|
|
58
|
+
if (typeof cfg.extends === 'string' && cfg.extends.startsWith('.')) {
|
|
59
|
+
file = path.resolve(path.dirname(file), cfg.extends);
|
|
60
|
+
if (!file.endsWith('.json'))
|
|
61
|
+
file += '.json';
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Resolve an import alias like `@/components` to an absolute directory using tsconfig paths. */
|
|
70
|
+
function aliasToDir(alias, ts, fallbackBase) {
|
|
71
|
+
if (ts) {
|
|
72
|
+
for (const [key, targets] of Object.entries(ts.paths)) {
|
|
73
|
+
if (!targets?.length)
|
|
74
|
+
continue;
|
|
75
|
+
if (key.endsWith('/*')) {
|
|
76
|
+
const prefix = key.slice(0, -1); // `@/`
|
|
77
|
+
if (alias.startsWith(prefix))
|
|
78
|
+
return path.resolve(ts.baseUrl, targets[0].replace(/\*$/, ''), alias.slice(prefix.length));
|
|
79
|
+
}
|
|
80
|
+
else if (key === alias)
|
|
81
|
+
return path.resolve(ts.baseUrl, targets[0]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return path.join(fallbackBase, alias.replace(/^[@~#$][^/]*\//, ''));
|
|
85
|
+
}
|
|
86
|
+
function detectPackageManager(cwd) {
|
|
87
|
+
let dir = cwd;
|
|
88
|
+
for (let i = 0; i < 5; i++) {
|
|
89
|
+
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')))
|
|
90
|
+
return 'pnpm';
|
|
91
|
+
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock')))
|
|
92
|
+
return 'bun';
|
|
93
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock')))
|
|
94
|
+
return 'yarn';
|
|
95
|
+
if (fs.existsSync(path.join(dir, 'package-lock.json')))
|
|
96
|
+
return 'npm';
|
|
97
|
+
const parent = path.dirname(dir);
|
|
98
|
+
if (parent === dir)
|
|
99
|
+
break;
|
|
100
|
+
dir = parent;
|
|
101
|
+
}
|
|
102
|
+
const ua = process.env.npm_config_user_agent ?? '';
|
|
103
|
+
if (ua.startsWith('pnpm'))
|
|
104
|
+
return 'pnpm';
|
|
105
|
+
if (ua.startsWith('yarn'))
|
|
106
|
+
return 'yarn';
|
|
107
|
+
if (ua.startsWith('bun'))
|
|
108
|
+
return 'bun';
|
|
109
|
+
return 'npm';
|
|
110
|
+
}
|
|
111
|
+
const CSS_CANDIDATES = [
|
|
112
|
+
'app/globals.css',
|
|
113
|
+
'src/app/globals.css',
|
|
114
|
+
'styles/globals.css',
|
|
115
|
+
'src/styles/globals.css',
|
|
116
|
+
'app/global.css',
|
|
117
|
+
'src/app/global.css',
|
|
118
|
+
'src/index.css',
|
|
119
|
+
'src/styles.css',
|
|
120
|
+
'src/global.css',
|
|
121
|
+
'src/globals.css',
|
|
122
|
+
'app/app.css',
|
|
123
|
+
'src/app.css',
|
|
124
|
+
'app/styles/globals.css',
|
|
125
|
+
'styles/global.css',
|
|
126
|
+
'index.css',
|
|
127
|
+
];
|
|
128
|
+
export function detectProject(cwd) {
|
|
129
|
+
cwd = path.resolve(cwd);
|
|
130
|
+
const pkg = readJsonSafe(path.join(cwd, 'package.json')) ?? {};
|
|
131
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
|
|
132
|
+
const installed = new Set(Object.keys(allDeps));
|
|
133
|
+
const components = readJsonSafe(path.join(cwd, 'components.json'));
|
|
134
|
+
const ts = readTsPaths(cwd);
|
|
135
|
+
const hasSrc = fs.existsSync(path.join(cwd, 'src')) && fs.statSync(path.join(cwd, 'src')).isDirectory();
|
|
136
|
+
const baseDir = hasSrc ? path.join(cwd, 'src') : cwd;
|
|
137
|
+
// Find a wildcard alias that maps to baseDir (`@/*` -> `./src/*`).
|
|
138
|
+
let aliasPrefix = null;
|
|
139
|
+
if (ts) {
|
|
140
|
+
for (const [key, targets] of Object.entries(ts.paths)) {
|
|
141
|
+
if (!key.endsWith('/*') || !targets?.[0])
|
|
142
|
+
continue;
|
|
143
|
+
const dir = path.resolve(ts.baseUrl, targets[0].replace(/\*$/, ''));
|
|
144
|
+
if (dir === baseDir || (dir === cwd && !hasSrc)) {
|
|
145
|
+
aliasPrefix = key.slice(0, -1);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!aliasPrefix && ts.paths['@/*'])
|
|
150
|
+
aliasPrefix = '@/';
|
|
151
|
+
}
|
|
152
|
+
let aliases;
|
|
153
|
+
if (components?.aliases) {
|
|
154
|
+
const a = components.aliases;
|
|
155
|
+
const comp = a.components ?? '@/components';
|
|
156
|
+
const prefix = comp.match(/^([^/]+\/)/)?.[1] ?? '@/';
|
|
157
|
+
aliases = {
|
|
158
|
+
components: comp,
|
|
159
|
+
ui: a.ui ?? `${comp}/ui`,
|
|
160
|
+
utils: a.utils ?? `${prefix}lib/utils`,
|
|
161
|
+
lib: a.lib ?? `${prefix}lib`,
|
|
162
|
+
hooks: a.hooks ?? `${prefix}hooks`,
|
|
163
|
+
};
|
|
164
|
+
if (!aliasPrefix)
|
|
165
|
+
aliasPrefix = prefix;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const p = aliasPrefix ?? '@/';
|
|
169
|
+
aliases = {
|
|
170
|
+
components: `${p}components`,
|
|
171
|
+
ui: `${p}components/ui`,
|
|
172
|
+
utils: `${p}lib/utils`,
|
|
173
|
+
lib: `${p}lib`,
|
|
174
|
+
hooks: `${p}hooks`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (!aliasPrefix && !components)
|
|
178
|
+
aliases = { ...DEFAULT_ALIASES };
|
|
179
|
+
const dirs = {
|
|
180
|
+
components: aliasToDir(aliases.components, ts, baseDir),
|
|
181
|
+
ui: aliasToDir(aliases.ui, ts, baseDir),
|
|
182
|
+
lib: aliasToDir(aliases.lib, ts, baseDir),
|
|
183
|
+
hooks: aliasToDir(aliases.hooks, ts, baseDir),
|
|
184
|
+
};
|
|
185
|
+
const utilsBase = aliasToDir(aliases.utils, ts, baseDir);
|
|
186
|
+
const utilsFile = ['.ts', '.tsx', '.js', '.jsx'].map((e) => utilsBase + e).find((f) => fs.existsSync(f)) ?? `${utilsBase}.ts`;
|
|
187
|
+
// Tailwind
|
|
188
|
+
let cssFile = components?.tailwind?.css ? path.resolve(cwd, components.tailwind.css) : null;
|
|
189
|
+
if (cssFile && !fs.existsSync(cssFile))
|
|
190
|
+
cssFile = null;
|
|
191
|
+
if (!cssFile)
|
|
192
|
+
cssFile = CSS_CANDIDATES.map((c) => path.join(cwd, c)).find((f) => fs.existsSync(f)) ?? null;
|
|
193
|
+
let version = null;
|
|
194
|
+
const twRange = allDeps.tailwindcss;
|
|
195
|
+
if (twRange) {
|
|
196
|
+
const m = twRange.match(/(\d+)/);
|
|
197
|
+
if (m)
|
|
198
|
+
version = Number(m[1]) >= 4 ? 4 : 3;
|
|
199
|
+
if (/latest|next/.test(twRange))
|
|
200
|
+
version = 4;
|
|
201
|
+
}
|
|
202
|
+
if (cssFile) {
|
|
203
|
+
const text = fs.readFileSync(cssFile, 'utf8');
|
|
204
|
+
if (/@import\s+["']tailwindcss/.test(text))
|
|
205
|
+
version = 4;
|
|
206
|
+
else if (!version && /@tailwind\s+base/.test(text))
|
|
207
|
+
version = 3;
|
|
208
|
+
}
|
|
209
|
+
if (!version && components?.tailwind?.config)
|
|
210
|
+
version = 3;
|
|
211
|
+
return {
|
|
212
|
+
cwd,
|
|
213
|
+
baseDir,
|
|
214
|
+
hasSrc,
|
|
215
|
+
aliases,
|
|
216
|
+
dirs,
|
|
217
|
+
utilsFile,
|
|
218
|
+
aliasPrefix,
|
|
219
|
+
tailwind: { version, css: cssFile },
|
|
220
|
+
packageManager: detectPackageManager(cwd),
|
|
221
|
+
rsc: components ? !!components.rsc : installed.has('next'),
|
|
222
|
+
installed,
|
|
223
|
+
hasPackageJson: fs.existsSync(path.join(cwd, 'package.json')),
|
|
224
|
+
hasComponentsJson: !!components,
|
|
225
|
+
typescript: !!ts || fs.existsSync(path.join(cwd, 'tsconfig.json')) || installed.has('typescript'),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Registry } from '../registry-types.js';
|
|
2
|
+
export declare const REGISTRY_URL = "https://raw.githubusercontent.com/yashjaiswal2818/getui/main/registry/index.json";
|
|
3
|
+
export declare function cacheFile(): string;
|
|
4
|
+
export interface LoadedRegistry {
|
|
5
|
+
registry: Registry;
|
|
6
|
+
source: 'remote' | 'cache' | 'bundled' | 'override';
|
|
7
|
+
}
|
|
8
|
+
export declare function loadRegistry(opts?: {
|
|
9
|
+
refresh?: boolean;
|
|
10
|
+
}): Promise<LoadedRegistry>;
|