@yopem-ui/cli 0.0.2 → 0.1.0
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 +98 -18
- package/src/init.ts +370 -164
- package/src/install.ts +422 -59
- package/src/project.ts +223 -0
package/src/install.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { isPackageName, packageRunner } from "@yopem-ui/cli/project"
|
|
1
2
|
import { createHash } from "node:crypto"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
link,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
readFile,
|
|
10
|
+
realpath,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
unlink,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from "node:fs/promises"
|
|
16
|
+
import { dirname, join, relative } from "node:path"
|
|
17
|
+
|
|
18
|
+
const defaultRegistryUrl = "https://ui.yopem.com/r"
|
|
6
19
|
|
|
7
20
|
const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
8
21
|
|
|
@@ -47,16 +60,112 @@ interface RegistryItem {
|
|
|
47
60
|
interface Manifest {
|
|
48
61
|
version: 1
|
|
49
62
|
files: Record<string, string>
|
|
63
|
+
importPrefix?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface InstallPreview {
|
|
67
|
+
files: {
|
|
68
|
+
path: string
|
|
69
|
+
action: "write" | "skip" | "conflict"
|
|
70
|
+
reason?: string
|
|
71
|
+
forced?: true
|
|
72
|
+
}[]
|
|
73
|
+
dependencies: string[]
|
|
74
|
+
devDependencies: string[]
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
export interface InstallOptions {
|
|
53
78
|
cwd?: string
|
|
79
|
+
dryRun?: boolean
|
|
54
80
|
force?: boolean
|
|
81
|
+
importPrefix?: string
|
|
55
82
|
mode?: "add" | "update"
|
|
56
|
-
|
|
83
|
+
registryUrl?: string
|
|
84
|
+
requestTimeoutMs?: number
|
|
85
|
+
fetcher?: (url: string, init?: RequestInit) => Promise<Response>
|
|
57
86
|
run?: (args: string[], cwd: string) => Promise<void>
|
|
58
87
|
}
|
|
59
88
|
|
|
89
|
+
function registryBaseUrl(value: string) {
|
|
90
|
+
const url = URL.parse(value)
|
|
91
|
+
|
|
92
|
+
const local =
|
|
93
|
+
url &&
|
|
94
|
+
(url.hostname === "localhost" ||
|
|
95
|
+
url.hostname === "[::1]" ||
|
|
96
|
+
/^127(?:\.\d+){3}$/.test(url.hostname))
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
!url ||
|
|
100
|
+
url.username ||
|
|
101
|
+
url.password ||
|
|
102
|
+
url.href.includes("?") ||
|
|
103
|
+
url.href.includes("#") ||
|
|
104
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && local))
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"Invalid registry URL. Use HTTPS (or local HTTP on localhost or a loopback address), without credentials, query or fragment.",
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return url.href.replace(/\/+$/, "")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function registryItemJson(
|
|
115
|
+
url: string,
|
|
116
|
+
fetcher: NonNullable<InstallOptions["fetcher"]>,
|
|
117
|
+
timeoutMs: number,
|
|
118
|
+
) {
|
|
119
|
+
const controller = new AbortController()
|
|
120
|
+
const deadline = Promise.withResolvers<never>()
|
|
121
|
+
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
deadline.reject(
|
|
124
|
+
new Error(
|
|
125
|
+
`Registry request timed out after ${timeoutMs}ms: ${url}. Check your connection and --registry URL.`,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
controller.abort()
|
|
129
|
+
}, timeoutMs)
|
|
130
|
+
|
|
131
|
+
async function read() {
|
|
132
|
+
let response: Response
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
response = await fetcher(url, { signal: controller.signal })
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Registry network error: ${url}. Check your connection and --registry URL.`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Registry request failed: ${url} (HTTP ${response.status}). Check --registry URL and item name.`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const value: JsonValue = await response.json()
|
|
150
|
+
|
|
151
|
+
return value
|
|
152
|
+
} catch (cause) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
cause instanceof SyntaxError
|
|
155
|
+
? `Registry returned malformed JSON: ${url}. Check --registry URL and registry contents.`
|
|
156
|
+
: `Registry network error reading response: ${url}. Check your connection and --registry URL.`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return await Promise.race([read(), deadline.promise])
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
controller.abort()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
60
169
|
export function isRecord(value: JsonValue | undefined): value is JsonObject {
|
|
61
170
|
// oxlint-disable-next-line quality/no-runtime-typeof -- SAFETY: reject primitives before reading untrusted registry JSON.
|
|
62
171
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -103,6 +212,10 @@ function hash(content: string) {
|
|
|
103
212
|
return `sha256-${createHash("sha256").update(content).digest("base64")}`
|
|
104
213
|
}
|
|
105
214
|
|
|
215
|
+
function validImportPrefix(value: unknown): value is string {
|
|
216
|
+
return value === "@" || isPackageName(value)
|
|
217
|
+
}
|
|
218
|
+
|
|
106
219
|
function parseItem(value: JsonValue, name: string): RegistryItem {
|
|
107
220
|
if (
|
|
108
221
|
!isRecord(value) ||
|
|
@@ -211,17 +324,18 @@ export async function existingFile(root: string, relative: string) {
|
|
|
211
324
|
return true
|
|
212
325
|
}
|
|
213
326
|
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
return { version: 1, files: {} }
|
|
217
|
-
}
|
|
327
|
+
function readManifest(content: string | null): Manifest {
|
|
328
|
+
if (content === null) return { version: 1, files: {} }
|
|
218
329
|
|
|
219
|
-
const value: JsonValue = JSON.parse(
|
|
220
|
-
await readFile(join(root, ".yopem-ui.json"), "utf8"),
|
|
221
|
-
)
|
|
330
|
+
const value: JsonValue = JSON.parse(content)
|
|
222
331
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
332
|
+
if (
|
|
333
|
+
!isRecord(value) ||
|
|
334
|
+
value.version !== 1 ||
|
|
335
|
+
!isRecord(value.files) ||
|
|
336
|
+
(value.importPrefix !== undefined && !validImportPrefix(value.importPrefix))
|
|
337
|
+
) {
|
|
338
|
+
throw new Error("Invalid ui.json")
|
|
225
339
|
}
|
|
226
340
|
|
|
227
341
|
const files: Record<string, string> = {}
|
|
@@ -233,13 +347,194 @@ async function readManifest(root: string): Promise<Manifest> {
|
|
|
233
347
|
!isString(integrity) ||
|
|
234
348
|
!hashPattern.test(integrity)
|
|
235
349
|
) {
|
|
236
|
-
throw new Error("Invalid
|
|
350
|
+
throw new Error("Invalid ui.json")
|
|
237
351
|
}
|
|
238
352
|
|
|
239
353
|
files[path] = integrity
|
|
240
354
|
}
|
|
241
355
|
|
|
242
|
-
return {
|
|
356
|
+
return {
|
|
357
|
+
version: 1,
|
|
358
|
+
files,
|
|
359
|
+
...(value.importPrefix !== undefined && {
|
|
360
|
+
importPrefix: value.importPrefix,
|
|
361
|
+
}),
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface FileChange {
|
|
366
|
+
before: string | null
|
|
367
|
+
after: string | null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface StagedFile {
|
|
371
|
+
path: string
|
|
372
|
+
before: Buffer | null
|
|
373
|
+
after: Buffer | null
|
|
374
|
+
mode: number | null
|
|
375
|
+
afterMode: number | null
|
|
376
|
+
changed: boolean
|
|
377
|
+
directory: string | null
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function writeFiles(
|
|
381
|
+
root: string,
|
|
382
|
+
changes: ReadonlyMap<string, FileChange>,
|
|
383
|
+
prepare?: () => Promise<void>,
|
|
384
|
+
) {
|
|
385
|
+
const staged: StagedFile[] = []
|
|
386
|
+
const committed: StagedFile[] = []
|
|
387
|
+
const recovery = new Set<StagedFile>()
|
|
388
|
+
|
|
389
|
+
async function verify(
|
|
390
|
+
file: StagedFile,
|
|
391
|
+
expected: Buffer | null,
|
|
392
|
+
mode: number | null,
|
|
393
|
+
) {
|
|
394
|
+
const exists = await existingFile(root, file.path)
|
|
395
|
+
const path = join(root, file.path)
|
|
396
|
+
const current = exists ? await readFile(path) : null
|
|
397
|
+
|
|
398
|
+
if (
|
|
399
|
+
(current === null
|
|
400
|
+
? expected !== null
|
|
401
|
+
: expected === null || !current.equals(expected)) ||
|
|
402
|
+
(exists && ((await lstat(path)).mode & 0o7777) !== mode)
|
|
403
|
+
) {
|
|
404
|
+
throw new Error(`File changed before commit: ${file.path}`)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function cleanup() {
|
|
409
|
+
const failures: string[] = []
|
|
410
|
+
|
|
411
|
+
for (const file of staged) {
|
|
412
|
+
if (!file.directory || recovery.has(file)) continue
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
await existingFile(root, relative(root, join(file.directory, "before")))
|
|
416
|
+
await rm(file.directory, { recursive: true, force: true })
|
|
417
|
+
} catch (cause) {
|
|
418
|
+
failures.push(
|
|
419
|
+
`Temporary cleanup failed: ${file.directory}: ${String(cause)}`,
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return failures
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
for (const [path, change] of changes) {
|
|
429
|
+
const exists = await existingFile(root, path)
|
|
430
|
+
const before = exists ? await readFile(join(root, path)) : null
|
|
431
|
+
|
|
432
|
+
if ((before?.toString("utf8") ?? null) !== change.before) {
|
|
433
|
+
throw new Error(`File changed before commit: ${path}`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const mode = exists ? (await lstat(join(root, path))).mode & 0o7777 : null
|
|
437
|
+
const after = change.after === null ? null : Buffer.from(change.after)
|
|
438
|
+
staged.push({
|
|
439
|
+
path,
|
|
440
|
+
before,
|
|
441
|
+
after,
|
|
442
|
+
mode,
|
|
443
|
+
afterMode: mode,
|
|
444
|
+
changed: change.before !== change.after,
|
|
445
|
+
directory: null,
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const file of staged) {
|
|
450
|
+
if (!file.changed) continue
|
|
451
|
+
const parent = dirname(join(root, file.path))
|
|
452
|
+
await mkdir(parent, { recursive: true })
|
|
453
|
+
await verify(file, file.before, file.mode)
|
|
454
|
+
file.directory = await mkdtemp(join(parent, ".yopem-ui-"))
|
|
455
|
+
|
|
456
|
+
if (file.after !== null) {
|
|
457
|
+
const path = join(file.directory, "after")
|
|
458
|
+
await writeFile(path, file.after, { flag: "wx" })
|
|
459
|
+
|
|
460
|
+
if (file.mode !== null) await chmod(path, file.mode)
|
|
461
|
+
file.afterMode = (await lstat(path)).mode & 0o7777
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (file.before !== null) {
|
|
465
|
+
const path = join(file.directory, "before")
|
|
466
|
+
await writeFile(path, file.before, { flag: "wx" })
|
|
467
|
+
await chmod(path, file.mode!)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await prepare?.()
|
|
472
|
+
|
|
473
|
+
for (const file of staged) await verify(file, file.before, file.mode)
|
|
474
|
+
|
|
475
|
+
for (const file of staged) {
|
|
476
|
+
if (!file.changed) continue
|
|
477
|
+
await verify(file, file.before, file.mode)
|
|
478
|
+
const path = join(root, file.path)
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
if (file.after === null) await unlink(path)
|
|
482
|
+
else if (file.before === null)
|
|
483
|
+
await link(join(file.directory!, "after"), path)
|
|
484
|
+
else await rename(join(file.directory!, "after"), path)
|
|
485
|
+
} catch (cause) {
|
|
486
|
+
throw new Error(`Failed to commit ${file.path}: ${String(cause)}`, {
|
|
487
|
+
cause,
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
committed.push(file)
|
|
492
|
+
}
|
|
493
|
+
} catch (cause) {
|
|
494
|
+
const failures: string[] = []
|
|
495
|
+
|
|
496
|
+
for (const file of committed.toReversed()) {
|
|
497
|
+
try {
|
|
498
|
+
await verify(
|
|
499
|
+
file,
|
|
500
|
+
file.after,
|
|
501
|
+
file.after === null ? null : file.afterMode,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if (file.before === null) await unlink(join(root, file.path))
|
|
505
|
+
else if (file.after === null)
|
|
506
|
+
await link(join(file.directory!, "before"), join(root, file.path))
|
|
507
|
+
else
|
|
508
|
+
await rename(join(file.directory!, "before"), join(root, file.path))
|
|
509
|
+
} catch (rollbackCause) {
|
|
510
|
+
const backup =
|
|
511
|
+
file.before === null
|
|
512
|
+
? ""
|
|
513
|
+
: `; original backup: ${join(file.directory!, "before")}`
|
|
514
|
+
|
|
515
|
+
if (file.before !== null) recovery.add(file)
|
|
516
|
+
failures.push(`${file.path}: ${String(rollbackCause)}${backup}`)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const rollback = committed.length
|
|
521
|
+
? failures.length
|
|
522
|
+
? `\nRollback incomplete: ${failures.join("\n")}`
|
|
523
|
+
: "\nCommitted files restored."
|
|
524
|
+
: ""
|
|
525
|
+
|
|
526
|
+
const cleanupFailures = await cleanup()
|
|
527
|
+
throw new Error(
|
|
528
|
+
`${cause instanceof Error ? cause.message : String(cause)}${rollback}${cleanupFailures.length ? `\n${cleanupFailures.join("\n")}` : ""}`,
|
|
529
|
+
{ cause },
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const failures = await cleanup()
|
|
534
|
+
|
|
535
|
+
if (failures.length) {
|
|
536
|
+
throw new Error(`Files committed. ${failures.join("\n")}`)
|
|
537
|
+
}
|
|
243
538
|
}
|
|
244
539
|
|
|
245
540
|
export async function runBun(args: string[], cwd: string) {
|
|
@@ -256,8 +551,45 @@ export async function runBun(args: string[], cwd: string) {
|
|
|
256
551
|
|
|
257
552
|
export async function installItem(name: string, options: InstallOptions = {}) {
|
|
258
553
|
if (!namePattern.test(name)) throw new Error(`Invalid item name: ${name}`)
|
|
554
|
+
const registryUrl = registryBaseUrl(options.registryUrl ?? defaultRegistryUrl)
|
|
555
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 30_000
|
|
556
|
+
|
|
557
|
+
if (
|
|
558
|
+
!Number.isInteger(requestTimeoutMs) ||
|
|
559
|
+
requestTimeoutMs <= 0 ||
|
|
560
|
+
requestTimeoutMs > 2_147_483_647
|
|
561
|
+
) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
"Registry request timeout must be an integer from 1 to 2147483647ms",
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
259
567
|
const root = await realpath(options.cwd ?? process.cwd())
|
|
260
|
-
const
|
|
568
|
+
const run = await packageRunner(root, options.run)
|
|
569
|
+
|
|
570
|
+
const manifestText = (await existingFile(root, "ui.json"))
|
|
571
|
+
? await readFile(join(root, "ui.json"), "utf8")
|
|
572
|
+
: null
|
|
573
|
+
|
|
574
|
+
const manifest = readManifest(manifestText)
|
|
575
|
+
const importPrefix = options.importPrefix ?? manifest.importPrefix ?? "@"
|
|
576
|
+
|
|
577
|
+
if (
|
|
578
|
+
!validImportPrefix(importPrefix) ||
|
|
579
|
+
(options.importPrefix !== undefined &&
|
|
580
|
+
!validImportPrefix(options.importPrefix))
|
|
581
|
+
) {
|
|
582
|
+
throw new Error(`Invalid import prefix: ${importPrefix}`)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (
|
|
586
|
+
importPrefix !== (manifest.importPrefix ?? "@") &&
|
|
587
|
+
Object.keys(manifest.files).length > 0
|
|
588
|
+
) {
|
|
589
|
+
throw new Error("Cannot change import prefix with tracked files")
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (options.importPrefix !== undefined) manifest.importPrefix = importPrefix
|
|
261
593
|
const items = new Map<string, RegistryItem>()
|
|
262
594
|
const visiting = new Set<string>()
|
|
263
595
|
const fetcher = options.fetcher ?? fetch
|
|
@@ -270,13 +602,15 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
270
602
|
}
|
|
271
603
|
|
|
272
604
|
visiting.add(itemName)
|
|
273
|
-
const response = await fetcher(`${registryUrl}/${itemName}.json`)
|
|
274
605
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
606
|
+
const item = parseItem(
|
|
607
|
+
await registryItemJson(
|
|
608
|
+
`${registryUrl}/${itemName}.json`,
|
|
609
|
+
fetcher,
|
|
610
|
+
requestTimeoutMs,
|
|
611
|
+
),
|
|
612
|
+
itemName,
|
|
613
|
+
)
|
|
280
614
|
|
|
281
615
|
for (const dependency of item.registryDependencies) await visit(dependency)
|
|
282
616
|
items.set(itemName, item)
|
|
@@ -296,39 +630,48 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
296
630
|
|
|
297
631
|
for (const file of item.files) {
|
|
298
632
|
const path = targetPath(file.target)
|
|
633
|
+
const content = file.content.replace(/(["'`])@\//g, `$1${importPrefix}/`)
|
|
634
|
+
const transformed = { ...file, content, integrity: hash(content) }
|
|
299
635
|
const previous = files.get(path)
|
|
300
636
|
|
|
301
|
-
if (previous && previous.integrity !==
|
|
637
|
+
if (previous && previous.integrity !== transformed.integrity) {
|
|
302
638
|
throw new Error(`Conflicting registry files: ${path}`)
|
|
303
639
|
}
|
|
304
640
|
|
|
305
|
-
files.set(path,
|
|
641
|
+
files.set(path, transformed)
|
|
306
642
|
}
|
|
307
643
|
}
|
|
308
644
|
|
|
309
|
-
const
|
|
645
|
+
const changes = new Map<string, FileChange>()
|
|
646
|
+
const previewFiles: InstallPreview["files"] = []
|
|
647
|
+
let installed = 0
|
|
310
648
|
let skipped = 0
|
|
311
649
|
|
|
312
650
|
for (const [path, file] of files) {
|
|
313
651
|
const exists = await existingFile(root, path)
|
|
314
652
|
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
653
|
+
const before = exists ? await readFile(join(root, path), "utf8") : null
|
|
654
|
+
const current = before === null ? null : hash(before)
|
|
655
|
+
changes.set(path, { before, after: before })
|
|
319
656
|
const previous = manifest.files[path]
|
|
657
|
+
const decision: InstallPreview["files"][number] = { path, action: "skip" }
|
|
658
|
+
previewFiles.push(decision)
|
|
320
659
|
|
|
321
660
|
if (current && previous && current !== previous && !options.force) {
|
|
661
|
+
decision.reason = `Modified file: ${path} (use --force to overwrite)`
|
|
662
|
+
|
|
322
663
|
if (options.mode === "update") {
|
|
323
|
-
|
|
324
|
-
|
|
664
|
+
decision.action = "conflict"
|
|
665
|
+
|
|
666
|
+
if (!options.dryRun) throw new Error(decision.reason)
|
|
667
|
+
} else skipped++
|
|
325
668
|
|
|
326
|
-
skipped++
|
|
327
669
|
continue
|
|
328
670
|
}
|
|
329
671
|
|
|
330
672
|
if (current === file.integrity) {
|
|
331
673
|
manifest.files[path] = file.integrity
|
|
674
|
+
decision.reason = `Identical file: ${path}`
|
|
332
675
|
skipped++
|
|
333
676
|
} else if (
|
|
334
677
|
current &&
|
|
@@ -336,45 +679,65 @@ export async function installItem(name: string, options: InstallOptions = {}) {
|
|
|
336
679
|
previous === current &&
|
|
337
680
|
options.mode !== "update"
|
|
338
681
|
) {
|
|
682
|
+
decision.reason = `Tracked file: ${path} (use update to replace)`
|
|
339
683
|
skipped++
|
|
340
684
|
} else if (current && !options.force && previous !== current) {
|
|
341
|
-
|
|
685
|
+
decision.action = "conflict"
|
|
686
|
+
decision.reason = `Existing file: ${path} (use --force to overwrite)`
|
|
687
|
+
|
|
688
|
+
if (!options.dryRun) throw new Error(decision.reason)
|
|
342
689
|
} else {
|
|
343
|
-
|
|
690
|
+
decision.action = "write"
|
|
691
|
+
|
|
692
|
+
if (current && options.force) decision.forced = true
|
|
693
|
+
changes.set(path, { before, after: file.content })
|
|
694
|
+
manifest.files[path] = file.integrity
|
|
695
|
+
installed++
|
|
344
696
|
}
|
|
345
697
|
}
|
|
346
698
|
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
699
|
+
const manifestAfter = `${JSON.stringify(manifest, null, 2)}\n`
|
|
700
|
+
changes.set("ui.json", { before: manifestText, after: manifestAfter })
|
|
701
|
+
previewFiles.push({
|
|
702
|
+
path: "ui.json",
|
|
703
|
+
action: manifestText === manifestAfter ? "skip" : "write",
|
|
704
|
+
})
|
|
350
705
|
|
|
351
706
|
for (const dependency of dependencies) devDependencies.delete(dependency)
|
|
352
707
|
|
|
353
|
-
if (
|
|
708
|
+
if (options.dryRun) {
|
|
709
|
+
return {
|
|
710
|
+
installed: 0,
|
|
711
|
+
skipped,
|
|
712
|
+
preview: {
|
|
713
|
+
files: previewFiles,
|
|
714
|
+
dependencies: [...dependencies],
|
|
715
|
+
devDependencies: [...devDependencies],
|
|
716
|
+
},
|
|
717
|
+
}
|
|
718
|
+
}
|
|
354
719
|
|
|
355
|
-
|
|
356
|
-
if (await existingFile(root, path)) {
|
|
357
|
-
const current = hash(await readFile(join(root, path), "utf8"))
|
|
358
|
-
const previous = manifest.files[path]
|
|
720
|
+
let dependenciesStarted = false
|
|
359
721
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
throw new Error(`Modified file: ${path} (use --force to overwrite)`)
|
|
722
|
+
try {
|
|
723
|
+
await writeFiles(root, changes, async () => {
|
|
724
|
+
if (dependencies.size) {
|
|
725
|
+
dependenciesStarted = true
|
|
726
|
+
await run(["add", ...dependencies], root)
|
|
366
727
|
}
|
|
367
|
-
}
|
|
368
728
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
729
|
+
if (devDependencies.size) {
|
|
730
|
+
dependenciesStarted = true
|
|
731
|
+
await run(["add", "-d", ...devDependencies], root)
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
} catch (cause) {
|
|
735
|
+
if (!dependenciesStarted) throw cause
|
|
736
|
+
throw new Error(
|
|
737
|
+
`${cause instanceof Error ? cause.message : String(cause)}\nDependencies may have changed (package.json, lockfiles, node_modules); package-manager changes were not rolled back.`,
|
|
738
|
+
{ cause },
|
|
739
|
+
)
|
|
372
740
|
}
|
|
373
741
|
|
|
374
|
-
|
|
375
|
-
join(root, ".yopem-ui.json"),
|
|
376
|
-
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
return { installed: writes.length, skipped }
|
|
742
|
+
return { installed, skipped }
|
|
380
743
|
}
|