@yopem-ui/cli 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yopem-ui/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
@@ -1547,8 +1568,9 @@ function detectFramework(
1547
1568
  return detected[0]!
1548
1569
  }
1549
1570
 
1550
- export async function initProject(options: InitOptions = {}) {
1551
- if (options.dryRun) throw new Error("--dry-run is not supported for init")
1571
+ export async function initProject(
1572
+ options: InitOptions = {},
1573
+ ): Promise<InitResult> {
1552
1574
  const root = await realpath(options.cwd ?? process.cwd())
1553
1575
 
1554
1576
  if (!(await existingFile(root, "package.json"))) {
@@ -1601,10 +1623,17 @@ export async function initProject(options: InitOptions = {}) {
1601
1623
  const uiRun = await packageRunner(uiRoot, options.run)
1602
1624
  const edits = new Map<string, { before: string | null; after: string }>()
1603
1625
 
1626
+ const plannedFiles: {
1627
+ path: string
1628
+ action: "write" | "skip" | "delete"
1629
+ reason?: string
1630
+ }[] = []
1631
+
1604
1632
  async function plan(
1605
1633
  path: string,
1606
1634
  transform: (content: string) => string,
1607
1635
  fallback?: string,
1636
+ reason?: string,
1608
1637
  ) {
1609
1638
  const exists = await existingFile(root, path)
1610
1639
 
@@ -1612,6 +1641,12 @@ export async function initProject(options: InitOptions = {}) {
1612
1641
  const before = exists ? await readFile(join(root, path), "utf8") : null
1613
1642
  const after = transform(before ?? fallback!)
1614
1643
 
1644
+ plannedFiles.push({
1645
+ path: resolve(root, path),
1646
+ action: before === after ? "skip" : "write",
1647
+ ...(reason && { reason }),
1648
+ })
1649
+
1615
1650
  if (before !== after) edits.set(path, { before, after })
1616
1651
  }
1617
1652
 
@@ -1669,43 +1704,48 @@ export async function initProject(options: InitOptions = {}) {
1669
1704
  if (shared) {
1670
1705
  const prefix = relative(root, uiRoot).replaceAll("\\", "/")
1671
1706
 
1672
- await plan(`${prefix}/package.json`, (content) => {
1673
- const value: JsonValue = JSON.parse(content)
1707
+ await plan(
1708
+ `${prefix}/package.json`,
1709
+ (content) => {
1710
+ const value: JsonValue = JSON.parse(content)
1674
1711
 
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
- }
1712
+ if (
1713
+ !object(value) ||
1714
+ (value.exports !== undefined && !object(value.exports))
1715
+ )
1716
+ throw new Error("Shared UI package requires object exports")
1717
+ const exports = object(value.exports) ? { ...value.exports } : {}
1718
+
1719
+ const paths = {
1720
+ "./components/ui/*": "./src/components/ui/*.tsx",
1721
+ "./lib/*": "./src/lib/*.ts",
1722
+ "./styles/tokens.stylex": "./src/styles/tokens.stylex.ts",
1723
+ "./styles/styles.css": "./src/styles/styles.css",
1724
+ "./theme/*": "./src/theme/*.tsx",
1725
+ }
1689
1726
 
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
- }
1727
+ for (const [path, target] of Object.entries(paths)) {
1728
+ if (exports[path] !== undefined && exports[path] !== target)
1729
+ throw new Error(`Conflicting UI package export: ${path}`)
1730
+ exports[path] = target
1731
+ }
1695
1732
 
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
- })
1733
+ const sideEffects =
1734
+ value.sideEffects === false
1735
+ ? ["**/*.css"]
1736
+ : Array.isArray(value.sideEffects) &&
1737
+ !value.sideEffects.includes("**/*.css")
1738
+ ? [...value.sideEffects, "**/*.css"]
1739
+ : value.sideEffects
1740
+
1741
+ return JSON.stringify(value.exports) === JSON.stringify(exports) &&
1742
+ JSON.stringify(value.sideEffects) === JSON.stringify(sideEffects)
1743
+ ? content
1744
+ : `${JSON.stringify({ ...value, exports, sideEffects }, null, 2)}\n`
1745
+ },
1746
+ undefined,
1747
+ "Shared UI exports: ./components/ui/*, ./lib/*, ./styles/tokens.stylex, ./styles/styles.css, ./theme/*; CSS side effects",
1748
+ )
1709
1749
  }
1710
1750
 
1711
1751
  const devDependencies = [
@@ -1868,15 +1908,165 @@ export async function initProject(options: InitOptions = {}) {
1868
1908
  }
1869
1909
  }
1870
1910
 
1911
+ const basePlan = await planInstall("base", {
1912
+ ...options,
1913
+ cwd: uiRoot,
1914
+ importPrefix: shared?.name,
1915
+ dryRun: true,
1916
+ run: uiRun,
1917
+ })
1918
+
1919
+ const conflict = basePlan.preview.files.find(
1920
+ (file) => file.action === "conflict",
1921
+ )
1922
+
1923
+ if (conflict) throw new Error(conflict.reason)
1924
+
1925
+ if (
1926
+ !basePlan.changes.get("src/styles/styles.css")?.after?.includes("@stylex;")
1927
+ ) {
1928
+ throw new Error(
1929
+ "Add @stylex; to src/styles/styles.css before configuring StyleX",
1930
+ )
1931
+ }
1932
+
1933
+ const runtimeRequirements =
1934
+ uiRoot === root
1935
+ ? [...basePlan.requirements.dependencies, ...runtimeDependencies]
1936
+ : runtimeDependencies
1937
+
1938
+ const devRequirements =
1939
+ uiRoot === root
1940
+ ? [...basePlan.requirements.devDependencies, ...devDependencies]
1941
+ : devDependencies
1942
+
1943
+ const appPending = await pendingDependencies(
1944
+ root,
1945
+ runtimeRequirements,
1946
+ devRequirements,
1947
+ )
1948
+
1949
+ const baseDependencyNames = new Set(
1950
+ [
1951
+ ...basePlan.requirements.dependencies,
1952
+ ...basePlan.requirements.devDependencies,
1953
+ ].map((argument) => {
1954
+ const separator = argument.indexOf("@", 1)
1955
+
1956
+ return separator < 0 ? argument : argument.slice(0, separator)
1957
+ }),
1958
+ )
1959
+
1960
+ function isBaseDependency(argument: string) {
1961
+ const separator = argument.indexOf("@", 1)
1962
+
1963
+ return baseDependencyNames.has(
1964
+ separator < 0 ? argument : argument.slice(0, separator),
1965
+ )
1966
+ }
1967
+
1968
+ const baseApplicationPlan =
1969
+ uiRoot === root
1970
+ ? {
1971
+ ...basePlan,
1972
+ preview: {
1973
+ ...basePlan.preview,
1974
+ dependencies: appPending.dependencies.filter(isBaseDependency),
1975
+ devDependencies:
1976
+ appPending.devDependencies.filter(isBaseDependency),
1977
+ },
1978
+ }
1979
+ : basePlan
1980
+
1981
+ if (options.dryRun) {
1982
+ const baseDependencies = {
1983
+ dependencies: basePlan.preview.dependencies,
1984
+ devDependencies: basePlan.preview.devDependencies,
1985
+ }
1986
+
1987
+ for (const [path] of retired)
1988
+ plannedFiles.push({ path: resolve(root, path), action: "delete" })
1989
+
1990
+ if (framework === "next") {
1991
+ if (!object(manifest.scripts))
1992
+ throw new Error("Next.js scripts are missing")
1993
+ const originalScripts = manifest.scripts
1994
+ const scripts = nextScripts(originalScripts)
1995
+ plannedFiles.push({
1996
+ path: join(root, "package.json"),
1997
+ action:
1998
+ JSON.stringify(scripts) === JSON.stringify(manifest.scripts)
1999
+ ? "skip"
2000
+ : "write",
2001
+ reason: `Next.js scripts: ${
2002
+ Object.entries(scripts)
2003
+ .filter(([name, value]) => value !== originalScripts[name])
2004
+ .map(
2005
+ ([name, value]) =>
2006
+ `${name}: ${JSON.stringify(originalScripts[name])} -> ${JSON.stringify(value)}`,
2007
+ )
2008
+ .join("; ") || "unchanged"
2009
+ }`,
2010
+ })
2011
+ }
2012
+
2013
+ const dependencyTargets =
2014
+ uiRoot === root
2015
+ ? [{ cwd: root, ...appPending }]
2016
+ : [
2017
+ { cwd: uiRoot, ...baseDependencies },
2018
+ { cwd: root, ...appPending },
2019
+ ]
2020
+
2021
+ for (const group of dependencyTargets) {
2022
+ if (!group.dependencies.length && !group.devDependencies.length) continue
2023
+ const path = join(group.cwd, "package.json")
2024
+ const planned = plannedFiles.find((file) => file.path === path)
2025
+
2026
+ if (planned) {
2027
+ planned.action = "write"
2028
+ planned.reason = [
2029
+ planned.reason,
2030
+ "Pending package-manager dependency changes",
2031
+ ]
2032
+ .filter(Boolean)
2033
+ .join("; ")
2034
+ } else {
2035
+ plannedFiles.push({
2036
+ path,
2037
+ action: "write",
2038
+ reason: "Pending package-manager dependency changes",
2039
+ })
2040
+ }
2041
+ }
2042
+
2043
+ for (const warning of basePlan.warnings) options.onWarning?.(warning)
2044
+
2045
+ return {
2046
+ framework,
2047
+ configured: 0,
2048
+ ...(basePlan.warnings.length && { warnings: basePlan.warnings }),
2049
+ preview: {
2050
+ files: [
2051
+ ...basePlan.preview.files.map((file) => ({
2052
+ ...file,
2053
+ path: join(uiRoot, file.path),
2054
+ })),
2055
+ ...plannedFiles,
2056
+ ],
2057
+ dependencies: dependencyTargets,
2058
+ prerequisites: [
2059
+ "Supported framework, safe project paths and configuration",
2060
+ "Validated registry base with @stylex; stylesheet directive",
2061
+ ],
2062
+ },
2063
+ }
2064
+ }
2065
+
1871
2066
  let baseInstalled = false
1872
2067
 
1873
2068
  try {
1874
- await installItem("base", {
1875
- ...options,
1876
- cwd: uiRoot,
1877
- importPrefix: shared?.name,
1878
- run: uiRun,
1879
- })
2069
+ const baseResult = await applyInstall(baseApplicationPlan, options)
1880
2070
  baseInstalled = true
1881
2071
 
1882
2072
  if (
@@ -1897,36 +2087,26 @@ export async function initProject(options: InitOptions = {}) {
1897
2087
  if (!object(installed))
1898
2088
  throw new Error("Invalid package.json after installation")
1899
2089
 
1900
- const available = Object.fromEntries(
1901
- [installed.dependencies, installed.devDependencies].flatMap((entry) =>
1902
- object(entry) ? Object.entries(entry) : [],
1903
- ),
2090
+ const pending = await pendingDependencies(
2091
+ root,
2092
+ runtimeRequirements,
2093
+ devRequirements,
1904
2094
  )
1905
2095
 
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)
2096
+ if (uiRoot === root) {
2097
+ pending.dependencies = pending.dependencies.filter(
2098
+ (argument) => !isBaseDependency(argument),
2099
+ )
2100
+ pending.devDependencies = pending.devDependencies.filter(
2101
+ (argument) => !isBaseDependency(argument),
2102
+ )
2103
+ }
1924
2104
 
1925
- const neededDev = devDependencies.filter(
1926
- (name) => !(name.split("@").slice(0, -1).join("@") in available),
1927
- )
2105
+ if (pending.dependencies.length)
2106
+ await packageRun(["add", ...pending.dependencies], root)
1928
2107
 
1929
- if (neededDev.length) await packageRun(["add", "-d", ...neededDev], root)
2108
+ if (pending.devDependencies.length)
2109
+ await packageRun(["add", "-d", ...pending.devDependencies], root)
1930
2110
 
1931
2111
  if (shared) {
1932
2112
  const path = `${relative(root, uiRoot).replaceAll("\\", "/")}/package.json`
@@ -1990,7 +2170,11 @@ export async function initProject(options: InitOptions = {}) {
1990
2170
 
1991
2171
  await writeFiles(root, changes)
1992
2172
 
1993
- return { framework, configured: edits.size }
2173
+ return {
2174
+ framework,
2175
+ configured: edits.size,
2176
+ ...(baseResult.warnings && { warnings: baseResult.warnings }),
2177
+ }
1994
2178
  } catch (cause) {
1995
2179
  if (!baseInstalled) throw cause
1996
2180
  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
  }),
@@ -549,7 +614,7 @@ export async function runBun(args: string[], cwd: string) {
549
614
  }
550
615
  }
551
616
 
552
- export async function installItem(name: string, options: InstallOptions = {}) {
617
+ export async function planInstall(name: string, options: InstallOptions = {}) {
553
618
  if (!namePattern.test(name)) throw new Error(`Invalid item name: ${name}`)
554
619
  const registryUrl = registryBaseUrl(options.registryUrl ?? defaultRegistryUrl)
555
620
  const requestTimeoutMs = options.requestTimeoutMs ?? 30_000
@@ -572,6 +637,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
572
637
  : null
573
638
 
574
639
  const manifest = readManifest(manifestText)
640
+ const originalManifest = JSON.stringify(manifest)
575
641
  const importPrefix = options.importPrefix ?? manifest.importPrefix ?? "@"
576
642
 
577
643
  if (
@@ -619,6 +685,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
619
685
 
620
686
  await visit(name)
621
687
  const files = new Map<string, RegistryFile>()
688
+ const owners = new Map<string, Record<string, string>>()
622
689
  const dependencies = new Set<string>()
623
690
  const devDependencies = new Set<string>()
624
691
 
@@ -639,6 +706,9 @@ export async function installItem(name: string, options: InstallOptions = {}) {
639
706
  }
640
707
 
641
708
  files.set(path, transformed)
709
+ const ownership = owners.get(path) ?? {}
710
+ ownership[item.name] = item.registryVersion
711
+ owners.set(path, ownership)
642
712
  }
643
713
  }
644
714
 
@@ -646,6 +716,26 @@ export async function installItem(name: string, options: InstallOptions = {}) {
646
716
  const previewFiles: InstallPreview["files"] = []
647
717
  let installed = 0
648
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
+ }
649
739
 
650
740
  for (const [path, file] of files) {
651
741
  const exists = await existingFile(root, path)
@@ -654,6 +744,14 @@ export async function installItem(name: string, options: InstallOptions = {}) {
654
744
  const current = before === null ? null : hash(before)
655
745
  changes.set(path, { before, after: before })
656
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
+
657
755
  const decision: InstallPreview["files"][number] = { path, action: "skip" }
658
756
  previewFiles.push(decision)
659
757
 
@@ -670,7 +768,7 @@ export async function installItem(name: string, options: InstallOptions = {}) {
670
768
  }
671
769
 
672
770
  if (current === file.integrity) {
673
- manifest.files[path] = file.integrity
771
+ track(path, file.integrity, false)
674
772
  decision.reason = `Identical file: ${path}`
675
773
  skipped++
676
774
  } else if (
@@ -691,29 +789,61 @@ export async function installItem(name: string, options: InstallOptions = {}) {
691
789
 
692
790
  if (current && options.force) decision.forced = true
693
791
  changes.set(path, { before, after: file.content })
694
- manifest.files[path] = file.integrity
792
+ track(path, file.integrity, true)
695
793
  installed++
696
794
  }
697
795
  }
698
796
 
699
- const manifestAfter = `${JSON.stringify(manifest, null, 2)}\n`
797
+ const manifestAfter =
798
+ manifestText !== null && JSON.stringify(manifest) === originalManifest
799
+ ? manifestText
800
+ : `${JSON.stringify(manifest, null, 2)}\n`
801
+
700
802
  changes.set("ui.json", { before: manifestText, after: manifestAfter })
701
803
  previewFiles.push({
702
804
  path: "ui.json",
703
805
  action: manifestText === manifestAfter ? "skip" : "write",
704
806
  })
705
807
 
706
- for (const dependency of dependencies) devDependencies.delete(dependency)
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
+ }
824
+
825
+ export async function installItem(
826
+ name: string,
827
+ options: InstallOptions = {},
828
+ ): Promise<InstallResult> {
829
+ return await applyInstall(await planInstall(name, options), options)
830
+ }
831
+
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)
707
840
 
708
841
  if (options.dryRun) {
709
842
  return {
710
843
  installed: 0,
711
844
  skipped,
712
- preview: {
713
- files: previewFiles,
714
- dependencies: [...dependencies],
715
- devDependencies: [...devDependencies],
716
- },
845
+ preview,
846
+ ...warningResult,
717
847
  }
718
848
  }
719
849
 
@@ -721,14 +851,14 @@ export async function installItem(name: string, options: InstallOptions = {}) {
721
851
 
722
852
  try {
723
853
  await writeFiles(root, changes, async () => {
724
- if (dependencies.size) {
854
+ if (preview.dependencies.length) {
725
855
  dependenciesStarted = true
726
- await run(["add", ...dependencies], root)
856
+ await run(["add", ...preview.dependencies], root)
727
857
  }
728
858
 
729
- if (devDependencies.size) {
859
+ if (preview.devDependencies.length) {
730
860
  dependenciesStarted = true
731
- await run(["add", "-d", ...devDependencies], root)
861
+ await run(["add", "-d", ...preview.devDependencies], root)
732
862
  }
733
863
  })
734
864
  } catch (cause) {
@@ -739,5 +869,5 @@ export async function installItem(name: string, options: InstallOptions = {}) {
739
869
  )
740
870
  }
741
871
 
742
- return { installed, skipped }
872
+ return { installed, skipped, ...warningResult }
743
873
  }
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(
@@ -221,3 +221,174 @@ export async function packageRunner(
221
221
  }
222
222
  }
223
223
  }
224
+
225
+ interface PendingDependencies {
226
+ dependencies: string[]
227
+ devDependencies: string[]
228
+ }
229
+
230
+ interface Requirement {
231
+ name: string
232
+ spec: string
233
+ argument: string
234
+ }
235
+
236
+ function requirement(argument: string): Requirement {
237
+ const separator = argument.indexOf("@", 1)
238
+ const name = separator < 0 ? argument : argument.slice(0, separator)
239
+ const spec = separator < 0 ? "" : argument.slice(separator + 1)
240
+
241
+ if (!isPackageName(name) || (separator >= 0 && !spec)) {
242
+ throw new Error(`Invalid package dependency: ${argument}`)
243
+ }
244
+
245
+ return { name, spec, argument }
246
+ }
247
+
248
+ function bounds(spec: string) {
249
+ const match = spec
250
+ .trim()
251
+ .match(/^(\^|~)?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/)
252
+
253
+ if (!match) return null
254
+ const major = Number(match[2])
255
+ const hasMinor = match[3] !== undefined && /^\d+$/.test(match[3])
256
+ const hasPatch = match[4] !== undefined && /^\d+$/.test(match[4])
257
+
258
+ if (!hasMinor && hasPatch) return null
259
+ const minor = hasMinor ? Number(match[3]) : 0
260
+ const patch = hasPatch ? Number(match[4]) : 0
261
+ const minimum = `${major}.${minor}.${patch}`
262
+
263
+ if (!Bun.semver.satisfies(minimum, spec)) return null
264
+
265
+ const maximum =
266
+ match[1] === "^"
267
+ ? major > 0 || !hasMinor
268
+ ? `${major + 1}.0.0`
269
+ : minor > 0 || !hasPatch
270
+ ? `0.${minor + 1}.0`
271
+ : `0.0.${patch + 1}`
272
+ : !hasMinor
273
+ ? `${major + 1}.0.0`
274
+ : match[1] === "~" || !hasPatch
275
+ ? `${major}.${minor + 1}.0`
276
+ : null
277
+
278
+ return { minimum, maximum }
279
+ }
280
+
281
+ function compatible(existing: string, requested: string) {
282
+ if (!requested || requested === "*" || existing === requested) return true
283
+
284
+ if (requested === "workspace:*") {
285
+ return existing === "*" || existing.startsWith("workspace:")
286
+ }
287
+
288
+ const current = bounds(existing)
289
+ const wanted = bounds(requested)
290
+
291
+ if (!current) {
292
+ return (
293
+ /^v?\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(
294
+ existing,
295
+ ) && Bun.semver.satisfies(existing, requested)
296
+ )
297
+ }
298
+
299
+ if (!current.maximum) return Bun.semver.satisfies(current.minimum, requested)
300
+
301
+ if (!wanted?.maximum) return false
302
+
303
+ return (
304
+ Bun.semver.satisfies(current.minimum, requested) &&
305
+ Bun.semver.order(current.maximum, wanted.maximum) <= 0
306
+ )
307
+ }
308
+
309
+ function section(manifest: JsonObject, name: string) {
310
+ const value = manifest[name]
311
+
312
+ if (value === undefined) return {}
313
+
314
+ if (!isRecord(value)) throw new Error(`Invalid package.json ${name}`)
315
+
316
+ for (const [name, spec] of Object.entries(value)) {
317
+ if (!isPackageName(name) || !isString(spec) || !spec.trim()) {
318
+ throw new Error(`Invalid package.json dependency: ${name}`)
319
+ }
320
+ }
321
+
322
+ return value
323
+ }
324
+
325
+ export async function pendingDependencies(
326
+ root: string,
327
+ runtime: Iterable<string>,
328
+ dev: Iterable<string>,
329
+ ) {
330
+ const manifest = await readPackage(root)
331
+ const dependencies = section(manifest, "dependencies")
332
+ const devDependencies = section(manifest, "devDependencies")
333
+
334
+ const groups = new Map<
335
+ string,
336
+ { runtime: boolean; requirements: Requirement[] }
337
+ >()
338
+
339
+ for (const [arguments_, isRuntime] of [
340
+ [runtime, true],
341
+ [dev, false],
342
+ ] as const) {
343
+ for (const argument of arguments_) {
344
+ const entry = requirement(argument)
345
+
346
+ const group = groups.get(entry.name) ?? {
347
+ runtime: false,
348
+ requirements: [],
349
+ }
350
+
351
+ group.runtime ||= isRuntime
352
+ group.requirements.push(entry)
353
+ groups.set(entry.name, group)
354
+ }
355
+ }
356
+
357
+ const pending: PendingDependencies = {
358
+ dependencies: [],
359
+ devDependencies: [],
360
+ }
361
+
362
+ for (const [name, group] of groups) {
363
+ const installed = dependencies[name] ?? devDependencies[name]
364
+
365
+ const satisfied =
366
+ isString(installed) &&
367
+ group.requirements.every((entry) => compatible(installed, entry.spec))
368
+
369
+ if (satisfied && (!group.runtime || dependencies[name] !== undefined))
370
+ continue
371
+
372
+ const chosen = group.requirements.find((candidate) =>
373
+ group.requirements.every((entry) =>
374
+ compatible(candidate.spec || "*", entry.spec),
375
+ ),
376
+ )
377
+
378
+ if (!chosen && !satisfied) {
379
+ throw new Error(
380
+ `Conflicting package requirements: ${group.requirements.map((entry) => entry.argument).join(", ")}`,
381
+ )
382
+ }
383
+
384
+ const argument = satisfied ? `${name}@${installed}` : chosen!.argument
385
+
386
+ const target = group.runtime
387
+ ? pending.dependencies
388
+ : pending.devDependencies
389
+
390
+ target.push(argument)
391
+ }
392
+
393
+ return pending
394
+ }