@yopem-ui/cli 0.0.2 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -3
- package/package.json +4 -2
- package/src/cli.ts +136 -21
- package/src/init.ts +546 -156
- package/src/install.ts +554 -61
- package/src/project.ts +394 -0
package/src/project.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InstallOptions,
|
|
3
|
+
JsonObject,
|
|
4
|
+
JsonValue,
|
|
5
|
+
} from "@yopem-ui/cli/install"
|
|
6
|
+
|
|
7
|
+
import { existingFile, isRecord, isString, runBun } from "@yopem-ui/cli/install"
|
|
8
|
+
import { readFile, realpath } from "node:fs/promises"
|
|
9
|
+
import { dirname, join, matchesGlob, relative } from "node:path"
|
|
10
|
+
|
|
11
|
+
type PackageManager = "bun" | "npm" | "pnpm" | "yarn"
|
|
12
|
+
|
|
13
|
+
export function isPackageName(value: unknown): value is string {
|
|
14
|
+
return (
|
|
15
|
+
isString(value) &&
|
|
16
|
+
value.length <= 214 &&
|
|
17
|
+
value === value.trim() &&
|
|
18
|
+
/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value) &&
|
|
19
|
+
value !== "node_modules" &&
|
|
20
|
+
value !== "favicon.ico"
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readPackage(root: string): Promise<JsonObject> {
|
|
25
|
+
if (!(await existingFile(root, "package.json"))) return {}
|
|
26
|
+
|
|
27
|
+
const value: JsonValue = JSON.parse(
|
|
28
|
+
await readFile(join(root, "package.json"), "utf8"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if (!isRecord(value)) throw new Error(`Invalid package.json in ${root}`)
|
|
32
|
+
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pnpmPatterns(content: string) {
|
|
37
|
+
// ponytail: string lists only; add YAML parser for anchors or flow lists.
|
|
38
|
+
const lines = content.split(/\r?\n/)
|
|
39
|
+
const start = lines.findIndex((line) => /^packages:\s*(?:#.*)?$/.test(line))
|
|
40
|
+
|
|
41
|
+
if (start < 0) {
|
|
42
|
+
if (
|
|
43
|
+
lines.some((line) => /^\s*(?:["']packages["']|packages)\s*:/.test(line))
|
|
44
|
+
) {
|
|
45
|
+
throw new Error("Unsupported pnpm workspace packages; use a string list")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return ["**"]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const patterns: string[] = []
|
|
52
|
+
|
|
53
|
+
for (const line of lines.slice(start + 1)) {
|
|
54
|
+
if (/^\s*(?:#.*)?$/.test(line)) continue
|
|
55
|
+
|
|
56
|
+
if (/^\S/.test(line) && !line.startsWith("-")) break
|
|
57
|
+
|
|
58
|
+
const entry = line.match(
|
|
59
|
+
/^\s*-\s*(?:"([^"]*)"|'([^']*)'|([^"'#]+?))\s*(?:#.*)?$/,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const pattern = entry?.[1] ?? entry?.[2] ?? entry?.[3]
|
|
63
|
+
|
|
64
|
+
if (!pattern || (entry?.[3] && /^[*&[\]{}>|]/.test(pattern))) {
|
|
65
|
+
throw new Error("Unsupported pnpm workspace pattern")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
patterns.push(pattern)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return patterns
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function workspaceRoot(root: string) {
|
|
75
|
+
const target = await realpath(root)
|
|
76
|
+
let current = target
|
|
77
|
+
|
|
78
|
+
while (true) {
|
|
79
|
+
const manifest = await readPackage(current)
|
|
80
|
+
|
|
81
|
+
let patterns = isRecord(manifest.workspaces)
|
|
82
|
+
? manifest.workspaces.packages
|
|
83
|
+
: manifest.workspaces
|
|
84
|
+
|
|
85
|
+
if (isRecord(manifest.workspaces) && patterns === undefined) {
|
|
86
|
+
throw new Error(`Invalid workspace packages in ${current}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (await existingFile(current, "pnpm-workspace.yaml")) {
|
|
90
|
+
patterns = pnpmPatterns(
|
|
91
|
+
await readFile(join(current, "pnpm-workspace.yaml"), "utf8"),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (patterns !== undefined) {
|
|
96
|
+
if (
|
|
97
|
+
!Array.isArray(patterns) ||
|
|
98
|
+
!patterns.every(isString) ||
|
|
99
|
+
patterns.some((pattern) => !pattern)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(`Invalid workspace packages in ${current}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const path = relative(current, target).replaceAll("\\", "/")
|
|
105
|
+
const includes: string[] = []
|
|
106
|
+
const excludes: string[] = []
|
|
107
|
+
|
|
108
|
+
for (const pattern of patterns) {
|
|
109
|
+
const excluded = pattern.startsWith("!")
|
|
110
|
+
|
|
111
|
+
const normalized = (excluded ? pattern.slice(1) : pattern)
|
|
112
|
+
.replace(/^\.\//, "")
|
|
113
|
+
.replace(/\/$/, "")
|
|
114
|
+
|
|
115
|
+
const group = excluded ? excludes : includes
|
|
116
|
+
group.push(normalized)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
path &&
|
|
121
|
+
(!includes.some((pattern) => matchesGlob(path, pattern)) ||
|
|
122
|
+
excludes.some((pattern) => matchesGlob(path, pattern)))
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(`Not a workspace member: ${target}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return current
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const parent = dirname(current)
|
|
131
|
+
|
|
132
|
+
if (parent === current) return target
|
|
133
|
+
current = parent
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function packageManager(root: string) {
|
|
138
|
+
const manifest = await readPackage(root)
|
|
139
|
+
const declared = manifest.packageManager
|
|
140
|
+
const found = new Set<PackageManager>()
|
|
141
|
+
|
|
142
|
+
if (declared !== undefined) {
|
|
143
|
+
if (!isString(declared)) {
|
|
144
|
+
throw new Error(`Package manager declaration is invalid in ${root}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const name = declared.split("@")[0]
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
name !== "bun" &&
|
|
151
|
+
name !== "npm" &&
|
|
152
|
+
name !== "pnpm" &&
|
|
153
|
+
name !== "yarn"
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(`Package manager is unsupported: ${declared}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
found.add(name)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const locks: [PackageManager, string][] = [
|
|
162
|
+
["bun", "bun.lock"],
|
|
163
|
+
["bun", "bun.lockb"],
|
|
164
|
+
["npm", "package-lock.json"],
|
|
165
|
+
["pnpm", "pnpm-lock.yaml"],
|
|
166
|
+
["yarn", "yarn.lock"],
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
for (const [manager, path] of locks) {
|
|
170
|
+
if (await existingFile(root, path)) found.add(manager)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (found.size > 1) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Package manager declarations and lockfiles conflict in ${root}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return [...found][0]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function packageRunner(
|
|
183
|
+
root: string,
|
|
184
|
+
run?: InstallOptions["run"],
|
|
185
|
+
): Promise<NonNullable<InstallOptions["run"]>> {
|
|
186
|
+
const target = await realpath(root)
|
|
187
|
+
const owner = await workspaceRoot(target)
|
|
188
|
+
const local = await packageManager(target)
|
|
189
|
+
const workspace = owner === target ? local : await packageManager(owner)
|
|
190
|
+
|
|
191
|
+
if (local && workspace && local !== workspace) {
|
|
192
|
+
throw new Error("Package manager differs between target and workspace root")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const manager = local ?? workspace ?? "bun"
|
|
196
|
+
|
|
197
|
+
if (run) return run
|
|
198
|
+
|
|
199
|
+
if (manager === "bun") return runBun
|
|
200
|
+
|
|
201
|
+
return async function runPackage(args: string[], cwd: string) {
|
|
202
|
+
const command =
|
|
203
|
+
manager === "npm" && args[0] === "add" ? "install" : args[0]!
|
|
204
|
+
|
|
205
|
+
const flags = args.slice(1).map((argument) => {
|
|
206
|
+
if (argument === "-d") return "-D"
|
|
207
|
+
|
|
208
|
+
return manager === "npm"
|
|
209
|
+
? argument.replace(/@workspace:\*$/, "@*")
|
|
210
|
+
: argument
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const child = Bun.spawn([manager, command, ...flags], {
|
|
214
|
+
cwd,
|
|
215
|
+
stdout: "inherit",
|
|
216
|
+
stderr: "inherit",
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
if ((await child.exited) !== 0) {
|
|
220
|
+
throw new Error(`${manager} ${command} failed`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
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
|
+
}
|