@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/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 { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"
3
- import { dirname, join } from "node:path"
4
-
5
- const registryUrl = "http://localhost:3100/r"
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
- fetcher?: (url: string) => Promise<Response>
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
- async function readManifest(root: string): Promise<Manifest> {
215
- if (!(await existingFile(root, ".yopem-ui.json"))) {
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 (!isRecord(value) || value.version !== 1 || !isRecord(value.files)) {
224
- throw new Error("Invalid .yopem-ui.json")
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 .yopem-ui.json")
380
+ throw new Error("Invalid ui.json")
237
381
  }
238
382
 
239
383
  files[path] = integrity
240
384
  }
241
385
 
242
- return { version: 1, files }
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 installItem(name: string, options: InstallOptions = {}) {
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 manifest = await readManifest(root)
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
- if (!response.ok)
276
- throw new Error(
277
- `Registry request failed: ${itemName} (${response.status})`,
278
- )
279
- const item = parseItem(await response.json(), itemName)
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 !== file.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, file)
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 writes: [string, RegistryFile][] = []
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 current = exists
316
- ? hash(await readFile(join(root, path), "utf8"))
317
- : null
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
- throw new Error(`Modified file: ${path} (use --force to overwrite)`)
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
- manifest.files[path] = file.integrity
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
- throw new Error(`Existing file: ${path} (use --force to overwrite)`)
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
- writes.push([path, file])
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 run = options.run ?? runBun
797
+ const manifestAfter =
798
+ manifestText !== null && JSON.stringify(manifest) === originalManifest
799
+ ? manifestText
800
+ : `${JSON.stringify(manifest, null, 2)}\n`
348
801
 
349
- if (dependencies.size) await run(["add", ...dependencies], root)
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
- for (const dependency of dependencies) devDependencies.delete(dependency)
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
- if (devDependencies.size) await run(["add", "-d", ...devDependencies], root)
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
- 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]
850
+ let dependenciesStarted = false
359
851
 
360
- if (
361
- current !== previous &&
362
- current !== file.integrity &&
363
- !options.force
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
- await mkdir(dirname(join(root, path)), { recursive: true })
370
- await writeFile(join(root, path), file.content, { flag: "w" })
371
- manifest.files[path] = file.integrity
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
- await writeFile(
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
  }