@brust/core 2.0.0-alpha.15 → 2.0.0-alpha.17

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/build/scan.ts CHANGED
@@ -10,7 +10,9 @@ import { installServerOnlyRuntime } from './server-only'
10
10
  export async function scanRoutes(
11
11
  entryFile: string,
12
12
  appRoot?: string,
13
- ): Promise<{ routes: Route[]; componentFile: Map<Function, string>; componentIssue: Map<Function, string> }> {
13
+ /** `[build] source_roots` (F256): app-root-relative directories outside the app; their modules are part of the graph (F276). */
14
+ sourceRoots: string[] = [],
15
+ ): Promise<{ routes: Route[]; componentFile: Map<Function, string>; componentIssue: Map<Function, string>; appModules: string[]; appGraph: Map<string, string[]> }> {
14
16
  // biome-ignore lint/suspicious/noExplicitAny: an arbitrary user module
15
17
  let mod: any
16
18
  installServerOnlyRuntime() // `server-only` resolves to an empty module: a loader may import server-only code
@@ -29,8 +31,13 @@ export async function scanRoutes(
29
31
  const componentIssue = new Map<Function, string>()
30
32
  const root = realpathSync(resolve(appRoot ?? dirname(entryFile)))
31
33
  const transpiler = new Bun.Transpiler({ loader: 'tsx' })
32
- const inRoot = (f: string) => f.startsWith(root + sep) && !f.split(sep).includes('node_modules')
34
+ // F276: the app root and every `source_roots` directory: a module in `../shared` is as much part of the app as one in
35
+ // the root, so a load-time write it makes is seen by the F230 scan. Roots are real paths, as `f` is.
36
+ const scopes = [root, ...sourceRoots.map((r) => realpathSync(resolve(root, r)))]
37
+ const inRoot = (f: string) => scopes.some((s) => f.startsWith(s + sep)) && !f.split(sep).includes('node_modules')
33
38
  const visited = new Set<string>()
39
+ // F230: each in-root module's in-root static imports (absolute paths), for the island chunks' load-time writers.
40
+ const appGraph = new Map<string, string[]>()
34
41
  // Post-order: a file's imports are mapped before the file itself, and the first file to claim a function
35
42
  // keeps it, so the module that defines a Component beats a barrel that re-exports it.
36
43
  const visit = async (file: string): Promise<void> => {
@@ -58,6 +65,7 @@ export async function scanRoutes(
58
65
  if (inRoot(dep)) deps.push(dep)
59
66
  else if (dep.endsWith('.tsx')) await note(dep, dep.split(sep).includes('node_modules') ? 'in node_modules' : `outside the app root ${root}`)
60
67
  }
68
+ appGraph.set(file, deps)
61
69
  for (const dep of deps) await visit(dep)
62
70
  if (!file.endsWith('.tsx')) return
63
71
  const m = await import(file)
@@ -79,5 +87,7 @@ export async function scanRoutes(
79
87
  await visit(entryFile)
80
88
  // A function reachable as a default somewhere is fine; only an unmapped one keeps its explanation.
81
89
  for (const f of componentFile.keys()) componentIssue.delete(f)
82
- return { routes, componentFile, componentIssue }
90
+ // F230: every app module of the static graph (`.ts`/`.tsx` inside the root, outside node_modules), for the scan of
91
+ // load-time writes into another module's exports (`scanLoadWrites`).
92
+ return { routes, componentFile, componentIssue, appModules: [...visited].filter(inRoot), appGraph }
83
93
  }
package/src/cli.ts CHANGED
@@ -6,7 +6,6 @@ import { BuildError, runBuild } from './build'
6
6
  import { runBunDev } from './dev/bun'
7
7
  import { runDev } from './dev'
8
8
  import { run } from './run'
9
- import { shadcnInit, spawnRunner } from './shadcn/init'
10
9
 
11
10
  const USAGE = `usage:
12
11
  brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare|static] [--actions-only] [--strict] [--wasm-from-source] [--dev]
@@ -135,6 +134,8 @@ export async function main(argv: string[]): Promise<void> {
135
134
  if (cmd === 'shadcn') {
136
135
  const [sub, ...components] = positionals
137
136
  if (sub !== 'init') usage(`shadcn takes a subcommand: init (got ${sub === undefined ? 'none' : JSON.stringify(sub)})`)
137
+ // F252: loaded here, so no other command pays for it (it pulls in tsconfig-paths)
138
+ const { shadcnInit, spawnRunner } = await import('./shadcn/init')
138
139
  const code = await shadcnInit({
139
140
  root: process.cwd(),
140
141
  components,
package/src/dev/bun.ts CHANGED
@@ -10,7 +10,8 @@ import { type Diag, parseBuildError, parseWarnings } from './diag'
10
10
  import { tierCounts } from './devtools-data'
11
11
  import { type DevProxy, startProxy } from './proxy'
12
12
  import { HOTKEYS, Tui, type TuiOut, tuiMode } from './tui'
13
- import { defaultIgnored, watchLoop } from './watch'
13
+ import { loadBuildConfig } from '../build/config'
14
+ import { broadRootWarnings, defaultIgnored, watchLoop } from './watch'
14
15
 
15
16
  export interface BunDevOptions {
16
17
  appRoot: string
@@ -290,8 +291,12 @@ export async function runBunDev(o: BunDevOptions): Promise<number> {
290
291
 
291
292
  const isCss = (rel: string) => rel.startsWith('public/') && rel.endsWith('.css')
292
293
 
294
+ // F273: `[build] source_roots` are watched too (a bad config is the build's error to report, not the watcher's)
295
+ const extraRoots = await loadBuildConfig(appRoot).then((c) => c.sourceRoots.map((r) => resolve(appRoot, r)), () => [] as string[])
296
+ for (const w of broadRootWarnings(extraRoots, appRoot)) tui.log(w)
293
297
  const loop = watchLoop({
294
298
  root: appRoot,
299
+ extraRoots,
295
300
  ignored: (rel) => defaultIgnored(rel, 'dist'),
296
301
  debounceMs: DEBOUNCE_MS,
297
302
  async run(changed) {
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
  }