@yopem-ui/cli 0.0.2 → 0.1.1
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 +121 -3
- package/package.json +4 -2
- package/src/cli.ts +136 -21
- package/src/init.ts +546 -156
- package/src/install.ts +554 -61
- package/src/project.ts +394 -0
package/src/install.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPackageName,
|
|
3
|
+
packageRunner,
|
|
4
|
+
pendingDependencies,
|
|
5
|
+
} from "@yopem-ui/cli/project"
|
|
1
6
|
import { createHash } from "node:crypto"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
import {
|
|
8
|
+
chmod,
|
|
9
|
+
link,
|
|
10
|
+
lstat,
|
|
11
|
+
mkdir,
|
|
12
|
+
mkdtemp,
|
|
13
|
+
readFile,
|
|
14
|
+
realpath,
|
|
15
|
+
rename,
|
|
16
|
+
rm,
|
|
17
|
+
unlink,
|
|
18
|
+
writeFile,
|
|
19
|
+
} from "node:fs/promises"
|
|
20
|
+
import { dirname, join, relative } from "node:path"
|
|
21
|
+
|
|
22
|
+
const defaultRegistryUrl = "https://ui.yopem.com/r"
|
|
6
23
|
|
|
7
24
|
const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
8
25
|
|
|
@@ -42,19 +59,139 @@ interface RegistryItem {
|
|
|
42
59
|
files: RegistryFile[]
|
|
43
60
|
name: string
|
|
44
61
|
registryDependencies: string[]
|
|
62
|
+
registryVersion: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Provenance {
|
|
66
|
+
registryUrl: string
|
|
67
|
+
items: Record<string, string>
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
interface Manifest {
|
|
48
71
|
version: 1
|
|
49
72
|
files: Record<string, string>
|
|
73
|
+
importPrefix?: string
|
|
74
|
+
provenance?: Record<string, Provenance>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface InstallPreview {
|
|
78
|
+
files: {
|
|
79
|
+
path: string
|
|
80
|
+
action: "write" | "skip" | "conflict"
|
|
81
|
+
reason?: string
|
|
82
|
+
forced?: true
|
|
83
|
+
}[]
|
|
84
|
+
dependencies: string[]
|
|
85
|
+
devDependencies: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface InstallResult {
|
|
89
|
+
installed: number
|
|
90
|
+
skipped: number
|
|
91
|
+
preview?: InstallPreview
|
|
92
|
+
warnings?: string[]
|
|
50
93
|
}
|
|
51
94
|
|
|
52
95
|
export interface InstallOptions {
|
|
53
96
|
cwd?: string
|
|
97
|
+
dryRun?: boolean
|
|
54
98
|
force?: boolean
|
|
99
|
+
importPrefix?: string
|
|
55
100
|
mode?: "add" | "update"
|
|
56
|
-
|
|
101
|
+
registryUrl?: string
|
|
102
|
+
requestTimeoutMs?: number
|
|
103
|
+
fetcher?: (url: string, init?: RequestInit) => Promise<Response>
|
|
57
104
|
run?: (args: string[], cwd: string) => Promise<void>
|
|
105
|
+
onWarning?: (warning: string) => void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function registryBaseUrl(value: string) {
|
|
109
|
+
const url = URL.parse(value)
|
|
110
|
+
|
|
111
|
+
const local =
|
|
112
|
+
url &&
|
|
113
|
+
(url.hostname === "localhost" ||
|
|
114
|
+
url.hostname === "[::1]" ||
|
|
115
|
+
/^127(?:\.\d+){3}$/.test(url.hostname))
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
!url ||
|
|
119
|
+
url.username ||
|
|
120
|
+
url.password ||
|
|
121
|
+
url.href.includes("?") ||
|
|
122
|
+
url.href.includes("#") ||
|
|
123
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && local))
|
|
124
|
+
) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"Invalid registry URL. Use HTTPS (or local HTTP on localhost or a loopback address), without credentials, query or fragment.",
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return url.href.replace(/\/+$/, "")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function registryItemJson(
|
|
134
|
+
url: string,
|
|
135
|
+
fetcher: NonNullable<InstallOptions["fetcher"]>,
|
|
136
|
+
timeoutMs: number,
|
|
137
|
+
) {
|
|
138
|
+
const controller = new AbortController()
|
|
139
|
+
const deadline = Promise.withResolvers<never>()
|
|
140
|
+
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
deadline.reject(
|
|
143
|
+
new Error(
|
|
144
|
+
`Registry request timed out after ${timeoutMs}ms: ${url}. Check your connection and --registry URL.`,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
controller.abort()
|
|
148
|
+
}, timeoutMs)
|
|
149
|
+
|
|
150
|
+
async function read() {
|
|
151
|
+
let response: Response
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
response = await fetcher(url, {
|
|
155
|
+
signal: controller.signal,
|
|
156
|
+
redirect: "error",
|
|
157
|
+
})
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Registry network error: ${url}. Check your connection and --registry URL.`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (response.status >= 300 && response.status < 400) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Registry redirect rejected: ${url} (HTTP ${response.status}). Redirects are not allowed; check --registry URL.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Registry request failed: ${url} (HTTP ${response.status}). Check --registry URL and item name.`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const value: JsonValue = await response.json()
|
|
178
|
+
|
|
179
|
+
return value
|
|
180
|
+
} catch (cause) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
cause instanceof SyntaxError
|
|
183
|
+
? `Registry returned malformed JSON: ${url}. Check --registry URL and registry contents.`
|
|
184
|
+
: `Registry network error reading response: ${url}. Check your connection and --registry URL.`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return await Promise.race([read(), deadline.promise])
|
|
191
|
+
} finally {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
controller.abort()
|
|
194
|
+
}
|
|
58
195
|
}
|
|
59
196
|
|
|
60
197
|
export function isRecord(value: JsonValue | undefined): value is JsonObject {
|
|
@@ -103,6 +240,10 @@ function hash(content: string) {
|
|
|
103
240
|
return `sha256-${createHash("sha256").update(content).digest("base64")}`
|
|
104
241
|
}
|
|
105
242
|
|
|
243
|
+
function validImportPrefix(value: unknown): value is string {
|
|
244
|
+
return value === "@" || isPackageName(value)
|
|
245
|
+
}
|
|
246
|
+
|
|
106
247
|
function parseItem(value: JsonValue, name: string): RegistryItem {
|
|
107
248
|
if (
|
|
108
249
|
!isRecord(value) ||
|
|
@@ -113,6 +254,7 @@ function parseItem(value: JsonValue, name: string): RegistryItem {
|
|
|
113
254
|
!isString(value.title) ||
|
|
114
255
|
!isString(value.description) ||
|
|
115
256
|
!isString(value.registryVersion) ||
|
|
257
|
+
!value.registryVersion.trim() ||
|
|
116
258
|
!isStringArray(value.categories) ||
|
|
117
259
|
!isStringArray(value.dependencies) ||
|
|
118
260
|
!isStringArray(value.devDependencies) ||
|
|
@@ -178,6 +320,7 @@ function parseItem(value: JsonValue, name: string): RegistryItem {
|
|
|
178
320
|
dependencies: value.dependencies,
|
|
179
321
|
devDependencies: value.devDependencies,
|
|
180
322
|
registryDependencies: value.registryDependencies,
|
|
323
|
+
registryVersion: value.registryVersion,
|
|
181
324
|
}
|
|
182
325
|
}
|
|
183
326
|
|
|
@@ -211,17 +354,18 @@ export async function existingFile(root: string, relative: string) {
|
|
|
211
354
|
return true
|
|
212
355
|
}
|
|
213
356
|
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
return { version: 1, files: {} }
|
|
217
|
-
}
|
|
357
|
+
function readManifest(content: string | null): Manifest {
|
|
358
|
+
if (content === null) return { version: 1, files: {} }
|
|
218
359
|
|
|
219
|
-
const value: JsonValue = JSON.parse(
|
|
220
|
-
await readFile(join(root, ".yopem-ui.json"), "utf8"),
|
|
221
|
-
)
|
|
360
|
+
const value: JsonValue = JSON.parse(content)
|
|
222
361
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
362
|
+
if (
|
|
363
|
+
!isRecord(value) ||
|
|
364
|
+
value.version !== 1 ||
|
|
365
|
+
!isRecord(value.files) ||
|
|
366
|
+
(value.importPrefix !== undefined && !validImportPrefix(value.importPrefix))
|
|
367
|
+
) {
|
|
368
|
+
throw new Error("Invalid ui.json")
|
|
225
369
|
}
|
|
226
370
|
|
|
227
371
|
const files: Record<string, string> = {}
|
|
@@ -233,13 +377,229 @@ async function readManifest(root: string): Promise<Manifest> {
|
|
|
233
377
|
!isString(integrity) ||
|
|
234
378
|
!hashPattern.test(integrity)
|
|
235
379
|
) {
|
|
236
|
-
throw new Error("Invalid
|
|
380
|
+
throw new Error("Invalid ui.json")
|
|
237
381
|
}
|
|
238
382
|
|
|
239
383
|
files[path] = integrity
|
|
240
384
|
}
|
|
241
385
|
|
|
242
|
-
|
|
386
|
+
const provenance: Record<string, Provenance> = {}
|
|
387
|
+
|
|
388
|
+
if (value.provenance !== undefined) {
|
|
389
|
+
if (!isRecord(value.provenance)) throw new Error("Invalid ui.json")
|
|
390
|
+
|
|
391
|
+
for (const [path, origin] of Object.entries(value.provenance)) {
|
|
392
|
+
if (
|
|
393
|
+
!files[path] ||
|
|
394
|
+
!isRecord(origin) ||
|
|
395
|
+
!isString(origin.registryUrl) ||
|
|
396
|
+
!isRecord(origin.items) ||
|
|
397
|
+
Object.keys(origin.items).length === 0
|
|
398
|
+
)
|
|
399
|
+
throw new Error("Invalid ui.json")
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
if (registryBaseUrl(origin.registryUrl) !== origin.registryUrl)
|
|
403
|
+
throw new Error("Invalid ui.json")
|
|
404
|
+
} catch {
|
|
405
|
+
throw new Error("Invalid ui.json provenance registry URL")
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const items: Record<string, string> = {}
|
|
409
|
+
|
|
410
|
+
for (const [name, version] of Object.entries(origin.items)) {
|
|
411
|
+
if (!namePattern.test(name) || !isString(version) || !version.trim())
|
|
412
|
+
throw new Error("Invalid ui.json")
|
|
413
|
+
items[name] = version
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
provenance[path] = { registryUrl: origin.registryUrl, items }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
version: 1,
|
|
422
|
+
files,
|
|
423
|
+
...(value.provenance !== undefined && { provenance }),
|
|
424
|
+
...(value.importPrefix !== undefined && {
|
|
425
|
+
importPrefix: value.importPrefix,
|
|
426
|
+
}),
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface FileChange {
|
|
431
|
+
before: string | null
|
|
432
|
+
after: string | null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
interface StagedFile {
|
|
436
|
+
path: string
|
|
437
|
+
before: Buffer | null
|
|
438
|
+
after: Buffer | null
|
|
439
|
+
mode: number | null
|
|
440
|
+
afterMode: number | null
|
|
441
|
+
changed: boolean
|
|
442
|
+
directory: string | null
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function writeFiles(
|
|
446
|
+
root: string,
|
|
447
|
+
changes: ReadonlyMap<string, FileChange>,
|
|
448
|
+
prepare?: () => Promise<void>,
|
|
449
|
+
) {
|
|
450
|
+
const staged: StagedFile[] = []
|
|
451
|
+
const committed: StagedFile[] = []
|
|
452
|
+
const recovery = new Set<StagedFile>()
|
|
453
|
+
|
|
454
|
+
async function verify(
|
|
455
|
+
file: StagedFile,
|
|
456
|
+
expected: Buffer | null,
|
|
457
|
+
mode: number | null,
|
|
458
|
+
) {
|
|
459
|
+
const exists = await existingFile(root, file.path)
|
|
460
|
+
const path = join(root, file.path)
|
|
461
|
+
const current = exists ? await readFile(path) : null
|
|
462
|
+
|
|
463
|
+
if (
|
|
464
|
+
(current === null
|
|
465
|
+
? expected !== null
|
|
466
|
+
: expected === null || !current.equals(expected)) ||
|
|
467
|
+
(exists && ((await lstat(path)).mode & 0o7777) !== mode)
|
|
468
|
+
) {
|
|
469
|
+
throw new Error(`File changed before commit: ${file.path}`)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function cleanup() {
|
|
474
|
+
const failures: string[] = []
|
|
475
|
+
|
|
476
|
+
for (const file of staged) {
|
|
477
|
+
if (!file.directory || recovery.has(file)) continue
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
await existingFile(root, relative(root, join(file.directory, "before")))
|
|
481
|
+
await rm(file.directory, { recursive: true, force: true })
|
|
482
|
+
} catch (cause) {
|
|
483
|
+
failures.push(
|
|
484
|
+
`Temporary cleanup failed: ${file.directory}: ${String(cause)}`,
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return failures
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
for (const [path, change] of changes) {
|
|
494
|
+
const exists = await existingFile(root, path)
|
|
495
|
+
const before = exists ? await readFile(join(root, path)) : null
|
|
496
|
+
|
|
497
|
+
if ((before?.toString("utf8") ?? null) !== change.before) {
|
|
498
|
+
throw new Error(`File changed before commit: ${path}`)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const mode = exists ? (await lstat(join(root, path))).mode & 0o7777 : null
|
|
502
|
+
const after = change.after === null ? null : Buffer.from(change.after)
|
|
503
|
+
staged.push({
|
|
504
|
+
path,
|
|
505
|
+
before,
|
|
506
|
+
after,
|
|
507
|
+
mode,
|
|
508
|
+
afterMode: mode,
|
|
509
|
+
changed: change.before !== change.after,
|
|
510
|
+
directory: null,
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const file of staged) {
|
|
515
|
+
if (!file.changed) continue
|
|
516
|
+
const parent = dirname(join(root, file.path))
|
|
517
|
+
await mkdir(parent, { recursive: true })
|
|
518
|
+
await verify(file, file.before, file.mode)
|
|
519
|
+
file.directory = await mkdtemp(join(parent, ".yopem-ui-"))
|
|
520
|
+
|
|
521
|
+
if (file.after !== null) {
|
|
522
|
+
const path = join(file.directory, "after")
|
|
523
|
+
await writeFile(path, file.after, { flag: "wx" })
|
|
524
|
+
|
|
525
|
+
if (file.mode !== null) await chmod(path, file.mode)
|
|
526
|
+
file.afterMode = (await lstat(path)).mode & 0o7777
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (file.before !== null) {
|
|
530
|
+
const path = join(file.directory, "before")
|
|
531
|
+
await writeFile(path, file.before, { flag: "wx" })
|
|
532
|
+
await chmod(path, file.mode!)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
await prepare?.()
|
|
537
|
+
|
|
538
|
+
for (const file of staged) await verify(file, file.before, file.mode)
|
|
539
|
+
|
|
540
|
+
for (const file of staged) {
|
|
541
|
+
if (!file.changed) continue
|
|
542
|
+
await verify(file, file.before, file.mode)
|
|
543
|
+
const path = join(root, file.path)
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
if (file.after === null) await unlink(path)
|
|
547
|
+
else if (file.before === null)
|
|
548
|
+
await link(join(file.directory!, "after"), path)
|
|
549
|
+
else await rename(join(file.directory!, "after"), path)
|
|
550
|
+
} catch (cause) {
|
|
551
|
+
throw new Error(`Failed to commit ${file.path}: ${String(cause)}`, {
|
|
552
|
+
cause,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
committed.push(file)
|
|
557
|
+
}
|
|
558
|
+
} catch (cause) {
|
|
559
|
+
const failures: string[] = []
|
|
560
|
+
|
|
561
|
+
for (const file of committed.toReversed()) {
|
|
562
|
+
try {
|
|
563
|
+
await verify(
|
|
564
|
+
file,
|
|
565
|
+
file.after,
|
|
566
|
+
file.after === null ? null : file.afterMode,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if (file.before === null) await unlink(join(root, file.path))
|
|
570
|
+
else if (file.after === null)
|
|
571
|
+
await link(join(file.directory!, "before"), join(root, file.path))
|
|
572
|
+
else
|
|
573
|
+
await rename(join(file.directory!, "before"), join(root, file.path))
|
|
574
|
+
} catch (rollbackCause) {
|
|
575
|
+
const backup =
|
|
576
|
+
file.before === null
|
|
577
|
+
? ""
|
|
578
|
+
: `; original backup: ${join(file.directory!, "before")}`
|
|
579
|
+
|
|
580
|
+
if (file.before !== null) recovery.add(file)
|
|
581
|
+
failures.push(`${file.path}: ${String(rollbackCause)}${backup}`)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const rollback = committed.length
|
|
586
|
+
? failures.length
|
|
587
|
+
? `\nRollback incomplete: ${failures.join("\n")}`
|
|
588
|
+
: "\nCommitted files restored."
|
|
589
|
+
: ""
|
|
590
|
+
|
|
591
|
+
const cleanupFailures = await cleanup()
|
|
592
|
+
throw new Error(
|
|
593
|
+
`${cause instanceof Error ? cause.message : String(cause)}${rollback}${cleanupFailures.length ? `\n${cleanupFailures.join("\n")}` : ""}`,
|
|
594
|
+
{ cause },
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const failures = await cleanup()
|
|
599
|
+
|
|
600
|
+
if (failures.length) {
|
|
601
|
+
throw new Error(`Files committed. ${failures.join("\n")}`)
|
|
602
|
+
}
|
|
243
603
|
}
|
|
244
604
|
|
|
245
605
|
export async function runBun(args: string[], cwd: string) {
|
|
@@ -254,10 +614,48 @@ export async function runBun(args: string[], cwd: string) {
|
|
|
254
614
|
}
|
|
255
615
|
}
|
|
256
616
|
|
|
257
|
-
export async function
|
|
617
|
+
export async function planInstall(name: string, options: InstallOptions = {}) {
|
|
258
618
|
if (!namePattern.test(name)) throw new Error(`Invalid item name: ${name}`)
|
|
619
|
+
const registryUrl = registryBaseUrl(options.registryUrl ?? defaultRegistryUrl)
|
|
620
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 30_000
|
|
621
|
+
|
|
622
|
+
if (
|
|
623
|
+
!Number.isInteger(requestTimeoutMs) ||
|
|
624
|
+
requestTimeoutMs <= 0 ||
|
|
625
|
+
requestTimeoutMs > 2_147_483_647
|
|
626
|
+
) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
"Registry request timeout must be an integer from 1 to 2147483647ms",
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
|
|
259
632
|
const root = await realpath(options.cwd ?? process.cwd())
|
|
260
|
-
const
|
|
633
|
+
const run = await packageRunner(root, options.run)
|
|
634
|
+
|
|
635
|
+
const manifestText = (await existingFile(root, "ui.json"))
|
|
636
|
+
? await readFile(join(root, "ui.json"), "utf8")
|
|
637
|
+
: null
|
|
638
|
+
|
|
639
|
+
const manifest = readManifest(manifestText)
|
|
640
|
+
const originalManifest = JSON.stringify(manifest)
|
|
641
|
+
const importPrefix = options.importPrefix ?? manifest.importPrefix ?? "@"
|
|
642
|
+
|
|
643
|
+
if (
|
|
644
|
+
!validImportPrefix(importPrefix) ||
|
|
645
|
+
(options.importPrefix !== undefined &&
|
|
646
|
+
!validImportPrefix(options.importPrefix))
|
|
647
|
+
) {
|
|
648
|
+
throw new Error(`Invalid import prefix: ${importPrefix}`)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (
|
|
652
|
+
importPrefix !== (manifest.importPrefix ?? "@") &&
|
|
653
|
+
Object.keys(manifest.files).length > 0
|
|
654
|
+
) {
|
|
655
|
+
throw new Error("Cannot change import prefix with tracked files")
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (options.importPrefix !== undefined) manifest.importPrefix = importPrefix
|
|
261
659
|
const items = new Map<string, RegistryItem>()
|
|
262
660
|
const visiting = new Set<string>()
|
|
263
661
|
const fetcher = options.fetcher ?? fetch
|
|
@@ -270,13 +668,15 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
270
668
|
}
|
|
271
669
|
|
|
272
670
|
visiting.add(itemName)
|
|
273
|
-
const response = await fetcher(`${registryUrl}/${itemName}.json`)
|
|
274
671
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
672
|
+
const item = parseItem(
|
|
673
|
+
await registryItemJson(
|
|
674
|
+
`${registryUrl}/${itemName}.json`,
|
|
675
|
+
fetcher,
|
|
676
|
+
requestTimeoutMs,
|
|
677
|
+
),
|
|
678
|
+
itemName,
|
|
679
|
+
)
|
|
280
680
|
|
|
281
681
|
for (const dependency of item.registryDependencies) await visit(dependency)
|
|
282
682
|
items.set(itemName, item)
|
|
@@ -285,6 +685,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
285
685
|
|
|
286
686
|
await visit(name)
|
|
287
687
|
const files = new Map<string, RegistryFile>()
|
|
688
|
+
const owners = new Map<string, Record<string, string>>()
|
|
288
689
|
const dependencies = new Set<string>()
|
|
289
690
|
const devDependencies = new Set<string>()
|
|
290
691
|
|
|
@@ -296,39 +697,79 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
296
697
|
|
|
297
698
|
for (const file of item.files) {
|
|
298
699
|
const path = targetPath(file.target)
|
|
700
|
+
const content = file.content.replace(/(["'`])@\//g, `$1${importPrefix}/`)
|
|
701
|
+
const transformed = { ...file, content, integrity: hash(content) }
|
|
299
702
|
const previous = files.get(path)
|
|
300
703
|
|
|
301
|
-
if (previous && previous.integrity !==
|
|
704
|
+
if (previous && previous.integrity !== transformed.integrity) {
|
|
302
705
|
throw new Error(`Conflicting registry files: ${path}`)
|
|
303
706
|
}
|
|
304
707
|
|
|
305
|
-
files.set(path,
|
|
708
|
+
files.set(path, transformed)
|
|
709
|
+
const ownership = owners.get(path) ?? {}
|
|
710
|
+
ownership[item.name] = item.registryVersion
|
|
711
|
+
owners.set(path, ownership)
|
|
306
712
|
}
|
|
307
713
|
}
|
|
308
714
|
|
|
309
|
-
const
|
|
715
|
+
const changes = new Map<string, FileChange>()
|
|
716
|
+
const previewFiles: InstallPreview["files"] = []
|
|
717
|
+
let installed = 0
|
|
310
718
|
let skipped = 0
|
|
719
|
+
const warnings: string[] = []
|
|
720
|
+
|
|
721
|
+
function track(path: string, integrity: string, applied: boolean) {
|
|
722
|
+
manifest.files[path] = integrity
|
|
723
|
+
const previous = manifest.provenance?.[path]
|
|
724
|
+
|
|
725
|
+
if (!applied && previous && previous.registryUrl !== registryUrl) return
|
|
726
|
+
|
|
727
|
+
const ownership =
|
|
728
|
+
applied || !previous ? { ...owners.get(path)! } : { ...previous.items }
|
|
729
|
+
|
|
730
|
+
if (!applied && previous) {
|
|
731
|
+
for (const [name, version] of Object.entries(owners.get(path)!)) {
|
|
732
|
+
if (!Object.hasOwn(ownership, name)) ownership[name] = version
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
manifest.provenance ??= {}
|
|
737
|
+
manifest.provenance[path] = { registryUrl, items: ownership }
|
|
738
|
+
}
|
|
311
739
|
|
|
312
740
|
for (const [path, file] of files) {
|
|
313
741
|
const exists = await existingFile(root, path)
|
|
314
742
|
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
743
|
+
const before = exists ? await readFile(join(root, path), "utf8") : null
|
|
744
|
+
const current = before === null ? null : hash(before)
|
|
745
|
+
changes.set(path, { before, after: before })
|
|
319
746
|
const previous = manifest.files[path]
|
|
747
|
+
const origin = manifest.provenance?.[path]
|
|
748
|
+
|
|
749
|
+
if (origin && origin.registryUrl !== registryUrl) {
|
|
750
|
+
warnings.push(
|
|
751
|
+
`Updating tracked file from a different registry: ${path}. Previous: ${origin.registryUrl}; requested: ${registryUrl}. Review source before applying.`,
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const decision: InstallPreview["files"][number] = { path, action: "skip" }
|
|
756
|
+
previewFiles.push(decision)
|
|
320
757
|
|
|
321
758
|
if (current && previous && current !== previous && !options.force) {
|
|
759
|
+
decision.reason = `Modified file: ${path} (use --force to overwrite)`
|
|
760
|
+
|
|
322
761
|
if (options.mode === "update") {
|
|
323
|
-
|
|
324
|
-
|
|
762
|
+
decision.action = "conflict"
|
|
763
|
+
|
|
764
|
+
if (!options.dryRun) throw new Error(decision.reason)
|
|
765
|
+
} else skipped++
|
|
325
766
|
|
|
326
|
-
skipped++
|
|
327
767
|
continue
|
|
328
768
|
}
|
|
329
769
|
|
|
330
770
|
if (current === file.integrity) {
|
|
331
|
-
|
|
771
|
+
track(path, file.integrity, false)
|
|
772
|
+
decision.reason = `Identical file: ${path}`
|
|
332
773
|
skipped++
|
|
333
774
|
} else if (
|
|
334
775
|
current &&
|
|
@@ -336,45 +777,97 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
336
777
|
previous === current &&
|
|
337
778
|
options.mode !== "update"
|
|
338
779
|
) {
|
|
780
|
+
decision.reason = `Tracked file: ${path} (use update to replace)`
|
|
339
781
|
skipped++
|
|
340
782
|
} else if (current && !options.force && previous !== current) {
|
|
341
|
-
|
|
783
|
+
decision.action = "conflict"
|
|
784
|
+
decision.reason = `Existing file: ${path} (use --force to overwrite)`
|
|
785
|
+
|
|
786
|
+
if (!options.dryRun) throw new Error(decision.reason)
|
|
342
787
|
} else {
|
|
343
|
-
|
|
788
|
+
decision.action = "write"
|
|
789
|
+
|
|
790
|
+
if (current && options.force) decision.forced = true
|
|
791
|
+
changes.set(path, { before, after: file.content })
|
|
792
|
+
track(path, file.integrity, true)
|
|
793
|
+
installed++
|
|
344
794
|
}
|
|
345
795
|
}
|
|
346
796
|
|
|
347
|
-
const
|
|
797
|
+
const manifestAfter =
|
|
798
|
+
manifestText !== null && JSON.stringify(manifest) === originalManifest
|
|
799
|
+
? manifestText
|
|
800
|
+
: `${JSON.stringify(manifest, null, 2)}\n`
|
|
348
801
|
|
|
349
|
-
|
|
802
|
+
changes.set("ui.json", { before: manifestText, after: manifestAfter })
|
|
803
|
+
previewFiles.push({
|
|
804
|
+
path: "ui.json",
|
|
805
|
+
action: manifestText === manifestAfter ? "skip" : "write",
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
const pending = await pendingDependencies(root, dependencies, devDependencies)
|
|
809
|
+
|
|
810
|
+
return {
|
|
811
|
+
root,
|
|
812
|
+
run,
|
|
813
|
+
changes,
|
|
814
|
+
installed,
|
|
815
|
+
skipped,
|
|
816
|
+
warnings,
|
|
817
|
+
requirements: {
|
|
818
|
+
dependencies: [...dependencies],
|
|
819
|
+
devDependencies: [...devDependencies],
|
|
820
|
+
},
|
|
821
|
+
preview: { files: previewFiles, ...pending },
|
|
822
|
+
}
|
|
823
|
+
}
|
|
350
824
|
|
|
351
|
-
|
|
825
|
+
export async function installItem(
|
|
826
|
+
name: string,
|
|
827
|
+
options: InstallOptions = {},
|
|
828
|
+
): Promise<InstallResult> {
|
|
829
|
+
return await applyInstall(await planInstall(name, options), options)
|
|
830
|
+
}
|
|
352
831
|
|
|
353
|
-
|
|
832
|
+
export async function applyInstall(
|
|
833
|
+
plan: Awaited<ReturnType<typeof planInstall>>,
|
|
834
|
+
options: InstallOptions = {},
|
|
835
|
+
): Promise<InstallResult> {
|
|
836
|
+
const { root, run, changes, installed, skipped, warnings, preview } = plan
|
|
837
|
+
const warningResult = warnings.length ? { warnings } : {}
|
|
838
|
+
|
|
839
|
+
for (const warning of warnings) options.onWarning?.(warning)
|
|
840
|
+
|
|
841
|
+
if (options.dryRun) {
|
|
842
|
+
return {
|
|
843
|
+
installed: 0,
|
|
844
|
+
skipped,
|
|
845
|
+
preview,
|
|
846
|
+
...warningResult,
|
|
847
|
+
}
|
|
848
|
+
}
|
|
354
849
|
|
|
355
|
-
|
|
356
|
-
if (await existingFile(root, path)) {
|
|
357
|
-
const current = hash(await readFile(join(root, path), "utf8"))
|
|
358
|
-
const previous = manifest.files[path]
|
|
850
|
+
let dependenciesStarted = false
|
|
359
851
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
throw new Error(`Modified file: ${path} (use --force to overwrite)`)
|
|
852
|
+
try {
|
|
853
|
+
await writeFiles(root, changes, async () => {
|
|
854
|
+
if (preview.dependencies.length) {
|
|
855
|
+
dependenciesStarted = true
|
|
856
|
+
await run(["add", ...preview.dependencies], root)
|
|
366
857
|
}
|
|
367
|
-
}
|
|
368
858
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
859
|
+
if (preview.devDependencies.length) {
|
|
860
|
+
dependenciesStarted = true
|
|
861
|
+
await run(["add", "-d", ...preview.devDependencies], root)
|
|
862
|
+
}
|
|
863
|
+
})
|
|
864
|
+
} catch (cause) {
|
|
865
|
+
if (!dependenciesStarted) throw cause
|
|
866
|
+
throw new Error(
|
|
867
|
+
`${cause instanceof Error ? cause.message : String(cause)}\nDependencies may have changed (package.json, lockfiles, node_modules); package-manager changes were not rolled back.`,
|
|
868
|
+
{ cause },
|
|
869
|
+
)
|
|
372
870
|
}
|
|
373
871
|
|
|
374
|
-
|
|
375
|
-
join(root, ".yopem-ui.json"),
|
|
376
|
-
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
return { installed: writes.length, skipped }
|
|
872
|
+
return { installed, skipped, ...warningResult }
|
|
380
873
|
}
|