@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 +1 -1
- package/src/cli.ts +41 -6
- package/src/init.ts +263 -75
- package/src/install.ts +187 -41
- package/src/project.ts +184 -5
package/package.json
CHANGED
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
|
-
|
|
102
|
-
|
|
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
|
|
1522
|
+
const existing = await Promise.all(
|
|
1523
|
+
names.map((name) => existingFile(root, name)),
|
|
1524
|
+
)
|
|
1502
1525
|
|
|
1503
|
-
|
|
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(
|
|
1551
|
-
|
|
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
|
|
1601
|
-
|
|
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(
|
|
1673
|
-
|
|
1711
|
+
await plan(
|
|
1712
|
+
`${prefix}/package.json`,
|
|
1713
|
+
(content) => {
|
|
1714
|
+
const value: JsonValue = JSON.parse(content)
|
|
1674
1715
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
|
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
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
2094
|
+
const pending = await pendingDependencies(
|
|
2095
|
+
root,
|
|
2096
|
+
runtimeRequirements,
|
|
2097
|
+
devRequirements,
|
|
1904
2098
|
)
|
|
1905
2099
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1926
|
-
|
|
1927
|
-
)
|
|
2109
|
+
if (pending.dependencies.length)
|
|
2110
|
+
await packageRun(["add", ...pending.dependencies], root)
|
|
1928
2111
|
|
|
1929
|
-
if (
|
|
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 {
|
|
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 {
|
|
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, {
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
808
|
+
track(path, file.integrity, true)
|
|
695
809
|
installed++
|
|
696
810
|
}
|
|
697
811
|
}
|
|
698
812
|
|
|
699
|
-
const manifestAfter =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
188
|
-
const local = await
|
|
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
|
+
}
|