fyn 3.1.10 → 3.2.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/bin/fun.mjs CHANGED
@@ -7,7 +7,8 @@ const { fun } = await import("./index.mjs");
7
7
 
8
8
  try {
9
9
  await fun();
10
- process.exit(0);
10
+ // no argument, so a code the CLI parser set (e.g. 1 on a parse error) is kept
11
+ process.exit();
11
12
  } catch (err) {
12
13
  console.error(err);
13
14
  process.exit(1);
package/bin/fyn.mjs CHANGED
@@ -7,7 +7,8 @@ const { run } = await import("./index.mjs");
7
7
 
8
8
  try {
9
9
  await run();
10
- process.exit(0);
10
+ // no argument, so a code the CLI parser set (e.g. 1 on a parse error) is kept
11
+ process.exit();
11
12
  } catch (err) {
12
13
  console.error(err);
13
14
  process.exit(1);
package/dist/fyn.mjs CHANGED
@@ -26180,7 +26180,7 @@ function simpleCompare(a, b) {
26180
26180
  function isVersionNewer(a, b) {
26181
26181
  return simpleCompare(a, b) < 0;
26182
26182
  }
26183
- function clean(v) {
26183
+ function clean$1(v) {
26184
26184
  const f = v.split("-");
26185
26185
  f[0] = f[0].split("+")[0].split(".").map((x) => x && parseInt(x, 10) || 0).join(".");
26186
26186
  return f.join("-");
@@ -26943,6 +26943,11 @@ const PACKAGE_FYN_JSON = "package-fyn.json";
26943
26943
  */
26944
26944
  const FYN_INSTALL_CONFIG_FILE = ".fyn.json";
26945
26945
  const FYN_LOCK_FILE = "fyn-lock.yaml";
26946
+ /**
26947
+ * Copy of the lock data saved under FV_DIR. It always matches what is installed in
26948
+ * node_modules. Install uses it when FYN_LOCK_FILE is missing.
26949
+ */
26950
+ const FYN_INSTALLED_LOCK_FILE = "lock.yaml";
26946
26951
 
26947
26952
  //#endregion
26948
26953
  //#region node_modules/filter-scan-dir/dist/util.js
@@ -30697,7 +30702,7 @@ var FynpoConfigManager = class {
30697
30702
  * Pipe streams together and resolve when the pipeline fully completes.
30698
30703
  * Replaces mississippi.pipe with node:stream/promises pipeline.
30699
30704
  */
30700
- const missPipe$3 = (...streams) => pipeline(...streams);
30705
+ const missPipe$3 = (source, destination) => pipeline(source, destination);
30701
30706
  const DIR_SYMLINK_TYPE = isWin32 ? "junction" : "dir";
30702
30707
  /**
30703
30708
  * Check if a value satisfies a list of rules.
@@ -30779,7 +30784,7 @@ const fyntil = {
30779
30784
  if (parent === treeTop) break;
30780
30785
  treeTop = parent;
30781
30786
  }
30782
- if (!gitPath || gitStat.isDirectory()) return dir;
30787
+ if (!gitPath || gitStat?.isDirectory()) return dir;
30783
30788
  try {
30784
30789
  const m = (await file_ops_default.readFile(gitPath, "utf8")).match(/^gitdir:\s*(.+)$/m);
30785
30790
  if (!m) return dir;
@@ -30870,7 +30875,7 @@ const fyntil = {
30870
30875
  existTarget = false;
30871
30876
  try {
30872
30877
  await file_ops_default.unlink(linkName);
30873
- } catch (e) {
30878
+ } catch {
30874
30879
  await file_ops_default.$.rimraf(linkName);
30875
30880
  }
30876
30881
  } else logger$1.debug("local link existTarget", existTarget, "match new target", actualTarget);
@@ -32117,7 +32122,6 @@ var PkgDepResolver = class {
32117
32122
  };
32118
32123
  if (item.specifier) newPkgV.specifier = item.specifier;
32119
32124
  pkgV = kpkg.versions[resolved] = newPkgV;
32120
- if (meta[LOCK_RSEMVERS]) pkgV.fromLock = true;
32121
32125
  const scripts = metaJson.scripts || {};
32122
32126
  if (metaJson.hasPI || scripts.preinstall || scripts.preInstall) pkgV.hasPI = 1;
32123
32127
  if (metaJson.hasI || scripts.install || scripts.postinstall || scripts.postInstall) pkgV.hasI = 1;
@@ -94231,7 +94235,8 @@ var PkgSrcManager = class {
94231
94235
  email: this._options.email,
94232
94236
  alwaysAuth: this._options["always-auth"],
94233
94237
  username: this._options.username,
94234
- password: this._options.password
94238
+ password: this._options.password,
94239
+ maxSockets: this._fyn.concurrency
94235
94240
  }, authTokens, registryData);
94236
94241
  this._pacoteOpts.Arborist = import_lib$5.default;
94237
94242
  this._regData = registryData;
@@ -96473,7 +96478,12 @@ var PkgDepLocker = class {
96473
96478
  }
96474
96479
  const badDepsVersion = Object.entries(locked.versions).find(([, vpkg]) => vpkg.dependencies && !this._depsResolvable(item, vpkg.dependencies))?.[0];
96475
96480
  if (badDepsVersion) {
96476
- logger$1.error(`lockfile entry ${item.name}@${badDepsVersion} has dependencies not satisfiable within the lock (corrupt lock) - ignoring and re-resolving from registry`);
96481
+ const entry = `${item.name}@${badDepsVersion}`;
96482
+ const msgKey = `staleLock:${entry}`;
96483
+ if (!this._fyn._shownMissingFiles.has(msgKey)) {
96484
+ this._fyn._shownMissingFiles.add(msgKey);
96485
+ this._fyn._staleLockEntries.push(entry);
96486
+ }
96477
96487
  return false;
96478
96488
  }
96479
96489
  return valid && locked;
@@ -96633,12 +96643,7 @@ var PkgDepLocker = class {
96633
96643
  let resolvedFilename = filename;
96634
96644
  if (!Path.isAbsolute(resolvedFilename)) resolvedFilename = Path.resolve(resolvedFilename);
96635
96645
  if (!this._lockOnly) {
96636
- assert(this._isFynFormat, "can't save lock data that's no longer in fyn format");
96637
- const basedir = Path.dirname(resolvedFilename);
96638
- this._lockData.$fyn = this._config;
96639
- const sortData = sortObjKeys(this._lockData);
96640
- this._relativeLocalPath(basedir, sortData);
96641
- const data = import_Yaml.default.stringify(sortData, 4, 1);
96646
+ const data = this._stringify(Path.dirname(resolvedFilename));
96642
96647
  if (this.shasum(data) !== this._shaSum) {
96643
96648
  logger$1.info("saving lock file", resolvedFilename);
96644
96649
  file_ops_default.writeFileSync(resolvedFilename, data);
@@ -96646,6 +96651,33 @@ var PkgDepLocker = class {
96646
96651
  }
96647
96652
  }
96648
96653
  /**
96654
+ * Save the lock copy that tracks what's installed in node_modules. Unlike save,
96655
+ * it always writes, even in lock-only mode, so it matches every install.
96656
+ */
96657
+ saveInstalled(filename) {
96658
+ if (!this._enable) return;
96659
+ file_ops_default.writeFileSync(filename, this._stringify(Path.dirname(filename)));
96660
+ }
96661
+ /**
96662
+ * Read the lock copy the last install saved in node_modules. The outside lockfile
96663
+ * is missing, so reset the checksum to make save write it again.
96664
+ */
96665
+ async readInstalled(filename) {
96666
+ const found = await this.read(filename);
96667
+ this._shaSum = Date.now();
96668
+ return found;
96669
+ }
96670
+ /**
96671
+ * Serialize lock data, with local package paths relative to basedir
96672
+ */
96673
+ _stringify(basedir) {
96674
+ assert(this._isFynFormat, "can't save lock data that's no longer in fyn format");
96675
+ this._lockData.$fyn = this._config;
96676
+ const sortData = sortObjKeys(this._lockData);
96677
+ this._relativeLocalPath(basedir, sortData);
96678
+ return import_Yaml.default.stringify(sortData, 4, 1);
96679
+ }
96680
+ /**
96649
96681
  * Read lock file
96650
96682
  */
96651
96683
  async read(filename) {
@@ -96722,11 +96754,6 @@ var DepData = class {
96722
96754
  this.pkgs = {};
96723
96755
  Object.keys(pkgs).sort().forEach((x) => this.pkgs[x] = pkgs[x]);
96724
96756
  }
96725
- cleanLinked() {
96726
- this.eachVersion((pkg) => {
96727
- pkg.linked = 0;
96728
- });
96729
- }
96730
96757
  getPkgsData(bad) {
96731
96758
  return bad ? this.badPkgs : this.pkgs;
96732
96759
  }
@@ -96742,15 +96769,6 @@ var DepData = class {
96742
96769
  if (!kpkg) return void 0;
96743
96770
  return version ? kpkg.versions[version] : kpkg;
96744
96771
  }
96745
- eachVersion(cb) {
96746
- const pkgs = this.pkgs;
96747
- Object.keys(pkgs).forEach((name) => {
96748
- const kpkg = pkgs[name];
96749
- Object.entries(kpkg.versions).forEach(([version, pkg]) => {
96750
- cb(pkg, version, kpkg);
96751
- });
96752
- });
96753
- }
96754
96772
  };
96755
96773
 
96756
96774
  //#endregion
@@ -100413,14 +100431,6 @@ var PkgDepLinker = class {
100413
100431
  async loadLocalPackageAppFynLink() {
100414
100432
  throw new Error("symlink local package is deprecated, only hard linking.");
100415
100433
  }
100416
- async loadPkgDepData(depInfo) {
100417
- const installedDir = this._fyn.getInstalledPkgDir(depInfo.name, depInfo.version, depInfo);
100418
- if (!depInfo.json) {
100419
- const fname = Path.join(installedDir, "package.json");
100420
- depInfo.json = JSON.parse(await file_ops_default.readFile(fname));
100421
- }
100422
- if (depInfo.local === "sym") throw new Error("sym linking local package deprecated. only hard linking.");
100423
- }
100424
100434
  };
100425
100435
 
100426
100436
  //#endregion
@@ -101351,6 +101361,7 @@ var Fyn = class Fyn {
101351
101361
  _shownMissingFiles;
101352
101362
  _localCopiesInfo;
101353
101363
  _skipBuildLocalPaths;
101364
+ _staleLockEntries;
101354
101365
  _options;
101355
101366
  _cwd;
101356
101367
  _initCwd;
@@ -101399,6 +101410,7 @@ var Fyn = class Fyn {
101399
101410
  this._shownMissingFiles = /* @__PURE__ */ new Set();
101400
101411
  this._localCopiesInfo = [];
101401
101412
  this._skipBuildLocalPaths = [];
101413
+ this._staleLockEntries = [];
101402
101414
  const options = this._options = fynConfig(opts);
101403
101415
  this._cwd = options.cwd || process.cwd();
101404
101416
  this._initCwd = options.initCwd || this._cwd;
@@ -101446,7 +101458,11 @@ var Fyn = class Fyn {
101446
101458
  if (this._depLocker) return null;
101447
101459
  this._npmLockData = null;
101448
101460
  this._depLocker = new PkgDepLocker(Boolean(this.lockOnly), Boolean(this._options.lockfile), this);
101449
- const foundLock = await this._depLocker.read(Path.join(this._cwd, FYN_LOCK_FILE));
101461
+ let foundLock = await this._depLocker.read(Path.join(this._cwd, FYN_LOCK_FILE));
101462
+ if (!foundLock && !this.lockOnly && this._options.lockfile) {
101463
+ foundLock = await this._depLocker.readInstalled(this.getInstalledLockFile());
101464
+ if (foundLock) logger$1.info(`${FYN_LOCK_FILE} not found - using lock data from last install. Remove ${this._options.targetDir} to resolve everything again.`);
101465
+ }
101450
101466
  this.updateConfigInLockfile("layout", this._options.layout);
101451
101467
  this.updateConfigInLockfile("flattenTop", this._options.flattenTop);
101452
101468
  this.checkLayoutOption();
@@ -101802,6 +101818,9 @@ var Fyn = class Fyn {
101802
101818
  getInstallConfigFile() {
101803
101819
  return Path.join(this.getFvDir(FYN_INSTALL_CONFIG_FILE));
101804
101820
  }
101821
+ getInstalledLockFile() {
101822
+ return this.getFvDir(FYN_INSTALLED_LOCK_FILE);
101823
+ }
101805
101824
  setLocalPkgLinks(localLinks) {
101806
101825
  this._installConfig.localPkgLinks = localLinks;
101807
101826
  }
@@ -102186,6 +102205,7 @@ var Fyn = class Fyn {
102186
102205
  const lines = this._localCopiesInfo.map(({ pkgName, depSec, names }) => ` ${pkgName} ${depSec}: ${names}`);
102187
102206
  logger$1.info(`Following package and their dependencies using local copies from monorepo:\n${lines.join("\n")}`);
102188
102207
  }
102208
+ if (this._staleLockEntries.length > 0) logger$1.verbose(`lockfile stale for following entries - refreshing:\n ${this._staleLockEntries.join(", ")}`);
102189
102209
  if (this._skipBuildLocalPaths.length > 0) {
102190
102210
  const lines = this._skipBuildLocalPaths.map((x) => ` ${x}`);
102191
102211
  logger$1.info(`installing fynpo top level modules - skip build local for packages at:\n${lines.join("\n")}`);
@@ -102349,7 +102369,7 @@ var Fyn = class Fyn {
102349
102369
  const pkgId = `${pkg.name}@${pkg.version}`;
102350
102370
  const id = `${json.name}@${json.version}`;
102351
102371
  if (json.version !== pkg.version) {
102352
- const cleanVersion = import_semver.default.valid(json.version) || clean(json.version);
102372
+ const cleanVersion = import_semver.default.valid(json.version) || clean$1(json.version);
102353
102373
  if (cleanVersion !== json.version) {
102354
102374
  assert(import_semver.default.valid(cleanVersion), `Pkg ${id} version is not valid semver and fyn was unable to fix it.`);
102355
102375
  json._origVersion = json.version;
@@ -103451,6 +103471,7 @@ var PkgInstaller = class {
103451
103471
  async install() {
103452
103472
  this._stepTime = Date.now();
103453
103473
  this.timeCheck("beginning");
103474
+ await file_ops_default.unlink(this._fyn.getInstalledLockFile()).catch(() => void 0);
103454
103475
  const outputDir = this._fyn.getOutputDir();
103455
103476
  this._binLinker = new PkgBinLinker({
103456
103477
  outputDir,
@@ -103462,7 +103483,6 @@ var PkgInstaller = class {
103462
103483
  this.blockedScripts = [];
103463
103484
  this.pendingScripts = [];
103464
103485
  this._blockedDeps = [];
103465
- this._data.cleanLinked();
103466
103486
  this._fyn._depResolver.resolvePkgPeerDep(this._fyn._pkg, "your app", this._data);
103467
103487
  const pkgsData = this._data.getPkgsData();
103468
103488
  this.timeCheck("queueing packages");
@@ -104030,11 +104050,10 @@ var PkgInstaller = class {
104030
104050
  }
104031
104051
  }
104032
104052
  _saveLockData() {
104033
- if (!this._fyn.lockOnly) {
104034
- const locker = this._fyn._depLocker || new PkgDepLocker(false, true, this._fyn);
104035
- locker.generate(this._fyn._data);
104036
- locker.save(Path.join(this._fyn.cwd, "fyn-lock.yaml"));
104037
- }
104053
+ const locker = this._fyn._depLocker || new PkgDepLocker(false, true, this._fyn);
104054
+ locker.generate(this._fyn._data);
104055
+ if (!this._fyn.lockOnly) locker.save(Path.join(this._fyn.cwd, FYN_LOCK_FILE));
104056
+ locker.saveInstalled(this._fyn.getInstalledLockFile());
104038
104057
  }
104039
104058
  };
104040
104059
 
@@ -104759,7 +104778,7 @@ var AuditReport = class {
104759
104778
  Object.keys(kpkg.versions).forEach((version) => {
104760
104779
  const pkgInfo = kpkg.versions[version];
104761
104780
  if (this.isOmitted(pkgInfo)) return;
104762
- if (pkgInfo.local || pkgInfo.localType) return;
104781
+ if (pkgInfo.local) return;
104763
104782
  if (!payload[name]) payload[name] = [];
104764
104783
  if (!payload[name].includes(version)) payload[name].push(version);
104765
104784
  });
@@ -105594,6 +105613,851 @@ const __filename$1 = fileURLToPath$1(import.meta.url);
105594
105613
  const __dirname$1 = Path.dirname(__filename$1);
105595
105614
  const myPkg = JSON.parse(fs.readFileSync(Path.join(__dirname$1, "../package.json"), "utf-8"));
105596
105615
 
105616
+ //#endregion
105617
+ //#region lib/prod-prune/licenses.ts
105618
+ /**
105619
+ * Recognisable licenses, most specific first, matched against the text with its
105620
+ * case and whitespace normalised. Each phrase is both the fingerprint of the
105621
+ * license and the point its terms begin, so it must be the opening words of the
105622
+ * license proper and cannot be wording another license shares.
105623
+ */
105624
+ const SIGNATURES = [
105625
+ {
105626
+ id: "Apache-2.0",
105627
+ name: "Apache License, Version 2.0",
105628
+ phrase: "apache license version 2.0 january 2004"
105629
+ },
105630
+ {
105631
+ id: "MPL-2.0",
105632
+ name: "Mozilla Public License, Version 2.0",
105633
+ phrase: "mozilla public license version 2.0"
105634
+ },
105635
+ {
105636
+ id: "CC0-1.0",
105637
+ name: "Creative Commons Zero, Version 1.0 Universal",
105638
+ phrase: "cc0 1.0 universal"
105639
+ },
105640
+ {
105641
+ id: "BlueOak-1.0.0",
105642
+ name: "Blue Oak Model License, Version 1.0.0",
105643
+ phrase: "blue oak model license version 1.0.0"
105644
+ },
105645
+ {
105646
+ id: "Unlicense",
105647
+ name: "The Unlicense",
105648
+ phrase: "this is free and unencumbered software released into the public domain"
105649
+ },
105650
+ {
105651
+ id: "Python-2.0",
105652
+ name: "Python Software Foundation License, Version 2",
105653
+ phrase: "python software foundation license version 2"
105654
+ },
105655
+ {
105656
+ id: "ISC",
105657
+ name: "ISC License",
105658
+ phrase: "permission to use copy modify and/or distribute this software for any purpose with or without fee is hereby granted provided that the above copyright notice"
105659
+ },
105660
+ {
105661
+ id: "0BSD",
105662
+ name: "BSD Zero Clause License",
105663
+ phrase: "permission to use copy modify and/or distribute this software for any purpose with or without fee is hereby granted"
105664
+ },
105665
+ {
105666
+ id: "MIT",
105667
+ name: "MIT License",
105668
+ phrase: "permission is hereby granted free of charge to any person obtaining a copy"
105669
+ },
105670
+ {
105671
+ id: "BSD-2-Clause",
105672
+ name: "BSD 2-Clause License",
105673
+ phrase: "redistribution and use in source and binary forms",
105674
+ refine: (flat) => flat.includes("neither the name") ? {
105675
+ id: "BSD-3-Clause",
105676
+ name: "BSD 3-Clause License"
105677
+ } : null
105678
+ }
105679
+ ];
105680
+ /** Words a line may consist of and still be only a title, not attribution. */
105681
+ const TITLE_WORDS = new Set("the mit isc bsd apache mozilla blue oak model unlicense python software foundation public license licence clause zero universal version cc0 0 1 2 3 1.0.0 2.0 v2 creative commons".split(" "));
105682
+ /**
105683
+ * Reduce a text to what it actually says, so wording can be compared without
105684
+ * layout getting in the way.
105685
+ *
105686
+ * Every run of anything that is not a letter, digit, `/` or `.` collapses to a
105687
+ * single space, which takes care of line wrapping and of the packages that
105688
+ * indent every line of their license. Trailing punctuation then goes too:
105689
+ * DefinitelyTyped ends its MIT with "SOFTWARE" where everyone else writes
105690
+ * "SOFTWARE.", and a full stop is not a difference in terms.
105691
+ *
105692
+ * `.` and `/` survive mid-string because the signature phrases need them —
105693
+ * "version 2.0 january 2004", "cc0 1.0 universal", "and/or distribute".
105694
+ */
105695
+ const normalise = (text) => text.toLowerCase().replace(/[^a-z0-9/.]+/g, " ").trim().replace(/[./]+$/, "");
105696
+ /**
105697
+ * @param text license file contents
105698
+ */
105699
+ function detectLicense(text) {
105700
+ const flat = normalise(text);
105701
+ for (const signature of SIGNATURES) {
105702
+ if (!flat.includes(signature.phrase)) continue;
105703
+ const refined = signature.refine?.(flat);
105704
+ return {
105705
+ id: refined?.id ?? signature.id,
105706
+ name: refined?.name ?? signature.name,
105707
+ phrase: signature.phrase
105708
+ };
105709
+ }
105710
+ return {
105711
+ id: "",
105712
+ name: "",
105713
+ phrase: ""
105714
+ };
105715
+ }
105716
+ const isTitleOnly = (line) => {
105717
+ const words = normalise(line).split(" ").filter(Boolean);
105718
+ return words.length > 0 && words.every((word) => TITLE_WORDS.has(word));
105719
+ };
105720
+ /**
105721
+ * Split a license file into the package's own attribution and the license terms.
105722
+ *
105723
+ * @param text
105724
+ * @param phrase opening words of the terms, normalised; `""` when the license
105725
+ * was not recognised, in which case the whole text is the terms
105726
+ */
105727
+ function splitAtTerms(text, phrase) {
105728
+ const lines = text.split("\n");
105729
+ let start = 0;
105730
+ if (phrase) {
105731
+ start = lines.length;
105732
+ for (let i = 0; i < lines.length; i++) if (normalise(lines.slice(i).join("\n")).startsWith(phrase)) {
105733
+ start = i;
105734
+ break;
105735
+ }
105736
+ if (start === lines.length) start = 0;
105737
+ }
105738
+ return {
105739
+ attribution: lines.slice(0, start).map((line) => line.trim().replace(/\s+/g, " ")).filter((line) => line && !isTitleOnly(line)),
105740
+ terms: lines.slice(start).join("\n").trim()
105741
+ };
105742
+ }
105743
+ /**
105744
+ * Merge collected notices into the contents of a single file.
105745
+ *
105746
+ * Everything is done from what the caller already read; nothing is re-read from
105747
+ * disk. Output is fully sorted, so identical input always produces identical
105748
+ * bytes and the image layer stays reproducible.
105749
+ *
105750
+ * @param entries
105751
+ */
105752
+ function combineLicenses(entries) {
105753
+ const groups = /* @__PURE__ */ new Map();
105754
+ for (const entry of entries) {
105755
+ const text = entry.text.trim();
105756
+ if (!text) continue;
105757
+ const license = /^notice/i.test(entry.file) ? {
105758
+ id: "NOTICE",
105759
+ name: "Attribution notice",
105760
+ phrase: ""
105761
+ } : detectLicense(text);
105762
+ const { attribution, terms } = splitAtTerms(text, license.phrase);
105763
+ const key = `${license.id}\u0000${normalise(terms)}`;
105764
+ if (!groups.has(key)) groups.set(key, {
105765
+ license,
105766
+ terms,
105767
+ packages: /* @__PURE__ */ new Map()
105768
+ });
105769
+ const group = groups.get(key);
105770
+ const notices = group.packages.get(entry.package) ?? /* @__PURE__ */ new Set();
105771
+ for (const line of attribution) notices.add(line);
105772
+ group.packages.set(entry.package, notices);
105773
+ }
105774
+ const collected = [...groups.values()].map((group) => ({
105775
+ ...group,
105776
+ packages: [...group.packages].map(([name, notices]) => ({
105777
+ name,
105778
+ notices: [...notices]
105779
+ })).sort((a, b) => a.name.localeCompare(b.name))
105780
+ })).sort((a, b) => b.packages.length - a.packages.length || a.license.id.localeCompare(b.license.id));
105781
+ const packages = /* @__PURE__ */ new Set();
105782
+ for (const group of collected) for (const entry of group.packages) packages.add(entry.name);
105783
+ const out = [
105784
+ "Third-party license and copyright notices",
105785
+ "=========================================",
105786
+ "",
105787
+ `${packages.size} packages installed under this directory ship a license or notice file.`,
105788
+ `They use ${collected.length} distinct ${collected.length === 1 ? "set of terms" : "sets of terms"}, reproduced once each below, with the`,
105789
+ "packages that use them, and their copyright notices, listed above each.",
105790
+ ""
105791
+ ];
105792
+ for (const group of collected) {
105793
+ out.push(RULE, group.license.id ? `${group.license.name} (${group.license.id})` : "Unrecognised license", "-".repeat(RULE.length), "");
105794
+ for (const entry of group.packages) {
105795
+ out.push(entry.name);
105796
+ for (const notice of entry.notices) out.push(` ${notice}`);
105797
+ }
105798
+ out.push("", RULE, "", group.terms, "", "");
105799
+ }
105800
+ return {
105801
+ content: out.join("\n"),
105802
+ licenses: collected.length,
105803
+ packages: packages.size
105804
+ };
105805
+ }
105806
+ const RULE = "=".repeat(78);
105807
+
105808
+ //#endregion
105809
+ //#region lib/prod-prune/rules.ts
105810
+ /**
105811
+ * What counts as non-runtime material in an installed `node_modules` tree.
105812
+ *
105813
+ * ESM, TypeScript. See index.ts for this module's own reasoning.
105814
+ *
105815
+ * Every rule is keyed on what a file *is*, never on which package ships it. A
105816
+ * rule that has to name a package is only correct for the dependency tree as it
105817
+ * looked on the day it was written, and it rots silently — the failure mode
105818
+ * being a runtime crash in a deployed image that no build-time check catches.
105819
+ *
105820
+ * License and notice files are never removed by these rules — they are gathered
105821
+ * into a single combined file first (see `isLicenseFile`). MIT, BSD and Apache
105822
+ * all require the copyright notice to travel with redistributed code, and
105823
+ * shipping a container image is redistribution.
105824
+ *
105825
+ * Build formats are deliberately not touched either. A package that publishes
105826
+ * both CJS and ESM has both trees reachable, and `require-in-the-middle` (which
105827
+ * @sentry/node and @opentelemetry monkey-patch through) needs the CJS one
105828
+ * present. Deleting a format risks a runtime failure no build-time check sees.
105829
+ * The one exception is `isBundlerOnlyEsnext`, which node provably cannot reach.
105830
+ */
105831
+ /**
105832
+ * Removed whole, wherever they appear in the tree, except as a package's own
105833
+ * directory (see `isPackageRoot`).
105834
+ */
105835
+ const REMOVE_DIRS = /* @__PURE__ */ new Set([
105836
+ "test",
105837
+ "tests",
105838
+ "__tests__",
105839
+ "spec",
105840
+ "__mocks__",
105841
+ "docs",
105842
+ "doc",
105843
+ "example",
105844
+ "examples",
105845
+ "benchmark",
105846
+ "benchmarks",
105847
+ "coverage",
105848
+ ".nyc_output",
105849
+ ".github",
105850
+ ".circleci",
105851
+ ".husky",
105852
+ ".idea",
105853
+ ".vscode"
105854
+ ]);
105855
+ /**
105856
+ * Removed by extension, compared lower-case. `.ts` covers `.d.ts`, since the
105857
+ * extension is taken from the last dot; both are compile-time only. `.mts` and
105858
+ * `.cts` are the same in the module and commonjs flavours — node loads `.mjs`
105859
+ * and `.cjs`, never their TypeScript spellings, so nothing there is reachable
105860
+ * either.
105861
+ */
105862
+ const REMOVE_EXTS = /* @__PURE__ */ new Set([
105863
+ ".ts",
105864
+ ".mts",
105865
+ ".cts",
105866
+ ".map",
105867
+ ".tsbuildinfo",
105868
+ ".md",
105869
+ ".markdown",
105870
+ ".log",
105871
+ ".tmp",
105872
+ ".temp",
105873
+ ".bak",
105874
+ ".orig",
105875
+ ".rej",
105876
+ ".swp",
105877
+ ".swo"
105878
+ ]);
105879
+ /** Removed by exact name, case-sensitive. */
105880
+ const REMOVE_NAMES = /* @__PURE__ */ new Set([
105881
+ ".editorconfig",
105882
+ ".npmignore",
105883
+ ".gitignore",
105884
+ ".gitattributes",
105885
+ ".jshintrc",
105886
+ ".taprc",
105887
+ ".c8rc",
105888
+ ".nojekyll",
105889
+ ".runkit_example.js",
105890
+ "Makefile",
105891
+ ".DS_Store",
105892
+ "Thumbs.db"
105893
+ ]);
105894
+ /**
105895
+ * YAML removed by exact name, compared lower-case. YAML is not removed by
105896
+ * extension: a package may read its own `.yaml` config, schema or spec at
105897
+ * runtime. These names are CI and tooling config that no package loads.
105898
+ */
105899
+ const REMOVE_YAML_NAMES = /* @__PURE__ */ new Set([
105900
+ ".travis.yml",
105901
+ "appveyor.yml",
105902
+ ".appveyor.yml",
105903
+ "azure-pipelines.yml",
105904
+ "bitbucket-pipelines.yml",
105905
+ ".drone.yml",
105906
+ ".gitlab-ci.yml",
105907
+ ".codeclimate.yml",
105908
+ "codecov.yml",
105909
+ ".codecov.yml",
105910
+ ".readthedocs.yml",
105911
+ ".readthedocs.yaml",
105912
+ ".pre-commit-config.yaml",
105913
+ ".yarnrc.yml",
105914
+ "pnpm-lock.yaml",
105915
+ "pnpm-workspace.yaml",
105916
+ ".mocharc.yml",
105917
+ ".mocharc.yaml",
105918
+ ".nycrc.yml",
105919
+ ".nycrc.yaml",
105920
+ ".lintstagedrc.yml",
105921
+ ".lintstagedrc.yaml",
105922
+ ".stylelintrc.yml",
105923
+ ".stylelintrc.yaml",
105924
+ ".markdownlint.yml",
105925
+ ".markdownlint.yaml",
105926
+ ".markdownlint-cli2.yaml",
105927
+ ".borp.yml",
105928
+ ".borp.yaml",
105929
+ ".airtap.yml"
105930
+ ]);
105931
+ /**
105932
+ * Tooling config removed when the file name starts with one of these, compared
105933
+ * lower-case. Any extension, since a dotfile with these names is never loaded
105934
+ * by a package.
105935
+ *
105936
+ * `.eslint` and `.prettier` rather than `.eslintrc` and `.prettierrc`, so the
105937
+ * matching `.eslintignore` and `.prettierignore` go with them.
105938
+ */
105939
+ const REMOVE_CONFIG_PREFIXES = [
105940
+ ".eslint",
105941
+ ".prettier",
105942
+ ".babelrc"
105943
+ ];
105944
+ /**
105945
+ * Project documents removed when the file name starts with one of these,
105946
+ * compared lower-case, and only with a document extension. The names are
105947
+ * ordinary words, so a code file can start with one: the `history` package
105948
+ * loads `umd/history.production.min.js`.
105949
+ */
105950
+ const REMOVE_DOC_PREFIXES = [
105951
+ "changelog",
105952
+ "history",
105953
+ "authors",
105954
+ "contributing",
105955
+ "code_of_conduct"
105956
+ ];
105957
+ const DOC_EXTS = /* @__PURE__ */ new Set([
105958
+ "",
105959
+ ".md",
105960
+ ".markdown",
105961
+ ".txt",
105962
+ ".rst"
105963
+ ]);
105964
+ const TSCONFIG_RE = /^tsconfig.*\.json$/;
105965
+ /**
105966
+ * Names that carry a copyright notice. The extension list keeps this from
105967
+ * matching source files such as `license.js`.
105968
+ *
105969
+ * This check runs *before* `isNonRuntimeFile`, which would otherwise delete the
105970
+ * nine `LICENSE.md` files in a BFF install under its `.md` rule.
105971
+ *
105972
+ * @param file file name, no directory part
105973
+ * @param ext extension including the dot, `""` for a dotfile
105974
+ */
105975
+ function isLicenseFile(file, ext) {
105976
+ if (!LICENSE_EXTS.has(ext.toLowerCase())) return false;
105977
+ return LICENSE_RE.test(file);
105978
+ }
105979
+ const LICENSE_EXTS = /* @__PURE__ */ new Set([
105980
+ "",
105981
+ ".txt",
105982
+ ".md",
105983
+ ".markdown"
105984
+ ]);
105985
+ const LICENSE_RE = /^(licen[cs]e|notice|copying)/i;
105986
+ /**
105987
+ * @param file file name, no directory part
105988
+ * @param ext extension including the dot, `""` for a dotfile
105989
+ * @returns true when node never loads this file
105990
+ */
105991
+ function isNonRuntimeFile(file, ext) {
105992
+ const lowerExt = ext.toLowerCase();
105993
+ if (REMOVE_EXTS.has(lowerExt)) return true;
105994
+ if (REMOVE_NAMES.has(file)) return true;
105995
+ const lower = file.toLowerCase();
105996
+ if (TSCONFIG_RE.test(lower) || REMOVE_YAML_NAMES.has(lower)) return true;
105997
+ if (REMOVE_CONFIG_PREFIXES.some((prefix) => lower.startsWith(prefix))) return true;
105998
+ return DOC_EXTS.has(lowerExt) && REMOVE_DOC_PREFIXES.some((prefix) => lower.startsWith(prefix));
105999
+ }
106000
+ /**
106001
+ * Is this fyn's installed-lock copy? fyn writes it at `.f/lock.yaml` to record
106002
+ * what it installed. A pruned tree no longer matches a fyn install, so the copy
106003
+ * goes, and fyn cannot trust it later. `.f/.fyn.json` stays: `fyn prod-prune`
106004
+ * reads its `production` flag.
106005
+ *
106006
+ * @param root the node_modules being cleaned
106007
+ * @param fullPath
106008
+ */
106009
+ function isFynInstalledLock(root, fullPath) {
106010
+ return Path.relative(root, fullPath) === Path.join(".f", "lock.yaml");
106011
+ }
106012
+ /**
106013
+ * Is this directory a package's own folder rather than one inside it?
106014
+ * `REMOVE_DIRS` names are real package names too (`benchmark` is one), and
106015
+ * `esnext` sitting beside the `esm` package would pass `isBundlerOnlyEsnext`.
106016
+ * A name match alone would delete a whole dependency.
106017
+ *
106018
+ * A package folder sits directly under a `node_modules` (or the tree root),
106019
+ * under an `@scope` there, or in fyn's store at `.f/_/<name>` or
106020
+ * `.f/_/@scope/<name>`.
106021
+ *
106022
+ * @param root the node_modules being cleaned
106023
+ * @param dir full path of the directory
106024
+ */
106025
+ function isPackageRoot(root, dir) {
106026
+ const parts = Path.relative(root, dir).split(Path.sep);
106027
+ const n = parts.length;
106028
+ const container = n >= 2 && parts[n - 2].startsWith("@") ? parts.slice(0, n - 2) : parts.slice(0, n - 1);
106029
+ const c = container.length;
106030
+ return c === 0 || container[c - 1] === "node_modules" || c === 2 && container[0] === ".f" && container[1] === "_";
106031
+ }
106032
+ /**
106033
+ * Media and archives that survive the prune and are worth a human's attention.
106034
+ *
106035
+ * These are *reported, never removed*. An image or a zip in a server's runtime
106036
+ * tree is odd — it is usually a test fixture, a README screenshot or vendored
106037
+ * source — but a package is free to read its own asset at runtime, and nothing
106038
+ * about the file says which case this is. Deleting on a guess would break a
106039
+ * dependency in a way no build-time check catches, so the tool surfaces them
106040
+ * and leaves the judgement to whoever reads the build log.
106041
+ *
106042
+ * Native and WebAssembly binaries (`.node`, `.wasm`, `.so`, `.dylib`) are
106043
+ * deliberately absent: those genuinely are loaded at runtime.
106044
+ *
106045
+ * @param ext extension including the dot
106046
+ */
106047
+ function isReviewableAsset(ext) {
106048
+ return REVIEW_EXTS.has(ext.toLowerCase());
106049
+ }
106050
+ const REVIEW_EXTS = /* @__PURE__ */ new Set([
106051
+ ".png",
106052
+ ".jpg",
106053
+ ".jpeg",
106054
+ ".gif",
106055
+ ".bmp",
106056
+ ".ico",
106057
+ ".webp",
106058
+ ".avif",
106059
+ ".svg",
106060
+ ".psd",
106061
+ ".mp4",
106062
+ ".webm",
106063
+ ".mov",
106064
+ ".mp3",
106065
+ ".wav",
106066
+ ".ogg",
106067
+ ".ttf",
106068
+ ".otf",
106069
+ ".woff",
106070
+ ".woff2",
106071
+ ".eot",
106072
+ ".zip",
106073
+ ".tar",
106074
+ ".tgz",
106075
+ ".gz",
106076
+ ".bz2",
106077
+ ".xz",
106078
+ ".7z",
106079
+ ".rar",
106080
+ ".br",
106081
+ ".pdf",
106082
+ ".drawio",
106083
+ ".sketch"
106084
+ ]);
106085
+ /**
106086
+ * Is this `esnext/` directory a build only a bundler will ever read?
106087
+ *
106088
+ * `esnext` is not a condition node implements, so node can enter such a
106089
+ * directory only if the package's own manifest points at it. Two questions
106090
+ * decide it, and both must agree:
106091
+ *
106092
+ * 1. **Can node reach it?** Every path a resolver can land on comes from the
106093
+ * manifest — `main`, `bin`, or an `exports` entry under a condition node
106094
+ * implements (`import`, `require`, `node`, `default`). If any of them lands
106095
+ * inside the directory, it stays, whatever else suggests otherwise. This is
106096
+ * what keeps the rule from going stale: a future package that does point
106097
+ * node at its `esnext/` is left alone instead of being broken at runtime.
106098
+ *
106099
+ * 2. **Is something else claiming it?** Absence of evidence is not enough, so
106100
+ * the directory also has to be claimed by a bundler: named under a condition
106101
+ * node does not implement (`esnext`, `module`, `browser`, `webpack`, …),
106102
+ * pointed at by the legacy `module` field, or sitting beside an `esm/`
106103
+ * sibling — which shows the package publishes per-syntax-level variants for
106104
+ * a bundler to choose between. Anything unclaimed is left alone.
106105
+ *
106106
+ * The one case this cannot see is a reachable file reaching sideways with a
106107
+ * relative `../esnext/x.js` import. Packages publish parallel compiled outputs
106108
+ * precisely so that they do not cross-import, so the assumption holds in
106109
+ * practice, but it is an assumption rather than a proof.
106110
+ *
106111
+ * @param dir full path of the candidate `esnext` directory
106112
+ * @param siblings names of the entries beside it
106113
+ * @param manifestTargets resolves a package directory to its manifest paths,
106114
+ * memoised by the caller
106115
+ */
106116
+ function isBundlerOnlyEsnext(dir, siblings, manifestTargets) {
106117
+ if (Path.basename(dir) !== "esnext") return false;
106118
+ const pkgDir = findPackageDir(dir);
106119
+ if (!pkgDir) return false;
106120
+ const inside = `${Path.relative(pkgDir, dir).split(Path.sep).join("/")}/`;
106121
+ const pointsInside = (target) => target.replace(/^\.\//, "").startsWith(inside);
106122
+ const targets = manifestTargets(pkgDir);
106123
+ if (targets.node.some(pointsInside)) return false;
106124
+ return targets.bundler.some(pointsInside) || siblings.includes("esm");
106125
+ }
106126
+ /** Nearest ancestor directory holding a package.json, or `""`. */
106127
+ function findPackageDir(dir) {
106128
+ let cur = Path.dirname(dir);
106129
+ for (let i = 0; i < 8 && cur && cur !== Path.dirname(cur); i++) {
106130
+ if (fs.existsSync(Path.join(cur, "package.json"))) return cur;
106131
+ cur = Path.dirname(cur);
106132
+ }
106133
+ return "";
106134
+ }
106135
+ /** Conditions node resolves. `module` and `esnext` are bundler conventions. */
106136
+ const NODE_CONDITIONS = /* @__PURE__ */ new Set([
106137
+ "import",
106138
+ "require",
106139
+ "node",
106140
+ "node-addons",
106141
+ "default"
106142
+ ]);
106143
+ /**
106144
+ * Split a manifest's entry points into the ones node can resolve and the ones
106145
+ * only a bundler will.
106146
+ *
106147
+ * @param pkgDir directory holding the package.json
106148
+ */
106149
+ function readManifestTargets(pkgDir) {
106150
+ const targets = {
106151
+ node: [],
106152
+ bundler: []
106153
+ };
106154
+ let manifest;
106155
+ try {
106156
+ manifest = JSON.parse(fs.readFileSync(Path.join(pkgDir, "package.json"), "utf8"));
106157
+ } catch {
106158
+ return targets;
106159
+ }
106160
+ if (typeof manifest.main === "string") targets.node.push(manifest.main);
106161
+ if (typeof manifest.bin === "string") targets.node.push(manifest.bin);
106162
+ else if (manifest.bin && typeof manifest.bin === "object") for (const key of Object.keys(manifest.bin)) targets.node.push(manifest.bin[key]);
106163
+ if (typeof manifest.module === "string") targets.bundler.push(manifest.module);
106164
+ collectExportTargets(manifest.exports, targets, false);
106165
+ return targets;
106166
+ }
106167
+ function collectExportTargets(node, targets, bundlerOnly) {
106168
+ if (typeof node === "string") {
106169
+ targets[bundlerOnly ? "bundler" : "node"].push(node);
106170
+ return;
106171
+ }
106172
+ if (!node || typeof node !== "object") return;
106173
+ for (const key of Object.keys(node)) if (key.startsWith(".")) collectExportTargets(node[key], targets, bundlerOnly);
106174
+ else if (key !== "types") collectExportTargets(node[key], targets, bundlerOnly || !NODE_CONDITIONS.has(key));
106175
+ }
106176
+
106177
+ //#endregion
106178
+ //#region lib/prod-prune/index.ts
106179
+ /**
106180
+ * Remove non-runtime files from a production `node_modules` tree, so a
106181
+ * container image carries only what node actually loads.
106182
+ *
106183
+ * ESM, TypeScript, bundled like the rest of fyn (see tsconfig.json for why the
106184
+ * lib target sits at ES2022). This backs the `fyn prod-prune` command rather
106185
+ * than running as its own standalone tool.
106186
+ *
106187
+ * None of what this deletes is reachable code, all of it is readable by anyone
106188
+ * who pulls the image, and every file of it is inventory a scanner has to
106189
+ * enumerate. Source maps reconstruct a dependency's original source; test and
106190
+ * fixture directories are a well-known home for hardcoded credentials and
106191
+ * sample tokens.
106192
+ *
106193
+ * fyn prod-prune [dir] [--max-kb <n>] [--max-files <n>]
106194
+ *
106195
+ * Nothing about a file's size says whether it may be deleted — the largest file
106196
+ * in a BFF install is loaded at runtime — so size is only ever reported, never
106197
+ * acted on. Every run lists the files over 100kB, so the build log says where
106198
+ * the weight sits. The budgets are the regression gate on top of that: they
106199
+ * make new material entering the image someone's decision at review time
106200
+ * rather than a surprise at deploy time, and exceeding either names the largest
106201
+ * packages and exits non-zero.
106202
+ */
106203
+ const LARGE_FILE_BYTES = 102400;
106204
+ const LARGE_LIST_LIMIT = 20;
106205
+ const REVIEW_LIST_LIMIT = 20;
106206
+ const COMBINED_LICENSE_FILE = "THIRD-PARTY-LICENSES.txt";
106207
+ /**
106208
+ * What the package owning this tree declares about its own pruning, under a
106209
+ * `prodPruning` key in the package.json beside `node_modules`.
106210
+ *
106211
+ * "prodPruning": {
106212
+ * "maxKb": 15600, // fail the build above this many kB
106213
+ * "maxFiles": 2850, // fail the build above this many files
106214
+ * "keepPaths": ["some-pkg/vendor", "some-pkg/table.bin"]
106215
+ * }
106216
+ *
106217
+ * Budgets live here rather than in a Dockerfile so that a dependency change and
106218
+ * the budget it moves land in the same file, in the same commit, under the same
106219
+ * review. `keepPaths` is the escape hatch that lets the rules stay generic: an
106220
+ * app that needs one specific file or directory spared says so here, instead of
106221
+ * a package name leaking into a rule where it would go stale.
106222
+ *
106223
+ * @param root the node_modules being cleaned
106224
+ */
106225
+ function readConfig(root) {
106226
+ let declared = {};
106227
+ try {
106228
+ const manifest = Path.join(Path.dirname(root), "package.json");
106229
+ declared = JSON.parse(fs.readFileSync(manifest, "utf8")).prodPruning ?? {};
106230
+ } catch {}
106231
+ return {
106232
+ maxKb: Number(declared.maxKb) || 0,
106233
+ maxFiles: Number(declared.maxFiles) || 0,
106234
+ keepPaths: Array.isArray(declared.keepPaths) ? declared.keepPaths : []
106235
+ };
106236
+ }
106237
+ /**
106238
+ * Walk the tree once, sorting every entry into what goes, what stays, and what
106239
+ * carries a copyright notice.
106240
+ *
106241
+ * @param root
106242
+ * @param keepPaths relative to `root`, each either a file or a directory;
106243
+ * naming a directory spares everything under it
106244
+ */
106245
+ async function scan(root, keepPaths) {
106246
+ const keepList = keepPaths.map((path) => path.split("/").join(Path.sep));
106247
+ const isKeepPath = (fullPath) => {
106248
+ const rel = Path.relative(root, fullPath);
106249
+ return keepList.some((path) => rel === path || rel.startsWith(`${path}${Path.sep}`));
106250
+ };
106251
+ const removeDirs = [];
106252
+ const kept = [];
106253
+ const licenses = [];
106254
+ let keptBytes = 0;
106255
+ const targetCache = /* @__PURE__ */ new Map();
106256
+ const manifestTargets = (pkgDir) => {
106257
+ if (!targetCache.has(pkgDir)) targetCache.set(pkgDir, readManifestTargets(pkgDir));
106258
+ return targetCache.get(pkgDir);
106259
+ };
106260
+ return {
106261
+ removeDirs,
106262
+ removeFiles: (await filterScanDir({
106263
+ cwd: root,
106264
+ prependCwd: true,
106265
+ grouping: true,
106266
+ rethrowError: true,
106267
+ filterDir: (file, path, extras) => {
106268
+ if (isKeepPath(extras.fullFile)) return true;
106269
+ if (!isPackageRoot(root, extras.fullFile) && (REMOVE_DIRS.has(file) || isBundlerOnlyEsnext(extras.fullFile, extras.files, manifestTargets))) {
106270
+ removeDirs.push(extras.fullFile);
106271
+ return false;
106272
+ }
106273
+ return true;
106274
+ },
106275
+ filter: (file, path, extras) => {
106276
+ if (isKeepPath(extras.fullFile)) {
106277
+ kept.push({
106278
+ path: extras.fullFile,
106279
+ size: extras.stat.size,
106280
+ review: false
106281
+ });
106282
+ keptBytes += extras.stat.size;
106283
+ return false;
106284
+ }
106285
+ if (isLicenseFile(file, extras.ext)) {
106286
+ const owner = packageOf(root, extras.fullFile);
106287
+ licenses.push({
106288
+ path: extras.fullFile,
106289
+ package: owner.version ? `${owner.name}@${owner.version}` : owner.name,
106290
+ file,
106291
+ text: fs.readFileSync(extras.fullFile, "utf8")
106292
+ });
106293
+ return false;
106294
+ }
106295
+ if (isNonRuntimeFile(file, extras.ext) || isFynInstalledLock(root, extras.fullFile)) return "remove";
106296
+ kept.push({
106297
+ path: extras.fullFile,
106298
+ size: extras.stat.size,
106299
+ review: isReviewableAsset(extras.ext)
106300
+ });
106301
+ keptBytes += extras.stat.size;
106302
+ return false;
106303
+ }
106304
+ })).remove || [],
106305
+ licenses,
106306
+ kept,
106307
+ keptBytes
106308
+ };
106309
+ }
106310
+ /**
106311
+ * Delete the directories the deletions left empty. Deepest first, so a parent
106312
+ * emptied by its own children going away is collected in the same pass.
106313
+ */
106314
+ async function removeEmptyDirs(root) {
106315
+ const dirs = await filterScanDir({
106316
+ cwd: root,
106317
+ prependCwd: true,
106318
+ includeDir: true,
106319
+ rethrowError: true,
106320
+ filter: () => false
106321
+ });
106322
+ const depth = (dir) => dir.split("/").length;
106323
+ dirs.sort((a, b) => depth(b) - depth(a));
106324
+ let removed = 0;
106325
+ for (const dir of dirs) try {
106326
+ fs.rmdirSync(dir);
106327
+ removed++;
106328
+ } catch {}
106329
+ return removed;
106330
+ }
106331
+ const packageCache = /* @__PURE__ */ new Map();
106332
+ /**
106333
+ * Which package ships this file, and at which version.
106334
+ *
106335
+ * The directory is found from the *last* `node_modules` in the path, not the
106336
+ * first, so a nested copy is attributed to the package itself rather than to
106337
+ * the store holding it — fyn keeps duplicate versions under
106338
+ * `.f/_/<name>/<version>/node_modules/`, and reporting those as a package
106339
+ * called `.f` tells you nothing. One path segment after that, or two when the
106340
+ * first starts with `@`.
106341
+ *
106342
+ * The name then comes from that directory's own package.json rather than from
106343
+ * the directory name, which is the only authoritative answer — and the reason
106344
+ * to compute the directory this way instead of walking up looking for the
106345
+ * nearest manifest, since packages routinely drop a `dist/cjs/package.json`
106346
+ * carrying nothing but `{"type": "commonjs"}`.
106347
+ */
106348
+ function packageOf(root, fullPath) {
106349
+ const parts = Path.relative(root, fullPath).split(Path.sep);
106350
+ const start = parts.lastIndexOf("node_modules") + 1;
106351
+ if (start >= parts.length - 1) return {
106352
+ name: parts.at(-1),
106353
+ version: ""
106354
+ };
106355
+ const span = parts[start].startsWith("@") ? 2 : 1;
106356
+ const dir = Path.join(root, ...parts.slice(0, start + span));
106357
+ if (!packageCache.has(dir)) packageCache.set(dir, readPackageIdentity(dir, parts.slice(start, start + span).join("/")));
106358
+ return packageCache.get(dir);
106359
+ }
106360
+ function readPackageIdentity(dir, fallbackName) {
106361
+ try {
106362
+ const manifest = JSON.parse(fs.readFileSync(Path.join(dir, "package.json"), "utf8"));
106363
+ return {
106364
+ name: manifest.name ?? fallbackName,
106365
+ version: manifest.version ?? ""
106366
+ };
106367
+ } catch {
106368
+ return {
106369
+ name: fallbackName,
106370
+ version: ""
106371
+ };
106372
+ }
106373
+ }
106374
+ /** Total size of the surviving files, grouped by the package that ships them. */
106375
+ function largestPackages(root, files, limit) {
106376
+ const byPackage = /* @__PURE__ */ new Map();
106377
+ for (const file of files) {
106378
+ const name = packageOf(root, file.path).name;
106379
+ const group = byPackage.get(name);
106380
+ if (group) group.push(file);
106381
+ else byPackage.set(name, [file]);
106382
+ }
106383
+ return [...byPackage.entries()].map(([name, group]) => [name, group.reduce((total, file) => total + file.size, 0)]).sort((a, b) => b[1] - a[1]).slice(0, limit);
106384
+ }
106385
+ function kb(bytes) {
106386
+ return Math.round(bytes / 1024);
106387
+ }
106388
+ /** The limits that are set, like `15600kB / 2850 files`, or `none`. */
106389
+ function describeBudget(maxKb, maxFiles) {
106390
+ const limits = [maxKb > 0 && `${maxKb}kB`, maxFiles > 0 && `${maxFiles} file${maxFiles === 1 ? "" : "s"}`].filter(Boolean);
106391
+ return limits.length > 0 ? limits.join(" / ") : "none";
106392
+ }
106393
+ /**
106394
+ * @returns the state of the tree afterwards. Returned rather than turned
106395
+ * straight into an exit code so the caller decides what a budget overrun
106396
+ * means — the CLI fails the build with it; a test just reads it.
106397
+ */
106398
+ async function clean(dir, cliMaxKb, cliMaxFiles) {
106399
+ const root = Path.resolve(dir);
106400
+ if (!fs.existsSync(root)) throw new Error(`no such directory: ${root}`);
106401
+ const config = readConfig(root);
106402
+ const maxKb = cliMaxKb || config.maxKb;
106403
+ const maxFiles = cliMaxFiles || config.maxFiles;
106404
+ if (config.keepPaths.length > 0) logger$1.info(`keeping ${config.keepPaths.length} path${config.keepPaths.length === 1 ? "" : "s"} named in prodPruning: ` + config.keepPaths.join(", "));
106405
+ const found = await scan(root, config.keepPaths);
106406
+ let combined = null;
106407
+ if (found.licenses.length > 0) {
106408
+ const merged = combineLicenses(found.licenses);
106409
+ const path = Path.join(root, COMBINED_LICENSE_FILE);
106410
+ fs.writeFileSync(path, merged.content);
106411
+ combined = {
106412
+ ...merged,
106413
+ path,
106414
+ size: Buffer.byteLength(merged.content)
106415
+ };
106416
+ }
106417
+ for (const path of found.removeDirs) fs.rmSync(path, {
106418
+ recursive: true,
106419
+ force: true
106420
+ });
106421
+ for (const path of found.removeFiles.concat(found.licenses.map((l) => l.path))) fs.rmSync(path, { force: true });
106422
+ const emptied = await removeEmptyDirs(root);
106423
+ const kept = combined ? found.kept.concat([{
106424
+ path: combined.path,
106425
+ size: combined.size
106426
+ }]) : found.kept;
106427
+ const keptBytes = found.keptBytes + (combined ? combined.size : 0);
106428
+ logger$1.info(`removed ${found.removeFiles.length} files, ${found.removeDirs.length} directories and ${emptied} more left empty`);
106429
+ if (combined) logger$1.info(`merged ${found.licenses.length} license files from ${combined.packages} packages into ${combined.licenses} distinct ${combined.licenses === 1 ? "license" : "licenses"} in ${COMBINED_LICENSE_FILE}`);
106430
+ logger$1.info(`${kept.length} files, ${kb(keptBytes)}kB remain (budget ${describeBudget(maxKb, maxFiles)})`);
106431
+ const review = found.kept.filter((file) => file.review).sort((a, b) => b.size - a.size);
106432
+ if (review.length > 0) {
106433
+ logger$1.info(`${review.length} media or archive ${review.length === 1 ? "file survives" : "files survive"} the prune. A server's runtime tree rarely needs one, but only a human can say whether a package reads its own asset, so these are reported and left alone:`);
106434
+ for (const file of review.slice(0, REVIEW_LIST_LIMIT)) logger$1.info(` ${String(kb(file.size)).padStart(7)}kB ${Path.relative(root, file.path)}`);
106435
+ if (review.length > REVIEW_LIST_LIMIT) logger$1.info(` ... and ${review.length - REVIEW_LIST_LIMIT} more`);
106436
+ }
106437
+ const large = kept.filter((file) => file.size > LARGE_FILE_BYTES).sort((a, b) => b.size - a.size);
106438
+ if (large.length > 0) {
106439
+ const total = large.reduce((sum, file) => sum + file.size, 0);
106440
+ logger$1.info(`${large.length} files over ${kb(LARGE_FILE_BYTES)}kB hold ${kb(total)}kB of the remaining ${kb(keptBytes)}kB:`);
106441
+ for (const file of large.slice(0, LARGE_LIST_LIMIT)) logger$1.info(` ${String(kb(file.size)).padStart(7)}kB ${Path.relative(root, file.path)}`);
106442
+ if (large.length > LARGE_LIST_LIMIT) logger$1.info(` ... and ${large.length - LARGE_LIST_LIMIT} more`);
106443
+ }
106444
+ const result = {
106445
+ files: kept.length,
106446
+ kb: kb(keptBytes),
106447
+ withinBudget: true
106448
+ };
106449
+ const overKb = maxKb > 0 && result.kb > maxKb;
106450
+ const overFiles = maxFiles > 0 && result.files > maxFiles;
106451
+ if (!overKb && !overFiles) return result;
106452
+ logger$1.error(`FATAL: ${root} holds ${result.files} files and ${result.kb}kB, over the ${describeBudget(maxKb, maxFiles)} budget.`);
106453
+ logger$1.error("Largest packages:");
106454
+ for (const [name, size] of largestPackages(root, kept, 20)) logger$1.error(` ${String(kb(size)).padStart(7)}kB ${name}`);
106455
+ return {
106456
+ ...result,
106457
+ withinBudget: false
106458
+ };
106459
+ }
106460
+
105597
106461
  //#endregion
105598
106462
  //#region cli/fyn-cli.ts
105599
106463
  function checkNewVersion(npmConfig) {
@@ -106076,6 +106940,23 @@ var FynCli = class {
106076
106940
  return this._opts.saveLogs && this.saveLogs(this._opts.saveLogs);
106077
106941
  });
106078
106942
  }
106943
+ /**
106944
+ * Remove non-runtime files from a production node_modules. Only runs on demand,
106945
+ * and refuses a tree that did not come from `fyn install --production` unless forced.
106946
+ */
106947
+ async prodPrune(argv) {
106948
+ const dir = argv.args.dir ? Path.resolve(this.fyn.cwd, argv.args.dir) : this.fyn.getOutputDir();
106949
+ try {
106950
+ if (!argv.opts.force) {
106951
+ const configFile = Path.join(dir, ".f", FYN_INSTALL_CONFIG_FILE);
106952
+ if ((await file_ops_default.readFile(configFile, "utf8").then((data) => JSON.parse(data)).catch(() => ({}))).production !== true) throw new Error(`${dir} is not from a production install (fyn install --production). Use --force to prune it anyway.`);
106953
+ }
106954
+ if (!(await clean(dir, argv.opts.maxKb || 0, argv.opts.maxFiles || 0)).withinBudget) fyntil.exit(1);
106955
+ } catch (err) {
106956
+ logger$1.error(err.message);
106957
+ fyntil.exit(1);
106958
+ }
106959
+ }
106079
106960
  async runScript(pkg, script, env, scriptArgs = []) {
106080
106961
  const config = (x) => this.fyn.allrc[x];
106081
106962
  const options = {
@@ -113659,12 +114540,16 @@ var FynGlobal = class {
113659
114540
  } catch (err) {}
113660
114541
  }
113661
114542
  /**
113662
- * Get next global package ID by reading directory
114543
+ * Get next global package ID by reading directory.
114544
+ * Reuses the lowest gN freed by a removed package.
113663
114545
  */
113664
114546
  async getNextGlobalId() {
113665
114547
  try {
113666
- const numbers = (await file_ops_default.readdir(this.packagesDir)).filter((d) => /^g\d+$/.test(d)).map((d) => parseInt(d.substring(1), 10)).filter((n) => !isNaN(n));
113667
- return `g${numbers.length > 0 ? Math.max(...numbers) + 1 : 1}`;
114548
+ const dirs = await file_ops_default.readdir(this.packagesDir);
114549
+ const used = new Set(dirs.filter((d) => /^g\d+$/.test(d)).map((d) => parseInt(d.substring(1), 10)));
114550
+ let nextNum = 1;
114551
+ while (used.has(nextNum)) nextNum++;
114552
+ return `g${nextNum}`;
113668
114553
  } catch (err) {
113669
114554
  return "g1";
113670
114555
  }
@@ -113883,7 +114768,7 @@ var FynGlobal = class {
113883
114768
  linked: shouldLink
113884
114769
  });
113885
114770
  await this.ensureBinSymlink();
113886
- logger$1.info(`${packageName}@${installedVersion || depVersion} installed globally`);
114771
+ logger$1.info(`${packageName}@${installedVersion || depVersion} installed globally. Tag: ${gId}`);
113887
114772
  if (Object.keys(bins).length > 0) logger$1.info(`Binaries available: ${Object.keys(bins).join(", ")}`);
113888
114773
  if (shouldLink) logger$1.info(`This version is now active (linked)`);
113889
114774
  else logger$1.info(`Note: An older version was installed. Use 'fyn global link ${packageName}@${installedVersion}' to activate it.`);
@@ -114038,46 +114923,32 @@ var FynGlobal = class {
114038
114923
  return [];
114039
114924
  }
114040
114925
  const result = [];
114041
- namesToShow.sort();
114042
- for (const packageName of namesToShow) {
114043
- const versions = registry.packages[packageName].versions || [];
114044
- if (versions.length === 0) continue;
114045
- const byVersion = {};
114046
- for (const v of versions) {
114047
- const key = v.version;
114048
- if (!byVersion[key]) byVersion[key] = [];
114049
- byVersion[key].push(v);
114050
- }
114051
- const sortedVersions = Object.keys(byVersion).sort((a, b) => {
114052
- if (import_semver.default.valid(a) && import_semver.default.valid(b)) return import_semver.default.rcompare(a, b);
114053
- return a.localeCompare(b);
114926
+ const tagNum = (v) => parseInt(v.dir.slice(1), 10);
114927
+ const packages = namesToShow.map((name) => ({
114928
+ name,
114929
+ entries: [...registry.packages[name].versions || []].sort((a, b) => {
114930
+ if (a.linked !== b.linked) return a.linked ? -1 : 1;
114931
+ return tagNum(a) - tagNum(b);
114932
+ })
114933
+ })).filter((p) => p.entries.length > 0).sort((a, b) => tagNum(a.entries[0]) - tagNum(b.entries[0]));
114934
+ const home = os$1.homedir();
114935
+ const pad = (n) => String(n).padStart(2, "0");
114936
+ for (const { name: packageName, entries } of packages) entries.forEach((v, ix) => {
114937
+ const d = new Date(v.installedAt);
114938
+ const time = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
114939
+ const fields = [
114940
+ v.dir,
114941
+ ix === 0 ? `${packageName}@${v.version}` : v.version,
114942
+ v.semver?.replace(`file:${home}`, "file:~"),
114943
+ time,
114944
+ v.bins?.length > 0 ? `[${v.bins.join(", ")}]` : ""
114945
+ ].filter(Boolean);
114946
+ console.log(`${ix === 0 ? "" : " "}${fields.join(" ")}`);
114947
+ result.push({
114948
+ package: packageName,
114949
+ ...v
114054
114950
  });
114055
- console.log(`\n${packageName}:`);
114056
- for (const version of sortedVersions) {
114057
- const entries = byVersion[version];
114058
- for (const v of entries) {
114059
- const tagLinked = v.linked ? " *" : "";
114060
- const local = v.local ? " (local)" : "";
114061
- const bins = v.bins?.length > 0 ? ` [${v.bins.join(", ")}]` : "";
114062
- const fromSemver = v.semver ? `(from '${v.semver}')` : "";
114063
- const installedDate = new Date(v.installedAt);
114064
- const dateStr = installedDate.toLocaleDateString();
114065
- const timeStr = installedDate.toLocaleTimeString([], {
114066
- hour12: true,
114067
- hour: "2-digit",
114068
- minute: "2-digit",
114069
- second: "2-digit"
114070
- });
114071
- console.log(` ${v.dir} (${v.version})${tagLinked}${local}${bins}${fromSemver ? ` ${fromSemver}` : ""}`);
114072
- console.log(` installed ${dateStr} ${timeStr}`);
114073
- result.push({
114074
- package: packageName,
114075
- ...v
114076
- });
114077
- }
114078
- }
114079
- }
114080
- console.log("\n* = linked (active) version");
114951
+ });
114081
114952
  return result;
114082
114953
  }
114083
114954
  /**
@@ -114234,11 +115105,10 @@ var FynGlobal = class {
114234
115105
  pkgJson.dependencies[targetPackageName] = targetVersion.semver || "latest";
114235
115106
  await file_ops_default.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n");
114236
115107
  }
114237
- const lockPath = Path.join(pkgDir, "fyn-lock.yaml");
114238
- try {
115108
+ const fyn = this._createFyn(pkgDir, targetVersion.local);
115109
+ for (const lockPath of [Path.join(pkgDir, "fyn-lock.yaml"), fyn.getInstalledLockFile()]) try {
114239
115110
  await file_ops_default.unlink(lockPath);
114240
115111
  } catch (err) {}
114241
- const fyn = this._createFyn(pkgDir, targetVersion.local);
114242
115112
  await fyn.resolveDependencies();
114243
115113
  await fyn.fetchPackages();
114244
115114
  await new PkgInstaller({ fyn }).install();
@@ -115902,6 +116772,30 @@ const commands = {
115902
116772
  }
115903
116773
  }
115904
116774
  },
116775
+ "prod-prune": {
116776
+ desc: "Remove non-runtime files from a production node_modules",
116777
+ usage: "$0 $1 [dir] [--max-kb <n>] [--max-files <n>] [--force]",
116778
+ args: "[dir string]",
116779
+ exec: async (cmd) => {
116780
+ return new FynCli(await pickOptions(cmd)).prodPrune(cmd.jsonMeta);
116781
+ },
116782
+ options: {
116783
+ "max-kb": {
116784
+ args: "<kb number>",
116785
+ desc: "Fail when the pruned tree exceeds this many kB",
116786
+ argDefault: "0"
116787
+ },
116788
+ "max-files": {
116789
+ args: "<count number>",
116790
+ desc: "Fail when the pruned tree exceeds this many files",
116791
+ argDefault: "0"
116792
+ },
116793
+ force: {
116794
+ args: "<flag boolean>",
116795
+ desc: "Prune even if the tree is not from a production install"
116796
+ }
116797
+ }
116798
+ },
115905
116799
  "sync-local": {
115906
116800
  desc: "Refresh locally linked package files",
115907
116801
  alias: "sl",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fyn",
3
3
  "type": "module",
4
- "version": "3.1.10",
4
+ "version": "3.2.1",
5
5
  "description": "fyn is a node.js package manager, and supports fynpo -- a zero setup monorepo tool",
6
6
  "main": "./bin/index.mjs",
7
7
  "homepage": "https://github.com/jchip/fynjs/tree/main/packages/fyn",
@@ -35,8 +35,7 @@
35
35
  },
36
36
  "author": "Joel Chen",
37
37
  "publishConfig": {
38
- "access": "public",
39
- "registry": "https://registry.npmjs.com/"
38
+ "access": "public"
40
39
  },
41
40
  "files": [
42
41
  "bin",
@@ -82,7 +81,7 @@
82
81
  "Walmart GTP.js Team and Contributors"
83
82
  ],
84
83
  "devDependencies": {
85
- "@fynjs/run": "^1.1.7",
84
+ "@fynjs/run": "^1.1.9",
86
85
  "@fynjs/ts-resolve": "^1.0.3",
87
86
  "@types/node": "^26.4.1",
88
87
  "@vitest/coverage-v8": "^5.0.0",
@@ -92,7 +91,7 @@
92
91
  "patch-package": "^8.0.0",
93
92
  "prettier": "^3.5.3",
94
93
  "rolldown": "^1.2.6",
95
- "run-verify": "^2.1.7",
94
+ "run-verify": "^2.1.8",
96
95
  "typescript": "^7.0.2",
97
96
  "vite": "^8.2.2",
98
97
  "vitest": "^5.0.0",