@yopem-ui/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yopem-ui/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for installing Yopem UI source components and StyleX setup",
5
5
  "keywords": [
6
6
  "cli",
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@ import { installItem, isRecord, isString } from "@yopem-ui/cli/install"
8
8
  import { resolve } from "node:path"
9
9
 
10
10
  const usage =
11
- "Usage: yopem-ui [--help | --version] | init [--framework vite|tanstack-router|tanstack-start|react-router|next|astro] [--ui <path>] [--cwd <path>] [--registry <URL>] | <add|update> <name> [--force] [--dry-run] [--cwd <path>] [--registry <URL>]"
11
+ "Usage: yopem-ui [--help | --version] | init [--framework vite|tanstack-router|tanstack-start|react-router|next|astro] [--ui <path>] [--dry-run] [--cwd <path>] [--registry <URL>] | <add|update> <name> [--force] [--dry-run] [--cwd <path>] [--registry <URL>]"
12
12
 
13
13
  function isFramework(
14
14
  value: string | undefined,
@@ -78,9 +78,12 @@ export async function runCli(args: string[], options: InitOptions = {}) {
78
78
  const cwd = resolve(options.cwd ?? process.cwd(), flags.get("--cwd") ?? ".")
79
79
  const registryUrl = flags.get("--registry") ?? options.registryUrl
80
80
 
81
+ function onWarning(warning: string) {
82
+ console.warn(`Warning: ${warning}`)
83
+ options.onWarning?.(warning)
84
+ }
85
+
81
86
  if (command === "init") {
82
- if (flags.has("--dry-run") || options.dryRun)
83
- throw new Error("--dry-run is not supported for init")
84
87
  const framework = flags.get("--framework")
85
88
 
86
89
  if (
@@ -94,13 +97,44 @@ export async function runCli(args: string[], options: InitOptions = {}) {
94
97
  ...options,
95
98
  cwd,
96
99
  registryUrl,
100
+ dryRun: flags.has("--dry-run") || options.dryRun,
101
+ onWarning,
97
102
  ui: flags.get("--ui") ?? options.ui,
98
103
  framework: isFramework(framework) ? framework : options.framework,
99
104
  })
100
105
 
101
- console.info(
102
- `Configured ${result.framework} (${result.configured} file(s))`,
103
- )
106
+ if (result.preview) {
107
+ for (const file of result.preview.files) {
108
+ const label =
109
+ file.action === "write"
110
+ ? "Write"
111
+ : file.action === "delete"
112
+ ? "Delete"
113
+ : "Skip"
114
+
115
+ console.info(
116
+ `${label} ${file.path}${file.reason ? ` (${file.reason})` : ""}`,
117
+ )
118
+ }
119
+
120
+ for (const group of result.preview.dependencies) {
121
+ console.info(
122
+ `Runtime dependencies (${group.cwd}): ${group.dependencies.join(", ") || "none"}`,
123
+ )
124
+ console.info(
125
+ `Dev dependencies (${group.cwd}): ${group.devDependencies.join(", ") || "none"}`,
126
+ )
127
+ }
128
+
129
+ console.info(`Prerequisites: ${result.preview.prerequisites.join("; ")}`)
130
+ console.info(
131
+ `Dry run: ${result.framework}. No files written or package-manager commands run.`,
132
+ )
133
+ } else {
134
+ console.info(
135
+ `Configured ${result.framework} (${result.configured} file(s))`,
136
+ )
137
+ }
104
138
 
105
139
  return result
106
140
  }
@@ -119,6 +153,7 @@ export async function runCli(args: string[], options: InitOptions = {}) {
119
153
  cwd,
120
154
  registryUrl,
121
155
  mode: command,
156
+ onWarning,
122
157
  force: flags.has("--force"),
123
158
  dryRun: flags.has("--dry-run") || options.dryRun,
124
159
  })
package/src/init.ts CHANGED
@@ -6,15 +6,17 @@ import type {
6
6
  } from "@yopem-ui/cli/install"
7
7
 
8
8
  import {
9
+ applyInstall,
9
10
  existingFile,
10
- installItem,
11
11
  isRecord,
12
12
  isString,
13
+ planInstall,
13
14
  writeFiles,
14
15
  } from "@yopem-ui/cli/install"
15
16
  import {
16
17
  isPackageName,
17
18
  packageRunner,
19
+ pendingDependencies,
18
20
  workspaceRoot,
19
21
  } from "@yopem-ui/cli/project"
20
22
  import { readFile, realpath } from "node:fs/promises"
@@ -40,6 +42,25 @@ interface Edit {
40
42
  text: string
41
43
  }
42
44
 
45
+ export interface InitResult {
46
+ framework: Framework
47
+ configured: number
48
+ warnings?: string[]
49
+ preview?: {
50
+ files: {
51
+ path: string
52
+ action: "write" | "skip" | "delete" | "conflict"
53
+ reason?: string
54
+ }[]
55
+ dependencies: {
56
+ cwd: string
57
+ dependencies: string[]
58
+ devDependencies: string[]
59
+ }[]
60
+ prerequisites: string[]
61
+ }
62
+ }
63
+
43
64
  export interface InitOptions extends InstallOptions {
44
65
  framework?: Framework
45
66
  ui?: string
@@ -1498,10 +1519,11 @@ function nextScripts(value: JsonValue | undefined) {
1498
1519
  }
1499
1520
 
1500
1521
  async function chooseFile(root: string, names: string[], fallback?: string) {
1501
- const matches: string[] = []
1522
+ const existing = await Promise.all(
1523
+ names.map((name) => existingFile(root, name)),
1524
+ )
1502
1525
 
1503
- for (const name of names)
1504
- if (await existingFile(root, name)) matches.push(name)
1526
+ const matches = names.filter((_, index) => existing[index])
1505
1527
 
1506
1528
  if (matches.length > 1)
1507
1529
  throw new Error(`Ambiguous configuration: ${matches.join(", ")}`)
@@ -1547,8 +1569,9 @@ function detectFramework(
1547
1569
  return detected[0]!
1548
1570
  }
1549
1571
 
1550
- export async function initProject(options: InitOptions = {}) {
1551
- if (options.dryRun) throw new Error("--dry-run is not supported for init")
1572
+ export async function initProject(
1573
+ options: InitOptions = {},
1574
+ ): Promise<InitResult> {
1552
1575
  const root = await realpath(options.cwd ?? process.cwd())
1553
1576
 
1554
1577
  if (!(await existingFile(root, "package.json"))) {
@@ -1597,14 +1620,24 @@ export async function initProject(options: InitOptions = {}) {
1597
1620
  }
1598
1621
  }
1599
1622
 
1600
- const packageRun = await packageRunner(root, options.run)
1601
- const uiRun = await packageRunner(uiRoot, options.run)
1623
+ const [packageRun, uiRun] = await Promise.all([
1624
+ packageRunner(root, options.run),
1625
+ packageRunner(uiRoot, options.run),
1626
+ ])
1627
+
1602
1628
  const edits = new Map<string, { before: string | null; after: string }>()
1603
1629
 
1630
+ const plannedFiles: {
1631
+ path: string
1632
+ action: "write" | "skip" | "delete"
1633
+ reason?: string
1634
+ }[] = []
1635
+
1604
1636
  async function plan(
1605
1637
  path: string,
1606
1638
  transform: (content: string) => string,
1607
1639
  fallback?: string,
1640
+ reason?: string,
1608
1641
  ) {
1609
1642
  const exists = await existingFile(root, path)
1610
1643
 
@@ -1612,6 +1645,12 @@ export async function initProject(options: InitOptions = {}) {
1612
1645
  const before = exists ? await readFile(join(root, path), "utf8") : null
1613
1646
  const after = transform(before ?? fallback!)
1614
1647
 
1648
+ plannedFiles.push({
1649
+ path: resolve(root, path),
1650
+ action: before === after ? "skip" : "write",
1651
+ ...(reason && { reason }),
1652
+ })
1653
+
1615
1654
  if (before !== after) edits.set(path, { before, after })
1616
1655
  }
1617
1656
 
@@ -1669,43 +1708,48 @@ export async function initProject(options: InitOptions = {}) {
1669
1708
  if (shared) {
1670
1709
  const prefix = relative(root, uiRoot).replaceAll("\\", "/")
1671
1710
 
1672
- await plan(`${prefix}/package.json`, (content) => {
1673
- const value: JsonValue = JSON.parse(content)
1711
+ await plan(
1712
+ `${prefix}/package.json`,
1713
+ (content) => {
1714
+ const value: JsonValue = JSON.parse(content)
1674
1715
 
1675
- if (
1676
- !object(value) ||
1677
- (value.exports !== undefined && !object(value.exports))
1678
- )
1679
- throw new Error("Shared UI package requires object exports")
1680
- const exports = object(value.exports) ? { ...value.exports } : {}
1681
-
1682
- const paths = {
1683
- "./components/ui/*": "./src/components/ui/*.tsx",
1684
- "./lib/*": "./src/lib/*.ts",
1685
- "./styles/tokens.stylex": "./src/styles/tokens.stylex.ts",
1686
- "./styles/styles.css": "./src/styles/styles.css",
1687
- "./theme/*": "./src/theme/*.tsx",
1688
- }
1716
+ if (
1717
+ !object(value) ||
1718
+ (value.exports !== undefined && !object(value.exports))
1719
+ )
1720
+ throw new Error("Shared UI package requires object exports")
1721
+ const exports = object(value.exports) ? { ...value.exports } : {}
1722
+
1723
+ const paths = {
1724
+ "./components/ui/*": "./src/components/ui/*.tsx",
1725
+ "./lib/*": "./src/lib/*.ts",
1726
+ "./styles/tokens.stylex": "./src/styles/tokens.stylex.ts",
1727
+ "./styles/styles.css": "./src/styles/styles.css",
1728
+ "./theme/*": "./src/theme/*.tsx",
1729
+ }
1689
1730
 
1690
- for (const [path, target] of Object.entries(paths)) {
1691
- if (exports[path] !== undefined && exports[path] !== target)
1692
- throw new Error(`Conflicting UI package export: ${path}`)
1693
- exports[path] = target
1694
- }
1731
+ for (const [path, target] of Object.entries(paths)) {
1732
+ if (exports[path] !== undefined && exports[path] !== target)
1733
+ throw new Error(`Conflicting UI package export: ${path}`)
1734
+ exports[path] = target
1735
+ }
1695
1736
 
1696
- const sideEffects =
1697
- value.sideEffects === false
1698
- ? ["**/*.css"]
1699
- : Array.isArray(value.sideEffects) &&
1700
- !value.sideEffects.includes("**/*.css")
1701
- ? [...value.sideEffects, "**/*.css"]
1702
- : value.sideEffects
1703
-
1704
- return JSON.stringify(value.exports) === JSON.stringify(exports) &&
1705
- JSON.stringify(value.sideEffects) === JSON.stringify(sideEffects)
1706
- ? content
1707
- : `${JSON.stringify({ ...value, exports, sideEffects }, null, 2)}\n`
1708
- })
1737
+ const sideEffects =
1738
+ value.sideEffects === false
1739
+ ? ["**/*.css"]
1740
+ : Array.isArray(value.sideEffects) &&
1741
+ !value.sideEffects.includes("**/*.css")
1742
+ ? [...value.sideEffects, "**/*.css"]
1743
+ : value.sideEffects
1744
+
1745
+ return JSON.stringify(value.exports) === JSON.stringify(exports) &&
1746
+ JSON.stringify(value.sideEffects) === JSON.stringify(sideEffects)
1747
+ ? content
1748
+ : `${JSON.stringify({ ...value, exports, sideEffects }, null, 2)}\n`
1749
+ },
1750
+ undefined,
1751
+ "Shared UI exports: ./components/ui/*, ./lib/*, ./styles/tokens.stylex, ./styles/styles.css, ./theme/*; CSS side effects",
1752
+ )
1709
1753
  }
1710
1754
 
1711
1755
  const devDependencies = [
@@ -1868,15 +1912,165 @@ export async function initProject(options: InitOptions = {}) {
1868
1912
  }
1869
1913
  }
1870
1914
 
1915
+ const basePlan = await planInstall("base", {
1916
+ ...options,
1917
+ cwd: uiRoot,
1918
+ importPrefix: shared?.name,
1919
+ dryRun: true,
1920
+ run: uiRun,
1921
+ })
1922
+
1923
+ const conflict = basePlan.preview.files.find(
1924
+ (file) => file.action === "conflict",
1925
+ )
1926
+
1927
+ if (conflict) throw new Error(conflict.reason)
1928
+
1929
+ if (
1930
+ !basePlan.changes.get("src/styles/styles.css")?.after?.includes("@stylex;")
1931
+ ) {
1932
+ throw new Error(
1933
+ "Add @stylex; to src/styles/styles.css before configuring StyleX",
1934
+ )
1935
+ }
1936
+
1937
+ const runtimeRequirements =
1938
+ uiRoot === root
1939
+ ? [...basePlan.requirements.dependencies, ...runtimeDependencies]
1940
+ : runtimeDependencies
1941
+
1942
+ const devRequirements =
1943
+ uiRoot === root
1944
+ ? [...basePlan.requirements.devDependencies, ...devDependencies]
1945
+ : devDependencies
1946
+
1947
+ const appPending = await pendingDependencies(
1948
+ root,
1949
+ runtimeRequirements,
1950
+ devRequirements,
1951
+ )
1952
+
1953
+ const baseDependencyNames = new Set(
1954
+ [
1955
+ ...basePlan.requirements.dependencies,
1956
+ ...basePlan.requirements.devDependencies,
1957
+ ].map((argument) => {
1958
+ const separator = argument.indexOf("@", 1)
1959
+
1960
+ return separator < 0 ? argument : argument.slice(0, separator)
1961
+ }),
1962
+ )
1963
+
1964
+ function isBaseDependency(argument: string) {
1965
+ const separator = argument.indexOf("@", 1)
1966
+
1967
+ return baseDependencyNames.has(
1968
+ separator < 0 ? argument : argument.slice(0, separator),
1969
+ )
1970
+ }
1971
+
1972
+ const baseApplicationPlan =
1973
+ uiRoot === root
1974
+ ? {
1975
+ ...basePlan,
1976
+ preview: {
1977
+ ...basePlan.preview,
1978
+ dependencies: appPending.dependencies.filter(isBaseDependency),
1979
+ devDependencies:
1980
+ appPending.devDependencies.filter(isBaseDependency),
1981
+ },
1982
+ }
1983
+ : basePlan
1984
+
1985
+ if (options.dryRun) {
1986
+ const baseDependencies = {
1987
+ dependencies: basePlan.preview.dependencies,
1988
+ devDependencies: basePlan.preview.devDependencies,
1989
+ }
1990
+
1991
+ for (const [path] of retired)
1992
+ plannedFiles.push({ path: resolve(root, path), action: "delete" })
1993
+
1994
+ if (framework === "next") {
1995
+ if (!object(manifest.scripts))
1996
+ throw new Error("Next.js scripts are missing")
1997
+ const originalScripts = manifest.scripts
1998
+ const scripts = nextScripts(originalScripts)
1999
+ plannedFiles.push({
2000
+ path: join(root, "package.json"),
2001
+ action:
2002
+ JSON.stringify(scripts) === JSON.stringify(manifest.scripts)
2003
+ ? "skip"
2004
+ : "write",
2005
+ reason: `Next.js scripts: ${
2006
+ Object.entries(scripts)
2007
+ .filter(([name, value]) => value !== originalScripts[name])
2008
+ .map(
2009
+ ([name, value]) =>
2010
+ `${name}: ${JSON.stringify(originalScripts[name])} -> ${JSON.stringify(value)}`,
2011
+ )
2012
+ .join("; ") || "unchanged"
2013
+ }`,
2014
+ })
2015
+ }
2016
+
2017
+ const dependencyTargets =
2018
+ uiRoot === root
2019
+ ? [{ cwd: root, ...appPending }]
2020
+ : [
2021
+ { cwd: uiRoot, ...baseDependencies },
2022
+ { cwd: root, ...appPending },
2023
+ ]
2024
+
2025
+ for (const group of dependencyTargets) {
2026
+ if (!group.dependencies.length && !group.devDependencies.length) continue
2027
+ const path = join(group.cwd, "package.json")
2028
+ const planned = plannedFiles.find((file) => file.path === path)
2029
+
2030
+ if (planned) {
2031
+ planned.action = "write"
2032
+ planned.reason = [
2033
+ planned.reason,
2034
+ "Pending package-manager dependency changes",
2035
+ ]
2036
+ .filter(Boolean)
2037
+ .join("; ")
2038
+ } else {
2039
+ plannedFiles.push({
2040
+ path,
2041
+ action: "write",
2042
+ reason: "Pending package-manager dependency changes",
2043
+ })
2044
+ }
2045
+ }
2046
+
2047
+ for (const warning of basePlan.warnings) options.onWarning?.(warning)
2048
+
2049
+ return {
2050
+ framework,
2051
+ configured: 0,
2052
+ ...(basePlan.warnings.length && { warnings: basePlan.warnings }),
2053
+ preview: {
2054
+ files: [
2055
+ ...basePlan.preview.files.map((file) => ({
2056
+ ...file,
2057
+ path: join(uiRoot, file.path),
2058
+ })),
2059
+ ...plannedFiles,
2060
+ ],
2061
+ dependencies: dependencyTargets,
2062
+ prerequisites: [
2063
+ "Supported framework, safe project paths and configuration",
2064
+ "Validated registry base with @stylex; stylesheet directive",
2065
+ ],
2066
+ },
2067
+ }
2068
+ }
2069
+
1871
2070
  let baseInstalled = false
1872
2071
 
1873
2072
  try {
1874
- await installItem("base", {
1875
- ...options,
1876
- cwd: uiRoot,
1877
- importPrefix: shared?.name,
1878
- run: uiRun,
1879
- })
2073
+ const baseResult = await applyInstall(baseApplicationPlan, options)
1880
2074
  baseInstalled = true
1881
2075
 
1882
2076
  if (
@@ -1897,36 +2091,26 @@ export async function initProject(options: InitOptions = {}) {
1897
2091
  if (!object(installed))
1898
2092
  throw new Error("Invalid package.json after installation")
1899
2093
 
1900
- const available = Object.fromEntries(
1901
- [installed.dependencies, installed.devDependencies].flatMap((entry) =>
1902
- object(entry) ? Object.entries(entry) : [],
1903
- ),
2094
+ const pending = await pendingDependencies(
2095
+ root,
2096
+ runtimeRequirements,
2097
+ devRequirements,
1904
2098
  )
1905
2099
 
1906
- const neededRuntime = runtimeDependencies.filter((name) => {
1907
- if (shared && name === `${shared.name}@workspace:*`) {
1908
- const version = available[shared.name]
1909
-
1910
- return (
1911
- !isString(version) ||
1912
- (version !== "*" && !version.startsWith("workspace:"))
1913
- )
1914
- }
1915
-
1916
- const packageName = name.includes("@", 1)
1917
- ? name.slice(0, name.lastIndexOf("@"))
1918
- : name
1919
-
1920
- return !(packageName in available)
1921
- })
1922
-
1923
- if (neededRuntime.length) await packageRun(["add", ...neededRuntime], root)
2100
+ if (uiRoot === root) {
2101
+ pending.dependencies = pending.dependencies.filter(
2102
+ (argument) => !isBaseDependency(argument),
2103
+ )
2104
+ pending.devDependencies = pending.devDependencies.filter(
2105
+ (argument) => !isBaseDependency(argument),
2106
+ )
2107
+ }
1924
2108
 
1925
- const neededDev = devDependencies.filter(
1926
- (name) => !(name.split("@").slice(0, -1).join("@") in available),
1927
- )
2109
+ if (pending.dependencies.length)
2110
+ await packageRun(["add", ...pending.dependencies], root)
1928
2111
 
1929
- if (neededDev.length) await packageRun(["add", "-d", ...neededDev], root)
2112
+ if (pending.devDependencies.length)
2113
+ await packageRun(["add", "-d", ...pending.devDependencies], root)
1930
2114
 
1931
2115
  if (shared) {
1932
2116
  const path = `${relative(root, uiRoot).replaceAll("\\", "/")}/package.json`
@@ -1990,7 +2174,11 @@ export async function initProject(options: InitOptions = {}) {
1990
2174
 
1991
2175
  await writeFiles(root, changes)
1992
2176
 
1993
- return { framework, configured: edits.size }
2177
+ return {
2178
+ framework,
2179
+ configured: edits.size,
2180
+ ...(baseResult.warnings && { warnings: baseResult.warnings }),
2181
+ }
1994
2182
  } catch (cause) {
1995
2183
  if (!baseInstalled) throw cause
1996
2184
  throw new Error(
package/src/install.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { isPackageName, packageRunner } from "@yopem-ui/cli/project"
1
+ import {
2
+ isPackageName,
3
+ packageRunner,
4
+ pendingDependencies,
5
+ } from "@yopem-ui/cli/project"
2
6
  import { createHash } from "node:crypto"
3
7
  import {
4
8
  chmod,
@@ -55,12 +59,19 @@ interface RegistryItem {
55
59
  files: RegistryFile[]
56
60
  name: string
57
61
  registryDependencies: string[]
62
+ registryVersion: string
63
+ }
64
+
65
+ interface Provenance {
66
+ registryUrl: string
67
+ items: Record<string, string>
58
68
  }
59
69
 
60
70
  interface Manifest {
61
71
  version: 1
62
72
  files: Record<string, string>
63
73
  importPrefix?: string
74
+ provenance?: Record<string, Provenance>
64
75
  }
65
76
 
66
77
  export interface InstallPreview {
@@ -74,6 +85,13 @@ export interface InstallPreview {
74
85
  devDependencies: string[]
75
86
  }
76
87
 
88
+ export interface InstallResult {
89
+ installed: number
90
+ skipped: number
91
+ preview?: InstallPreview
92
+ warnings?: string[]
93
+ }
94
+
77
95
  export interface InstallOptions {
78
96
  cwd?: string
79
97
  dryRun?: boolean
@@ -84,6 +102,7 @@ export interface InstallOptions {
84
102
  requestTimeoutMs?: number
85
103
  fetcher?: (url: string, init?: RequestInit) => Promise<Response>
86
104
  run?: (args: string[], cwd: string) => Promise<void>
105
+ onWarning?: (warning: string) => void
87
106
  }
88
107
 
89
108
  function registryBaseUrl(value: string) {
@@ -132,13 +151,22 @@ async function registryItemJson(
132
151
  let response: Response
133
152
 
134
153
  try {
135
- response = await fetcher(url, { signal: controller.signal })
154
+ response = await fetcher(url, {
155
+ signal: controller.signal,
156
+ redirect: "error",
157
+ })
136
158
  } catch {
137
159
  throw new Error(
138
160
  `Registry network error: ${url}. Check your connection and --registry URL.`,
139
161
  )
140
162
  }
141
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
+
142
170
  if (!response.ok) {
143
171
  throw new Error(
144
172
  `Registry request failed: ${url} (HTTP ${response.status}). Check --registry URL and item name.`,
@@ -226,6 +254,7 @@ function parseItem(value: JsonValue, name: string): RegistryItem {
226
254
  !isString(value.title) ||
227
255
  !isString(value.description) ||
228
256
  !isString(value.registryVersion) ||
257
+ !value.registryVersion.trim() ||
229
258
  !isStringArray(value.categories) ||
230
259
  !isStringArray(value.dependencies) ||
231
260
  !isStringArray(value.devDependencies) ||
@@ -291,6 +320,7 @@ function parseItem(value: JsonValue, name: string): RegistryItem {
291
320
  dependencies: value.dependencies,
292
321
  devDependencies: value.devDependencies,
293
322
  registryDependencies: value.registryDependencies,
323
+ registryVersion: value.registryVersion,
294
324
  }
295
325
  }
296
326
 
@@ -353,9 +383,44 @@ function readManifest(content: string | null): Manifest {
353
383
  files[path] = integrity
354
384
  }
355
385
 
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
+
356
420
  return {
357
421
  version: 1,
358
422
  files,
423
+ ...(value.provenance !== undefined && { provenance }),
359
424
  ...(value.importPrefix !== undefined && {
360
425
  importPrefix: value.importPrefix,
361
426
  }),
@@ -425,26 +490,34 @@ export async function writeFiles(
425
490
  }
426
491
 
427
492
  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
- }
493
+ staged.push(
494
+ ...(await Promise.all(
495
+ [...changes].map(async ([path, change]): Promise<StagedFile> => {
496
+ const exists = await existingFile(root, path)
497
+ const before = exists ? await readFile(join(root, path)) : null
498
+
499
+ if ((before?.toString("utf8") ?? null) !== change.before) {
500
+ throw new Error(`File changed before commit: ${path}`)
501
+ }
502
+
503
+ const mode = exists
504
+ ? (await lstat(join(root, path))).mode & 0o7777
505
+ : null
506
+
507
+ const after = change.after === null ? null : Buffer.from(change.after)
508
+
509
+ return {
510
+ path,
511
+ before,
512
+ after,
513
+ mode,
514
+ afterMode: mode,
515
+ changed: change.before !== change.after,
516
+ directory: null,
517
+ }
518
+ }),
519
+ )),
520
+ )
448
521
 
449
522
  for (const file of staged) {
450
523
  if (!file.changed) continue
@@ -470,7 +543,9 @@ export async function writeFiles(
470
543
 
471
544
  await prepare?.()
472
545
 
473
- for (const file of staged) await verify(file, file.before, file.mode)
546
+ await Promise.all(
547
+ staged.map((file) => verify(file, file.before, file.mode)),
548
+ )
474
549
 
475
550
  for (const file of staged) {
476
551
  if (!file.changed) continue
@@ -549,7 +624,7 @@ export async function runBun(args: string[], cwd: string) {
549
624
  }
550
625
  }
551
626
 
552
- export async function installItem(name: string, options: InstallOptions = {}) {
627
+ export async function planInstall(name: string, options: InstallOptions = {}) {
553
628
  if (!namePattern.test(name)) throw new Error(`Invalid item name: ${name}`)
554
629
  const registryUrl = registryBaseUrl(options.registryUrl ?? defaultRegistryUrl)
555
630
  const requestTimeoutMs = options.requestTimeoutMs ?? 30_000
@@ -572,6 +647,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
572
647
  : null
573
648
 
574
649
  const manifest = readManifest(manifestText)
650
+ const originalManifest = JSON.stringify(manifest)
575
651
  const importPrefix = options.importPrefix ?? manifest.importPrefix ?? "@"
576
652
 
577
653
  if (
@@ -619,6 +695,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
619
695
 
620
696
  await visit(name)
621
697
  const files = new Map<string, RegistryFile>()
698
+ const owners = new Map<string, Record<string, string>>()
622
699
  const dependencies = new Set<string>()
623
700
  const devDependencies = new Set<string>()
624
701
 
@@ -639,6 +716,9 @@ export async function installItem(name: string, options: InstallOptions = {}) {
639
716
  }
640
717
 
641
718
  files.set(path, transformed)
719
+ const ownership = owners.get(path) ?? {}
720
+ ownership[item.name] = item.registryVersion
721
+ owners.set(path, ownership)
642
722
  }
643
723
  }
644
724
 
@@ -646,14 +726,48 @@ export async function installItem(name: string, options: InstallOptions = {}) {
646
726
  const previewFiles: InstallPreview["files"] = []
647
727
  let installed = 0
648
728
  let skipped = 0
729
+ const warnings: string[] = []
730
+
731
+ function track(path: string, integrity: string, applied: boolean) {
732
+ manifest.files[path] = integrity
733
+ const previous = manifest.provenance?.[path]
734
+
735
+ if (!applied && previous && previous.registryUrl !== registryUrl) return
736
+
737
+ const ownership =
738
+ applied || !previous ? { ...owners.get(path)! } : { ...previous.items }
739
+
740
+ if (!applied && previous) {
741
+ for (const [name, version] of Object.entries(owners.get(path)!)) {
742
+ if (!Object.hasOwn(ownership, name)) ownership[name] = version
743
+ }
744
+ }
745
+
746
+ manifest.provenance ??= {}
747
+ manifest.provenance[path] = { registryUrl, items: ownership }
748
+ }
649
749
 
650
- for (const [path, file] of files) {
651
- const exists = await existingFile(root, path)
750
+ const currentFiles = await Promise.all(
751
+ [...files].map(async ([path, file]) => {
752
+ const exists = await existingFile(root, path)
753
+ const before = exists ? await readFile(join(root, path), "utf8") : null
754
+
755
+ return { path, file, before }
756
+ }),
757
+ )
652
758
 
653
- const before = exists ? await readFile(join(root, path), "utf8") : null
759
+ for (const { path, file, before } of currentFiles) {
654
760
  const current = before === null ? null : hash(before)
655
761
  changes.set(path, { before, after: before })
656
762
  const previous = manifest.files[path]
763
+ const origin = manifest.provenance?.[path]
764
+
765
+ if (origin && origin.registryUrl !== registryUrl) {
766
+ warnings.push(
767
+ `Updating tracked file from a different registry: ${path}. Previous: ${origin.registryUrl}; requested: ${registryUrl}. Review source before applying.`,
768
+ )
769
+ }
770
+
657
771
  const decision: InstallPreview["files"][number] = { path, action: "skip" }
658
772
  previewFiles.push(decision)
659
773
 
@@ -670,7 +784,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
670
784
  }
671
785
 
672
786
  if (current === file.integrity) {
673
- manifest.files[path] = file.integrity
787
+ track(path, file.integrity, false)
674
788
  decision.reason = `Identical file: ${path}`
675
789
  skipped++
676
790
  } else if (
@@ -691,29 +805,61 @@ export async function installItem(name: string, options: InstallOptions = {}) {
691
805
 
692
806
  if (current && options.force) decision.forced = true
693
807
  changes.set(path, { before, after: file.content })
694
- manifest.files[path] = file.integrity
808
+ track(path, file.integrity, true)
695
809
  installed++
696
810
  }
697
811
  }
698
812
 
699
- const manifestAfter = `${JSON.stringify(manifest, null, 2)}\n`
813
+ const manifestAfter =
814
+ manifestText !== null && JSON.stringify(manifest) === originalManifest
815
+ ? manifestText
816
+ : `${JSON.stringify(manifest, null, 2)}\n`
817
+
700
818
  changes.set("ui.json", { before: manifestText, after: manifestAfter })
701
819
  previewFiles.push({
702
820
  path: "ui.json",
703
821
  action: manifestText === manifestAfter ? "skip" : "write",
704
822
  })
705
823
 
706
- for (const dependency of dependencies) devDependencies.delete(dependency)
824
+ const pending = await pendingDependencies(root, dependencies, devDependencies)
825
+
826
+ return {
827
+ root,
828
+ run,
829
+ changes,
830
+ installed,
831
+ skipped,
832
+ warnings,
833
+ requirements: {
834
+ dependencies: [...dependencies],
835
+ devDependencies: [...devDependencies],
836
+ },
837
+ preview: { files: previewFiles, ...pending },
838
+ }
839
+ }
840
+
841
+ export async function installItem(
842
+ name: string,
843
+ options: InstallOptions = {},
844
+ ): Promise<InstallResult> {
845
+ return await applyInstall(await planInstall(name, options), options)
846
+ }
847
+
848
+ export async function applyInstall(
849
+ plan: Awaited<ReturnType<typeof planInstall>>,
850
+ options: InstallOptions = {},
851
+ ): Promise<InstallResult> {
852
+ const { root, run, changes, installed, skipped, warnings, preview } = plan
853
+ const warningResult = warnings.length ? { warnings } : {}
854
+
855
+ for (const warning of warnings) options.onWarning?.(warning)
707
856
 
708
857
  if (options.dryRun) {
709
858
  return {
710
859
  installed: 0,
711
860
  skipped,
712
- preview: {
713
- files: previewFiles,
714
- dependencies: [...dependencies],
715
- devDependencies: [...devDependencies],
716
- },
861
+ preview,
862
+ ...warningResult,
717
863
  }
718
864
  }
719
865
 
@@ -721,14 +867,14 @@ export async function installItem(name: string, options: InstallOptions = {}) {
721
867
 
722
868
  try {
723
869
  await writeFiles(root, changes, async () => {
724
- if (dependencies.size) {
870
+ if (preview.dependencies.length) {
725
871
  dependenciesStarted = true
726
- await run(["add", ...dependencies], root)
872
+ await run(["add", ...preview.dependencies], root)
727
873
  }
728
874
 
729
- if (devDependencies.size) {
875
+ if (preview.devDependencies.length) {
730
876
  dependenciesStarted = true
731
- await run(["add", "-d", ...devDependencies], root)
877
+ await run(["add", "-d", ...preview.devDependencies], root)
732
878
  }
733
879
  })
734
880
  } catch (cause) {
@@ -739,5 +885,5 @@ export async function installItem(name: string, options: InstallOptions = {}) {
739
885
  )
740
886
  }
741
887
 
742
- return { installed, skipped }
888
+ return { installed, skipped, ...warningResult }
743
889
  }
package/src/project.ts CHANGED
@@ -21,7 +21,7 @@ export function isPackageName(value: unknown): value is string {
21
21
  )
22
22
  }
23
23
 
24
- async function readPackage(root: string): Promise<JsonObject> {
24
+ export async function readPackage(root: string): Promise<JsonObject> {
25
25
  if (!(await existingFile(root, "package.json"))) return {}
26
26
 
27
27
  const value: JsonValue = JSON.parse(
@@ -166,8 +166,12 @@ async function packageManager(root: string) {
166
166
  ["yarn", "yarn.lock"],
167
167
  ]
168
168
 
169
- for (const [manager, path] of locks) {
170
- if (await existingFile(root, path)) found.add(manager)
169
+ const existingLocks = await Promise.all(
170
+ locks.map(([, path]) => existingFile(root, path)),
171
+ )
172
+
173
+ for (const [index, [manager]] of locks.entries()) {
174
+ if (existingLocks[index]) found.add(manager)
171
175
  }
172
176
 
173
177
  if (found.size > 1) {
@@ -184,8 +188,12 @@ export async function packageRunner(
184
188
  run?: InstallOptions["run"],
185
189
  ): Promise<NonNullable<InstallOptions["run"]>> {
186
190
  const target = await realpath(root)
187
- const owner = await workspaceRoot(target)
188
- const local = await packageManager(target)
191
+
192
+ const [owner, local] = await Promise.all([
193
+ workspaceRoot(target),
194
+ packageManager(target),
195
+ ])
196
+
189
197
  const workspace = owner === target ? local : await packageManager(owner)
190
198
 
191
199
  if (local && workspace && local !== workspace) {
@@ -221,3 +229,174 @@ export async function packageRunner(
221
229
  }
222
230
  }
223
231
  }
232
+
233
+ interface PendingDependencies {
234
+ dependencies: string[]
235
+ devDependencies: string[]
236
+ }
237
+
238
+ interface Requirement {
239
+ name: string
240
+ spec: string
241
+ argument: string
242
+ }
243
+
244
+ function requirement(argument: string): Requirement {
245
+ const separator = argument.indexOf("@", 1)
246
+ const name = separator < 0 ? argument : argument.slice(0, separator)
247
+ const spec = separator < 0 ? "" : argument.slice(separator + 1)
248
+
249
+ if (!isPackageName(name) || (separator >= 0 && !spec)) {
250
+ throw new Error(`Invalid package dependency: ${argument}`)
251
+ }
252
+
253
+ return { name, spec, argument }
254
+ }
255
+
256
+ function bounds(spec: string) {
257
+ const match = spec
258
+ .trim()
259
+ .match(/^(\^|~)?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/)
260
+
261
+ if (!match) return null
262
+ const major = Number(match[2])
263
+ const hasMinor = match[3] !== undefined && /^\d+$/.test(match[3])
264
+ const hasPatch = match[4] !== undefined && /^\d+$/.test(match[4])
265
+
266
+ if (!hasMinor && hasPatch) return null
267
+ const minor = hasMinor ? Number(match[3]) : 0
268
+ const patch = hasPatch ? Number(match[4]) : 0
269
+ const minimum = `${major}.${minor}.${patch}`
270
+
271
+ if (!Bun.semver.satisfies(minimum, spec)) return null
272
+
273
+ const maximum =
274
+ match[1] === "^"
275
+ ? major > 0 || !hasMinor
276
+ ? `${major + 1}.0.0`
277
+ : minor > 0 || !hasPatch
278
+ ? `0.${minor + 1}.0`
279
+ : `0.0.${patch + 1}`
280
+ : !hasMinor
281
+ ? `${major + 1}.0.0`
282
+ : match[1] === "~" || !hasPatch
283
+ ? `${major}.${minor + 1}.0`
284
+ : null
285
+
286
+ return { minimum, maximum }
287
+ }
288
+
289
+ function compatible(existing: string, requested: string) {
290
+ if (!requested || requested === "*" || existing === requested) return true
291
+
292
+ if (requested === "workspace:*") {
293
+ return existing === "*" || existing.startsWith("workspace:")
294
+ }
295
+
296
+ const current = bounds(existing)
297
+ const wanted = bounds(requested)
298
+
299
+ if (!current) {
300
+ return (
301
+ /^v?\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(
302
+ existing,
303
+ ) && Bun.semver.satisfies(existing, requested)
304
+ )
305
+ }
306
+
307
+ if (!current.maximum) return Bun.semver.satisfies(current.minimum, requested)
308
+
309
+ if (!wanted?.maximum) return false
310
+
311
+ return (
312
+ Bun.semver.satisfies(current.minimum, requested) &&
313
+ Bun.semver.order(current.maximum, wanted.maximum) <= 0
314
+ )
315
+ }
316
+
317
+ function section(manifest: JsonObject, name: string) {
318
+ const value = manifest[name]
319
+
320
+ if (value === undefined) return {}
321
+
322
+ if (!isRecord(value)) throw new Error(`Invalid package.json ${name}`)
323
+
324
+ for (const [name, spec] of Object.entries(value)) {
325
+ if (!isPackageName(name) || !isString(spec) || !spec.trim()) {
326
+ throw new Error(`Invalid package.json dependency: ${name}`)
327
+ }
328
+ }
329
+
330
+ return value
331
+ }
332
+
333
+ export async function pendingDependencies(
334
+ root: string,
335
+ runtime: Iterable<string>,
336
+ dev: Iterable<string>,
337
+ ) {
338
+ const manifest = await readPackage(root)
339
+ const dependencies = section(manifest, "dependencies")
340
+ const devDependencies = section(manifest, "devDependencies")
341
+
342
+ const groups = new Map<
343
+ string,
344
+ { runtime: boolean; requirements: Requirement[] }
345
+ >()
346
+
347
+ for (const [arguments_, isRuntime] of [
348
+ [runtime, true],
349
+ [dev, false],
350
+ ] as const) {
351
+ for (const argument of arguments_) {
352
+ const entry = requirement(argument)
353
+
354
+ const group = groups.get(entry.name) ?? {
355
+ runtime: false,
356
+ requirements: [],
357
+ }
358
+
359
+ group.runtime ||= isRuntime
360
+ group.requirements.push(entry)
361
+ groups.set(entry.name, group)
362
+ }
363
+ }
364
+
365
+ const pending: PendingDependencies = {
366
+ dependencies: [],
367
+ devDependencies: [],
368
+ }
369
+
370
+ for (const [name, group] of groups) {
371
+ const installed = dependencies[name] ?? devDependencies[name]
372
+
373
+ const satisfied =
374
+ isString(installed) &&
375
+ group.requirements.every((entry) => compatible(installed, entry.spec))
376
+
377
+ if (satisfied && (!group.runtime || dependencies[name] !== undefined))
378
+ continue
379
+
380
+ const chosen = group.requirements.find((candidate) =>
381
+ group.requirements.every((entry) =>
382
+ compatible(candidate.spec || "*", entry.spec),
383
+ ),
384
+ )
385
+
386
+ if (!chosen && !satisfied) {
387
+ throw new Error(
388
+ `Conflicting package requirements: ${group.requirements.map((entry) => entry.argument).join(", ")}`,
389
+ )
390
+ }
391
+
392
+ const argument = satisfied ? `${name}@${installed}` : chosen!.argument
393
+
394
+ const target = group.runtime
395
+ ? pending.dependencies
396
+ : pending.devDependencies
397
+
398
+ target.push(argument)
399
+ }
400
+
401
+ return pending
402
+ }