@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/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 { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"
3
- import { dirname, join } from "node:path"
4
-
5
- const registryUrl = "http://localhost:3100/r"
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
- fetcher?: (url: string) => Promise<Response>
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
- async function readManifest(root: string): Promise<Manifest> {
215
- if (!(await existingFile(root, ".yopem-ui.json"))) {
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 (!isRecord(value) || value.version !== 1 || !isRecord(value.files)) {
224
- throw new Error("Invalid .yopem-ui.json")
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 .yopem-ui.json")
350
+ throw new Error("Invalid ui.json")
237
351
  }
238
352
 
239
353
  files[path] = integrity
240
354
  }
241
355
 
242
- return { version: 1, files }
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 manifest = await readManifest(root)
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
- if (!response.ok)
276
- throw new Error(
277
- `Registry request failed: ${itemName} (${response.status})`,
278
- )
279
- const item = parseItem(await response.json(), itemName)
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 !== file.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, file)
641
+ files.set(path, transformed)
306
642
  }
307
643
  }
308
644
 
309
- const writes: [string, RegistryFile][] = []
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 current = exists
316
- ? hash(await readFile(join(root, path), "utf8"))
317
- : null
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
- throw new Error(`Modified file: ${path} (use --force to overwrite)`)
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
- throw new Error(`Existing file: ${path} (use --force to overwrite)`)
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
- writes.push([path, file])
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 run = options.run ?? runBun
348
-
349
- if (dependencies.size) await run(["add", ...dependencies], root)
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 (devDependencies.size) await run(["add", "-d", ...devDependencies], root)
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
- 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]
720
+ let dependenciesStarted = false
359
721
 
360
- if (
361
- current !== previous &&
362
- current !== file.integrity &&
363
- !options.force
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
- await mkdir(dirname(join(root, path)), { recursive: true })
370
- await writeFile(join(root, path), file.content, { flag: "w" })
371
- manifest.files[path] = file.integrity
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
- await writeFile(
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
  }