@bensandee/tooling 0.60.0 → 0.60.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.
Files changed (3) hide show
  1. package/README.md +15 -1
  2. package/dist/bin.mjs +118 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,7 +32,7 @@ The tool auto-detects project structure, CI platform, project type, and Docker p
32
32
  | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
33
  | `tooling repo:sync [dir]` | Detect, generate, and sync project tooling (idempotent). First run prompts for release strategy, CI platform (if not detected), and formatter (if Prettier found). Subsequent runs are non-interactive. |
34
34
  | `tooling repo:sync --check [dir]` | Dry-run drift detection. Exits 1 if files would change. CI-friendly. |
35
- | `tooling checks:run` | Run project checks (build, docker:build, typecheck, lint, test, format, knip, tooling:check, docker:check). Flags: `--skip`, `--add`, `--fail-fast`, `--verbose`. |
35
+ | `tooling checks:run` | Run project checks (build, docker:build, typecheck, lint, test, format, knip, tooling:check, docker:check). Flags: `--skip`, `--add`, `--fail-fast`, `--verbose`, `--with-coverage`. |
36
36
 
37
37
  **Flags:** `--yes` (accept all defaults), `--no-ci`, `--no-prompt`, `--eslint-plugin`
38
38
 
@@ -56,6 +56,20 @@ The `--add` flag appends extra checks (must be defined in `package.json`):
56
56
  tooling checks:run --add e2e
57
57
  ```
58
58
 
59
+ The `--with-coverage` flag runs the `test` step via `pnpm run test:coverage` (when that script is defined), producing `coverage/coverage-summary.json` for the `coverage:*` commands.
60
+
61
+ **Extend the `check` script with `--add`, never a shell chain.** `repo:sync` generates `check` as a single rooted command (`bst checks:run`) and `ci:check` as `pnpm check --skip 'docker:*'`. When CI runs `pnpm ci:check --with-coverage`, pnpm appends the forwarded flag to the _end_ of the resolved script — so it reaches `bst checks:run` only if that is the last command. Adding extra checks via an `&&` chain silently drops `--with-coverage` and `--skip 'docker:*'`:
62
+
63
+ ```jsonc
64
+ // ✓ forwarded flags reach bst checks:run
65
+ "check": "bst checks:run --add check:env-drift,check:custom"
66
+
67
+ // ✗ --with-coverage / --skip land on check:custom instead
68
+ "check": "bst checks:run && pnpm check:env-drift && pnpm check:custom"
69
+ ```
70
+
71
+ `repo:sync` warns when it detects the broken shape, and `coverage:summary` / `coverage:check` name it as the likely cause if the summary file is missing.
72
+
59
73
  The generated `ci:check` script defaults to `pnpm check --skip 'docker:*'` since CI environments typically lack Docker support.
60
74
 
61
75
  ### Custom blocks in workflows
package/dist/bin.mjs CHANGED
@@ -1564,6 +1564,72 @@ async function generateDeployCi(ctx) {
1564
1564
  };
1565
1565
  }
1566
1566
  //#endregion
1567
+ //#region src/utils/script-forwarding.ts
1568
+ /**
1569
+ * pnpm appends args forwarded via `pnpm <script> <args>` to the END of the
1570
+ * resolved script string. A forwarded flag therefore only reaches its intended
1571
+ * command when that command is the LAST command in the script. The generated
1572
+ * CI workflow depends on this two-hop forwarding:
1573
+ *
1574
+ * pnpm ci:check --with-coverage
1575
+ * → pnpm check --skip 'docker:*' --with-coverage
1576
+ * → bst checks:run --skip 'docker:*' --with-coverage
1577
+ *
1578
+ * If `check` or `ci:check` ends in a different command (e.g. an `&&` chain),
1579
+ * the forwarded flags silently land on the wrong command — tests run without
1580
+ * coverage and `--skip` becomes a no-op. This module detects that shape so
1581
+ * callers can surface a precise diagnosis instead of a confusing downstream
1582
+ * failure.
1583
+ */
1584
+ /** The final command of a script, after splitting on `&&`, `||`, `|`, and `;`. */
1585
+ function lastCommandSegment(script) {
1586
+ return (script.split(/&&|\|\|?|;/).at(-1) ?? "").trim();
1587
+ }
1588
+ /**
1589
+ * True when args forwarded to a `check` script would reach `bst checks:run`
1590
+ * — its last command must invoke `bst checks:run` (or the `bin.mjs checks:run`
1591
+ * variant used by the tooling repo itself). Trailing flags like `--add` are
1592
+ * fine; only the position of the invocation matters.
1593
+ */
1594
+ function isCheckScriptForwardingSafe(script) {
1595
+ return /(?:^|[\s/])(?:bst|bin\.mjs)\s+checks:run(?=\s|$)/.test(lastCommandSegment(script));
1596
+ }
1597
+ /**
1598
+ * True when args forwarded to a `ci:check` script would reach `pnpm check`
1599
+ * — its last command must invoke `pnpm check` (or `pnpm run check`).
1600
+ */
1601
+ function isCiCheckScriptForwardingSafe(script) {
1602
+ return /(?:^|\s)pnpm\s+(?:run\s+)?check(?=\s|$)/.test(lastCommandSegment(script));
1603
+ }
1604
+ /**
1605
+ * Inspect a package.json `scripts` record for `check` / `ci:check` definitions
1606
+ * whose shape would swallow pnpm-forwarded args. Returns a human-readable hint
1607
+ * naming the problem and the fix, or undefined when forwarding is sound.
1608
+ */
1609
+ function diagnoseCheckScriptForwarding(scripts) {
1610
+ if (!scripts) return void 0;
1611
+ const problems = [];
1612
+ const check = scripts["check"];
1613
+ if (check !== void 0 && !isCheckScriptForwardingSafe(check)) problems.push(`the \`check\` script does not end with \`bst checks:run\` (its last command is \`${lastCommandSegment(check)}\`)`);
1614
+ const ciCheck = scripts["ci:check"];
1615
+ if (ciCheck !== void 0 && !isCiCheckScriptForwardingSafe(ciCheck)) problems.push(`the \`ci:check\` script does not end with \`pnpm check\` (its last command is \`${lastCommandSegment(ciCheck)}\`)`);
1616
+ if (problems.length === 0) return void 0;
1617
+ return `Likely cause: ${problems.join(", and ")}. pnpm appends forwarded args (e.g. \`--with-coverage\`, \`--skip 'docker:*'\`) to the end of the resolved script, so they never reach \`bst checks:run\`. Make \`bst checks:run\` the last command — move extra checks into \`bst checks:run --add <name,...>\`.`;
1618
+ }
1619
+ /**
1620
+ * Filesystem wrapper around {@link diagnoseCheckScriptForwarding}: reads
1621
+ * `package.json` from `targetDir` (best-effort) and diagnoses its scripts.
1622
+ */
1623
+ function diagnoseCheckScriptForwardingAt(targetDir) {
1624
+ let scripts;
1625
+ try {
1626
+ scripts = parsePackageJson(readFileSync(path.join(targetDir, "package.json"), "utf-8"))?.scripts;
1627
+ } catch (_error) {
1628
+ return;
1629
+ }
1630
+ return diagnoseCheckScriptForwarding(scripts);
1631
+ }
1632
+ //#endregion
1567
1633
  //#region src/generators/package-json.ts
1568
1634
  const STANDARD_SCRIPTS_SINGLE = {
1569
1635
  build: "tsdown",
@@ -1841,7 +1907,7 @@ async function generatePackageJson(ctx) {
1841
1907
  const devDeps = { ...ROOT_DEV_DEPS };
1842
1908
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1843
1909
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.2";
1844
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.60.0";
1910
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.60.1";
1845
1911
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.0";
1846
1912
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1847
1913
  "@changesets/cli": "2.31.0",
@@ -1901,6 +1967,8 @@ async function generatePackageJson(ctx) {
1901
1967
  changes.push(`removed deprecated script: ${key}`);
1902
1968
  }
1903
1969
  pkg.scripts = existingScripts;
1970
+ const forwardingHint = diagnoseCheckScriptForwarding(existingScripts);
1971
+ if (forwardingHint) log.warn(`package.json: pnpm-forwarded check flags may be dropped. ${forwardingHint}`);
1904
1972
  const existingDevDeps = pkg.devDependencies ?? {};
1905
1973
  for (const [key, value] of Object.entries(devDeps)) if (!(key in existingDevDeps)) {
1906
1974
  existingDevDeps[key] = value;
@@ -4903,7 +4971,7 @@ async function runGenerators(ctx) {
4903
4971
  function generateMigratePrompt(results, config, detected) {
4904
4972
  const sections = [];
4905
4973
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
4906
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.60.0 repo:sync\` on ${timestamp}_\n`);
4974
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.60.1 repo:sync\` on ${timestamp}_\n`);
4907
4975
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
4908
4976
  if (changed.length > 0) {
4909
4977
  const items = changed.map((r) => {
@@ -5971,19 +6039,39 @@ async function runRelease(config, executor) {
5971
6039
  }
5972
6040
  //#endregion
5973
6041
  //#region src/release/dispatch.ts
6042
+ const INPUT_FLAG = "--input";
6043
+ const INPUT_FLAG_EQ = "--input=";
5974
6044
  /**
5975
- * Parse repeatable `--input key=value` flags into an inputs object.
6045
+ * Collect every `--input` value from a command's raw CLI args.
5976
6046
  *
5977
- * Citty types a repeatable string flag as `string`, but the underlying parser
5978
- * collects repeats into an array — so the raw value may be a string, an array
5979
- * of strings, or undefined. Throws {@link FatalError} on malformed input.
6047
+ * citty@0.2.2 keeps only the LAST occurrence of a repeated named flag, so a
6048
+ * declared `input` arg cannot capture `--input a=1 --input b=2` — it silently
6049
+ * yields just `b=2`. Scanning the raw argv directly recovers all occurrences.
6050
+ * Both `--input k=v` (two tokens) and `--input=k=v` (one token) forms are
6051
+ * supported. Throws {@link FatalError} when `--input` has no following value.
6052
+ */
6053
+ function collectInputArgs(rawArgs) {
6054
+ const values = [];
6055
+ for (let i = 0; i < rawArgs.length; i += 1) {
6056
+ const arg = rawArgs[i];
6057
+ if (arg === void 0) continue;
6058
+ if (arg === INPUT_FLAG) {
6059
+ const next = rawArgs[i + 1];
6060
+ if (next === void 0) throw new FatalError("Missing value for --input: expected `--input key=value`");
6061
+ values.push(next);
6062
+ i += 1;
6063
+ } else if (arg.startsWith(INPUT_FLAG_EQ)) values.push(arg.slice(8));
6064
+ }
6065
+ return values;
6066
+ }
6067
+ /**
6068
+ * Parse `key=value` workflow-input strings (as collected by
6069
+ * {@link collectInputArgs}) into an inputs object. Throws {@link FatalError} on
6070
+ * a malformed entry — no `=`, or an empty key.
5980
6071
  */
5981
6072
  function parseInputs(raw) {
5982
6073
  const inputs = {};
5983
- if (raw === void 0 || raw === null) return inputs;
5984
- const values = Array.isArray(raw) ? raw : [raw];
5985
- for (const value of values) {
5986
- if (typeof value !== "string") throw new FatalError(`Invalid --input value: expected key=value, got ${typeof value}`);
6074
+ for (const value of raw) {
5987
6075
  const eq = value.indexOf("=");
5988
6076
  if (eq <= 0) throw new FatalError(`Invalid --input "${value}": expected key=value`);
5989
6077
  inputs[value.slice(0, eq)] = value.slice(eq + 1);
@@ -6101,10 +6189,10 @@ const workflowDispatchCommand = defineCommand({
6101
6189
  required: false
6102
6190
  }
6103
6191
  },
6104
- async run({ args }) {
6192
+ async run({ args, rawArgs }) {
6105
6193
  if (!args.workflow) throw new FatalError("--workflow is required");
6106
6194
  const ref = args.ref ?? "main";
6107
- const inputs = parseInputs(args.input);
6195
+ const inputs = parseInputs(collectInputArgs(rawArgs));
6108
6196
  await dispatchWorkflow(resolveConnection(process.cwd()), {
6109
6197
  repo: args.repo,
6110
6198
  workflow: args.workflow,
@@ -8405,12 +8493,26 @@ function formatTimestamp(iso) {
8405
8493
  return `${String(date.getUTCFullYear())}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
8406
8494
  }
8407
8495
  //#endregion
8496
+ //#region src/commands/coverage-missing-summary.ts
8497
+ const COVERAGE_SUMMARY_FILE$3 = "coverage/coverage-summary.json";
8498
+ /**
8499
+ * Build the "coverage summary not found" error message. When the project's
8500
+ * `check` / `ci:check` scripts have a shape that drops pnpm-forwarded flags,
8501
+ * a precise diagnosis is appended — that mis-forwarding is the usual reason a
8502
+ * CI `--with-coverage` run produces no summary file.
8503
+ */
8504
+ function missingSummaryMessage(targetDir) {
8505
+ const base = `${COVERAGE_SUMMARY_FILE$3} not found. Run \`pnpm test:coverage\` first, or add "json-summary" to your vitest reporters.`;
8506
+ const hint = diagnoseCheckScriptForwardingAt(targetDir);
8507
+ return hint ? `${base}\n\n${hint}` : base;
8508
+ }
8509
+ //#endregion
8408
8510
  //#region src/commands/coverage-summary.ts
8409
8511
  const COVERAGE_SUMMARY_FILE$2 = "coverage/coverage-summary.json";
8410
8512
  const out$3 = (msg) => console.log(msg);
8411
8513
  function readSummary(targetDir) {
8412
8514
  const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE$2);
8413
- if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE$2} not found. Run \`pnpm test:coverage\` first, or add "json-summary" to your vitest reporters.`);
8515
+ if (!existsSync(summaryPath)) throw new FatalError(missingSummaryMessage(targetDir));
8414
8516
  return readFileSync(summaryPath, "utf-8");
8415
8517
  }
8416
8518
  function getHeadSha(targetDir) {
@@ -8493,7 +8595,7 @@ const coverageCheckCommand = defineCommand({
8493
8595
  const targetDir = path.resolve(args.dir ?? ".");
8494
8596
  const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE$1);
8495
8597
  const baselinePath = path.join(targetDir, BASELINE_FILE);
8496
- if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE$1} not found. Run \`pnpm test:coverage\` first, or add "json-summary" to your vitest reporters.`);
8598
+ if (!existsSync(summaryPath)) throw new FatalError(missingSummaryMessage(targetDir));
8497
8599
  const current = deriveBaseline(parseV8Summary(readFileSync(summaryPath, "utf-8")), { rootDir: targetDir });
8498
8600
  if (!existsSync(baselinePath)) {
8499
8601
  if (args["update-baseline"] === true) {
@@ -8580,7 +8682,7 @@ const out = (msg) => console.log(msg);
8580
8682
  const main = defineCommand({
8581
8683
  meta: {
8582
8684
  name: "bst",
8583
- version: "0.60.0",
8685
+ version: "0.60.1",
8584
8686
  description: "Bootstrap and maintain standardized TypeScript project tooling"
8585
8687
  },
8586
8688
  subCommands: {
@@ -8653,7 +8755,7 @@ const main = defineCommand({
8653
8755
  })
8654
8756
  }
8655
8757
  });
8656
- console.log(`@bensandee/tooling v0.60.0`);
8758
+ console.log(`@bensandee/tooling v0.60.1`);
8657
8759
  async function run() {
8658
8760
  await runMain(main);
8659
8761
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.60.0",
3
+ "version": "0.60.1",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {