@shadow-js/ui 1.0.1 → 1.0.3

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/dist/cli.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { createInterface } from "node:readline/promises";
5
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
5
  import { parseArgs } from "node:util";
6
+ import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  // src/cli/fs.ts
9
9
  /**
@@ -11,6 +11,59 @@ import { fileURLToPath } from "node:url";
11
11
  * directory or an in-memory tree, and so every path goes through one place.
12
12
  * @packageDocumentation
13
13
  */
14
+ /** Symbolic links followed while resolving one path; the same bound the operating system uses. */
15
+ const MAX_LINK_HOPS = 40;
16
+ /** Folders `ancestors` climbs at most: far beyond any real project, so a path loop cannot spin. */
17
+ const MAX_LEVELS = 64;
18
+ /**
19
+ * `start` and its parent folders up to the filesystem root, nearest first (at most 64).
20
+ *
21
+ * @param start An absolute folder.
22
+ * @returns The folders, `start` first.
23
+ * @example
24
+ * ancestors("/a/b"); // ["/a/b", "/a", "/"]
25
+ * @since 1.0.0
26
+ *
27
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
28
+ */
29
+ function ancestors(start) {
30
+ const folders = [start];
31
+ for (let folder = dirname(start); folders.length < MAX_LEVELS; folder = dirname(folder)) {
32
+ if (folder === folders[folders.length - 1]) break;
33
+ folders.push(folder);
34
+ }
35
+ return folders;
36
+ }
37
+ /** Whether a failed file call means "nothing is there" (or a file stands where a directory should). */
38
+ function isMissing(error) {
39
+ const code = error.code;
40
+ return code === "ENOENT" || code === "ENOTDIR";
41
+ }
42
+ /**
43
+ * The real path of `path`, where the part that does not exist yet is kept as written. A symbolic
44
+ * link whose target is missing is followed to that target: a write through it would create the
45
+ * target, so the guard has to see where it is.
46
+ */
47
+ function resolveReal(path, hops) {
48
+ try {
49
+ return realpathSync(path);
50
+ } catch (error) {
51
+ if (!isMissing(error)) throw error;
52
+ }
53
+ let link;
54
+ try {
55
+ link = readlinkSync(path);
56
+ } catch (error) {
57
+ if (!isMissing(error) && error.code !== "EINVAL") throw error;
58
+ }
59
+ if (link !== void 0) {
60
+ if (hops >= MAX_LINK_HOPS) throw Object.assign(/* @__PURE__ */ new Error(`Too many symbolic links: ${path}`), { code: "ELOOP" });
61
+ return resolveReal(resolve(resolveReal(dirname(path), hops + 1), link), hops + 1);
62
+ }
63
+ const parent = dirname(path);
64
+ if (parent === path) return path;
65
+ return join(resolveReal(parent, hops), basename(path));
66
+ }
14
67
  /**
15
68
  * The real filesystem.
16
69
  *
@@ -26,7 +79,7 @@ function nodeFs() {
26
79
  exists: (path) => existsSync(path),
27
80
  read(path) {
28
81
  try {
29
- return readFileSync(path, "utf8");
82
+ return readFileSync(path, "utf8").replace(/^\uFEFF/, "");
30
83
  } catch (error) {
31
84
  if (error.code === "ENOENT") return void 0;
32
85
  throw error;
@@ -40,15 +93,7 @@ function nodeFs() {
40
93
  rmSync(path, { force: true });
41
94
  },
42
95
  realpath(path) {
43
- const tail = [];
44
- let current = path;
45
- while (!existsSync(current)) {
46
- const parent = dirname(current);
47
- if (parent === current) return path;
48
- tail.unshift(basename(current));
49
- current = parent;
50
- }
51
- return join(realpathSync(current), ...tail);
96
+ return resolveReal(path, 0);
52
97
  }
53
98
  };
54
99
  }
@@ -210,25 +255,37 @@ function installCrashHandlers(argv) {
210
255
  process.on("uncaughtException", crash);
211
256
  process.on("unhandledRejection", crash);
212
257
  }
213
- /** The edit script turning `a` into `b`; one big replacement when the texts are too large. */
214
- function edits(a, b) {
215
- const n = a.length;
216
- const m = b.length;
217
- const max = n + m;
218
- if (max > 2e4) return [...a.map((line) => ({
258
+ /** The whole of `a` deleted and the whole of `b` inserted: always correct, never minimal. */
259
+ function replacement(a, b) {
260
+ return [...a.map((line) => ({
219
261
  kind: "delete",
220
262
  line
221
263
  })), ...b.map((line) => ({
222
264
  kind: "insert",
223
265
  line
224
266
  }))];
267
+ }
268
+ /** The edit script turning `a` into `b`; one big replacement when the texts are too large or too different. */
269
+ function edits(a, b) {
270
+ const n = a.length;
271
+ const m = b.length;
272
+ const max = n + m;
273
+ if (max > 2e4) return replacement(a, b);
225
274
  const offset = max;
226
- let frontier = new Int32Array(2 * max + 2);
275
+ const frontier = new Int32Array(2 * max + 2);
276
+ /** The frontier before each round, only the diagonals the next round can read: `[offset - d - 1, offset + d + 1]`. */
227
277
  const trace = [];
278
+ let kept = 0;
228
279
  let reached = -1;
229
280
  for (let d = 0; d <= max && reached === -1; d += 1) {
230
- trace.push(frontier.slice());
231
- const next = frontier.slice();
281
+ const from = Math.max(0, offset - d - 1);
282
+ const cells = frontier.slice(from, Math.min(frontier.length, offset + d + 2));
283
+ kept += cells.length;
284
+ if (kept > 16e6) return replacement(a, b);
285
+ trace.push({
286
+ from,
287
+ cells
288
+ });
232
289
  for (let k = -d; k <= d; k += 2) {
233
290
  let x;
234
291
  const below = frontier[offset + k - 1] ?? 0;
@@ -240,24 +297,24 @@ function edits(a, b) {
240
297
  x += 1;
241
298
  y += 1;
242
299
  }
243
- next[offset + k] = x;
300
+ frontier[offset + k] = x;
244
301
  if (x >= n && y >= m) {
245
302
  reached = d;
246
303
  break;
247
304
  }
248
305
  }
249
- frontier = next;
250
306
  }
251
307
  const result = [];
252
308
  let x = n;
253
309
  let y = m;
254
310
  for (let d = reached; d > 0; d -= 1) {
255
- const previous = trace[d] ?? /* @__PURE__ */ new Int32Array(0);
311
+ const previous = trace[d];
312
+ const at = (index) => previous === void 0 ? 0 : previous.cells[index - previous.from] ?? 0;
256
313
  const k = x - y;
257
- const below = previous[offset + k - 1] ?? 0;
258
- const above = previous[offset + k + 1] ?? 0;
314
+ const below = at(offset + k - 1);
315
+ const above = at(offset + k + 1);
259
316
  const previousK = k === -d || k !== d && below < above ? k + 1 : k - 1;
260
- const previousX = previous[offset + previousK] ?? 0;
317
+ const previousX = at(offset + previousK);
261
318
  const previousY = previousX - previousK;
262
319
  while (x > previousX && y > previousY) {
263
320
  x -= 1;
@@ -371,6 +428,186 @@ function unifiedDiff(before, after, labels, context = 3) {
371
428
  ...hunks
372
429
  ].join("\n");
373
430
  }
431
+ // src/cli/schema.ts
432
+ /**
433
+ * The base colors, in documentation order.
434
+ * @since 1.0.0
435
+ *
436
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
437
+ */
438
+ const BASE_COLORS = [
439
+ "neutral",
440
+ "zinc",
441
+ "slate",
442
+ "stone",
443
+ "gray"
444
+ ];
445
+ /**
446
+ * The default aliases (shadcn's).
447
+ * @since 1.0.0
448
+ *
449
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
450
+ */
451
+ const DEFAULT_ALIASES = {
452
+ components: "@/components",
453
+ ui: "@/components/ui",
454
+ lib: "@/lib",
455
+ utils: "@/lib/utils",
456
+ hooks: "@/hooks"
457
+ };
458
+ /** Registry item names are kebab-case; nothing else may reach a file path. */
459
+ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
460
+ /** A file name inside `registry/<name>/`. */
461
+ const FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
462
+ /**
463
+ * Whether `name` is a valid registry item name.
464
+ *
465
+ * @param name Any string.
466
+ * @returns `true` for kebab-case names such as `dropdown-menu`.
467
+ * @example
468
+ * isValidItemName("../etc/passwd"); // false
469
+ * @since 1.0.0
470
+ *
471
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
472
+ */
473
+ function isValidItemName(name) {
474
+ return NAME_PATTERN.test(name);
475
+ }
476
+ /**
477
+ * Thrown for malformed registry or configuration data.
478
+ * @since 1.0.0
479
+ *
480
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
481
+ */
482
+ var SchemaError = class extends Error {
483
+ /** The stable code `--json` prints. */
484
+ code = "SHADOW_UI_INVALID_FILE";
485
+ constructor(message) {
486
+ super(message);
487
+ this.name = "SchemaError";
488
+ }
489
+ };
490
+ function record(value, where) {
491
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SchemaError(`${where} must be an object`);
492
+ return value;
493
+ }
494
+ function text(value, where) {
495
+ if (typeof value !== "string" || value === "") throw new SchemaError(`${where} must be a string`);
496
+ return value;
497
+ }
498
+ function strings(value, where) {
499
+ if (!Array.isArray(value)) throw new SchemaError(`${where} must be an array`);
500
+ return value.map((entry, index) => text(entry, `${where}[${index}]`));
501
+ }
502
+ function itemType(value, where) {
503
+ if (value === "ui" || value === "lib" || value === "hook") return value;
504
+ throw new SchemaError(`${where} must be "ui", "lib" or "hook"`);
505
+ }
506
+ function parseItem(value, index) {
507
+ const where = `items[${index}]`;
508
+ const item = record(value, where);
509
+ const name = text(item["name"], `${where}.name`);
510
+ if (!isValidItemName(name)) throw new SchemaError(`${where}.name "${name}" is not a valid name`);
511
+ const files = item["files"];
512
+ if (!Array.isArray(files) || files.length === 0) throw new SchemaError(`${where}.files must be a non-empty array`);
513
+ const dependencies = record(item["dependencies"], `${where}.dependencies`);
514
+ const tailwind = record(item["tailwind"], `${where}.tailwind`);
515
+ return {
516
+ name,
517
+ type: itemType(item["type"], `${where}.type`),
518
+ title: text(item["title"], `${where}.title`),
519
+ description: text(item["description"], `${where}.description`),
520
+ files: files.map((file, fileIndex) => {
521
+ const entry = record(file, `${where}.files[${fileIndex}]`);
522
+ const path = text(entry["path"], `${where}.files[${fileIndex}].path`);
523
+ const segments = path.split("/");
524
+ const [root, folder, base] = segments;
525
+ if (segments.length !== 3 || root !== "registry" || folder !== name || base === void 0 || !FILE_PATTERN.test(base)) throw new SchemaError(`${where}.files[${fileIndex}].path "${path}" must be registry/${name}/<file>`);
526
+ return {
527
+ path,
528
+ type: itemType(entry["type"], `${where}.files[${fileIndex}].type`)
529
+ };
530
+ }),
531
+ registryDependencies: strings(item["registryDependencies"], `${where}.registryDependencies`),
532
+ dependencies: Object.fromEntries(Object.entries(dependencies).map(([pkg, version]) => [pkg, text(version, `${where}.dependencies.${pkg}`)])),
533
+ exports: strings(item["exports"], `${where}.exports`),
534
+ primitives: strings(item["primitives"], `${where}.primitives`),
535
+ tailwind: {
536
+ tokens: strings(tailwind["tokens"], `${where}.tailwind.tokens`),
537
+ animations: tailwind["animations"] === true
538
+ }
539
+ };
540
+ }
541
+ /**
542
+ * Validates parsed `registry.json` data.
543
+ *
544
+ * @param value The parsed JSON.
545
+ * @returns The typed index.
546
+ * @throws SchemaError naming the first malformed field.
547
+ * @example
548
+ * const index = parseRegistryIndex(JSON.parse(readFileSync("registry.json", "utf8")));
549
+ * @since 1.0.0
550
+ *
551
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
552
+ */
553
+ function parseRegistryIndex(value) {
554
+ const root = record(value, "registry");
555
+ const items = root["items"];
556
+ if (!Array.isArray(items)) throw new SchemaError("registry.items must be an array");
557
+ const parsed = items.map(parseItem);
558
+ const seen = /* @__PURE__ */ new Set();
559
+ for (const item of parsed) {
560
+ if (seen.has(item.name)) throw new SchemaError(`registry has two items named "${item.name}"`);
561
+ seen.add(item.name);
562
+ }
563
+ return {
564
+ name: text(root["name"], "registry.name"),
565
+ version: text(root["version"], "registry.version"),
566
+ frameworkDependencies: strings(root["frameworkDependencies"], "registry.frameworkDependencies"),
567
+ items: parsed
568
+ };
569
+ }
570
+ /**
571
+ * Validates parsed `components.json` data.
572
+ *
573
+ * @param value The parsed JSON.
574
+ * @returns The typed configuration, with defaults for missing aliases.
575
+ * @throws SchemaError naming the first malformed field.
576
+ * @example
577
+ * const config = parseComponentsConfig(JSON.parse(readFileSync("components.json", "utf8")));
578
+ * @since 1.0.0
579
+ *
580
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
581
+ */
582
+ function parseComponentsConfig(value) {
583
+ const root = record(value, "components.json");
584
+ const tailwind = record(root["tailwind"], "components.json tailwind");
585
+ const baseColor = tailwind["baseColor"] ?? "neutral";
586
+ if (!BASE_COLORS.includes(baseColor)) throw new SchemaError(`components.json tailwind.baseColor must be one of ${BASE_COLORS.join(", ")}`);
587
+ const given = root["aliases"] === void 0 ? {} : record(root["aliases"], "components.json aliases");
588
+ const alias = (key) => {
589
+ const entry = given[key];
590
+ if (entry === void 0) return DEFAULT_ALIASES[key];
591
+ const aliasText = text(entry, `components.json aliases.${key}`);
592
+ if (aliasText.includes("..") || /\s/.test(aliasText)) throw new SchemaError(`components.json aliases.${key} "${aliasText}" is not a valid alias`);
593
+ return aliasText;
594
+ };
595
+ const framework = root["framework"];
596
+ return {
597
+ framework: framework === "vite" || framework === "dusk" ? framework : "unknown",
598
+ tailwind: {
599
+ css: text(tailwind["css"], "components.json tailwind.css"),
600
+ baseColor
601
+ },
602
+ aliases: {
603
+ components: alias("components"),
604
+ ui: alias("ui"),
605
+ lib: alias("lib"),
606
+ utils: alias("utils"),
607
+ hooks: alias("hooks")
608
+ }
609
+ };
610
+ }
374
611
  // src/cli/paths.ts
375
612
  /**
376
613
  * Path handling: reading `tsconfig` path aliases, mapping an import alias to a directory, and the
@@ -459,9 +696,59 @@ function parseJsonc(text) {
459
696
  function asRecord(value) {
460
697
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
461
698
  }
699
+ /** The `extends` links followed from one tsconfig: far beyond any real chain. */
700
+ const MAX_EXTENDS = 16;
701
+ /** The file an `extends` entry names, or `undefined`: a path, or a package looked up in `node_modules`. */
702
+ function extendsFile(fs, folder, entry) {
703
+ const candidates = (base) => base.endsWith(".json") ? [base] : [`${base}.json`, join(base, "tsconfig.json")];
704
+ if (entry.startsWith(".") || isAbsolute(entry)) return candidates(resolve(folder, entry)).find((file) => fs.exists(file));
705
+ for (const parent of ancestors(folder)) {
706
+ const found = candidates(join(parent, "node_modules", entry)).find((file) => fs.exists(file));
707
+ if (found !== void 0) return found;
708
+ }
709
+ }
710
+ /**
711
+ * The `paths` and `baseUrl` of one tsconfig with its `extends` merged in, as TypeScript does: later
712
+ * bases override earlier ones, the config itself overrides its bases, and `paths` is replaced as a
713
+ * whole. `seen` holds the chain so far, so a cycle or a very long chain ends.
714
+ */
715
+ function readOptions(fs, file, label, seen) {
716
+ const text = fs.read(file);
717
+ if (text === void 0) return void 0;
718
+ let parsed;
719
+ try {
720
+ parsed = parseJsonc(text);
721
+ } catch (error) {
722
+ if (error instanceof SyntaxError) throw new SchemaError(`${label} is not valid JSON: ${error.message}`);
723
+ throw error;
724
+ }
725
+ const config = asRecord(parsed);
726
+ const options = asRecord(config?.["compilerOptions"]);
727
+ const folder = dirname(file);
728
+ let merged = {};
729
+ const bases = [config?.["extends"]].flat();
730
+ for (const entry of seen.length < MAX_EXTENDS ? bases : []) {
731
+ const base = typeof entry === "string" ? extendsFile(fs, folder, entry) : void 0;
732
+ if (base !== void 0 && !seen.includes(base)) merged = {
733
+ ...merged,
734
+ ...readOptions(fs, base, base, [...seen, base])
735
+ };
736
+ }
737
+ const paths = asRecord(options?.["paths"]);
738
+ const baseUrl = options?.["baseUrl"];
739
+ return {
740
+ ...merged,
741
+ ...paths !== void 0 && {
742
+ paths,
743
+ pathsDir: folder
744
+ },
745
+ ...typeof baseUrl === "string" && { baseUrl: resolve(folder, baseUrl) }
746
+ };
747
+ }
462
748
  /**
463
749
  * Reads the `compilerOptions.paths` of the project's tsconfig files (`tsconfig.json`,
464
- * `tsconfig.app.json`). Targets are resolved against `baseUrl` or the config's directory.
750
+ * `tsconfig.app.json`), following their `extends` (relative files and packages in `node_modules`).
751
+ * Targets are resolved against `baseUrl` or the directory of the config that declares `paths`.
465
752
  *
466
753
  * @param fs The project filesystem.
467
754
  * @param root The project root.
@@ -475,12 +762,10 @@ function asRecord(value) {
475
762
  function readPathMappings(fs, root) {
476
763
  const mappings = [];
477
764
  for (const name of ["tsconfig.json", "tsconfig.app.json"]) {
478
- const text = fs.read(join(root, name));
479
- if (text === void 0) continue;
480
- const options = asRecord(asRecord(parseJsonc(text))?.["compilerOptions"]);
481
- const paths = asRecord(options?.["paths"]);
765
+ const file = join(root, name);
766
+ const { paths, pathsDir, baseUrl } = readOptions(fs, file, name, [file]) ?? {};
482
767
  if (paths === void 0) continue;
483
- const base = typeof options?.["baseUrl"] === "string" ? resolve(root, options["baseUrl"]) : root;
768
+ const base = baseUrl ?? pathsDir ?? root;
484
769
  for (const [pattern, targets] of Object.entries(paths)) {
485
770
  if (!Array.isArray(targets)) continue;
486
771
  mappings.push({
@@ -507,19 +792,26 @@ function readPathMappings(fs, root) {
507
792
  * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
508
793
  */
509
794
  function aliasToDirectory(alias, mappings) {
795
+ let best;
510
796
  for (const { pattern, targets } of mappings) {
797
+ const target = targets[0];
798
+ if (target === void 0) continue;
511
799
  const star = pattern.indexOf("*");
512
800
  if (star === -1) {
513
- if (pattern === alias && targets[0] !== void 0) return targets[0];
801
+ if (pattern === alias) return target;
514
802
  continue;
515
803
  }
516
804
  const prefix = pattern.slice(0, star);
517
805
  const suffix = pattern.slice(star + 1);
518
- if (!alias.startsWith(prefix) || !alias.endsWith(suffix) || alias.length < prefix.length + suffix.length) continue;
519
- const rest = alias.slice(prefix.length, alias.length - suffix.length);
520
- const target = targets[0];
521
- if (target !== void 0) return target.replace("*", rest);
806
+ if (alias.startsWith(prefix) && alias.endsWith(suffix) && alias.length >= prefix.length + suffix.length && (best === void 0 || prefix.length > best.length)) {
807
+ const rest = alias.slice(prefix.length, alias.length - suffix.length);
808
+ best = {
809
+ length: prefix.length,
810
+ directory: target.replace("*", rest)
811
+ };
812
+ }
522
813
  }
814
+ if (best !== void 0) return best.directory;
523
815
  throw new PathError(`The alias "${alias}" is not covered by "paths" in tsconfig.json. Add, for example:\n "compilerOptions": { "paths": { "@/*": ["./src/*"] } }`);
524
816
  }
525
817
  /**
@@ -569,12 +861,31 @@ function resolveInsideReal(fs, root, target) {
569
861
  // src/cli/pm.ts
570
862
  /**
571
863
  * Package manager detection and the install command line. npm is the default; pnpm, yarn and bun
572
- * are used when their lockfile (or the `packageManager` field) says so.
864
+ * are used when their lockfile (or the `packageManager` field) says so, in the project or a parent folder.
573
865
  * @packageDocumentation
574
866
  */
867
+ /** The package manager one folder says it uses (field first, then lockfiles), or `undefined`. */
868
+ function managerOf(fs, folder) {
869
+ const manifest = fs.read(join(folder, "package.json"));
870
+ if (manifest !== void 0) try {
871
+ const field = JSON.parse(manifest).packageManager;
872
+ if (typeof field === "string") {
873
+ const name = field.split("@")[0];
874
+ if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
875
+ }
876
+ } catch (error) {
877
+ if (!(error instanceof SyntaxError)) throw error;
878
+ }
879
+ if (fs.exists(join(folder, "pnpm-lock.yaml"))) return "pnpm";
880
+ if (fs.exists(join(folder, "yarn.lock"))) return "yarn";
881
+ if (fs.exists(join(folder, "bun.lock")) || fs.exists(join(folder, "bun.lockb"))) return "bun";
882
+ if (fs.exists(join(folder, "package-lock.json"))) return "npm";
883
+ }
575
884
  /**
576
- * Detects the project's package manager: the `packageManager` field of `package.json` first, then
577
- * lockfiles, otherwise npm.
885
+ * Detects the project's package manager. In the project folder and then each parent folder (a
886
+ * package inside a workspace is managed from the workspace root) it reads the `packageManager`
887
+ * field of `package.json`, then the lockfiles; the nearest folder that says anything wins, and npm
888
+ * is the default.
578
889
  *
579
890
  * @param fs The project filesystem.
580
891
  * @param root The project root.
@@ -586,19 +897,10 @@ function resolveInsideReal(fs, root, target) {
586
897
  * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
587
898
  */
588
899
  function detectPackageManager(fs, root) {
589
- const manifest = fs.read(join(root, "package.json"));
590
- if (manifest !== void 0) try {
591
- const field = JSON.parse(manifest).packageManager;
592
- if (typeof field === "string") {
593
- const name = field.split("@")[0];
594
- if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
595
- }
596
- } catch (error) {
597
- if (!(error instanceof SyntaxError)) throw error;
900
+ for (const folder of ancestors(root)) {
901
+ const manager = managerOf(fs, folder);
902
+ if (manager !== void 0) return manager;
598
903
  }
599
- if (fs.exists(join(root, "pnpm-lock.yaml"))) return "pnpm";
600
- if (fs.exists(join(root, "yarn.lock"))) return "yarn";
601
- if (fs.exists(join(root, "bun.lock")) || fs.exists(join(root, "bun.lockb"))) return "bun";
602
904
  return "npm";
603
905
  }
604
906
  /**
@@ -633,186 +935,6 @@ function installCommand(manager, packages) {
633
935
  };
634
936
  }
635
937
  }
636
- // src/cli/schema.ts
637
- /**
638
- * The base colors, in documentation order.
639
- * @since 1.0.0
640
- *
641
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
642
- */
643
- const BASE_COLORS = [
644
- "neutral",
645
- "zinc",
646
- "slate",
647
- "stone",
648
- "gray"
649
- ];
650
- /**
651
- * The default aliases (shadcn's).
652
- * @since 1.0.0
653
- *
654
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
655
- */
656
- const DEFAULT_ALIASES = {
657
- components: "@/components",
658
- ui: "@/components/ui",
659
- lib: "@/lib",
660
- utils: "@/lib/utils",
661
- hooks: "@/hooks"
662
- };
663
- /** Registry item names are kebab-case; nothing else may reach a file path. */
664
- const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
665
- /** A file name inside `registry/<name>/`. */
666
- const FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
667
- /**
668
- * Whether `name` is a valid registry item name.
669
- *
670
- * @param name Any string.
671
- * @returns `true` for kebab-case names such as `dropdown-menu`.
672
- * @example
673
- * isValidItemName("../etc/passwd"); // false
674
- * @since 1.0.0
675
- *
676
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
677
- */
678
- function isValidItemName(name) {
679
- return NAME_PATTERN.test(name);
680
- }
681
- /**
682
- * Thrown for malformed registry or configuration data.
683
- * @since 1.0.0
684
- *
685
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
686
- */
687
- var SchemaError = class extends Error {
688
- /** The stable code `--json` prints. */
689
- code = "SHADOW_UI_INVALID_FILE";
690
- constructor(message) {
691
- super(message);
692
- this.name = "SchemaError";
693
- }
694
- };
695
- function record(value, where) {
696
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SchemaError(`${where} must be an object`);
697
- return value;
698
- }
699
- function text(value, where) {
700
- if (typeof value !== "string" || value === "") throw new SchemaError(`${where} must be a string`);
701
- return value;
702
- }
703
- function strings(value, where) {
704
- if (!Array.isArray(value)) throw new SchemaError(`${where} must be an array`);
705
- return value.map((entry, index) => text(entry, `${where}[${index}]`));
706
- }
707
- function itemType(value, where) {
708
- if (value === "ui" || value === "lib" || value === "hook") return value;
709
- throw new SchemaError(`${where} must be "ui", "lib" or "hook"`);
710
- }
711
- function parseItem(value, index) {
712
- const where = `items[${index}]`;
713
- const item = record(value, where);
714
- const name = text(item["name"], `${where}.name`);
715
- if (!isValidItemName(name)) throw new SchemaError(`${where}.name "${name}" is not a valid name`);
716
- const files = item["files"];
717
- if (!Array.isArray(files) || files.length === 0) throw new SchemaError(`${where}.files must be a non-empty array`);
718
- const dependencies = record(item["dependencies"], `${where}.dependencies`);
719
- const tailwind = record(item["tailwind"], `${where}.tailwind`);
720
- return {
721
- name,
722
- type: itemType(item["type"], `${where}.type`),
723
- title: text(item["title"], `${where}.title`),
724
- description: text(item["description"], `${where}.description`),
725
- files: files.map((file, fileIndex) => {
726
- const entry = record(file, `${where}.files[${fileIndex}]`);
727
- const path = text(entry["path"], `${where}.files[${fileIndex}].path`);
728
- const segments = path.split("/");
729
- const [root, folder, base] = segments;
730
- if (segments.length !== 3 || root !== "registry" || folder !== name || base === void 0 || !FILE_PATTERN.test(base)) throw new SchemaError(`${where}.files[${fileIndex}].path "${path}" must be registry/${name}/<file>`);
731
- return {
732
- path,
733
- type: itemType(entry["type"], `${where}.files[${fileIndex}].type`)
734
- };
735
- }),
736
- registryDependencies: strings(item["registryDependencies"], `${where}.registryDependencies`),
737
- dependencies: Object.fromEntries(Object.entries(dependencies).map(([pkg, version]) => [pkg, text(version, `${where}.dependencies.${pkg}`)])),
738
- exports: strings(item["exports"], `${where}.exports`),
739
- primitives: strings(item["primitives"], `${where}.primitives`),
740
- tailwind: {
741
- tokens: strings(tailwind["tokens"], `${where}.tailwind.tokens`),
742
- animations: tailwind["animations"] === true
743
- }
744
- };
745
- }
746
- /**
747
- * Validates parsed `registry.json` data.
748
- *
749
- * @param value The parsed JSON.
750
- * @returns The typed index.
751
- * @throws SchemaError naming the first malformed field.
752
- * @example
753
- * const index = parseRegistryIndex(JSON.parse(readFileSync("registry.json", "utf8")));
754
- * @since 1.0.0
755
- *
756
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
757
- */
758
- function parseRegistryIndex(value) {
759
- const root = record(value, "registry");
760
- const items = root["items"];
761
- if (!Array.isArray(items)) throw new SchemaError("registry.items must be an array");
762
- const parsed = items.map(parseItem);
763
- const seen = /* @__PURE__ */ new Set();
764
- for (const item of parsed) {
765
- if (seen.has(item.name)) throw new SchemaError(`registry has two items named "${item.name}"`);
766
- seen.add(item.name);
767
- }
768
- return {
769
- name: text(root["name"], "registry.name"),
770
- version: text(root["version"], "registry.version"),
771
- frameworkDependencies: strings(root["frameworkDependencies"], "registry.frameworkDependencies"),
772
- items: parsed
773
- };
774
- }
775
- /**
776
- * Validates parsed `components.json` data.
777
- *
778
- * @param value The parsed JSON.
779
- * @returns The typed configuration, with defaults for missing aliases.
780
- * @throws SchemaError naming the first malformed field.
781
- * @example
782
- * const config = parseComponentsConfig(JSON.parse(readFileSync("components.json", "utf8")));
783
- * @since 1.0.0
784
- *
785
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
786
- */
787
- function parseComponentsConfig(value) {
788
- const root = record(value, "components.json");
789
- const tailwind = record(root["tailwind"], "components.json tailwind");
790
- const baseColor = tailwind["baseColor"] ?? "neutral";
791
- if (!BASE_COLORS.includes(baseColor)) throw new SchemaError(`components.json tailwind.baseColor must be one of ${BASE_COLORS.join(", ")}`);
792
- const given = root["aliases"] === void 0 ? {} : record(root["aliases"], "components.json aliases");
793
- const alias = (key) => {
794
- const entry = given[key];
795
- if (entry === void 0) return DEFAULT_ALIASES[key];
796
- const aliasText = text(entry, `components.json aliases.${key}`);
797
- if (aliasText.includes("..") || /\s/.test(aliasText)) throw new SchemaError(`components.json aliases.${key} "${aliasText}" is not a valid alias`);
798
- return aliasText;
799
- };
800
- const framework = root["framework"];
801
- return {
802
- framework: framework === "vite" || framework === "dusk" ? framework : "unknown",
803
- tailwind: {
804
- css: text(tailwind["css"], "components.json tailwind.css"),
805
- baseColor
806
- },
807
- aliases: {
808
- components: alias("components"),
809
- ui: alias("ui"),
810
- lib: alias("lib"),
811
- utils: alias("utils"),
812
- hooks: alias("hooks")
813
- }
814
- };
815
- }
816
938
  // src/cli/project.ts
817
939
  /**
818
940
  * The project the CLI works in: `components.json`, `package.json`, the stylesheet that imports
@@ -1243,6 +1365,7 @@ function dependentsOf(index, name, candidates) {
1243
1365
  *
1244
1366
  * @param specifier An import specifier such as `@/components/ui/button`.
1245
1367
  * @param aliases The aliases from `components.json`.
1368
+ * @param options Rewrites besides the aliases (relative imports between ui components).
1246
1369
  * @returns The rewritten specifier, or the input unchanged when it is not a registry alias.
1247
1370
  * @example
1248
1371
  * rewriteSpecifier("@/lib/utils", { ...aliases, utils: "~/helpers/cn" }); // "~/helpers/cn"
@@ -1250,9 +1373,12 @@ function dependentsOf(index, name, candidates) {
1250
1373
  *
1251
1374
  * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
1252
1375
  */
1253
- function rewriteSpecifier(specifier, aliases) {
1376
+ function rewriteSpecifier(specifier, aliases, options = {}) {
1254
1377
  if (specifier === "@/lib/utils") return aliases.utils;
1255
- if (specifier.startsWith("@/components/ui/")) return `${aliases.ui}/${specifier.slice(16)}`;
1378
+ if (specifier.startsWith("@/components/ui/")) {
1379
+ const rest = specifier.slice(16);
1380
+ return options.relativeUi === true ? `./${rest}` : `${aliases.ui}/${rest}`;
1381
+ }
1256
1382
  if (specifier.startsWith("@/hooks/")) return `${aliases.hooks}/${specifier.slice(8)}`;
1257
1383
  if (specifier.startsWith("@/lib/")) return `${aliases.lib}/${specifier.slice(6)}`;
1258
1384
  return specifier;
@@ -1265,6 +1391,7 @@ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(@\/[^"'\n]+)\2
1265
1391
  *
1266
1392
  * @param source A registry file's text.
1267
1393
  * @param aliases The aliases from `components.json`.
1394
+ * @param options Rewrites besides the aliases (relative imports between ui components).
1268
1395
  * @returns The text with rewritten specifiers.
1269
1396
  * @example
1270
1397
  * rewriteImports('import { cn } from "@/lib/utils";', aliases);
@@ -1272,8 +1399,8 @@ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(@\/[^"'\n]+)\2
1272
1399
  *
1273
1400
  * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
1274
1401
  */
1275
- function rewriteImports(source, aliases) {
1276
- return source.replace(SPECIFIER, (_match, lead, quote, specifier) => `${lead}${quote}${rewriteSpecifier(specifier, aliases)}${quote}`);
1402
+ function rewriteImports(source, aliases, options = {}) {
1403
+ return source.replace(SPECIFIER, (_match, lead, quote, specifier) => `${lead}${quote}${rewriteSpecifier(specifier, aliases, options)}${quote}`);
1277
1404
  }
1278
1405
  // src/cli/commands.ts
1279
1406
  /**
@@ -1334,12 +1461,26 @@ function targetFor(item, file, config, mappings, ctx, pathOverride) {
1334
1461
  if (item.type === "lib" && item.name === "utils") return resolveInsideReal(ctx.fs, ctx.cwd, `${aliasToDirectory(config.aliases.utils, mappings)}${extname(base)}`);
1335
1462
  return resolveInsideReal(ctx.fs, ctx.cwd, join(directoryFor(item, config, mappings, ctx, pathOverride), base));
1336
1463
  }
1464
+ /**
1465
+ * Whether ui components written with `--path` need relative imports between them: they do when that directory is not the one the
1466
+ * `ui` alias names (the alias would point at a directory that does not hold them), or when the alias names no directory at all.
1467
+ */
1468
+ function importsBetweenUiAreRelative(ctx, config, mappings, pathOverride) {
1469
+ if (pathOverride === void 0) return false;
1470
+ try {
1471
+ return resolve(aliasToDirectory(config.aliases.ui, mappings)) !== resolve(ctx.cwd, pathOverride);
1472
+ } catch (error) {
1473
+ if (error instanceof PathError) return true;
1474
+ throw error;
1475
+ }
1476
+ }
1337
1477
  /** Builds the per-file plan for the items, reading what is on disk. */
1338
1478
  function planFiles(ctx, config, items, pathOverride, mappings = readPathMappings(ctx.fs, ctx.cwd)) {
1339
1479
  const plans = [];
1480
+ const relativeUi = importsBetweenUiAreRelative(ctx, config, mappings, pathOverride);
1340
1481
  for (const item of items) for (const file of item.files) {
1341
1482
  const raw = ctx.registry.readFile(file.path);
1342
- const content = file.path.endsWith(".css") ? raw : rewriteImports(raw, config.aliases);
1483
+ const content = file.path.endsWith(".css") ? raw : rewriteImports(raw, config.aliases, { relativeUi: relativeUi && item.type === "ui" });
1343
1484
  const target = targetFor(item, file, config, mappings, ctx, pathOverride);
1344
1485
  const current = ctx.fs.read(target);
1345
1486
  plans.push({
@@ -1536,6 +1677,11 @@ async function runInit(ctx, flags) {
1536
1677
  recordFile(ctx, label, current === void 0 ? "create" : "update");
1537
1678
  return "created";
1538
1679
  };
1680
+ const configPath = resolveInsideReal(ctx.fs, ctx.cwd, "components.json");
1681
+ const themeFile = themePathFor(config.tailwind.css);
1682
+ resolveInsideReal(ctx.fs, ctx.cwd, themeFile);
1683
+ const cssPath = resolveInsideReal(ctx.fs, ctx.cwd, config.tailwind.css);
1684
+ const utils = planInitUtils(ctx, config);
1539
1685
  ctx.report?.set("dryRun", flags.dryRun);
1540
1686
  ctx.report?.set("config", {
1541
1687
  baseColor,
@@ -1544,14 +1690,12 @@ async function runInit(ctx, flags) {
1544
1690
  ctx.report?.set("files", []);
1545
1691
  ctx.out("Setting up @shadow-js/ui");
1546
1692
  const configText = serializeConfig(config);
1547
- const configPath = resolveInsideReal(ctx.fs, ctx.cwd, "components.json");
1548
1693
  const configNow = ctx.fs.read(configPath);
1549
1694
  if (existing !== void 0 && !flags.overwrite && configNow !== void 0 && (config.tailwind.baseColor !== existing.tailwind.baseColor || config.tailwind.css !== existing.tailwind.css) && !sameContent(configNow, configText)) {
1550
1695
  ctx.out(` ${flags.dryRun ? "would update" : "updated"} components.json (base color ${config.tailwind.baseColor}, stylesheet ${config.tailwind.css})`);
1551
1696
  if (!flags.dryRun) ctx.fs.write(configPath, configText);
1552
1697
  recordFile(ctx, "components.json", "update");
1553
1698
  } else write("components.json", configText, "components.json");
1554
- const themeFile = themePathFor(config.tailwind.css);
1555
1699
  const themeLabel = themeFile.replaceAll("\\", "/");
1556
1700
  const themeText = buildThemeCss(ctx.registry, config.tailwind.baseColor);
1557
1701
  const themeCurrent = ctx.fs.read(resolveInsideReal(ctx.fs, ctx.cwd, themeFile));
@@ -1564,7 +1708,6 @@ async function runInit(ctx, flags) {
1564
1708
  ctx.out(` kept ${themeLabel} (edited; pass --overwrite to regenerate it for ${config.tailwind.baseColor})`);
1565
1709
  recordFile(ctx, themeLabel, "kept");
1566
1710
  } else write(themeFile, themeText, themeLabel);
1567
- const cssPath = resolveInsideReal(ctx.fs, ctx.cwd, config.tailwind.css);
1568
1711
  const css = ctx.fs.read(cssPath);
1569
1712
  if (css === void 0) write(config.tailwind.css, `@import "tailwindcss";\n${THEME_IMPORT_LINE}\n`, config.tailwind.css);
1570
1713
  else {
@@ -1584,7 +1727,7 @@ async function runInit(ctx, flags) {
1584
1727
  recordFile(ctx, config.tailwind.css, "skipped");
1585
1728
  }
1586
1729
  }
1587
- const added = await runAddForInit(ctx, config, flags);
1730
+ const added = await runAddForInit(ctx, utils, flags);
1588
1731
  if (added !== OK) return added;
1589
1732
  const tooling = [];
1590
1733
  if (!info.hasTailwind) tooling.push("tailwindcss");
@@ -1596,14 +1739,21 @@ async function runInit(ctx, flags) {
1596
1739
  return failed(ctx, error);
1597
1740
  }
1598
1741
  }
1599
- /** `init` copies `utils` through the same pipeline as `add`. */
1600
- async function runAddForInit(ctx, config, flags) {
1742
+ /** Plans `utils` through the same pipeline as `add`. */
1743
+ function planInitUtils(ctx, config) {
1601
1744
  const plan = resolveItems(ctx.registry.index, ["utils"]);
1602
1745
  const mappings = [...readPathMappings(ctx.fs, ctx.cwd), {
1603
1746
  pattern: "@/*",
1604
1747
  targets: [join(ctx.cwd, "src", "*")]
1605
1748
  }];
1606
- for (const entry of planFiles(ctx, config, plan.items, void 0, mappings)) {
1749
+ return {
1750
+ plan,
1751
+ entries: planFiles(ctx, config, plan.items, void 0, mappings)
1752
+ };
1753
+ }
1754
+ /** `init` copies `utils` through the same pipeline as `add`. */
1755
+ async function runAddForInit(ctx, { plan, entries }, flags) {
1756
+ for (const entry of entries) {
1607
1757
  const where = display(ctx.cwd, entry.target);
1608
1758
  if (entry.status === "unchanged") {
1609
1759
  ctx.out(` unchanged ${where}`);
@@ -1759,7 +1909,8 @@ function runDiff(ctx, names) {
1759
1909
  }
1760
1910
  /**
1761
1911
  * Deletes components' files from the project. Refuses when another component that is in the
1762
- * project imports one of them (unless `force`), and never removes npm packages.
1912
+ * project imports one of them, and keeps (exit 1) a file that differs from the registry, because
1913
+ * the user edited it; `force` overrides both. It never removes npm packages.
1763
1914
  *
1764
1915
  * @param ctx The CLI context.
1765
1916
  * @param names Component names.
@@ -1786,6 +1937,7 @@ function runRemove(ctx, names, flags) {
1786
1937
  ctx.report?.set("dryRun", flags.dryRun);
1787
1938
  ctx.report?.set("removed", []);
1788
1939
  let code = OK;
1940
+ let kept = 0;
1789
1941
  for (const name of removing) {
1790
1942
  const blockers = dependentsOf(ctx.registry.index, name, installedNames).filter((other) => !removing.includes(other));
1791
1943
  if (blockers.length > 0 && !flags.force) {
@@ -1799,18 +1951,30 @@ function runRemove(ctx, names, flags) {
1799
1951
  }
1800
1952
  const item = findItem(ctx.registry.index, name);
1801
1953
  if (item === void 0) continue;
1802
- for (const file of item.files) {
1803
- const target = targetFor(item, file, config, mappings, ctx, void 0);
1804
- if (!ctx.fs.exists(target)) continue;
1805
- const where = display(ctx.cwd, target);
1954
+ for (const entry of planFiles(ctx, config, [item], void 0, mappings)) {
1955
+ if (entry.status === "create") continue;
1956
+ const where = display(ctx.cwd, entry.target);
1957
+ if (entry.status === "differs" && !flags.force) {
1958
+ kept += 1;
1959
+ ctx.out(` kept ${where} (differs from the registry; pass --force to remove it)`);
1960
+ ctx.report?.push("kept", {
1961
+ name,
1962
+ path: where
1963
+ });
1964
+ continue;
1965
+ }
1806
1966
  ctx.out(` ${flags.dryRun ? "would remove" : "remove"} ${where}`);
1807
- if (!flags.dryRun) ctx.fs.remove(target);
1967
+ if (!flags.dryRun) ctx.fs.remove(entry.target);
1808
1968
  ctx.report?.push("removed", {
1809
1969
  name,
1810
1970
  path: where
1811
1971
  });
1812
1972
  }
1813
1973
  }
1974
+ if (kept > 0) {
1975
+ problem(ctx, "SHADOW_UI_FILES_DIFFER", `${kept} file(s) differ from the registry and were kept; pass --force to remove them.`);
1976
+ code = FAILED;
1977
+ }
1814
1978
  return code;
1815
1979
  } catch (error) {
1816
1980
  return failed(ctx, error);
@@ -2160,6 +2324,42 @@ async function dispatch(ctx, command, rest, values, common) {
2160
2324
  }
2161
2325
  }
2162
2326
  }
2327
+ // src/cli/prompt.ts
2328
+ /**
2329
+ * The yes/no question of the executable, kept apart from `bin.ts` (which runs on import) so a test
2330
+ * can drive it with in-memory streams.
2331
+ * @packageDocumentation
2332
+ */
2333
+ /**
2334
+ * Asks a yes/no question and resolves `true` only for `y` or `yes` (any case). An empty answer is
2335
+ * no, and so is the end of input (Ctrl-D, a closed pipe): readline never settles `question()` when
2336
+ * its input ends, which left Node to exit with code 13 ("unsettled top-level await").
2337
+ *
2338
+ * @param question The question, without the `(y/N)` suffix.
2339
+ * @param input The stream the answer is read from.
2340
+ * @param output The stream the question is written to.
2341
+ * @returns Whether the user said yes.
2342
+ * @example
2343
+ * const yes = await askYesNo("Install packages?", process.stdin, process.stdout);
2344
+ * @since 1.0.0
2345
+ *
2346
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
2347
+ */
2348
+ async function askYesNo(question, input, output) {
2349
+ const prompt = createInterface({
2350
+ input,
2351
+ output
2352
+ });
2353
+ try {
2354
+ const answer = await new Promise((settle, fail) => {
2355
+ prompt.once("close", () => settle(""));
2356
+ prompt.question(`${question} (y/N) `).then(settle, fail);
2357
+ });
2358
+ return /^y(es)?$/i.test(answer.trim());
2359
+ } finally {
2360
+ prompt.close();
2361
+ }
2362
+ }
2163
2363
  // src/cli/registry-source.ts
2164
2364
  /**
2165
2365
  * Where the CLI reads the registry from. The registry ships inside the package, so nothing is
@@ -2243,21 +2443,12 @@ function realContext(cwd) {
2243
2443
  cwd: resolve(cwd ?? process.cwd()),
2244
2444
  fs: nodeFs(),
2245
2445
  registry: loadRegistrySource(),
2246
- version: "1.0.1",
2446
+ version: "1.0.3",
2247
2447
  out: (line) => process.stdout.write(`${line}\n`),
2248
2448
  err: (line) => process.stderr.write(`${line}\n`),
2249
2449
  async confirm(question) {
2250
2450
  if (!process.stdin.isTTY) return false;
2251
- const prompt = createInterface({
2252
- input: process.stdin,
2253
- output: process.stdout
2254
- });
2255
- try {
2256
- const answer = await prompt.question(`${question} (y/N) `);
2257
- return /^y(es)?$/i.test(answer.trim());
2258
- } finally {
2259
- prompt.close();
2260
- }
2451
+ return askYesNo(question, process.stdin, process.stdout);
2261
2452
  },
2262
2453
  install: ({ cwd: directory, manager, packages, output }) => new Promise((done, reject) => {
2263
2454
  const args = manager === "npm" ? ["install", ...packages] : ["add", ...packages];