@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/README.md +21 -0
- package/package.json +44 -0
- package/src/cli.ts +75 -0
- package/src/init.ts +1795 -0
- package/src/install.ts +380 -0
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
|
+
}
|