@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/native/index.d.ts +18 -1
- package/native/index.js +55 -54
- package/package.json +12 -9
- package/src/build/bundle.ts +54 -10
- package/src/build/cloudflare.ts +5 -2
- package/src/build/compile.ts +31 -2
- package/src/build/config.ts +43 -5
- package/src/build/esm-resolve.ts +137 -10
- package/src/build/features.ts +5 -1
- package/src/build/index.ts +46 -7
- package/src/build/pkg-src.ts +51 -4
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +13 -3
- 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/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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
76
|
+
const onEvent = (base: string) => (_ev: string, name: string | null) => {
|
|
48
77
|
if (!name) return
|
|
49
|
-
const
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|