@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.20

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.
Files changed (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +89 -4
  3. package/native/index.js +60 -54
  4. package/package.json +25 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +332 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +462 -52
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +320 -0
  19. package/src/build/compile.ts +114 -5
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +275 -0
  22. package/src/build/esm-resolve.ts +170 -0
  23. package/src/build/features.ts +114 -0
  24. package/src/build/index.ts +264 -20
  25. package/src/build/manifest.ts +85 -7
  26. package/src/build/pkg-src.ts +514 -0
  27. package/src/build/recognised.ts +19 -0
  28. package/src/build/scan.ts +71 -20
  29. package/src/build/server-only.ts +39 -0
  30. package/src/build/ssg-render.ts +65 -0
  31. package/src/build/ssg.ts +146 -0
  32. package/src/build/static-worker.ts +91 -0
  33. package/src/build/static.ts +258 -0
  34. package/src/build/tailwind.ts +117 -0
  35. package/src/build/tsconfig-paths.ts +67 -0
  36. package/src/build-time.ts +10 -0
  37. package/src/cache-adapter/bridge.ts +91 -0
  38. package/src/cache-adapter/conformance.ts +226 -0
  39. package/src/cache-adapter/index.ts +10 -0
  40. package/src/cache-adapter/load.ts +144 -0
  41. package/src/cache-adapter/memory.ts +229 -0
  42. package/src/cache-adapter/runtime.ts +124 -0
  43. package/src/cache-adapter/types.ts +96 -0
  44. package/src/cache-adapter/workers.ts +118 -0
  45. package/src/cache.ts +4 -1
  46. package/src/cf.ts +19 -0
  47. package/src/cli.ts +71 -2
  48. package/src/config.ts +127 -25
  49. package/src/css-modules.d.ts +8 -0
  50. package/src/dev/bun.ts +371 -0
  51. package/src/dev/client.ts +92 -0
  52. package/src/dev/devtools-data.ts +23 -0
  53. package/src/dev/devtools.ts +356 -0
  54. package/src/dev/diag.ts +30 -0
  55. package/src/dev/proxy.ts +236 -0
  56. package/src/dev/tui.ts +144 -0
  57. package/src/dev/watch.ts +130 -0
  58. package/src/dev.ts +97 -0
  59. package/src/handlers.ts +282 -0
  60. package/src/index.ts +3 -1
  61. package/src/native.ts +38 -1
  62. package/src/routes.ts +20 -1
  63. package/src/run.ts +59 -12
  64. package/src/server-handlers.ts +32 -0
  65. package/src/shadcn/init.ts +513 -0
  66. package/src/shadcn/jsonc.ts +129 -0
  67. package/src/shadcn/registry.ts +149 -0
  68. package/src/worker.ts +127 -245
@@ -0,0 +1,513 @@
1
+ // `brust shadcn init [component ...]`: sets a brust project up for shadcn/ui and adds components (F175, R41.1-R41.10).
2
+ // shadcn's own `init` does not detect brust, so this writes what `add` needs (components.json, the `@/*` path),
3
+ // installs the four packages the components import, and runs ONE pinned shadcn CLI for the theme and the components.
4
+ // Every child process is spawned from an argv array, never through a shell; the runner is injected for tests.
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'
11
+ import { type ObjectNode, raw, readObject } from './jsonc'
12
+ import { fetchRegistryItem, vetItem } from './registry'
13
+
14
+ /** The shadcn CLI version this command runs (R41.6). A release that moves it re-runs the transcript. */
15
+ export const SHADCN_VERSION = '4.21.4'
16
+
17
+ /**
18
+ * The packages the components import (R41.5), each pinned to the exact version the transcript ran
19
+ * (`docs/transcripts/shadcn-init-guide.md`, F181). They move together with `SHADCN_VERSION`: a release that moves one
20
+ * re-runs the transcript. A package the project already depends on, at any version, is left alone.
21
+ */
22
+ export const SHADCN_DEP_PINS = {
23
+ 'radix-ui': '1.8.1',
24
+ 'lucide-react': '1.57.0',
25
+ 'class-variance-authority': '0.7.1',
26
+ 'tw-animate-css': '1.4.0',
27
+ } as const
28
+ export const SHADCN_DEPS = Object.keys(SHADCN_DEP_PINS) as (keyof typeof SHADCN_DEP_PINS)[]
29
+
30
+ /** Runs `argv` in `cwd` with the terminal attached; resolves to the exit code. Never a shell string. */
31
+ export type Runner = (argv: string[], cwd: string) => Promise<number>
32
+
33
+ export interface InitOptions {
34
+ /** Project root (cwd). */
35
+ root: string
36
+ components: string[]
37
+ force?: boolean
38
+ css?: string
39
+ baseColor?: string
40
+ shadcnVersion?: string
41
+ run: Runner
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>
45
+ }
46
+
47
+ const COMPONENT = /^[a-z0-9][a-z0-9-]*$/
48
+ const REGISTRY_URL = /^https:\/\/[^\s]+$/
49
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
50
+ const COLOR = /^[a-z]+$/
51
+
52
+ /** True for a symlink, also a dangling one (`existsSync` follows links and says false for those). */
53
+ function isLink(path: string): boolean {
54
+ try {
55
+ return lstatSync(path).isSymbolicLink()
56
+ } catch {
57
+ return false
58
+ }
59
+ }
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
+
240
+ /** The name a registry URL installs: its last path segment without `.json`; undefined when it is not a plain name. */
241
+ function urlItemName(url: string): string | undefined {
242
+ try {
243
+ const seg = new URL(url).pathname.split('/').filter(Boolean).pop()?.replace(/\.json$/, '')
244
+ return seg !== undefined && COMPONENT.test(seg) ? seg : undefined
245
+ } catch {
246
+ return undefined
247
+ }
248
+ }
249
+
250
+ /** The `components.json` of the tested fixture (`tests/fixtures/shadcn/components.json`), for `css` and `baseColor` (R41.2). */
251
+ export function componentsJson(css: string, baseColor: string): string {
252
+ return `{
253
+ "$schema": "https://ui.shadcn.com/schema.json",
254
+ "style": "new-york",
255
+ "rsc": false,
256
+ "tsx": true,
257
+ "tailwind": { "config": "", "css": ${JSON.stringify(css)}, "baseColor": ${JSON.stringify(baseColor)}, "cssVariables": true, "prefix": "" },
258
+ "iconLibrary": "lucide",
259
+ "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
260
+ }
261
+ `
262
+ }
263
+
264
+ export type PathEdit =
265
+ | { kind: 'added'; text: string }
266
+ | { kind: 'present' }
267
+ | { kind: 'other'; target: string }
268
+ | { kind: 'unparseable'; why: string }
269
+
270
+ const ENTRY = '"@/*": ["./*"]'
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
+
281
+ /** The separator before a non-empty object's first member, so the new entry takes the same line and indent. */
282
+ function lead(text: string, o: ObjectNode): string {
283
+ const first = o.members[0]
284
+ if (!first) return ' '
285
+ const nl = text.lastIndexOf('\n', first.keyStart)
286
+ const indent = nl > o.open ? text.slice(nl + 1, first.keyStart) : ''
287
+ if (nl > o.open && /^\s*$/.test(indent)) return `\n${indent}`
288
+ const same = text.slice(o.open + 1, first.keyStart)
289
+ return /^\s*$/.test(same) ? same : ' '
290
+ }
291
+
292
+ /** Adds `"@/*": ["./*"]` to `compilerOptions.paths` as a text splice (R41.4): comments and layout are kept. */
293
+ export function addAliasPath(text: string): PathEdit {
294
+ let top: ObjectNode
295
+ try {
296
+ top = readObject(text)
297
+ } catch (e) {
298
+ return { kind: 'unparseable', why: (e as Error).message }
299
+ }
300
+ const co = top.members.find((m) => m.key === 'compilerOptions')?.object
301
+ if (!co) return { kind: 'unparseable', why: 'no compilerOptions object' }
302
+ const pathsMember = co.members.find((m) => m.key === 'paths')
303
+ if (!pathsMember) {
304
+ const ws = lead(text, co)
305
+ const body = co.members.length > 0 ? `${ws}"paths": { ${ENTRY} },` : ` "paths": { ${ENTRY} } `
306
+ return { kind: 'added', text: text.slice(0, co.open + 1) + body + text.slice(co.open + 1) }
307
+ }
308
+ const paths = pathsMember.object
309
+ if (!paths) return { kind: 'unparseable', why: 'compilerOptions.paths is not an object' }
310
+ const existing = paths.members.find((m) => m.key === '@/*')
311
+ if (existing) {
312
+ const target = raw(text, existing)
313
+ return target.replace(/\s+/g, '') === '["./*"]' ? { kind: 'present' } : { kind: 'other', target }
314
+ }
315
+ const ws = lead(text, paths)
316
+ const body = paths.members.length > 0 ? `${ws}${ENTRY},` : ` ${ENTRY} `
317
+ return { kind: 'added', text: text.slice(0, paths.open + 1) + body + text.slice(paths.open + 1) }
318
+ }
319
+
320
+ /** The Tailwind entry CSS: `--css`, else the one top-level `*.css` that imports `tailwindcss` (R41.3). */
321
+ function tailwindEntry(root: string, css: string | undefined): { css: string } | { error: string } {
322
+ if (css !== undefined) {
323
+ const abs = resolve(root, css)
324
+ const rel = relative(root, abs)
325
+ if (css.startsWith('-') || isAbsolute(css) || rel.startsWith('..') || !existsSync(abs)) {
326
+ return { error: `--css ${JSON.stringify(css)} must be an existing CSS file inside the project` }
327
+ }
328
+ return { css: rel.split('\\').join('/') }
329
+ }
330
+ const hit = ['app.css', 'styles.css', 'global.css', 'globals.css', 'index.css'].filter((f) => {
331
+ const p = join(root, f)
332
+ return existsSync(p) && /@import\s+["']tailwindcss["']/.test(readFileSync(p, 'utf8'))
333
+ })
334
+ if (hit.length === 1) return { css: hit[0]! }
335
+ return { error: hit.length === 0 ? 'cannot tell which CSS file is the Tailwind entry; pass --css <file>' : `more than one Tailwind entry (${hit.join(', ')}); pass --css <file>` }
336
+ }
337
+
338
+ type Step = 'done' | 'skipped' | 'failed'
339
+
340
+ /** Returns the process exit code: 0 ok, 2 usage / not a brust project, else the failing child's code. */
341
+ export async function shadcnInit(o: InitOptions): Promise<number> {
342
+ const { root, out } = o
343
+ const fail2 = (msg: string): number => {
344
+ out(`error shadcn-init ${msg}`)
345
+ return 2
346
+ }
347
+ // R41.1: a brust project root.
348
+ const pkgPath = join(root, 'package.json')
349
+ if (!existsSync(pkgPath)) return fail2('no package.json here: run it in the root of a brust project')
350
+ let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
351
+ try {
352
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
353
+ } catch {
354
+ return fail2('package.json is not valid JSON')
355
+ }
356
+ if (!pkg.dependencies?.['@brust/core'] && !pkg.devDependencies?.['@brust/core']) {
357
+ return fail2('package.json does not depend on @brust/core: run it in the root of a brust project')
358
+ }
359
+ // R41.8: validate everything that reaches an argv before anything is written.
360
+ for (const c of o.components) {
361
+ if (!COMPONENT.test(c) && !REGISTRY_URL.test(c)) return fail2(`component ${JSON.stringify(c)} must match ^[a-z0-9][a-z0-9-]*$ or be an https:// registry URL`)
362
+ }
363
+ const version = o.shadcnVersion ?? SHADCN_VERSION
364
+ if (!VERSION.test(version)) return fail2(`--shadcn-version ${JSON.stringify(version)} must be an exact version like ${SHADCN_VERSION}`)
365
+ const baseColor = o.baseColor ?? 'neutral'
366
+ if (!COLOR.test(baseColor)) return fail2(`--base-color ${JSON.stringify(baseColor)} must be a lowercase colour name`)
367
+ const entry = tailwindEntry(root, o.css)
368
+ if ('error' in entry) return fail2(entry.error)
369
+ // F181: never write through a symlink (the target may be outside the project, e.g. a shared tsconfig). Fail-safe:
370
+ // refuse before the first write, even with --force; the reader edits the real file by hand.
371
+ const cj = join(root, 'components.json')
372
+ const tsPath = join(root, 'tsconfig.json')
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')
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
+ }
424
+
425
+ const report = (name: string, step: Step, why = ''): void => out(`${step === 'done' ? '✓' : step === 'skipped' ? '-' : '✗'} ${name}: ${step}${why ? ` (${why})` : ''}`)
426
+ const stop = (name: string, code: number): number => {
427
+ report(name, 'failed', `exit ${code}`)
428
+ out(`error shadcn-init ${name} failed with exit code ${code}; fix it and run the command again (finished steps are skipped)`)
429
+ return code
430
+ }
431
+
432
+ // R41.2: components.json.
433
+ if (!existsSync(cj) || o.force) {
434
+ writeFileSync(cj, componentsJson(entry.css, baseColor))
435
+ report('components.json', 'done', entry.css)
436
+ } else report('components.json', 'skipped', 'exists; --force replaces it')
437
+
438
+ // R41.4: the `@/*` path.
439
+ if (!existsSync(tsPath)) {
440
+ report('tsconfig.json', 'skipped', `no tsconfig.json; add ${JSON.stringify({ compilerOptions: { paths: { '@/*': ['./*'] } } })}`)
441
+ } else {
442
+ const edit = tsEdit!
443
+ if (edit.kind === 'added') {
444
+ writeFileSync(tsPath, edit.text)
445
+ report('tsconfig.json', 'done', 'added "@/*": ["./*"] under compilerOptions.paths')
446
+ } else if (edit.kind === 'present') report('tsconfig.json', 'skipped', '"@/*" is already there')
447
+ else if (edit.kind === 'other') report('tsconfig.json', 'skipped', `warning: "@/*" already points at ${edit.target}; shadcn's imports need ["./*"]`)
448
+ else report('tsconfig.json', 'skipped', `not edited (${edit.why}); add this line to compilerOptions.paths yourself: ${ENTRY}`)
449
+ }
450
+
451
+ // R41.5: dependencies.
452
+ const have = { ...pkg.devDependencies, ...pkg.dependencies }
453
+ const missing = SHADCN_DEPS.filter((d) => !have[d])
454
+ if (missing.length === 0) report('dependencies', 'skipped', 'all four are installed')
455
+ else {
456
+ const specs = missing.map((d) => `${d}@${SHADCN_DEP_PINS[d]}`)
457
+ const code = await o.run(['bun', 'add', ...specs], root)
458
+ if (code !== 0) return stop('bun add', code)
459
+ report('dependencies', 'done', specs.join(' '))
460
+ }
461
+
462
+ // R41.6 / R41.7: the pinned shadcn CLI. `bun x` is `bunx`; argv only.
463
+ const shadcn = (...args: string[]): string[] => ['bun', 'x', `shadcn@${version}`, ...args]
464
+ const cssText = readFileSync(join(root, entry.css), 'utf8')
465
+ if (cssText.includes('shadcn/tailwind.css') && !o.force) report('theme', 'skipped', `${entry.css} already has the shadcn theme; --force runs it again`)
466
+ else {
467
+ const code = await o.run(shadcn('add', 'index', '-y'), root)
468
+ if (code !== 0) return stop('shadcn add index', code)
469
+ report('theme', 'done', `shadcn@${version} add index`)
470
+ }
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')
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`)
473
+ if (todo.length > 0) {
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
+ }
489
+ for (const c of todo) report(c, 'done')
490
+ }
491
+
492
+ out('')
493
+ out('next:')
494
+ out(" import the components from './components/ui/<name>' in a page and add the route to routes.tsx")
495
+ out(' bun run build')
496
+ return 0
497
+ }
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
+
509
+ /** The real runner: argv spawn with the terminal attached, no shell. */
510
+ export const spawnRunner: Runner = async (argv, cwd) => {
511
+ const p = Bun.spawn(argv, { cwd, env: cliEnv(process.env), stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
512
+ return await p.exited
513
+ }
@@ -0,0 +1,129 @@
1
+ // A minimal JSONC reader that keeps byte offsets, so a file can be edited with a text splice (comments, trailing
2
+ // commas and formatting survive). It does not build values; it finds object members. Anything it cannot read throws.
3
+
4
+ export interface Member {
5
+ key: string
6
+ /** Offset of the member's key (its opening quote). */
7
+ keyStart: number
8
+ /** Offset of the member's value (the `{` of an object value). */
9
+ value: number
10
+ /** Offset just past the value. */
11
+ end: number
12
+ /** Parsed object when the value is an object, else `undefined`. */
13
+ object?: ObjectNode
14
+ }
15
+
16
+ export interface ObjectNode {
17
+ /** Offset of `{`. */
18
+ open: number
19
+ /** Offset of `}`. */
20
+ close: number
21
+ members: Member[]
22
+ }
23
+
24
+ class Reader {
25
+ i = 0
26
+ constructor(readonly s: string) {}
27
+
28
+ ws(): void {
29
+ const { s } = this
30
+ for (;;) {
31
+ const c = s[this.i]
32
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '') this.i++
33
+ else if (c === '/' && s[this.i + 1] === '/') {
34
+ while (this.i < s.length && s[this.i] !== '\n') this.i++
35
+ } else if (c === '/' && s[this.i + 1] === '*') {
36
+ const e = s.indexOf('*/', this.i + 2)
37
+ if (e < 0) throw new Error('unterminated comment')
38
+ this.i = e + 2
39
+ } else return
40
+ }
41
+ }
42
+
43
+ string(): string {
44
+ const { s } = this
45
+ if (s[this.i] !== '"') throw new Error(`expected a string at ${this.i}`)
46
+ let j = this.i + 1
47
+ let out = ''
48
+ while (j < s.length && s[j] !== '"') {
49
+ if (s[j] === '\\') {
50
+ out += s[j + 1] ?? ''
51
+ j += 2
52
+ } else if (s[j] === '\n') throw new Error('newline in a string')
53
+ else out += s[j++]
54
+ }
55
+ if (s[j] !== '"') throw new Error('unterminated string')
56
+ this.i = j + 1
57
+ return out
58
+ }
59
+
60
+ /** Skips one value; returns its object node when it is an object. */
61
+ value(): ObjectNode | undefined {
62
+ this.ws()
63
+ const c = this.s[this.i]
64
+ if (c === '{') return this.object()
65
+ if (c === '[') {
66
+ this.i++
67
+ for (;;) {
68
+ this.ws()
69
+ if (this.s[this.i] === ']') {
70
+ this.i++
71
+ return undefined
72
+ }
73
+ this.value()
74
+ this.ws()
75
+ if (this.s[this.i] === ',') this.i++
76
+ else if (this.s[this.i] !== ']') throw new Error(`expected , or ] at ${this.i}`)
77
+ }
78
+ }
79
+ if (c === '"') {
80
+ this.string()
81
+ return undefined
82
+ }
83
+ const m = /^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(this.s.slice(this.i, this.i + 40))
84
+ if (!m) throw new Error(`unexpected character at ${this.i}`)
85
+ this.i += m[0].length
86
+ return undefined
87
+ }
88
+
89
+ object(): ObjectNode {
90
+ const open = this.i
91
+ this.i++
92
+ const members: Member[] = []
93
+ for (;;) {
94
+ this.ws()
95
+ if (this.s[this.i] === '}') {
96
+ this.i++
97
+ return { open, close: this.i - 1, members }
98
+ }
99
+ const keyStart = this.i
100
+ const key = this.string()
101
+ this.ws()
102
+ if (this.s[this.i] !== ':') throw new Error(`expected : at ${this.i}`)
103
+ this.i++
104
+ this.ws()
105
+ const value = this.i
106
+ const object = this.value()
107
+ members.push({ key, keyStart, value, end: this.i, ...(object && { object }) })
108
+ this.ws()
109
+ if (this.s[this.i] === ',') this.i++
110
+ else if (this.s[this.i] !== '}') throw new Error(`expected , or } at ${this.i}`)
111
+ }
112
+ }
113
+ }
114
+
115
+ /** The top-level object of a JSONC text. Throws on anything that is not JSONC. */
116
+ export function readObject(text: string): ObjectNode {
117
+ const r = new Reader(text)
118
+ r.ws()
119
+ if (text[r.i] !== '{') throw new Error('not an object')
120
+ const o = r.object()
121
+ r.ws()
122
+ if (r.i !== text.length) throw new Error('trailing content')
123
+ return o
124
+ }
125
+
126
+ /** The raw text of a member's value. */
127
+ export function raw(text: string, m: Member): string {
128
+ return text.slice(m.value, m.end)
129
+ }