@yopem-ui/cli 0.0.2

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/install.ts ADDED
@@ -0,0 +1,380 @@
1
+ import { createHash } from "node:crypto"
2
+ import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+
5
+ const registryUrl = "http://localhost:3100/r"
6
+
7
+ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
8
+
9
+ const hashPattern = /^sha256-[A-Za-z0-9+/]{43}=$/
10
+
11
+ const types = new Set([
12
+ "registry:base",
13
+ "registry:hook",
14
+ "registry:lib",
15
+ "registry:style",
16
+ "registry:ui",
17
+ ])
18
+
19
+ export type JsonValue =
20
+ | boolean
21
+ | null
22
+ | number
23
+ | string
24
+ | JsonObject
25
+ | JsonValue[]
26
+
27
+ export interface JsonObject {
28
+ [key: string]: JsonValue | undefined
29
+ }
30
+
31
+ interface RegistryFile {
32
+ content: string
33
+ integrity: string
34
+ path: string
35
+ target: string
36
+ type: string
37
+ }
38
+
39
+ interface RegistryItem {
40
+ dependencies: string[]
41
+ devDependencies: string[]
42
+ files: RegistryFile[]
43
+ name: string
44
+ registryDependencies: string[]
45
+ }
46
+
47
+ interface Manifest {
48
+ version: 1
49
+ files: Record<string, string>
50
+ }
51
+
52
+ export interface InstallOptions {
53
+ cwd?: string
54
+ force?: boolean
55
+ mode?: "add" | "update"
56
+ fetcher?: (url: string) => Promise<Response>
57
+ run?: (args: string[], cwd: string) => Promise<void>
58
+ }
59
+
60
+ export function isRecord(value: JsonValue | undefined): value is JsonObject {
61
+ // oxlint-disable-next-line quality/no-runtime-typeof -- SAFETY: reject primitives before reading untrusted registry JSON.
62
+ return typeof value === "object" && value !== null && !Array.isArray(value)
63
+ }
64
+
65
+ export function isString(value: unknown): value is string {
66
+ // oxlint-disable-next-line quality/no-runtime-typeof -- SAFETY: validate untrusted registry fields before use.
67
+ return typeof value === "string"
68
+ }
69
+
70
+ function isStringArray(value: JsonValue | undefined): value is string[] {
71
+ return Array.isArray(value) && value.every(isString)
72
+ }
73
+
74
+ function safePath(path: string) {
75
+ const parts = path.split("/")
76
+
77
+ if (
78
+ parts.length < 2 ||
79
+ parts.some(
80
+ (part) =>
81
+ part === "." ||
82
+ part === ".." ||
83
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part),
84
+ )
85
+ ) {
86
+ throw new Error(`Unsafe registry path: ${path}`)
87
+ }
88
+
89
+ return parts
90
+ }
91
+
92
+ function targetPath(target: string) {
93
+ if (!target.startsWith("@/")) {
94
+ throw new Error(`Unsafe registry target: ${target}`)
95
+ }
96
+
97
+ const parts = safePath(target.slice(2))
98
+
99
+ return join("src", ...parts)
100
+ }
101
+
102
+ function hash(content: string) {
103
+ return `sha256-${createHash("sha256").update(content).digest("base64")}`
104
+ }
105
+
106
+ function parseItem(value: JsonValue, name: string): RegistryItem {
107
+ if (
108
+ !isRecord(value) ||
109
+ value.schemaVersion !== 1 ||
110
+ value.name !== name ||
111
+ !isString(value.type) ||
112
+ !types.has(value.type) ||
113
+ !isString(value.title) ||
114
+ !isString(value.description) ||
115
+ !isString(value.registryVersion) ||
116
+ !isStringArray(value.categories) ||
117
+ !isStringArray(value.dependencies) ||
118
+ !isStringArray(value.devDependencies) ||
119
+ !isStringArray(value.peerDependencies) ||
120
+ !isStringArray(value.registryDependencies) ||
121
+ !Array.isArray(value.files) ||
122
+ value.files.length === 0
123
+ ) {
124
+ throw new Error(`Invalid registry item: ${name}`)
125
+ }
126
+
127
+ for (const dependency of value.registryDependencies) {
128
+ if (!namePattern.test(dependency)) {
129
+ throw new Error(`Invalid registry dependency: ${dependency}`)
130
+ }
131
+ }
132
+
133
+ for (const dependency of [...value.dependencies, ...value.devDependencies]) {
134
+ if (
135
+ !dependency ||
136
+ dependency.startsWith("-") ||
137
+ /[\r\n\0]/.test(dependency)
138
+ ) {
139
+ throw new Error(`Invalid package dependency: ${dependency}`)
140
+ }
141
+ }
142
+
143
+ const files: RegistryFile[] = []
144
+
145
+ for (const file of value.files) {
146
+ if (
147
+ !isRecord(file) ||
148
+ !isString(file.path) ||
149
+ !isString(file.target) ||
150
+ !isString(file.type) ||
151
+ !types.has(file.type) ||
152
+ !isString(file.content) ||
153
+ !isString(file.integrity) ||
154
+ !hashPattern.test(file.integrity)
155
+ ) {
156
+ throw new Error(`Invalid registry file in ${name}`)
157
+ }
158
+
159
+ safePath(file.path)
160
+ targetPath(file.target)
161
+
162
+ if (hash(file.content) !== file.integrity) {
163
+ throw new Error(`Integrity mismatch: ${file.target}`)
164
+ }
165
+
166
+ files.push({
167
+ content: file.content,
168
+ integrity: file.integrity,
169
+ path: file.path,
170
+ target: file.target,
171
+ type: file.type,
172
+ })
173
+ }
174
+
175
+ return {
176
+ name,
177
+ files,
178
+ dependencies: value.dependencies,
179
+ devDependencies: value.devDependencies,
180
+ registryDependencies: value.registryDependencies,
181
+ }
182
+ }
183
+
184
+ export async function existingFile(root: string, relative: string) {
185
+ const parts = relative.split("/")
186
+ let current = root
187
+
188
+ for (const [index, part] of parts.entries()) {
189
+ current = join(current, part)
190
+
191
+ const entry = await lstat(current).catch((cause: unknown) => {
192
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
193
+ return null
194
+ throw cause
195
+ })
196
+
197
+ if (!entry) return false
198
+
199
+ if (
200
+ entry.isSymbolicLink() ||
201
+ (index < parts.length - 1 && !entry.isDirectory())
202
+ ) {
203
+ throw new Error(`Unsafe existing path: ${relative}`)
204
+ }
205
+
206
+ if (index === parts.length - 1 && !entry.isFile()) {
207
+ throw new Error(`Not a regular file: ${relative}`)
208
+ }
209
+ }
210
+
211
+ return true
212
+ }
213
+
214
+ async function readManifest(root: string): Promise<Manifest> {
215
+ if (!(await existingFile(root, ".yopem-ui.json"))) {
216
+ return { version: 1, files: {} }
217
+ }
218
+
219
+ const value: JsonValue = JSON.parse(
220
+ await readFile(join(root, ".yopem-ui.json"), "utf8"),
221
+ )
222
+
223
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.files)) {
224
+ throw new Error("Invalid .yopem-ui.json")
225
+ }
226
+
227
+ const files: Record<string, string> = {}
228
+
229
+ for (const [path, integrity] of Object.entries(value.files)) {
230
+ if (
231
+ path !== targetPath(`@/${path.slice(4)}`) ||
232
+ !path.startsWith("src/") ||
233
+ !isString(integrity) ||
234
+ !hashPattern.test(integrity)
235
+ ) {
236
+ throw new Error("Invalid .yopem-ui.json")
237
+ }
238
+
239
+ files[path] = integrity
240
+ }
241
+
242
+ return { version: 1, files }
243
+ }
244
+
245
+ export async function runBun(args: string[], cwd: string) {
246
+ const process = Bun.spawn(["bun", ...args], {
247
+ cwd,
248
+ stdout: "inherit",
249
+ stderr: "inherit",
250
+ })
251
+
252
+ if ((await process.exited) !== 0) {
253
+ throw new Error(`bun ${args.join(" ")} failed`)
254
+ }
255
+ }
256
+
257
+ export async function installItem(name: string, options: InstallOptions = {}) {
258
+ if (!namePattern.test(name)) throw new Error(`Invalid item name: ${name}`)
259
+ const root = await realpath(options.cwd ?? process.cwd())
260
+ const manifest = await readManifest(root)
261
+ const items = new Map<string, RegistryItem>()
262
+ const visiting = new Set<string>()
263
+ const fetcher = options.fetcher ?? fetch
264
+
265
+ async function visit(itemName: string): Promise<void> {
266
+ if (items.has(itemName)) return
267
+
268
+ if (visiting.has(itemName)) {
269
+ throw new Error(`Cyclic registry dependency: ${itemName}`)
270
+ }
271
+
272
+ visiting.add(itemName)
273
+ const response = await fetcher(`${registryUrl}/${itemName}.json`)
274
+
275
+ if (!response.ok)
276
+ throw new Error(
277
+ `Registry request failed: ${itemName} (${response.status})`,
278
+ )
279
+ const item = parseItem(await response.json(), itemName)
280
+
281
+ for (const dependency of item.registryDependencies) await visit(dependency)
282
+ items.set(itemName, item)
283
+ visiting.delete(itemName)
284
+ }
285
+
286
+ await visit(name)
287
+ const files = new Map<string, RegistryFile>()
288
+ const dependencies = new Set<string>()
289
+ const devDependencies = new Set<string>()
290
+
291
+ for (const item of items.values()) {
292
+ for (const dependency of item.dependencies) dependencies.add(dependency)
293
+
294
+ for (const dependency of item.devDependencies)
295
+ devDependencies.add(dependency)
296
+
297
+ for (const file of item.files) {
298
+ const path = targetPath(file.target)
299
+ const previous = files.get(path)
300
+
301
+ if (previous && previous.integrity !== file.integrity) {
302
+ throw new Error(`Conflicting registry files: ${path}`)
303
+ }
304
+
305
+ files.set(path, file)
306
+ }
307
+ }
308
+
309
+ const writes: [string, RegistryFile][] = []
310
+ let skipped = 0
311
+
312
+ for (const [path, file] of files) {
313
+ const exists = await existingFile(root, path)
314
+
315
+ const current = exists
316
+ ? hash(await readFile(join(root, path), "utf8"))
317
+ : null
318
+
319
+ const previous = manifest.files[path]
320
+
321
+ if (current && previous && current !== previous && !options.force) {
322
+ if (options.mode === "update") {
323
+ throw new Error(`Modified file: ${path} (use --force to overwrite)`)
324
+ }
325
+
326
+ skipped++
327
+ continue
328
+ }
329
+
330
+ if (current === file.integrity) {
331
+ manifest.files[path] = file.integrity
332
+ skipped++
333
+ } else if (
334
+ current &&
335
+ !options.force &&
336
+ previous === current &&
337
+ options.mode !== "update"
338
+ ) {
339
+ skipped++
340
+ } else if (current && !options.force && previous !== current) {
341
+ throw new Error(`Existing file: ${path} (use --force to overwrite)`)
342
+ } else {
343
+ writes.push([path, file])
344
+ }
345
+ }
346
+
347
+ const run = options.run ?? runBun
348
+
349
+ if (dependencies.size) await run(["add", ...dependencies], root)
350
+
351
+ for (const dependency of dependencies) devDependencies.delete(dependency)
352
+
353
+ if (devDependencies.size) await run(["add", "-d", ...devDependencies], root)
354
+
355
+ for (const [path, file] of writes) {
356
+ if (await existingFile(root, path)) {
357
+ const current = hash(await readFile(join(root, path), "utf8"))
358
+ const previous = manifest.files[path]
359
+
360
+ if (
361
+ current !== previous &&
362
+ current !== file.integrity &&
363
+ !options.force
364
+ ) {
365
+ throw new Error(`Modified file: ${path} (use --force to overwrite)`)
366
+ }
367
+ }
368
+
369
+ await mkdir(dirname(join(root, path)), { recursive: true })
370
+ await writeFile(join(root, path), file.content, { flag: "w" })
371
+ manifest.files[path] = file.integrity
372
+ }
373
+
374
+ await writeFile(
375
+ join(root, ".yopem-ui.json"),
376
+ `${JSON.stringify(manifest, null, 2)}\n`,
377
+ )
378
+
379
+ return { installed: writes.length, skipped }
380
+ }