@brust/core 2.0.0-alpha.14 → 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/src/dev/watch.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // ONE run at a time, and every change that arrives while a run is in flight is kept for the next
3
3
  // run (post-mortem 8f29c26: an edit during a rebuild was dropped). `run` receives the app-relative
4
4
  // paths that changed since the previous run started.
5
- import { type FSWatcher, statSync, watch } from 'node:fs'
6
- import { join, sep } from 'node:path'
5
+ import { type FSWatcher, readdirSync, statSync, watch } from 'node:fs'
6
+ import { join, relative, resolve, sep } from 'node:path'
7
7
 
8
8
  export interface WatchLoop {
9
9
  /** Queue a run as if `rel` changed (the `r` hotkey, tests). */
@@ -14,7 +14,36 @@ export interface WatchLoop {
14
14
  idle(): Promise<void>
15
15
  }
16
16
 
17
- export function watchLoop(o: { root: string; ignored: (rel: string) => boolean; debounceMs: number; run: (changed: Set<string>) => Promise<void> }): WatchLoop {
17
+ /** F273: a source root holding more files than this gets a warning (a root that broad slows every rebuild check). */
18
+ export const ROOT_FILE_WARN = 5_000
19
+
20
+ /** Files under `dir` (skipping dependency/VCS dirs), counting stops once past `limit`. */
21
+ export function countFiles(dir: string, limit: number): number {
22
+ let n = 0
23
+ const walk = (d: string): void => {
24
+ let ents: import('node:fs').Dirent[]
25
+ try {
26
+ ents = readdirSync(d, { withFileTypes: true })
27
+ } catch {
28
+ return
29
+ }
30
+ for (const e of ents) {
31
+ if (n > limit) return
32
+ if (e.isDirectory()) {
33
+ if (!SKIP_DIRS.has(e.name)) walk(join(d, e.name))
34
+ } else n++
35
+ }
36
+ }
37
+ walk(dir)
38
+ return n
39
+ }
40
+
41
+ /** Warning lines for source roots broader than `ROOT_FILE_WARN` files; `roots` are absolute. */
42
+ export function broadRootWarnings(roots: string[], appRoot: string, limit = ROOT_FILE_WARN): string[] {
43
+ return roots.flatMap((r) => (countFiles(r, limit) > limit ? [`[brust] warning: source root ${relative(appRoot, r) || r} holds more than ${limit} files; watching it slows every change check. Point source_roots at a narrower directory`] : []))
44
+ }
45
+
46
+ export function watchLoop(o: { root: string; /** F273: `[build] source_roots`, absolute: watched too; their changes are reported app-relative (`../shared/x.ts`). */ extraRoots?: string[]; ignored: (rel: string) => boolean; debounceMs: number; run: (changed: Set<string>) => Promise<void> }): WatchLoop {
18
47
  let pending = new Set<string>()
19
48
  let timer: ReturnType<typeof setTimeout> | undefined
20
49
  let running = false
@@ -44,24 +73,36 @@ export function watchLoop(o: { root: string; ignored: (rel: string) => boolean;
44
73
  clearTimeout(timer)
45
74
  timer = setTimeout(() => void pump(), o.debounceMs)
46
75
  }
47
- const watcher: FSWatcher = watch(o.root, { recursive: true }, (_ev, name) => {
76
+ const onEvent = (base: string) => (_ev: string, name: string | null) => {
48
77
  if (!name) return
49
- const rel = String(name).split(sep).join('/')
78
+ const abs = join(base, String(name))
79
+ const rel = (base === o.root ? String(name) : relative(o.root, abs)).split(sep).join('/')
50
80
  if (o.ignored(rel)) return
51
81
  // A directory's own event is metadata noise (reading a dir bumps its atime, which FSEvents reports): a real
52
82
  // change inside it reports the file itself. A path that no longer exists is a delete, which counts.
53
83
  try {
54
- if (statSync(join(o.root, rel)).isDirectory()) return
84
+ if (statSync(abs).isDirectory()) return
55
85
  } catch {}
56
86
  queue(rel)
57
- })
87
+ }
88
+ const watchers: FSWatcher[] = [watch(o.root, { recursive: true }, onEvent(o.root))]
89
+ for (const r of o.extraRoots ?? []) {
90
+ const abs = resolve(r)
91
+ // a root inside the app is already watched; one that cannot be watched must not stop the session
92
+ if (abs === o.root || abs.startsWith(o.root + sep)) continue
93
+ try {
94
+ watchers.push(watch(abs, { recursive: true }, onEvent(abs)))
95
+ } catch (e) {
96
+ console.error(`[brust] cannot watch source root ${abs}: ${(e as Error).message}`)
97
+ }
98
+ }
58
99
  return {
59
100
  trigger: (rel = '.') => queue(rel),
60
101
  close() {
61
102
  closed = true
62
103
  clearTimeout(timer)
63
104
  pending.clear()
64
- watcher.close()
105
+ for (const w of watchers) w.close()
65
106
  for (const w of idleWaiters.splice(0)) w()
66
107
  },
67
108
  idle: () => (!running && pending.size === 0 ? Promise.resolve() : new Promise<void>((r) => idleWaiters.push(r))),
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 { defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
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() {
@@ -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 { isAbsolute, join, relative, resolve } from 'node:path'
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
- if (isLink(tsPath)) 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')
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 = addAliasPath(readFileSync(tsPath, 'utf8'))
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
- const code = await o.run(shadcn('add', ...todo, '-y'), root)
239
- if (code !== 0) return stop(`shadcn add ${todo.join(' ')}`, code)
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
+ }