@bensandee/tooling 0.35.0 → 0.37.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 (3) hide show
  1. package/README.md +20 -0
  2. package/dist/bin.mjs +240 -199
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,6 +58,26 @@ tooling checks:run --add e2e
58
58
 
59
59
  The generated `ci:check` script defaults to `pnpm check --skip 'docker:*'` since CI environments typically lack Docker support.
60
60
 
61
+ ### Custom blocks in workflows
62
+
63
+ Generated workflow files are regenerated on each `repo:sync` run. To preserve custom additions (extra steps, environment variables, etc.), wrap them in custom block markers:
64
+
65
+ ```yaml
66
+ - name: Run checks
67
+ run: pnpm ci:check
68
+ # @tooling:custom
69
+ - name: Upload coverage
70
+ uses: actions/upload-artifact@v4
71
+ with:
72
+ name: coverage
73
+ path: coverage/
74
+ # @tooling:endcustom
75
+ ```
76
+
77
+ Custom blocks are anchored to the nearest preceding non-blank line. When `repo:sync` regenerates the workflow, it extracts custom blocks from the existing file, generates fresh content, then re-inserts each block after its anchor line. If the anchor line is no longer present (e.g. a step was renamed), the block is appended at the end of the file.
78
+
79
+ To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first line — `repo:sync` will leave the file untouched.
80
+
61
81
  ### Release management
62
82
 
63
83
  | Command | Description |
package/dist/bin.mjs CHANGED
@@ -956,7 +956,76 @@ function runDockerPublish(executor, config) {
956
956
  //#endregion
957
957
  //#region src/utils/yaml-merge.ts
958
958
  const IGNORE_PATTERN = "@bensandee/tooling:ignore";
959
+ const CUSTOM_START = "# @tooling:custom";
960
+ const CUSTOM_END = "# @tooling:endcustom";
959
961
  const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
962
+ /** Extract custom blocks from a workflow file, recording their anchor lines. */
963
+ function extractCustomBlocks(content) {
964
+ const lines = content.split("\n");
965
+ const blocks = [];
966
+ let i = 0;
967
+ while (i < lines.length) if (lines[i].trim() === CUSTOM_START) {
968
+ let anchor = "";
969
+ for (let j = i - 1; j >= 0; j--) if (lines[j].trim() !== "") {
970
+ anchor = lines[j].trim();
971
+ break;
972
+ }
973
+ const blockLines = [lines[i]];
974
+ i++;
975
+ while (i < lines.length && lines[i].trim() !== CUSTOM_END) {
976
+ blockLines.push(lines[i]);
977
+ i++;
978
+ }
979
+ if (i < lines.length) {
980
+ blockLines.push(lines[i]);
981
+ i++;
982
+ }
983
+ blocks.push({
984
+ anchor,
985
+ lines: blockLines
986
+ });
987
+ } else i++;
988
+ return blocks;
989
+ }
990
+ /** Remove custom blocks (markers + content) from a workflow string. */
991
+ function stripCustomBlocks(content) {
992
+ const lines = content.split("\n");
993
+ const result = [];
994
+ let inCustom = false;
995
+ for (const line of lines) {
996
+ if (line.trim() === CUSTOM_START) {
997
+ inCustom = true;
998
+ continue;
999
+ }
1000
+ if (inCustom && line.trim() === CUSTOM_END) {
1001
+ inCustom = false;
1002
+ continue;
1003
+ }
1004
+ if (!inCustom) result.push(line);
1005
+ }
1006
+ return result.join("\n");
1007
+ }
1008
+ /** Insert previously extracted custom blocks into freshly generated content, anchored by preceding line. */
1009
+ function insertCustomBlocks(generated, blocks) {
1010
+ if (blocks.length === 0) return generated;
1011
+ const lines = generated.split("\n");
1012
+ const insertions = [];
1013
+ for (const block of blocks) {
1014
+ let insertAfter = -1;
1015
+ for (let i = 0; i < lines.length; i++) if (lines[i].trim() === block.anchor) {
1016
+ insertAfter = i;
1017
+ break;
1018
+ }
1019
+ if (insertAfter < 0) insertAfter = lines.length - 1;
1020
+ insertions.push({
1021
+ afterIndex: insertAfter,
1022
+ block
1023
+ });
1024
+ }
1025
+ insertions.sort((a, b) => b.afterIndex - a.afterIndex);
1026
+ for (const { afterIndex, block } of insertions) lines.splice(afterIndex + 1, 0, ...block.lines);
1027
+ return lines.join("\n");
1028
+ }
960
1029
  /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
961
1030
  function ensureSchemaComment(content, ci) {
962
1031
  if (ci !== "forgejo") return content;
@@ -1014,7 +1083,7 @@ function mergeLefthookCommands(existing, requiredCommands) {
1014
1083
  * don't cause unnecessary overwrites.
1015
1084
  */
1016
1085
  function normalizeWorkflow(content) {
1017
- const doc = parseDocument(content.replaceAll(/node\s+packages\/tooling-cli\/dist\/bin\.mjs/g, "pnpm exec bst"));
1086
+ const doc = parseDocument(stripCustomBlocks(content).replaceAll(/node\s+packages\/tooling-cli\/dist\/bin\.mjs/g, "pnpm exec bst"));
1018
1087
  doc.comment = null;
1019
1088
  doc.commentBefore = null;
1020
1089
  visit(doc, {
@@ -1156,7 +1225,9 @@ async function generateDeployCi(ctx) {
1156
1225
  description: "Publish workflow already up to date"
1157
1226
  };
1158
1227
  }
1159
- ctx.write(workflowPath, preserveDevBinaryPath(content, existing));
1228
+ const withDevPath = preserveDevBinaryPath(content, existing);
1229
+ const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
1230
+ ctx.write(workflowPath, final);
1160
1231
  return {
1161
1232
  filePath: workflowPath,
1162
1233
  action: alreadyExists ? "updated" : "created",
@@ -1345,8 +1416,6 @@ function isWorkspacePackage(ctx, packageName) {
1345
1416
  } catch (_error) {}
1346
1417
  return false;
1347
1418
  }
1348
- /** Deps that should not be blindly bumped to latest (version-sensitive). */
1349
- const UPDATE_EXCLUDE = new Set(["@types/node"]);
1350
1419
  /** Add release-strategy-specific devDeps to a deps record. */
1351
1420
  function addReleaseDeps(deps, config) {
1352
1421
  switch (config.releaseStrategy) {
@@ -1422,47 +1491,6 @@ function addReleaseDeps(deps, config) {
1422
1491
  break;
1423
1492
  }
1424
1493
  }
1425
- /** Returns the list of pinned devDependency names that the tool would add for a given config. */
1426
- function getAddedDevDepNames(config) {
1427
- const deps = { ...ROOT_DEV_DEPS };
1428
- if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1429
- deps["@bensandee/config"] = "0.9.1";
1430
- deps["@bensandee/tooling"] = "0.35.0";
1431
- if (config.formatter === "oxfmt") deps["oxfmt"] = {
1432
- "@changesets/cli": "2.30.0",
1433
- "@release-it/bumper": "7.0.5",
1434
- "@types/node": "24.12.0",
1435
- "commit-and-tag-version": "12.7.0",
1436
- "knip": "5.87.0",
1437
- "lefthook": "2.1.4",
1438
- "oxfmt": "0.41.0",
1439
- "oxlint": "1.56.0",
1440
- "prettier": "3.8.1",
1441
- "release-it": "19.2.4",
1442
- "tsdown": "0.21.4",
1443
- "typescript": "5.9.3",
1444
- "vitest": "4.1.0",
1445
- "pnpm": "10.32.1"
1446
- }["oxfmt"];
1447
- if (config.formatter === "prettier") deps["prettier"] = {
1448
- "@changesets/cli": "2.30.0",
1449
- "@release-it/bumper": "7.0.5",
1450
- "@types/node": "24.12.0",
1451
- "commit-and-tag-version": "12.7.0",
1452
- "knip": "5.87.0",
1453
- "lefthook": "2.1.4",
1454
- "oxfmt": "0.41.0",
1455
- "oxlint": "1.56.0",
1456
- "prettier": "3.8.1",
1457
- "release-it": "19.2.4",
1458
- "tsdown": "0.21.4",
1459
- "typescript": "5.9.3",
1460
- "vitest": "4.1.0",
1461
- "pnpm": "10.32.1"
1462
- }["prettier"];
1463
- addReleaseDeps(deps, config);
1464
- return Object.keys(deps).filter((name) => !UPDATE_EXCLUDE.has(name));
1465
- }
1466
1494
  async function generatePackageJson(ctx) {
1467
1495
  const filePath = "package.json";
1468
1496
  const existing = ctx.read(filePath);
@@ -1482,7 +1510,7 @@ async function generatePackageJson(ctx) {
1482
1510
  const devDeps = { ...ROOT_DEV_DEPS };
1483
1511
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1484
1512
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1485
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.35.0";
1513
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.37.0";
1486
1514
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1487
1515
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1488
1516
  "@changesets/cli": "2.30.0",
@@ -2116,7 +2144,9 @@ async function generateCi(ctx) {
2116
2144
  description: "CI workflow already up to date"
2117
2145
  };
2118
2146
  }
2119
- ctx.write(filePath, preserveDevBinaryPath(content, existing));
2147
+ const withDevPath = preserveDevBinaryPath(content, existing);
2148
+ const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
2149
+ ctx.write(filePath, final);
2120
2150
  return {
2121
2151
  filePath,
2122
2152
  action: alreadyExists ? "updated" : "created",
@@ -2332,6 +2362,7 @@ function buildSettings(ctx) {
2332
2362
  `Bash(${pm} knip)`,
2333
2363
  `Bash(${pm} knip *)`,
2334
2364
  `Bash(${pm} check)`,
2365
+ `Bash(${pm} ci:check)`,
2335
2366
  `Bash(${pm} exec oxlint *)`,
2336
2367
  `Bash(${pm} exec knip *)`,
2337
2368
  "Bash(git status *)",
@@ -2505,7 +2536,7 @@ async function generateClaudeSettings(ctx) {
2505
2536
  }
2506
2537
  //#endregion
2507
2538
  //#region src/generators/release-it.ts
2508
- function buildConfig$2(ci, isMonorepo) {
2539
+ function buildConfig$3(ci, isMonorepo) {
2509
2540
  const config = {
2510
2541
  $schema: "https://unpkg.com/release-it/schema/release-it.json",
2511
2542
  git: {
@@ -2533,7 +2564,7 @@ async function generateReleaseIt(ctx) {
2533
2564
  action: "skipped",
2534
2565
  description: "release-it not requested"
2535
2566
  };
2536
- const content = JSON.stringify(buildConfig$2(ctx.config.ci, ctx.config.structure === "monorepo"), null, 2) + "\n";
2567
+ const content = JSON.stringify(buildConfig$3(ctx.config.ci, ctx.config.structure === "monorepo"), null, 2) + "\n";
2537
2568
  const existing = ctx.read(filePath);
2538
2569
  if (existing) {
2539
2570
  if (contentEqual(filePath, existing, content)) return {
@@ -2556,7 +2587,7 @@ async function generateReleaseIt(ctx) {
2556
2587
  }
2557
2588
  //#endregion
2558
2589
  //#region src/generators/changesets.ts
2559
- function buildConfig$1() {
2590
+ function buildConfig$2() {
2560
2591
  return {
2561
2592
  $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
2562
2593
  changelog: "@changesets/cli/changelog",
@@ -2576,7 +2607,7 @@ async function generateChangesets(ctx) {
2576
2607
  action: "skipped",
2577
2608
  description: "Changesets not requested"
2578
2609
  };
2579
- const content = JSON.stringify(buildConfig$1(), null, 2) + "\n";
2610
+ const content = JSON.stringify(buildConfig$2(), null, 2) + "\n";
2580
2611
  const existing = ctx.read(filePath);
2581
2612
  if (existing) return {
2582
2613
  filePath,
@@ -2743,7 +2774,9 @@ async function generateReleaseCi(ctx) {
2743
2774
  description: "Release workflow already up to date"
2744
2775
  };
2745
2776
  }
2746
- ctx.write(workflowPath, preserveDevBinaryPath(content, existing));
2777
+ const withDevPath = preserveDevBinaryPath(content, existing);
2778
+ const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
2779
+ ctx.write(workflowPath, final);
2747
2780
  return {
2748
2781
  filePath: workflowPath,
2749
2782
  action: alreadyExists ? "updated" : "created",
@@ -2761,7 +2794,7 @@ function requiredCommands(formatter) {
2761
2794
  }
2762
2795
  };
2763
2796
  }
2764
- function buildConfig(formatter) {
2797
+ function buildConfig$1(formatter) {
2765
2798
  return [
2766
2799
  "pre-commit:",
2767
2800
  " commands:",
@@ -2865,7 +2898,7 @@ function cleanPackageJson(ctx, results) {
2865
2898
  }
2866
2899
  async function generateLefthook(ctx) {
2867
2900
  const filePath = "lefthook.yml";
2868
- const content = buildConfig(ctx.config.formatter);
2901
+ const content = buildConfig$1(ctx.config.formatter);
2869
2902
  const results = [];
2870
2903
  archiveHuskyHooks(ctx, results);
2871
2904
  archiveLintStagedConfigs(ctx, results);
@@ -3006,6 +3039,72 @@ async function generateVscodeSettings(ctx) {
3006
3039
  return results;
3007
3040
  }
3008
3041
  //#endregion
3042
+ //#region src/generators/versionrc.ts
3043
+ function buildConfig() {
3044
+ return {
3045
+ header: "# Changelog\n\nAll notable changes to this project will be documented in this file.\n",
3046
+ types: [
3047
+ {
3048
+ type: "feat",
3049
+ section: "Features"
3050
+ },
3051
+ {
3052
+ type: "fix",
3053
+ section: "Bug Fixes"
3054
+ },
3055
+ {
3056
+ type: "chore",
3057
+ section: "Chores"
3058
+ },
3059
+ {
3060
+ type: "refactor",
3061
+ section: "Refactoring"
3062
+ },
3063
+ {
3064
+ type: "perf",
3065
+ section: "Performance"
3066
+ },
3067
+ {
3068
+ type: "docs",
3069
+ hidden: true
3070
+ },
3071
+ {
3072
+ type: "style",
3073
+ hidden: true
3074
+ },
3075
+ {
3076
+ type: "test",
3077
+ hidden: true
3078
+ },
3079
+ {
3080
+ type: "ci",
3081
+ hidden: true
3082
+ }
3083
+ ],
3084
+ tagPrefix: "v"
3085
+ };
3086
+ }
3087
+ async function generateVersionrc(ctx) {
3088
+ const filePath = ".versionrc.json";
3089
+ if (ctx.config.releaseStrategy !== "simple") return {
3090
+ filePath,
3091
+ action: "skipped",
3092
+ description: "simple release not requested"
3093
+ };
3094
+ if (ctx.exists(filePath)) return {
3095
+ filePath,
3096
+ action: "skipped",
3097
+ description: "Existing versionrc config preserved"
3098
+ };
3099
+ const content = JSON.stringify(buildConfig(), null, 2) + "\n";
3100
+ ctx.write(filePath, content);
3101
+ return {
3102
+ filePath,
3103
+ action: "created",
3104
+ description: "Generated .versionrc.json"
3105
+ };
3106
+ }
3107
+ //#endregion
3009
3108
  //#region src/generators/pipeline.ts
3010
3109
  /** Run all generators sequentially and return their results. */
3011
3110
  async function runGenerators(ctx) {
@@ -3023,6 +3122,7 @@ async function runGenerators(ctx) {
3023
3122
  results.push(await generateCi(ctx));
3024
3123
  results.push(...await generateClaudeSettings(ctx));
3025
3124
  results.push(await generateReleaseIt(ctx));
3125
+ results.push(await generateVersionrc(ctx));
3026
3126
  results.push(await generateChangesets(ctx));
3027
3127
  results.push(await generateReleaseCi(ctx));
3028
3128
  results.push(await generateDeployCi(ctx));
@@ -3035,51 +3135,30 @@ async function runGenerators(ctx) {
3035
3135
  //#region src/generators/migrate-prompt.ts
3036
3136
  /**
3037
3137
  * Generate a context-aware AI migration prompt based on what the CLI did.
3038
- * This prompt can be pasted into Claude Code (or similar) to finish the migration.
3138
+ * Piped into Claude Code via `cat .tooling-migrate.md | claude`.
3039
3139
  */
3040
3140
  function generateMigratePrompt(results, config, detected) {
3041
3141
  const sections = [];
3042
3142
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3043
3143
  sections.push("# Migration Prompt");
3044
3144
  sections.push("");
3045
- sections.push(`_Generated by \`@bensandee/tooling@0.35.0 repo:sync\` on ${timestamp}_`);
3046
- sections.push("");
3047
- sections.push("The following prompt was generated by `@bensandee/tooling repo:sync`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
3048
- sections.push("");
3049
- sections.push("> **Tip:** Before starting, run `/init` in Claude Code to generate a `CLAUDE.md` that gives the AI a complete picture of your repository's structure, conventions, and build commands.");
3145
+ sections.push(`_Generated by \`@bensandee/tooling@0.37.0 repo:sync\` on ${timestamp}_`);
3050
3146
  sections.push("");
3051
- sections.push("## What was changed");
3052
- sections.push("");
3053
- const created = results.filter((r) => r.action === "created");
3054
- const updated = results.filter((r) => r.action === "updated");
3055
- const skipped = results.filter((r) => r.action === "skipped");
3056
- const archived = results.filter((r) => r.action === "archived");
3057
- if (created.length > 0) {
3058
- sections.push("**Created:**");
3059
- for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
3060
- sections.push("");
3061
- }
3062
- if (updated.length > 0) {
3063
- sections.push("**Updated:**");
3064
- for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
3065
- sections.push("");
3066
- }
3067
- if (archived.length > 0) {
3068
- sections.push("**Archived:**");
3069
- for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
3147
+ const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3148
+ if (changed.length > 0) {
3149
+ sections.push("## Files changed");
3070
3150
  sections.push("");
3071
- }
3072
- if (skipped.length > 0) {
3073
- sections.push("**Skipped (review these):**");
3074
- for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
3151
+ for (const r of changed) {
3152
+ const label = r.action === "archived" ? "archived" : r.action;
3153
+ sections.push(`- \`${r.filePath}\` (${label}) — ${r.description}`);
3154
+ }
3075
3155
  sections.push("");
3076
3156
  }
3077
- sections.push("## Migration tasks");
3078
- sections.push("");
3157
+ const tasks = [];
3079
3158
  const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
3080
3159
  if (legacyToRemove.length > 0) {
3081
- sections.push("### Remove legacy tooling");
3082
- sections.push("");
3160
+ tasks.push("### Remove legacy tooling");
3161
+ tasks.push("");
3083
3162
  for (const legacy of legacyToRemove) {
3084
3163
  const replacement = {
3085
3164
  eslint: "oxlint",
@@ -3088,118 +3167,83 @@ function generateMigratePrompt(results, config, detected) {
3088
3167
  webpack: "tsdown",
3089
3168
  rollup: "tsdown"
3090
3169
  }[legacy.tool];
3091
- sections.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
3092
- sections.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
3093
- if (legacy.tool === "eslint") sections.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
3094
- if (legacy.tool === "jest") sections.push(" - Migrate any jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
3170
+ tasks.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
3171
+ tasks.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
3172
+ if (legacy.tool === "eslint") tasks.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
3173
+ if (legacy.tool === "jest") tasks.push(" - Migrate jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
3095
3174
  }
3096
- sections.push("");
3175
+ tasks.push("");
3097
3176
  }
3177
+ const archived = results.filter((r) => r.action === "archived");
3098
3178
  if (archived.length > 0) {
3099
- sections.push("### Review archived files");
3100
- sections.push("");
3101
- sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
3102
- sections.push("");
3103
- for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
3104
- sections.push("");
3105
- sections.push("For each archived file, **diff the old version against the new one** and look for features, categories, or modules that were enabled in the original but are missing from the replacement. Focus on broad capability gaps rather than individual rule strictness (in general, being stricter is fine). Examples of what to look for:");
3106
- sections.push("");
3107
- sections.push("- **Lint configs**: enabled plugin categories (e.g. `jsx-a11y`, `import`, `react`, `nextjs`), custom `plugins` or `overrides`, file-scoped rule blocks");
3108
- sections.push("- **TypeScript configs**: compiler features like `jsx`, `paths`, `baseUrl`, or `references` that affect build behavior");
3109
- sections.push("- **Other configs**: feature flags, custom presets, or integrations that go beyond the default template");
3110
- sections.push("");
3111
- sections.push("If the old config had capabilities the new one lacks, port them into the new file. Then:");
3112
- sections.push("");
3113
- sections.push("1. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
3114
- sections.push("2. Delete the `.tooling-archived/` directory when migration is complete");
3115
- sections.push("");
3179
+ tasks.push("### Review archived files");
3180
+ tasks.push("");
3181
+ tasks.push("The originals have been saved to `.tooling-archived/`. For each, diff the old version against the new one and port any capabilities the new file lacks:");
3182
+ tasks.push("");
3183
+ for (const r of archived) tasks.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
3184
+ tasks.push("");
3185
+ tasks.push("If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`. Delete `.tooling-archived/` when done.");
3186
+ tasks.push("");
3116
3187
  }
3117
3188
  const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
3118
3189
  if (detected.hasLegacyOxlintJson) {
3119
- sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
3120
- sections.push("");
3121
- sections.push("A new `oxlint.config.ts` has been generated using `defineConfig` from the `oxlint` package. The existing `.oxlintrc.json` needs to be migrated:");
3122
- sections.push("");
3123
- sections.push("1. Read `.oxlintrc.json` and compare its `rules` against the rules provided by `@bensandee/config/oxlint/recommended` (check `node_modules/@bensandee/config`). Most standard rules are already included in the recommended config.");
3124
- sections.push("2. If there are any custom rules, overrides, settings, or `jsPlugins` not covered by the recommended config, add them to `oxlint.config.ts` alongside the `extends`.");
3125
- sections.push("3. Delete `.oxlintrc.json`.");
3126
- sections.push("4. Run `pnpm lint` to verify the new config works correctly.");
3127
- sections.push("");
3190
+ tasks.push("### Migrate .oxlintrc.json to oxlint.config.ts");
3191
+ tasks.push("");
3192
+ tasks.push("Compare `.oxlintrc.json` rules against `@bensandee/config/oxlint/recommended`. Port any custom rules/overrides into `oxlint.config.ts`, then delete `.oxlintrc.json`.");
3193
+ tasks.push("");
3128
3194
  } else if (oxlintWasSkipped && detected.hasOxlintConfig) {
3129
- sections.push("### Verify oxlint.config.ts includes recommended rules");
3130
- sections.push("");
3131
- sections.push("The existing `oxlint.config.ts` was kept as-is. Verify that it extends the recommended config from `@bensandee/config/oxlint`:");
3132
- sections.push("");
3133
- sections.push("1. Open `oxlint.config.ts` and check that it imports and extends `@bensandee/config/oxlint/recommended`.");
3134
- sections.push("2. The expected pattern is:");
3135
- sections.push(" ```ts");
3136
- sections.push(" import recommended from \"@bensandee/config/oxlint/recommended\";");
3137
- sections.push(" import { defineConfig } from \"oxlint\";");
3138
- sections.push("");
3139
- sections.push(" export default defineConfig({ extends: [recommended] });");
3140
- sections.push(" ```");
3141
- sections.push("3. If it uses a different pattern, update it to extend the recommended config while preserving any project-specific customizations.");
3142
- sections.push("4. Run `pnpm lint` to verify the config works correctly.");
3143
- sections.push("");
3195
+ tasks.push("### Verify oxlint.config.ts extends recommended rules");
3196
+ tasks.push("");
3197
+ tasks.push("Ensure `oxlint.config.ts` imports and extends `@bensandee/config/oxlint/recommended`:");
3198
+ tasks.push("");
3199
+ tasks.push("```ts");
3200
+ tasks.push("import recommended from \"@bensandee/config/oxlint/recommended\";");
3201
+ tasks.push("import { defineConfig } from \"oxlint\";");
3202
+ tasks.push("");
3203
+ tasks.push("export default defineConfig({ extends: [recommended] });");
3204
+ tasks.push("```");
3205
+ tasks.push("");
3144
3206
  }
3145
3207
  if (config.structure === "monorepo" && !detected.hasPnpmWorkspace) {
3146
- sections.push("### Migrate to monorepo structure");
3147
- sections.push("");
3148
- sections.push("This project was converted from a single repo to a monorepo. Complete the migration:");
3149
- sections.push("");
3150
- sections.push("1. Move existing source into `packages/<name>/` (using the existing package name)");
3151
- sections.push("2. Split the root `package.json` into a root workspace manifest + package-level `package.json`");
3152
- sections.push("3. Move the existing `tsconfig.json` into the package and update the root tsconfig with project references");
3153
- sections.push("4. Create a package-level `tsdown.config.ts` in the new package");
3154
- sections.push("5. Update any import paths or build scripts affected by the move");
3155
- sections.push("");
3208
+ tasks.push("### Migrate to monorepo structure");
3209
+ tasks.push("");
3210
+ tasks.push("This project was converted to a monorepo. Complete the migration:");
3211
+ tasks.push("");
3212
+ tasks.push("1. Move existing source into `packages/<name>/`");
3213
+ tasks.push("2. Split root `package.json` into workspace manifest + package-level `package.json`");
3214
+ tasks.push("3. Move `tsconfig.json` into the package, add project references to root");
3215
+ tasks.push("4. Create a package-level `tsdown.config.ts`");
3216
+ tasks.push("5. Update import paths and build scripts");
3217
+ tasks.push("");
3156
3218
  }
3157
- const skippedConfigs = skipped.filter((r) => r.filePath !== "ci" && r.description !== "Not a monorepo");
3158
- if (skippedConfigs.length > 0) {
3159
- sections.push("### Review skipped files");
3160
- sections.push("");
3161
- sections.push("The following files were left unchanged. Review them for compatibility:");
3219
+ if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
3220
+ tasks.push("### Generate tests");
3221
+ tasks.push("");
3222
+ tasks.push("A starter test was created at `test/example.test.ts`. Review `src/` and create additional tests covering exported functions and edge cases.");
3223
+ tasks.push("");
3224
+ }
3225
+ if (tasks.length > 0) {
3226
+ sections.push("## Migration tasks");
3162
3227
  sections.push("");
3163
- for (const r of skippedConfigs) sections.push(`- \`${r.filePath}\` — ${r.description}`);
3228
+ sections.push(...tasks);
3229
+ sections.push("## Ground rules");
3164
3230
  sections.push("");
3165
- }
3166
- if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
3167
- sections.push("### Generate tests");
3231
+ sections.push("Prefer fixing root causes over suppressing issues:");
3168
3232
  sections.push("");
3169
- sections.push("A starter test was created at `test/example.test.ts`. Now:");
3233
+ sections.push("- **Lint errors**: fix the code, not disable comments");
3234
+ sections.push("- **Type errors**: add proper types, not `any` or `@ts-expect-error`");
3235
+ sections.push("- **Test failures**: fix the test or bug, don't skip/delete");
3236
+ sections.push("- **Knip findings**: remove unused code, don't add ignores");
3170
3237
  sections.push("");
3171
- sections.push("1. Review the existing source code in `src/`");
3172
- sections.push("2. Create additional test files following the starter test's patterns (import style, describe/it structure)");
3173
- sections.push("3. Focus on edge cases and core business logic");
3174
- sections.push("4. Aim for meaningful coverage of exported functions and key code paths");
3238
+ sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) to resolve errors. Only suppress an issue with a comment explaining why the fix is not feasible.");
3175
3239
  sections.push("");
3176
3240
  }
3177
- sections.push("## Ground rules");
3178
- sections.push("");
3179
- sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) if they are needed to resolve errors.");
3180
- sections.push("");
3181
- sections.push("When resolving errors from the checklist below, prefer fixing the root cause over suppressing the issue. For example:");
3182
- sections.push("");
3183
- sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
3184
- sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
3185
- sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
3186
- sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
3241
+ sections.push("## Verify");
3187
3242
  sections.push("");
3188
- sections.push("Only suppress an issue if there is a clear, documented reason why the fix is not feasible (e.g. a third-party type mismatch). Leave a comment explaining why.");
3243
+ sections.push("Run `pnpm check` and fix any errors. This runs the full pipeline:");
3244
+ sections.push("build → typecheck → lint → test → format → knip");
3189
3245
  sections.push("");
3190
- sections.push("## Verification checklist");
3191
- sections.push("");
3192
- sections.push("Run each of these commands and fix any errors before moving on:");
3193
- sections.push("");
3194
- sections.push("1. `pnpm install`");
3195
- const updateCmd = `pnpm update --latest ${getAddedDevDepNames(config).join(" ")}`;
3196
- sections.push(`2. \`${updateCmd}\` — bump added dependencies to their latest versions`);
3197
- sections.push("3. `pnpm typecheck` — fix any type errors");
3198
- sections.push("4. `pnpm build` — fix any build errors");
3199
- sections.push("5. `pnpm test` — fix any test failures");
3200
- sections.push("6. `pnpm lint` — fix the code to satisfy lint rules");
3201
- sections.push("7. `pnpm knip` — remove unused exports, dependencies, and dead code");
3202
- sections.push("8. `pnpm format` — fix any formatting issues");
3246
+ sections.push("When everything passes, run `/init` to generate a `CLAUDE.md` for this repository.");
3203
3247
  sections.push("");
3204
3248
  return sections.join("\n");
3205
3249
  }
@@ -3226,11 +3270,11 @@ function contextAsDockerReader(ctx) {
3226
3270
  function logDetectionSummary(ctx) {
3227
3271
  if (ctx.config.publishDocker) {
3228
3272
  const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3229
- if (dockerPackages.length > 0) log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3273
+ if (dockerPackages.length > 0) log.info(`Docker images to build and publish: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3230
3274
  }
3231
3275
  if (ctx.config.publishNpm) {
3232
3276
  const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3233
- if (publishable.length > 0) log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3277
+ if (publishable.length > 0) log.info(`npm packages to publish: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3234
3278
  }
3235
3279
  }
3236
3280
  async function runInit(config, options = {}) {
@@ -3278,22 +3322,19 @@ async function runInit(config, options = {}) {
3278
3322
  if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
3279
3323
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
3280
3324
  note(summaryLines.join("\n"), "Summary");
3281
- if (!options.noPrompt) {
3282
- log.info(`Migration prompt written to ${promptPath}`);
3283
- log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3284
- }
3285
- const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
3325
+ if (!options.noPrompt) log.info(`Migration prompt written to ${promptPath}`);
3326
+ const packageJsonChanged = results.some((r) => r.filePath === "package.json" && (r.action === "created" || r.action === "updated"));
3286
3327
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
3287
- if (bensandeeDeps.length > 0 && hasLockfile) {
3288
- log.info("Updating @bensandee/* packages...");
3328
+ if (packageJsonChanged && hasLockfile) {
3329
+ log.info("package.json changed — running pnpm install...");
3289
3330
  try {
3290
- execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
3331
+ execSync("pnpm install", {
3291
3332
  cwd: config.targetDir,
3292
3333
  stdio: "inherit",
3293
- timeout: 6e4
3334
+ timeout: 12e4
3294
3335
  });
3295
3336
  } catch (_error) {
3296
- log.warn("Could not update @bensandee/* packages — run pnpm install manually");
3337
+ log.warn("pnpm install failed — run it manually");
3297
3338
  }
3298
3339
  }
3299
3340
  if (hasChanges && ctx.exists("package.json")) try {
@@ -3304,10 +3345,10 @@ async function runInit(config, options = {}) {
3304
3345
  });
3305
3346
  } catch (_error) {}
3306
3347
  note([
3307
- "1. Run: pnpm install",
3308
- "2. Run: pnpm check",
3309
- ...options.noPrompt ? [] : ["3. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
3310
- ].join("\n"), "Next steps");
3348
+ ...packageJsonChanged && !hasLockfile ? ["Run: pnpm install"] : [],
3349
+ "Run: pnpm check",
3350
+ ...!options.noPrompt ? ["Run: claude \"Execute the migration steps in .tooling-migrate.md\""] : []
3351
+ ].map((step, i) => `${i + 1}. ${step}`).join("\n"), "Next steps");
3311
3352
  return results;
3312
3353
  }
3313
3354
  //#endregion
@@ -4915,7 +4956,7 @@ const dockerCheckCommand = defineCommand({
4915
4956
  const main = defineCommand({
4916
4957
  meta: {
4917
4958
  name: "bst",
4918
- version: "0.35.0",
4959
+ version: "0.37.0",
4919
4960
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4920
4961
  },
4921
4962
  subCommands: {
@@ -4931,7 +4972,7 @@ const main = defineCommand({
4931
4972
  "docker:check": dockerCheckCommand
4932
4973
  }
4933
4974
  });
4934
- console.log(`@bensandee/tooling v0.35.0`);
4975
+ console.log(`@bensandee/tooling v0.37.0`);
4935
4976
  async function run() {
4936
4977
  await runMain(main);
4937
4978
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "bst": "./dist/bin.mjs"