@brust/core 2.0.0-alpha.15 → 2.0.0-alpha.16
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/native/index.d.ts +17 -1
- package/native/index.js +55 -54
- package/package.json +11 -9
- package/src/build/bundle.ts +54 -10
- package/src/build/cloudflare.ts +5 -2
- package/src/build/compile.ts +29 -2
- package/src/build/config.ts +43 -5
- package/src/build/esm-resolve.ts +137 -10
- package/src/build/index.ts +45 -6
- package/src/build/pkg-src.ts +5 -2
- package/src/build/scan.ts +7 -2
- package/src/cli.ts +2 -1
- package/src/dev/bun.ts +6 -1
- package/src/dev/watch.ts +49 -8
- package/src/dev.ts +5 -1
- package/src/shadcn/init.ts +275 -16
- package/src/shadcn/registry.ts +149 -0
package/src/dev.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// process: a failed build keeps the old wrangler, and module caches never leak between builds.
|
|
4
4
|
import { join, relative, resolve } from 'node:path'
|
|
5
5
|
import { BuildError } from './build/errors'
|
|
6
|
-
import {
|
|
6
|
+
import { loadBuildConfig } from './build/config'
|
|
7
|
+
import { broadRootWarnings, defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
|
|
7
8
|
|
|
8
9
|
export interface DevOptions {
|
|
9
10
|
appRoot: string
|
|
@@ -65,8 +66,11 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// The shared watch/build loop (dev/watch.ts): one rebuild at a time, edits during a rebuild are kept for the next.
|
|
69
|
+
const extraRoots = await loadBuildConfig(appRoot).then((c) => c.sourceRoots.map((r) => resolve(appRoot, r)), () => [] as string[])
|
|
70
|
+
for (const w of broadRootWarnings(extraRoots, appRoot)) o.log(w)
|
|
68
71
|
const loop: WatchLoop = watchLoop({
|
|
69
72
|
root: appRoot,
|
|
73
|
+
extraRoots,
|
|
70
74
|
ignored: (rel) => defaultIgnored(rel, relative(appRoot, dist)),
|
|
71
75
|
debounceMs: DEBOUNCE_MS,
|
|
72
76
|
async run() {
|
package/src/shadcn/init.ts
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
// shadcn's own `init` does not detect brust, so this writes what `add` needs (components.json, the `@/*` path),
|
|
3
3
|
// installs the four packages the components import, and runs ONE pinned shadcn CLI for the theme and the components.
|
|
4
4
|
// Every child process is spawned from an argv array, never through a shell; the runner is injected for tests.
|
|
5
|
-
import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
-
import {
|
|
5
|
+
import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
8
|
+
import { createMatchPath } from 'tsconfig-paths'
|
|
9
|
+
import { configLoader } from 'tsconfig-paths/lib/config-loader'
|
|
10
|
+
import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'
|
|
7
11
|
import { type ObjectNode, raw, readObject } from './jsonc'
|
|
12
|
+
import { fetchRegistryItem, vetItem } from './registry'
|
|
8
13
|
|
|
9
14
|
/** The shadcn CLI version this command runs (R41.6). A release that moves it re-runs the transcript. */
|
|
10
15
|
export const SHADCN_VERSION = '4.21.4'
|
|
@@ -35,6 +40,8 @@ export interface InitOptions {
|
|
|
35
40
|
shadcnVersion?: string
|
|
36
41
|
run: Runner
|
|
37
42
|
out: (line: string) => void
|
|
43
|
+
/** F228: fetches the JSON of a registry URL item (default: `fetchRegistryItem`, https only); injected for tests. */
|
|
44
|
+
fetchItem?: (url: string) => Promise<string>
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
const COMPONENT = /^[a-z0-9][a-z0-9-]*$/
|
|
@@ -51,6 +58,185 @@ function isLink(path: string): boolean {
|
|
|
51
58
|
}
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
/** True when `rel` (project-relative), or any directory on the way to it, is a symlink. */
|
|
62
|
+
function linkUnder(root: string, rel: string): boolean {
|
|
63
|
+
let cur = root
|
|
64
|
+
for (const seg of rel.split('/').filter(Boolean)) {
|
|
65
|
+
cur = join(cur, seg)
|
|
66
|
+
if (isLink(cur)) return true
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type PathsMap = Record<string, string[]>
|
|
72
|
+
|
|
73
|
+
function readJsonc(file: string): { text: string; top: ObjectNode } | { error: string } {
|
|
74
|
+
let text: string
|
|
75
|
+
try {
|
|
76
|
+
text = readFileSync(file, 'utf8')
|
|
77
|
+
} catch {
|
|
78
|
+
return { error: `cannot read ${file}` }
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return { text, top: readObject(text) }
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { error: `cannot parse ${file} (${(e as Error).message})` }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The `tsconfig-paths` version the pinned shadcn CLI resolves its aliases with (`shadcn@4.21.4` → `@shadcn/registry`
|
|
88
|
+
* 0.1.3 → `tsconfig-paths ^4.2.0`, read from its lockfile and source). It is a dependency of this package at exactly this
|
|
89
|
+
* version, and it moves together with `SHADCN_VERSION`: a release that moves the CLI re-checks the resolver. */
|
|
90
|
+
export const TSCONFIG_PATHS_VERSION = '4.2.0'
|
|
91
|
+
|
|
92
|
+
/** What the shadcn CLI sees of the tsconfig: it calls `tsconfig-paths` `loadConfig(cwd)` and then
|
|
93
|
+
* `createMatchPath(absoluteBaseUrl, paths)` for each alias. This asks the same code, so the `extends` rules (the paths
|
|
94
|
+
* base is the directory of the TOP file, an inherited `baseUrl` is rebased by the extends specifier string, a package
|
|
95
|
+
* config is looked up only at `<dir>/node_modules/<spec>`, an unresolved parent counts as empty) are its rules, not
|
|
96
|
+
* TypeScript's (F226, Mellow's review). `inherited` is true when the `paths` come from a parent file. */
|
|
97
|
+
interface TsInfo {
|
|
98
|
+
absoluteBaseUrl: string
|
|
99
|
+
/** Whether the chain sets a `baseUrl` (then a bare alias like `components/ui` is TypeScript-legal). */
|
|
100
|
+
hasBaseUrl: boolean
|
|
101
|
+
paths: PathsMap
|
|
102
|
+
inherited: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Undefined when `tsconfig-paths` finds no config at all (the CLI then fails by itself before writing anything). An
|
|
106
|
+
* error when it throws (a parent that is not JSON, a cycle, a non-string `extends`): the CLI would throw the same way. */
|
|
107
|
+
function tsconfigInfo(root: string, file: string): TsInfo | { error: string } | undefined {
|
|
108
|
+
let cfg: ReturnType<typeof configLoader>
|
|
109
|
+
try {
|
|
110
|
+
// `loadConfig(root)` with `TS_NODE_PROJECT` / `TS_NODE_BASEURL` ignored: the spawned CLI does not get them either (F253)
|
|
111
|
+
cfg = configLoader({ cwd: root, tsConfigLoader: (p) => tsConfigLoader({ ...p, getEnv: () => undefined }) })
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return { error: `the shadcn CLI cannot read the tsconfig chain of ${file} (${(e as Error).message})` }
|
|
114
|
+
}
|
|
115
|
+
if (cfg.resultType !== 'success') return undefined
|
|
116
|
+
const own = existsSync(file) ? readJsonc(file) : undefined
|
|
117
|
+
const ownPaths = own && !('error' in own) ? own.top.members.find((m) => m.key === 'compilerOptions')?.object?.members.some((m) => m.key === 'paths') : undefined
|
|
118
|
+
const paths: PathsMap = {}
|
|
119
|
+
for (const [k, v] of Object.entries(cfg.paths ?? {})) paths[k] = Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
|
|
120
|
+
return { absoluteBaseUrl: cfg.absoluteBaseUrl, hasBaseUrl: cfg.baseUrl !== undefined, paths, inherited: !ownPaths && Object.keys(paths).length > 0 }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The path the CLI writes an alias to, the way it resolves one (`resolveAlias` in `@shadcn/registry`: `createMatchPath`
|
|
124
|
+
* over the tsconfig, first candidate), as a project-relative path; or `{ error }` when it cannot be judged here:
|
|
125
|
+
* a `#` import (package.json `imports`), nothing matches, or the result is outside the project. A package-shaped alias
|
|
126
|
+
* (`@scope/name`) is refused (F251), and so is any alias that is not `@/...` or `~/...` (F261). */
|
|
127
|
+
function resolveAlias(alias: string, root: string, ts: { absoluteBaseUrl: string; hasBaseUrl: boolean; paths: PathsMap }): { rel: string } | { why: string } {
|
|
128
|
+
if (alias.startsWith('#')) return { why: 'is a package.json "imports" alias, which cannot be checked here' }
|
|
129
|
+
// F251: the CLI tries npm workspace packages (a `workspaces` root above the project, a package named like the alias)
|
|
130
|
+
// BEFORE the tsconfig, and that route is not modelled here. A package-shaped alias (`@scope/name...`) could land there,
|
|
131
|
+
// so it is refused whether or not a `paths` entry names it; the default `@/...` aliases are not package-shaped.
|
|
132
|
+
if (/^@[^/]+\/[^/]+(?:\/.*)?$/.test(alias)) return { why: 'looks like a package name (`@scope/name`); the shadcn CLI tries npm workspace packages before the tsconfig "paths", which cannot be checked here. Use an alias like "@/components" or "~/components"' }
|
|
133
|
+
// F261: the same workspace route takes the FIRST path segment of ANY alias as a package name, before the tsconfig and
|
|
134
|
+
// whatever its `paths` say, so even an exact `paths` key like `ui` is written through a workspace package named `ui`.
|
|
135
|
+
// Only `@/...` and `~/...` cannot be a package name (`@/components` and `~` are not valid ones): all else is refused.
|
|
136
|
+
if (!alias.startsWith('@/') && !alias.startsWith('~/')) {
|
|
137
|
+
return { why: 'is not "@/..." or "~/..."; the shadcn CLI takes the first segment of any other alias for an npm workspace package name and tries workspace packages before the tsconfig "paths" (an exact "paths" key does not change that), which cannot be checked here. Rename the alias to "@/..." or "~/..."' }
|
|
138
|
+
}
|
|
139
|
+
const named = Object.keys(ts.paths).some((k) => (k.includes('*') ? alias.startsWith(k.slice(0, k.indexOf('*'))) && alias.endsWith(k.slice(k.indexOf('*') + 1)) && alias.length >= k.length - 1 : k === alias))
|
|
140
|
+
// `createMatchPath` maps any alias under the base directory (it adds a match-all); TypeScript does that only with a
|
|
141
|
+
// `baseUrl`. An alias no `paths` key names and no `baseUrl` covers is a misconfiguration: refused, not written to `./<alias>`.
|
|
142
|
+
if (!named && !ts.hasBaseUrl) return { why: 'does not resolve by the tsconfig "paths" (no entry names it and there is no "baseUrl")' }
|
|
143
|
+
let hit: string | undefined
|
|
144
|
+
try {
|
|
145
|
+
hit = createMatchPath(ts.absoluteBaseUrl, ts.paths)(alias, undefined, () => true, ['.ts', '.tsx', '.jsx', '.js', '.css'])
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return { why: `cannot be resolved by tsconfig-paths (${(e as Error).message})` }
|
|
148
|
+
}
|
|
149
|
+
if (hit === undefined) return { why: 'does not resolve by the tsconfig "paths"' }
|
|
150
|
+
const rel = relative(root, resolve(hit))
|
|
151
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { why: `resolves to ${hit}, which is outside the project` }
|
|
152
|
+
return { rel: rel.split(sep).join('/') }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** F193: every path the shadcn CLI may write, project-relative, or an error. The CLI follows symlinks, so the caller
|
|
156
|
+
* refuses when one is, or sits under, a symlink. Fails closed: an alias that cannot be resolved by the tsconfig `paths`
|
|
157
|
+
* (read through `extends`, F226; `@/*` → `./*` is assumed only when no `@/*` is there and none is inherited: it is what
|
|
158
|
+
* this command adds) is an error, not a skipped check. */
|
|
159
|
+
function cliTargets(
|
|
160
|
+
componentsText: string | undefined,
|
|
161
|
+
ts: TsInfo | undefined,
|
|
162
|
+
css: string | undefined,
|
|
163
|
+
components: string[],
|
|
164
|
+
opts: { root: string },
|
|
165
|
+
): { paths: string[]; dirs: string[] } | { error: string } {
|
|
166
|
+
let aliases: Record<string, string> = { components: '@/components', utils: '@/lib/utils', ui: '@/components/ui', lib: '@/lib', hooks: '@/hooks' }
|
|
167
|
+
if (componentsText !== undefined) {
|
|
168
|
+
try {
|
|
169
|
+
const a = (JSON.parse(componentsText) as { aliases?: unknown }).aliases
|
|
170
|
+
if (a !== undefined) {
|
|
171
|
+
if (typeof a !== 'object' || a === null || Array.isArray(a)) return { error: 'components.json "aliases" is not an object' }
|
|
172
|
+
aliases = {}
|
|
173
|
+
for (const [k, v] of Object.entries(a)) {
|
|
174
|
+
if (typeof v !== 'string') return { error: `components.json alias ${JSON.stringify(k)} is not a string` }
|
|
175
|
+
aliases[k] = v
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
return { error: 'components.json is not valid JSON, so the shadcn CLI target paths cannot be checked' }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const root = opts.root
|
|
183
|
+
// With no tsconfig the command adds `paths: { "@/*": ["./*"] }` to nothing (the CLI fails by itself). `@/*` → `./*` is the
|
|
184
|
+
// mapping this command adds when the tsconfig has no `@/*` of its own. When the `paths` are inherited through `extends`
|
|
185
|
+
// nothing is added (an own `paths` would replace them), so only what the chain says counts.
|
|
186
|
+
const resolver = { absoluteBaseUrl: ts?.absoluteBaseUrl ?? root, hasBaseUrl: ts?.hasBaseUrl ?? false, paths: { ...(ts?.paths ?? {}) } }
|
|
187
|
+
if (!ts?.inherited && resolver.paths['@/*'] === undefined) resolver.paths['@/*'] = ['./*']
|
|
188
|
+
const paths: string[] = []
|
|
189
|
+
const dirs: string[] = []
|
|
190
|
+
const roots: Record<string, string> = {}
|
|
191
|
+
for (const [k, v] of Object.entries(aliases)) {
|
|
192
|
+
const r = resolveAlias(v, root, resolver)
|
|
193
|
+
if ('why' in r) return { error: `alias ${JSON.stringify(k)} (${JSON.stringify(v)}) in components.json ${r.why}; the shadcn CLI target cannot be checked` }
|
|
194
|
+
const rel = r.rel
|
|
195
|
+
roots[k] = rel
|
|
196
|
+
if (k === 'utils') paths.push(rel, `${rel}.ts`)
|
|
197
|
+
else {
|
|
198
|
+
paths.push(rel)
|
|
199
|
+
dirs.push(rel)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const ui = roots.ui ?? 'components/ui'
|
|
203
|
+
return { paths: [...paths, 'package.json', ...(css === undefined ? [] : [css]), ...components.filter((c) => COMPONENT.test(c)).map((c) => `${ui}/${c}.tsx`)], dirs }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** F227: the most entries `cliLink` looks at under the alias directories before it refuses (no fixed depth: a link at
|
|
207
|
+
* any depth is found; a tree this big is not a components directory). */
|
|
208
|
+
export const SCAN_LIMIT = 10_000
|
|
209
|
+
|
|
210
|
+
/** Where a path or directory entry the CLI could write through is a symlink (`link`), or the directory walk hit its
|
|
211
|
+
* entry bound (`tooMany`); undefined when clean. */
|
|
212
|
+
function cliLink(root: string, t: { paths: string[]; dirs: string[] }): { link: string } | { tooMany: string } | undefined {
|
|
213
|
+
const hit = t.paths.find((p) => linkUnder(root, p))
|
|
214
|
+
if (hit !== undefined) return { link: hit }
|
|
215
|
+
// dependency files of a registry item (hooks, lib helpers) land in these directories: no entry may be a link
|
|
216
|
+
let seen = 0
|
|
217
|
+
const scan = (rel: string): { link: string } | { tooMany: string } | undefined => {
|
|
218
|
+
let names: string[]
|
|
219
|
+
try {
|
|
220
|
+
names = readdirSync(join(root, rel))
|
|
221
|
+
} catch {
|
|
222
|
+
return undefined // not a directory, or it does not exist yet (the CLI creates it): nothing to follow
|
|
223
|
+
}
|
|
224
|
+
for (const e of names) {
|
|
225
|
+
const sub = `${rel}/${e}`
|
|
226
|
+
if (++seen > SCAN_LIMIT) return { tooMany: rel }
|
|
227
|
+
if (isLink(join(root, sub))) return { link: sub }
|
|
228
|
+
const found = scan(sub)
|
|
229
|
+
if (found !== undefined) return found
|
|
230
|
+
}
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
for (const d of t.dirs) {
|
|
234
|
+
const found = scan(d)
|
|
235
|
+
if (found !== undefined) return found
|
|
236
|
+
}
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
54
240
|
/** The name a registry URL installs: its last path segment without `.json`; undefined when it is not a plain name. */
|
|
55
241
|
function urlItemName(url: string): string | undefined {
|
|
56
242
|
try {
|
|
@@ -83,6 +269,15 @@ export type PathEdit =
|
|
|
83
269
|
|
|
84
270
|
const ENTRY = '"@/*": ["./*"]'
|
|
85
271
|
|
|
272
|
+
/** F226: the `paths` come from an extended file, so the file is not edited (an own `paths` would replace them whole):
|
|
273
|
+
* the inherited `@/*` is judged like an own one. */
|
|
274
|
+
function inheritedEdit(root: string, ts: TsInfo): PathEdit {
|
|
275
|
+
const hit = ts.paths['@/*']
|
|
276
|
+
if (!hit) return { kind: 'unparseable', why: 'its "paths" are inherited through "extends" and have no "@/*"; an own "paths" would replace them' }
|
|
277
|
+
const r = resolveAlias('@/x', root, { absoluteBaseUrl: ts.absoluteBaseUrl, hasBaseUrl: ts.hasBaseUrl, paths: { '@/*': hit } })
|
|
278
|
+
return 'rel' in r && r.rel === 'x' ? { kind: 'present' } : { kind: 'other', target: JSON.stringify(hit) }
|
|
279
|
+
}
|
|
280
|
+
|
|
86
281
|
/** The separator before a non-empty object's first member, so the new entry takes the same line and indent. */
|
|
87
282
|
function lead(text: string, o: ObjectNode): string {
|
|
88
283
|
const first = o.members[0]
|
|
@@ -175,8 +370,57 @@ export async function shadcnInit(o: InitOptions): Promise<number> {
|
|
|
175
370
|
// refuse before the first write, even with --force; the reader edits the real file by hand.
|
|
176
371
|
const cj = join(root, 'components.json')
|
|
177
372
|
const tsPath = join(root, 'tsconfig.json')
|
|
178
|
-
|
|
373
|
+
// F192: a symlinked tsconfig.json is only a problem when the command would edit it (the `@/*` path is missing).
|
|
374
|
+
// F226: the `paths` the CLI will see are the ones of the whole `extends` chain; a chain that cannot be read is refused.
|
|
375
|
+
const ts = tsconfigInfo(root, tsPath)
|
|
376
|
+
if (ts && 'error' in ts) return fail2(`${ts.error}; fix the tsconfig.json chain, then run again`)
|
|
377
|
+
const tsEdit = existsSync(tsPath) ? (ts?.inherited ? inheritedEdit(root, ts) : addAliasPath(readFileSync(tsPath, 'utf8'))) : undefined
|
|
378
|
+
if (isLink(tsPath) && tsEdit?.kind === 'added') return fail2('tsconfig.json is a symbolic link; refusing to write through it. Add "@/*": ["./*"] to compilerOptions.paths of the real file yourself, then run again')
|
|
179
379
|
if (isLink(cj) && (!existsSync(cj) || o.force)) return fail2('components.json is a symbolic link; refusing to write through it. Replace it with a real file or remove the link, then run again')
|
|
380
|
+
// F193: the shadcn CLI writes files itself (the theme, `lib/utils.ts`, each component, the CSS entry). It follows
|
|
381
|
+
// links, so refuse before spawning it when one of its target paths is, or sits under, a symlink.
|
|
382
|
+
const writing = !existsSync(cj) || o.force
|
|
383
|
+
const themeRuns = o.force || !readFileSync(join(root, entry.css), 'utf8').includes('shadcn/tailwind.css')
|
|
384
|
+
const targets = cliTargets(
|
|
385
|
+
writing ? undefined : readFileSync(cj, 'utf8'),
|
|
386
|
+
ts,
|
|
387
|
+
themeRuns ? entry.css : undefined,
|
|
388
|
+
o.components,
|
|
389
|
+
{ root },
|
|
390
|
+
)
|
|
391
|
+
if ('error' in targets) return fail2(targets.error)
|
|
392
|
+
const hit = cliLink(root, targets)
|
|
393
|
+
if (hit && 'link' in hit) return fail2(`${hit.link} is, or is inside, a symbolic link; the shadcn CLI would write through it. Replace the link with a real directory or file, then run again`)
|
|
394
|
+
if (hit) return fail2(`${hit.tooMany} holds more than ${SCAN_LIMIT} entries, so it cannot be checked for symbolic links before the shadcn CLI writes into it; point the alias at a smaller directory, then run again`)
|
|
395
|
+
|
|
396
|
+
// A component whose file is already in components/ui is not added again (idempotent). A registry URL is judged by
|
|
397
|
+
// the item name in its path (`.../acme-card.json` → `acme-card`), looked up in components/ui and components.
|
|
398
|
+
const present = (c: string): boolean => {
|
|
399
|
+
const name = REGISTRY_URL.test(c) ? urlItemName(c) : c
|
|
400
|
+
if (name === undefined) return false
|
|
401
|
+
return existsSync(join(root, 'components/ui', `${name}.tsx`)) || (name !== c && existsSync(join(root, 'components', `${name}.tsx`)))
|
|
402
|
+
}
|
|
403
|
+
const todo = o.components.filter((c) => o.force || !present(c))
|
|
404
|
+
|
|
405
|
+
// F228: a registry URL item declares its own file targets, known only once it is fetched. Fetch it now, before the CLI
|
|
406
|
+
// runs, and refuse when a target is outside the project or is, or sits under, a symlink. The CLI is then given the file
|
|
407
|
+
// checked here (not the URL), so the item cannot change between the check and the write.
|
|
408
|
+
const fetchItem = o.fetchItem ?? ((url: string) => fetchRegistryItem(url))
|
|
409
|
+
const vetted = new Map<string, string>()
|
|
410
|
+
for (const c of todo) {
|
|
411
|
+
if (!REGISTRY_URL.test(c) || vetted.has(c)) continue
|
|
412
|
+
let text: string
|
|
413
|
+
try {
|
|
414
|
+
text = await fetchItem(c)
|
|
415
|
+
} catch (e) {
|
|
416
|
+
return fail2(`registry item ${c} was not fetched (${(e as Error).message}), so its file targets cannot be checked; nothing was added`)
|
|
417
|
+
}
|
|
418
|
+
const v = vetItem(text, entry.css)
|
|
419
|
+
if ('error' in v) return fail2(`registry item ${c} ${v.error}; nothing was added`)
|
|
420
|
+
const bad = v.paths.find((p) => linkUnder(root, p))
|
|
421
|
+
if (bad !== undefined) return fail2(`registry item ${c} would write ${bad}, which is, or is inside, a symbolic link; the shadcn CLI would write through it. Replace the link with a real file or directory, then run again`)
|
|
422
|
+
vetted.set(c, text)
|
|
423
|
+
}
|
|
180
424
|
|
|
181
425
|
const report = (name: string, step: Step, why = ''): void => out(`${step === 'done' ? '✓' : step === 'skipped' ? '-' : '✗'} ${name}: ${step}${why ? ` (${why})` : ''}`)
|
|
182
426
|
const stop = (name: string, code: number): number => {
|
|
@@ -195,7 +439,7 @@ export async function shadcnInit(o: InitOptions): Promise<number> {
|
|
|
195
439
|
if (!existsSync(tsPath)) {
|
|
196
440
|
report('tsconfig.json', 'skipped', `no tsconfig.json; add ${JSON.stringify({ compilerOptions: { paths: { '@/*': ['./*'] } } })}`)
|
|
197
441
|
} else {
|
|
198
|
-
const edit =
|
|
442
|
+
const edit = tsEdit!
|
|
199
443
|
if (edit.kind === 'added') {
|
|
200
444
|
writeFileSync(tsPath, edit.text)
|
|
201
445
|
report('tsconfig.json', 'done', 'added "@/*": ["./*"] under compilerOptions.paths')
|
|
@@ -224,19 +468,24 @@ export async function shadcnInit(o: InitOptions): Promise<number> {
|
|
|
224
468
|
if (code !== 0) return stop('shadcn add index', code)
|
|
225
469
|
report('theme', 'done', `shadcn@${version} add index`)
|
|
226
470
|
}
|
|
227
|
-
// A component whose file is already in components/ui is not added again (idempotent). A registry URL is judged by
|
|
228
|
-
// the item name in its path (`.../acme-card.json` → `acme-card`), looked up in components/ui and components.
|
|
229
|
-
const present = (c: string): boolean => {
|
|
230
|
-
const name = REGISTRY_URL.test(c) ? urlItemName(c) : c
|
|
231
|
-
if (name === undefined) return false
|
|
232
|
-
return existsSync(join(root, 'components/ui', `${name}.tsx`)) || (name !== c && existsSync(join(root, 'components', `${name}.tsx`)))
|
|
233
|
-
}
|
|
234
|
-
const todo = o.components.filter((c) => o.force || !present(c))
|
|
235
471
|
for (const c of o.components) if (!todo.includes(c)) report(c, 'skipped', REGISTRY_URL.test(c) ? 'its item is already in components; --force adds it again' : 'components/ui already has it')
|
|
236
|
-
for (const c of todo) if (REGISTRY_URL.test(c)) out(`warning shadcn-init ${c} is third-party code: it is written into your project and runs in your build and in the browser. Read it before you ship`)
|
|
472
|
+
for (const c of todo) if (REGISTRY_URL.test(c)) out(`warning shadcn-init ${c} is third-party code: it is written into your project and runs in your build and in the browser. Read it before you ship. Only the item itself was checked (its file paths, and that none is a symlink); the shadcn components it names in registryDependencies and the npm packages it lists in dependencies are installed by the CLI and were not`)
|
|
237
473
|
if (todo.length > 0) {
|
|
238
|
-
|
|
239
|
-
|
|
474
|
+
// a URL item is added from the copy that was checked above
|
|
475
|
+
const dir = vetted.size > 0 ? mkdtempSync(join(tmpdir(), 'brust-shadcn-item-')) : undefined
|
|
476
|
+
try {
|
|
477
|
+
const args = todo.map((c, i) => {
|
|
478
|
+
const text = vetted.get(c)
|
|
479
|
+
if (text === undefined || dir === undefined) return c
|
|
480
|
+
const file = join(dir, `${urlItemName(c) ?? 'item'}-${i}.json`)
|
|
481
|
+
writeFileSync(file, text)
|
|
482
|
+
return file
|
|
483
|
+
})
|
|
484
|
+
const code = await o.run(shadcn('add', ...args, '-y'), root)
|
|
485
|
+
if (code !== 0) return stop(`shadcn add ${todo.join(' ')}`, code)
|
|
486
|
+
} finally {
|
|
487
|
+
if (dir !== undefined) rmSync(dir, { recursive: true, force: true })
|
|
488
|
+
}
|
|
240
489
|
for (const c of todo) report(c, 'done')
|
|
241
490
|
}
|
|
242
491
|
|
|
@@ -247,8 +496,18 @@ export async function shadcnInit(o: InitOptions): Promise<number> {
|
|
|
247
496
|
return 0
|
|
248
497
|
}
|
|
249
498
|
|
|
499
|
+
/** F253: the variables that make `tsconfig-paths` (the resolver the shadcn CLI uses) read another tsconfig than the
|
|
500
|
+
* project's. The pre-check ignores them and the spawned CLI does not get them, so both see the same file. */
|
|
501
|
+
export const RESOLVER_ENV = ['TS_NODE_PROJECT', 'TS_NODE_BASEURL'] as const
|
|
502
|
+
|
|
503
|
+
export function cliEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
504
|
+
const out = { ...env }
|
|
505
|
+
for (const k of RESOLVER_ENV) delete out[k]
|
|
506
|
+
return out
|
|
507
|
+
}
|
|
508
|
+
|
|
250
509
|
/** The real runner: argv spawn with the terminal attached, no shell. */
|
|
251
510
|
export const spawnRunner: Runner = async (argv, cwd) => {
|
|
252
|
-
const p = Bun.spawn(argv, { cwd, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
|
|
511
|
+
const p = Bun.spawn(argv, { cwd, env: cliEnv(process.env), stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
|
|
253
512
|
return await p.exited
|
|
254
513
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// F228: a registry URL given to `brust shadcn init` names a third-party item that declares its own file targets, known
|
|
2
|
+
// only once the item is fetched. This fetches the item JSON BEFORE the shadcn CLI is spawned (https only, a size cap, a
|
|
3
|
+
// timeout, redirects followed by hand and only to https) and checks every path it can make the CLI write. The command
|
|
4
|
+
// then hands the CLI the file it checked, not the URL, so the item cannot change between the check and the write.
|
|
5
|
+
|
|
6
|
+
/** The most bytes of item JSON read; a bigger answer is refused. */
|
|
7
|
+
export const ITEM_MAX_BYTES = 1024 * 1024
|
|
8
|
+
/** The whole fetch (redirects included) gets this long. */
|
|
9
|
+
export const ITEM_TIMEOUT_MS = 15_000
|
|
10
|
+
/** Redirects followed by hand; each target must be https. */
|
|
11
|
+
export const MAX_REDIRECTS = 3
|
|
12
|
+
|
|
13
|
+
export interface FetchOptions {
|
|
14
|
+
/** URL schemes allowed for the request and every redirect. Default `https:` only; a test passes `http:` for a local server. */
|
|
15
|
+
schemes?: string[]
|
|
16
|
+
maxBytes?: number
|
|
17
|
+
timeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Fetches `url` and returns the body text, or throws an `Error` whose message is the reason it was refused. */
|
|
21
|
+
export async function fetchRegistryItem(url: string, o: FetchOptions = {}): Promise<string> {
|
|
22
|
+
const schemes = o.schemes ?? ['https:']
|
|
23
|
+
const maxBytes = o.maxBytes ?? ITEM_MAX_BYTES
|
|
24
|
+
const signal = AbortSignal.timeout(o.timeoutMs ?? ITEM_TIMEOUT_MS)
|
|
25
|
+
let cur = url
|
|
26
|
+
try {
|
|
27
|
+
for (let hop = 0; ; hop++) {
|
|
28
|
+
let u: URL
|
|
29
|
+
try {
|
|
30
|
+
u = new URL(cur)
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`${cur} is not a URL`)
|
|
33
|
+
}
|
|
34
|
+
if (!schemes.includes(u.protocol)) throw new Error(`${cur} is not an ${schemes.join(' or ').replace(/:/g, '')} URL`)
|
|
35
|
+
if (u.username !== '' || u.password !== '') throw new Error(`${u.origin}${u.pathname} carries credentials in the URL`)
|
|
36
|
+
const res = await fetch(u, { redirect: 'manual', signal, headers: { accept: 'application/json' } })
|
|
37
|
+
if (res.status >= 300 && res.status < 400) {
|
|
38
|
+
const next = res.headers.get('location')
|
|
39
|
+
await res.body?.cancel()
|
|
40
|
+
if (!next) throw new Error(`${cur} answered ${res.status} with no Location`)
|
|
41
|
+
if (hop >= MAX_REDIRECTS) throw new Error(`${url} redirects more than ${MAX_REDIRECTS} times`)
|
|
42
|
+
cur = new URL(next, u).toString() // the next loop turn refuses a non-https target
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
await res.body?.cancel()
|
|
47
|
+
throw new Error(`${cur} answered HTTP ${res.status}`)
|
|
48
|
+
}
|
|
49
|
+
const declared = Number(res.headers.get('content-length'))
|
|
50
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
51
|
+
await res.body?.cancel()
|
|
52
|
+
throw new Error(`${cur} is ${declared} bytes; the limit is ${maxBytes}`)
|
|
53
|
+
}
|
|
54
|
+
const reader = res.body?.getReader()
|
|
55
|
+
if (!reader) return ''
|
|
56
|
+
const chunks: Uint8Array[] = []
|
|
57
|
+
let size = 0
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read()
|
|
60
|
+
if (done) break
|
|
61
|
+
size += value.length
|
|
62
|
+
if (size > maxBytes) {
|
|
63
|
+
await reader.cancel()
|
|
64
|
+
throw new Error(`${cur} is bigger than the limit of ${maxBytes} bytes`)
|
|
65
|
+
}
|
|
66
|
+
chunks.push(value)
|
|
67
|
+
}
|
|
68
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (signal.aborted) throw new Error(`${url} did not answer within ${(o.timeoutMs ?? ITEM_TIMEOUT_MS) / 1000} s`)
|
|
72
|
+
throw e instanceof Error ? e : new Error(String(e))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True for a path that is not a plain relative path: absolute, a drive, a backslash, a NUL, a control character or `..`. */
|
|
77
|
+
export function unsafeRel(p: string): boolean {
|
|
78
|
+
return p === '' || p.startsWith('/') || /^[a-zA-Z]:/.test(p) || p.includes('\\') || /[\x00-\x1f]/.test(p) || p.split('/').some((s) => s === '..') || (p.includes('%') && /%2e|%2f|%5c|%00/i.test(p))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VettedItem {
|
|
82
|
+
/** Project-relative paths the CLI may write for this item, beyond the alias directories (checked for symlinks). */
|
|
83
|
+
paths: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const PLAIN_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
87
|
+
|
|
88
|
+
/** The top-level keys of an item in the pinned registry schema (`@shadcn/registry` 0.1.3). Allowlist, not denylist: a key
|
|
89
|
+
* this does not name (`font`, which makes the CLI write the CSS entry and a layout file, or one a later schema adds) is a
|
|
90
|
+
* refusal, because the CLI could write something this never judged. */
|
|
91
|
+
const ITEM_KEYS = new Set(['$schema', 'extends', 'name', 'type', 'title', 'author', 'description', 'dependencies', 'devDependencies', 'registryDependencies', 'files', 'tailwind', 'cssVars', 'css', 'envVars', 'meta', 'docs', 'categories'])
|
|
92
|
+
/** Item types whose files the CLI rewrites to a framework path (`registry:page`) or whose write set is not judged (`registry:font`). */
|
|
93
|
+
const REFUSED_TYPES = new Set(['registry:font', 'registry:page'])
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Judges item JSON: the file targets it declares and the other things it makes the CLI write or fetch. Returns the
|
|
97
|
+
* paths to check for symlinks, or the reason it is refused. Fail closed: anything it cannot judge is a refusal.
|
|
98
|
+
*/
|
|
99
|
+
export function vetItem(text: string, cssEntry: string): VettedItem | { error: string } {
|
|
100
|
+
let item: unknown
|
|
101
|
+
try {
|
|
102
|
+
item = JSON.parse(text)
|
|
103
|
+
} catch {
|
|
104
|
+
return { error: 'is not valid JSON' }
|
|
105
|
+
}
|
|
106
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) return { error: 'is not a JSON object' }
|
|
107
|
+
const o = item as Record<string, unknown>
|
|
108
|
+
if (o.type !== undefined && (typeof o.type !== 'string' || REFUSED_TYPES.has(o.type))) return { error: `is of type ${JSON.stringify(o.type)}, whose files the CLI places by rules this check does not model` }
|
|
109
|
+
const unknown = Object.keys(o).find((k) => !ITEM_KEYS.has(k))
|
|
110
|
+
if (unknown !== undefined) return { error: `has the key ${JSON.stringify(unknown)}, which this check does not know; it may make the CLI write files that cannot be checked` }
|
|
111
|
+
const paths: string[] = []
|
|
112
|
+
if (o.files !== undefined) {
|
|
113
|
+
if (!Array.isArray(o.files)) return { error: '"files" is not an array' }
|
|
114
|
+
for (const f of o.files) {
|
|
115
|
+
if (typeof f !== 'object' || f === null) return { error: 'has a file entry that is not an object' }
|
|
116
|
+
const { path, target } = f as { path?: unknown; target?: unknown }
|
|
117
|
+
if (path !== undefined && typeof path !== 'string') return { error: 'has a file with a "path" that is not a string' }
|
|
118
|
+
if (target !== undefined && typeof target !== 'string') return { error: 'has a file with a "target" that is not a string' }
|
|
119
|
+
if (typeof path === 'string' && unsafeRel(path)) return { error: `declares the file path ${JSON.stringify(path)}, which is not a plain path inside the project` }
|
|
120
|
+
if (typeof target === 'string' && target !== '') {
|
|
121
|
+
// The CLI's own rules (resolveFilePath): `~/x` is project-relative; `@components|ui|lib|hooks/x` is inside that alias
|
|
122
|
+
// directory (scanned already); any other target is joined to the project root, once as written and, in a `src/`
|
|
123
|
+
// project, under `src/` (it drops one `src/` from the target either way). All of them are checked.
|
|
124
|
+
const t = target.startsWith('~/') ? target.slice(2) : target
|
|
125
|
+
const alias = /^@(components|ui|lib|hooks)\/(.+)$/.exec(t)
|
|
126
|
+
if (alias) {
|
|
127
|
+
if (unsafeRel(alias[2]!)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
|
|
128
|
+
} else {
|
|
129
|
+
const rel = t.replace(/^@([^/]+)\//, '$1/')
|
|
130
|
+
if (unsafeRel(rel)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
|
|
131
|
+
const bare = rel.replace('src/', '')
|
|
132
|
+
paths.push(rel, bare, `src/${bare}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// what else the CLI writes: the Tailwind CSS entry (css, cssVars), env files (envVars)
|
|
138
|
+
if (o.css !== undefined || o.cssVars !== undefined || o.tailwind !== undefined) paths.push(cssEntry)
|
|
139
|
+
if (o.envVars !== undefined) paths.push('.env', '.env.local')
|
|
140
|
+
// dependencies on other items the CLI would fetch after this check (a `config` that would merge into components.json is an unknown key, refused above)
|
|
141
|
+
const deps = [...(Array.isArray(o.registryDependencies) ? o.registryDependencies : o.registryDependencies === undefined ? [] : [null]), ...(o.extends === undefined ? [] : [o.extends])]
|
|
142
|
+
for (const d of deps) {
|
|
143
|
+
if (typeof d !== 'string') return { error: 'has a registryDependencies / extends entry that is not a string' }
|
|
144
|
+
if (!PLAIN_NAME.test(d)) {
|
|
145
|
+
return { error: `depends on ${JSON.stringify(d)}, which the CLI would fetch after this check; pass that URL to the command as its own argument so it is checked too` }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { paths }
|
|
149
|
+
}
|