@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// F228: a registry URL given to `brust shadcn init` names a third-party item that declares its own file targets, known
|
|
2
|
+
// only once the item is fetched. This fetches the item JSON BEFORE the shadcn CLI is spawned (https only, a size cap, a
|
|
3
|
+
// timeout, redirects followed by hand and only to https) and checks every path it can make the CLI write. The command
|
|
4
|
+
// then hands the CLI the file it checked, not the URL, so the item cannot change between the check and the write.
|
|
5
|
+
|
|
6
|
+
/** The most bytes of item JSON read; a bigger answer is refused. */
|
|
7
|
+
export const ITEM_MAX_BYTES = 1024 * 1024
|
|
8
|
+
/** The whole fetch (redirects included) gets this long. */
|
|
9
|
+
export const ITEM_TIMEOUT_MS = 15_000
|
|
10
|
+
/** Redirects followed by hand; each target must be https. */
|
|
11
|
+
export const MAX_REDIRECTS = 3
|
|
12
|
+
|
|
13
|
+
export interface FetchOptions {
|
|
14
|
+
/** URL schemes allowed for the request and every redirect. Default `https:` only; a test passes `http:` for a local server. */
|
|
15
|
+
schemes?: string[]
|
|
16
|
+
maxBytes?: number
|
|
17
|
+
timeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Fetches `url` and returns the body text, or throws an `Error` whose message is the reason it was refused. */
|
|
21
|
+
export async function fetchRegistryItem(url: string, o: FetchOptions = {}): Promise<string> {
|
|
22
|
+
const schemes = o.schemes ?? ['https:']
|
|
23
|
+
const maxBytes = o.maxBytes ?? ITEM_MAX_BYTES
|
|
24
|
+
const signal = AbortSignal.timeout(o.timeoutMs ?? ITEM_TIMEOUT_MS)
|
|
25
|
+
let cur = url
|
|
26
|
+
try {
|
|
27
|
+
for (let hop = 0; ; hop++) {
|
|
28
|
+
let u: URL
|
|
29
|
+
try {
|
|
30
|
+
u = new URL(cur)
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`${cur} is not a URL`)
|
|
33
|
+
}
|
|
34
|
+
if (!schemes.includes(u.protocol)) throw new Error(`${cur} is not an ${schemes.join(' or ').replace(/:/g, '')} URL`)
|
|
35
|
+
if (u.username !== '' || u.password !== '') throw new Error(`${u.origin}${u.pathname} carries credentials in the URL`)
|
|
36
|
+
const res = await fetch(u, { redirect: 'manual', signal, headers: { accept: 'application/json' } })
|
|
37
|
+
if (res.status >= 300 && res.status < 400) {
|
|
38
|
+
const next = res.headers.get('location')
|
|
39
|
+
await res.body?.cancel()
|
|
40
|
+
if (!next) throw new Error(`${cur} answered ${res.status} with no Location`)
|
|
41
|
+
if (hop >= MAX_REDIRECTS) throw new Error(`${url} redirects more than ${MAX_REDIRECTS} times`)
|
|
42
|
+
cur = new URL(next, u).toString() // the next loop turn refuses a non-https target
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
await res.body?.cancel()
|
|
47
|
+
throw new Error(`${cur} answered HTTP ${res.status}`)
|
|
48
|
+
}
|
|
49
|
+
const declared = Number(res.headers.get('content-length'))
|
|
50
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
51
|
+
await res.body?.cancel()
|
|
52
|
+
throw new Error(`${cur} is ${declared} bytes; the limit is ${maxBytes}`)
|
|
53
|
+
}
|
|
54
|
+
const reader = res.body?.getReader()
|
|
55
|
+
if (!reader) return ''
|
|
56
|
+
const chunks: Uint8Array[] = []
|
|
57
|
+
let size = 0
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read()
|
|
60
|
+
if (done) break
|
|
61
|
+
size += value.length
|
|
62
|
+
if (size > maxBytes) {
|
|
63
|
+
await reader.cancel()
|
|
64
|
+
throw new Error(`${cur} is bigger than the limit of ${maxBytes} bytes`)
|
|
65
|
+
}
|
|
66
|
+
chunks.push(value)
|
|
67
|
+
}
|
|
68
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (signal.aborted) throw new Error(`${url} did not answer within ${(o.timeoutMs ?? ITEM_TIMEOUT_MS) / 1000} s`)
|
|
72
|
+
throw e instanceof Error ? e : new Error(String(e))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True for a path that is not a plain relative path: absolute, a drive, a backslash, a NUL, a control character or `..`. */
|
|
77
|
+
export function unsafeRel(p: string): boolean {
|
|
78
|
+
return p === '' || p.startsWith('/') || /^[a-zA-Z]:/.test(p) || p.includes('\\') || /[\x00-\x1f]/.test(p) || p.split('/').some((s) => s === '..') || (p.includes('%') && /%2e|%2f|%5c|%00/i.test(p))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VettedItem {
|
|
82
|
+
/** Project-relative paths the CLI may write for this item, beyond the alias directories (checked for symlinks). */
|
|
83
|
+
paths: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const PLAIN_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
87
|
+
|
|
88
|
+
/** The top-level keys of an item in the pinned registry schema (`@shadcn/registry` 0.1.3). Allowlist, not denylist: a key
|
|
89
|
+
* this does not name (`font`, which makes the CLI write the CSS entry and a layout file, or one a later schema adds) is a
|
|
90
|
+
* refusal, because the CLI could write something this never judged. */
|
|
91
|
+
const ITEM_KEYS = new Set(['$schema', 'extends', 'name', 'type', 'title', 'author', 'description', 'dependencies', 'devDependencies', 'registryDependencies', 'files', 'tailwind', 'cssVars', 'css', 'envVars', 'meta', 'docs', 'categories'])
|
|
92
|
+
/** Item types whose files the CLI rewrites to a framework path (`registry:page`) or whose write set is not judged (`registry:font`). */
|
|
93
|
+
const REFUSED_TYPES = new Set(['registry:font', 'registry:page'])
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Judges item JSON: the file targets it declares and the other things it makes the CLI write or fetch. Returns the
|
|
97
|
+
* paths to check for symlinks, or the reason it is refused. Fail closed: anything it cannot judge is a refusal.
|
|
98
|
+
*/
|
|
99
|
+
export function vetItem(text: string, cssEntry: string): VettedItem | { error: string } {
|
|
100
|
+
let item: unknown
|
|
101
|
+
try {
|
|
102
|
+
item = JSON.parse(text)
|
|
103
|
+
} catch {
|
|
104
|
+
return { error: 'is not valid JSON' }
|
|
105
|
+
}
|
|
106
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) return { error: 'is not a JSON object' }
|
|
107
|
+
const o = item as Record<string, unknown>
|
|
108
|
+
if (o.type !== undefined && (typeof o.type !== 'string' || REFUSED_TYPES.has(o.type))) return { error: `is of type ${JSON.stringify(o.type)}, whose files the CLI places by rules this check does not model` }
|
|
109
|
+
const unknown = Object.keys(o).find((k) => !ITEM_KEYS.has(k))
|
|
110
|
+
if (unknown !== undefined) return { error: `has the key ${JSON.stringify(unknown)}, which this check does not know; it may make the CLI write files that cannot be checked` }
|
|
111
|
+
const paths: string[] = []
|
|
112
|
+
if (o.files !== undefined) {
|
|
113
|
+
if (!Array.isArray(o.files)) return { error: '"files" is not an array' }
|
|
114
|
+
for (const f of o.files) {
|
|
115
|
+
if (typeof f !== 'object' || f === null) return { error: 'has a file entry that is not an object' }
|
|
116
|
+
const { path, target } = f as { path?: unknown; target?: unknown }
|
|
117
|
+
if (path !== undefined && typeof path !== 'string') return { error: 'has a file with a "path" that is not a string' }
|
|
118
|
+
if (target !== undefined && typeof target !== 'string') return { error: 'has a file with a "target" that is not a string' }
|
|
119
|
+
if (typeof path === 'string' && unsafeRel(path)) return { error: `declares the file path ${JSON.stringify(path)}, which is not a plain path inside the project` }
|
|
120
|
+
if (typeof target === 'string' && target !== '') {
|
|
121
|
+
// The CLI's own rules (resolveFilePath): `~/x` is project-relative; `@components|ui|lib|hooks/x` is inside that alias
|
|
122
|
+
// directory (scanned already); any other target is joined to the project root, once as written and, in a `src/`
|
|
123
|
+
// project, under `src/` (it drops one `src/` from the target either way). All of them are checked.
|
|
124
|
+
const t = target.startsWith('~/') ? target.slice(2) : target
|
|
125
|
+
const alias = /^@(components|ui|lib|hooks)\/(.+)$/.exec(t)
|
|
126
|
+
if (alias) {
|
|
127
|
+
if (unsafeRel(alias[2]!)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
|
|
128
|
+
} else {
|
|
129
|
+
const rel = t.replace(/^@([^/]+)\//, '$1/')
|
|
130
|
+
if (unsafeRel(rel)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
|
|
131
|
+
const bare = rel.replace('src/', '')
|
|
132
|
+
paths.push(rel, bare, `src/${bare}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// what else the CLI writes: the Tailwind CSS entry (css, cssVars), env files (envVars)
|
|
138
|
+
if (o.css !== undefined || o.cssVars !== undefined || o.tailwind !== undefined) paths.push(cssEntry)
|
|
139
|
+
if (o.envVars !== undefined) paths.push('.env', '.env.local')
|
|
140
|
+
// dependencies on other items the CLI would fetch after this check (a `config` that would merge into components.json is an unknown key, refused above)
|
|
141
|
+
const deps = [...(Array.isArray(o.registryDependencies) ? o.registryDependencies : o.registryDependencies === undefined ? [] : [null]), ...(o.extends === undefined ? [] : [o.extends])]
|
|
142
|
+
for (const d of deps) {
|
|
143
|
+
if (typeof d !== 'string') return { error: 'has a registryDependencies / extends entry that is not a string' }
|
|
144
|
+
if (!PLAIN_NAME.test(d)) {
|
|
145
|
+
return { error: `depends on ${JSON.stringify(d)}, which the CLI would fetch after this check; pass that URL to the command as its own argument so it is checked too` }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { paths }
|
|
149
|
+
}
|