@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/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
+ }