@bensandee/tooling 0.63.0 → 0.64.0

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.
Files changed (2) hide show
  1. package/dist/bin.mjs +203 -21
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { execFileSync, execSync } from "node:child_process";
9
9
  import JSON5 from "json5";
10
- import { parse } from "jsonc-parser";
10
+ import { applyEdits, modify, parse } from "jsonc-parser";
11
11
  import { z } from "zod";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
13
  import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify, visit } from "yaml";
@@ -2006,23 +2006,149 @@ function diagnoseCheckScriptForwardingAt(targetDir) {
2006
2006
  //#endregion
2007
2007
  //#region src/generators/package-json.ts
2008
2008
  /**
2009
- * Node major pinned for the dev/CI runtime via `devEngines.runtime`.
2009
+ * Single source of truth for the supported Node floor. `engines.node` is the
2010
+ * honest minimum (`>=<floor>`); `devEngines.runtime.version` is the caret form
2011
+ * (`^<floor>` = `>=floor <nextmajor>`), which serves two masters at once:
2010
2012
  *
2011
- * `actions/setup-node` reads `devEngines.runtime` (for `name: "node"`) before
2012
- * `engines.node`, and a bounded `<major>.x` resolves to the latest patch of that
2013
- * one major. Without it, a `>=`-range `engines.node` makes setup-node install the
2014
- * newest available major (e.g. Node 26), silently drifting CI off the LTS line.
2015
- * `engines.node` stays the honest minimum-supported floor; this is the version CI
2016
- * actually runs. `onFail: "warn"` keeps a local Node mismatch non-fatal.
2013
+ * - `actions/setup-node` reads `devEngines.runtime` (for `name: "node"`) before
2014
+ * `engines.node`. The `<nextmajor>` upper bound keeps it on the pinned LTS
2015
+ * major (resolving to the latest patch within it) instead of floating to the
2016
+ * newest released major (e.g. Node 26) the way a bare `>=` range would.
2017
+ * - pnpm resolves `devEngines.runtime.version` to its floor and checks it
2018
+ * against `engines.node`. The caret's floor equals the engines floor, so the
2019
+ * two never contradict — a `<major>.x` floor of `<major>.0.0` sits below
2020
+ * `>=<floor>` and makes `pnpm install` emit a spurious "Unsupported engine"
2021
+ * warning. The floor MUST track `engines.node`, so it is derived from it
2022
+ * rather than hard-coded.
2023
+ *
2024
+ * `onFail: "warn"` keeps a local Node mismatch non-fatal.
2017
2025
  */
2018
- const DEV_NODE_VERSION = "24.x";
2026
+ const NODE_ENGINES_FLOOR = "24.13.0";
2027
+ const ENGINES_NODE = `>=${NODE_ENGINES_FLOOR}`;
2028
+ /** The `^<floor>` runtime range whose floor satisfies a `>=<floor>` engines range. */
2029
+ function caretRange(floor) {
2030
+ return `^${floor}`;
2031
+ }
2019
2032
  function devEnginesRuntime() {
2020
2033
  return { runtime: {
2021
2034
  name: "node",
2022
- version: DEV_NODE_VERSION,
2035
+ version: caretRange(NODE_ENGINES_FLOOR),
2023
2036
  onFail: "warn"
2024
2037
  } };
2025
2038
  }
2039
+ function versionPart(s) {
2040
+ return s && s !== "x" && s !== "*" ? Number(s) : 0;
2041
+ }
2042
+ function compareTuples(a, b) {
2043
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
2044
+ return 0;
2045
+ }
2046
+ /**
2047
+ * Effective lower bound of a single conjunction (space-separated comparators like
2048
+ * `>=24.14.0 <25.0.0`). Upper bounds (`<`, `<=`) don't raise the floor and are
2049
+ * ignored; the floor is the **max** of the remaining lower-bound tokens (`>=`,
2050
+ * `>`, `^`, `~`, `=`, bare, `<major>.x`). Missing/wildcard positions read as 0.
2051
+ * `undefined` when no token carries a numeric lower bound.
2052
+ */
2053
+ function conjunctionFloor(conjunction) {
2054
+ let floor;
2055
+ for (const token of conjunction.trim().split(/\s+/)) {
2056
+ if (!token || token.startsWith("<")) continue;
2057
+ const match = /(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(token);
2058
+ if (!match) continue;
2059
+ const candidate = [
2060
+ Number(match[1]),
2061
+ versionPart(match[2]),
2062
+ versionPart(match[3])
2063
+ ];
2064
+ if (!floor || compareTuples(candidate, floor) > 0) floor = candidate;
2065
+ }
2066
+ return floor;
2067
+ }
2068
+ /**
2069
+ * Lowest version a semver range permits, as a `[major, minor, patch]` tuple —
2070
+ * what pnpm resolves a `devEngines.runtime.version` to when cross-checking
2071
+ * `engines.node`. Handles floor-first forms (`>=24.14.0`, `^24.13.0`, `24.x`,
2072
+ * `24`), compound ranges in any order (`>=24.14.0 <25.0.0`, `<25 >=24.14.0` →
2073
+ * `24.14.0`), and `||` alternation (the minimum across alternatives).
2074
+ *
2075
+ * Returns `undefined` when no floor can be determined (e.g. `*`, `latest`, an
2076
+ * alternative with only an upper bound) — callers must warn rather than guess.
2077
+ */
2078
+ function minVersionTuple(range) {
2079
+ let min;
2080
+ for (const alternative of range.split("||")) {
2081
+ const floor = conjunctionFloor(alternative);
2082
+ if (!floor) return void 0;
2083
+ if (!min || compareTuples(floor, min) < 0) min = floor;
2084
+ }
2085
+ return min;
2086
+ }
2087
+ /** True when `range`'s floor is >= `enginesNode`'s floor (i.e. pnpm won't warn). */
2088
+ function runtimeFloorSatisfies(range, enginesNode) {
2089
+ const dev = minVersionTuple(range);
2090
+ const eng = minVersionTuple(enginesNode);
2091
+ if (!dev || !eng) return true;
2092
+ return compareTuples(dev, eng) >= 0;
2093
+ }
2094
+ /**
2095
+ * Decide how to reconcile a package's `devEngines.runtime` (node entries) with
2096
+ * its `engines.node` floor, without mutating anything. The caret form `^<floor>`
2097
+ * keeps pnpm quiet (its floor equals the engines floor) while staying bounded to
2098
+ * the same major for `setup-node`.
2099
+ *
2100
+ * - `noop` — no node runtime declared, or every node runtime already satisfies
2101
+ * the engines floor (a deliberate higher pin is left alone). Never adds a
2102
+ * `devEngines.runtime` that isn't already there.
2103
+ * - `warn` — a node runtime is declared but `engines.node` has no determinable
2104
+ * floor; leave it unchanged rather than guess.
2105
+ * - `fix` — `staleIndices` index the node runtimes (in array order, or `[0]`
2106
+ * for the single-object form) whose `version` must become `desired`.
2107
+ */
2108
+ function planDevEnginesFix(pkg, enginesNode) {
2109
+ const runtimeField = pkg["devEngines"]?.runtime;
2110
+ if (!runtimeField) return { kind: "noop" };
2111
+ const nodeEntries = (Array.isArray(runtimeField) ? runtimeField : [runtimeField]).map((runtime, index) => ({
2112
+ runtime,
2113
+ index
2114
+ })).filter(({ runtime }) => runtime.name === "node" && typeof runtime.version === "string");
2115
+ if (nodeEntries.length === 0) return { kind: "noop" };
2116
+ const floor = minVersionTuple(enginesNode);
2117
+ if (!floor) return {
2118
+ kind: "warn",
2119
+ message: `could not determine a floor from engines.node "${enginesNode}"`
2120
+ };
2121
+ const desired = caretRange(`${floor[0]}.${floor[1]}.${floor[2]}`);
2122
+ const staleIndices = nodeEntries.filter(({ runtime }) => !runtimeFloorSatisfies(runtime.version ?? "", enginesNode)).map(({ index }) => index);
2123
+ if (staleIndices.length === 0) return { kind: "noop" };
2124
+ return {
2125
+ kind: "fix",
2126
+ desired,
2127
+ staleIndices
2128
+ };
2129
+ }
2130
+ /**
2131
+ * Correct an existing `devEngines.runtime` (node) in place so its floor matches
2132
+ * `engines.node` — only the `version` changes; `name` and `onFail` are
2133
+ * preserved. Never adds `devEngines.runtime` to a package that lacks it.
2134
+ */
2135
+ function ensureDevEnginesConsistent(pkg, enginesNode, changes) {
2136
+ const plan = planDevEnginesFix(pkg, enginesNode);
2137
+ if (plan.kind === "warn") {
2138
+ log.warn(`package.json: ${plan.message}; leaving devEngines.runtime unchanged`);
2139
+ return;
2140
+ }
2141
+ if (plan.kind !== "fix") return;
2142
+ const runtimeField = pkg["devEngines"]?.runtime;
2143
+ const runtimes = Array.isArray(runtimeField) ? runtimeField : runtimeField ? [runtimeField] : [];
2144
+ for (const index of plan.staleIndices) {
2145
+ const runtime = runtimes[index];
2146
+ if (!runtime) continue;
2147
+ const previous = runtime.version;
2148
+ runtime.version = plan.desired;
2149
+ changes.push(`bumped devEngines.runtime node ${previous} → ${plan.desired}`);
2150
+ }
2151
+ }
2026
2152
  const STANDARD_SCRIPTS_SINGLE = {
2027
2153
  build: "tsdown",
2028
2154
  dev: "tsdown --watch",
@@ -2332,13 +2458,11 @@ function applyMetadataDefaults(pkg, ctx, changes) {
2332
2458
  }
2333
2459
  }
2334
2460
  if (!pkg["engines"]) {
2335
- pkg["engines"] = { node: ">=24.13.0" };
2336
- changes.push("set engines.node >= 24.13.0");
2337
- }
2338
- if (!pkg["devEngines"]) {
2339
- pkg["devEngines"] = devEnginesRuntime();
2340
- changes.push(`set devEngines.runtime node ${DEV_NODE_VERSION}`);
2461
+ pkg["engines"] = { node: ENGINES_NODE };
2462
+ changes.push(`set engines.node ${ENGINES_NODE}`);
2341
2463
  }
2464
+ const enginesNode = pkg["engines"]?.["node"];
2465
+ if (typeof enginesNode === "string") ensureDevEnginesConsistent(pkg, enginesNode, changes);
2342
2466
  const pinnedPnpm = {
2343
2467
  "@changesets/cli": "2.31.0",
2344
2468
  "@release-it/bumper": "7.0.6",
@@ -2392,7 +2516,7 @@ function buildManagedDevDeps(ctx) {
2392
2516
  const devDeps = { ...ROOT_DEV_DEPS };
2393
2517
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
2394
2518
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.12.0";
2395
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.63.0";
2519
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.64.0";
2396
2520
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.1";
2397
2521
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
2398
2522
  "@changesets/cli": "2.31.0",
@@ -2472,7 +2596,7 @@ async function generatePackageJson(ctx) {
2472
2596
  type: "module",
2473
2597
  scripts: allScripts,
2474
2598
  devDependencies: devDeps,
2475
- engines: { node: ">=24.13.0" },
2599
+ engines: { node: ENGINES_NODE },
2476
2600
  devEngines: devEnginesRuntime(),
2477
2601
  packageManager: `pnpm@${{
2478
2602
  "@changesets/cli": "2.31.0",
@@ -2498,6 +2622,63 @@ async function generatePackageJson(ctx) {
2498
2622
  description: "Generated package.json"
2499
2623
  };
2500
2624
  }
2625
+ /**
2626
+ * Reconcile `devEngines.runtime` against `engines.node` in nested workspace
2627
+ * packages — pnpm's "Unsupported engine" warning fires per-package, so a
2628
+ * subpackage that declares both fields can warn even when the root is clean.
2629
+ *
2630
+ * Unlike the root `package.json` (a fully managed file), a subpackage's
2631
+ * `package.json` is otherwise owned by the user, so this edits *only* the stale
2632
+ * `version` string via `jsonc-parser` rather than reserialising the whole file
2633
+ * (which would reorder keys and produce a large surprising diff). It never adds
2634
+ * `engines.node` or `devEngines.runtime` — it touches a package only when both
2635
+ * already exist and a node runtime's floor is below the engines floor.
2636
+ *
2637
+ * Scans `packages/*` (the same convention as the rest of the tool); other
2638
+ * workspace layouts (`apps/*`, custom globs) are out of scope.
2639
+ */
2640
+ async function generateWorkspaceEngines(ctx) {
2641
+ if (ctx.config.structure !== "monorepo") return [];
2642
+ const results = [];
2643
+ for (const member of getMonorepoPackages(ctx.targetDir)) {
2644
+ const result = reconcileWorkspaceDevEngines(ctx, path.join(path.relative(ctx.targetDir, member.dir), "package.json"));
2645
+ if (result) results.push(result);
2646
+ }
2647
+ return results;
2648
+ }
2649
+ /** Surgically fix a single nested package.json's devEngines, preserving its formatting. */
2650
+ function reconcileWorkspaceDevEngines(ctx, relPath) {
2651
+ const raw = ctx.read(relPath);
2652
+ if (!raw) return void 0;
2653
+ const pkg = parsePackageJson(raw);
2654
+ if (!pkg) return void 0;
2655
+ const enginesNode = pkg["engines"]?.["node"];
2656
+ if (typeof enginesNode !== "string") return void 0;
2657
+ const plan = planDevEnginesFix(pkg, enginesNode);
2658
+ if (plan.kind === "warn") {
2659
+ log.warn(`${relPath}: ${plan.message}; leaving devEngines.runtime unchanged`);
2660
+ return;
2661
+ }
2662
+ if (plan.kind !== "fix") return void 0;
2663
+ const isArray = Array.isArray(pkg["devEngines"]?.runtime);
2664
+ let text = raw;
2665
+ for (const index of plan.staleIndices) text = applyEdits(text, modify(text, isArray ? [
2666
+ "devEngines",
2667
+ "runtime",
2668
+ index,
2669
+ "version"
2670
+ ] : [
2671
+ "devEngines",
2672
+ "runtime",
2673
+ "version"
2674
+ ], plan.desired, {}));
2675
+ ctx.write(relPath, text);
2676
+ return {
2677
+ filePath: relPath,
2678
+ action: "updated",
2679
+ description: `bumped devEngines.runtime node → ${plan.desired}`
2680
+ };
2681
+ }
2501
2682
  //#endregion
2502
2683
  //#region src/generators/tsconfig.ts
2503
2684
  async function generateTsconfig(ctx) {
@@ -5701,6 +5882,7 @@ async function generateVersionrc(ctx) {
5701
5882
  async function runGenerators(ctx) {
5702
5883
  const results = [];
5703
5884
  results.push(await generatePackageJson(ctx));
5885
+ results.push(...await generateWorkspaceEngines(ctx));
5704
5886
  results.push(await generatePnpmWorkspace(ctx));
5705
5887
  results.push(...await generateTsconfig(ctx));
5706
5888
  results.push(await generateTsdown(ctx));
@@ -5734,7 +5916,7 @@ async function runGenerators(ctx) {
5734
5916
  function generateMigratePrompt(results, config, detected) {
5735
5917
  const sections = [];
5736
5918
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
5737
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.63.0 repo:sync\` on ${timestamp}_\n`);
5919
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.64.0 repo:sync\` on ${timestamp}_\n`);
5738
5920
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
5739
5921
  if (changed.length > 0) {
5740
5922
  const items = changed.map((r) => {
@@ -10214,7 +10396,7 @@ const out = (msg) => console.log(msg);
10214
10396
  const main = defineCommand({
10215
10397
  meta: {
10216
10398
  name: "bst",
10217
- version: "0.63.0",
10399
+ version: "0.64.0",
10218
10400
  description: "Bootstrap and maintain standardized TypeScript project tooling"
10219
10401
  },
10220
10402
  subCommands: {
@@ -10286,7 +10468,7 @@ const main = defineCommand({
10286
10468
  })
10287
10469
  }
10288
10470
  });
10289
- console.log(`@bensandee/tooling v0.63.0`);
10471
+ console.log(`@bensandee/tooling v0.64.0`);
10290
10472
  async function run() {
10291
10473
  await runMain(main);
10292
10474
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {