@bensandee/tooling 0.3.0 → 0.5.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.
package/dist/bin.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import * as p from "@clack/prompts";
4
4
  import path from "node:path";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { parse } from "jsonc-parser";
7
7
  import { z } from "zod";
8
8
  import { execSync } from "node:child_process";
@@ -48,18 +48,6 @@ const RenovateSchema = z.object({
48
48
  $schema: z.string().optional(),
49
49
  extends: z.array(z.string()).optional()
50
50
  }).loose();
51
- const KnipWorkspaceConfig = z.object({
52
- entry: z.array(z.string()).optional(),
53
- project: z.array(z.string()).optional(),
54
- ignore: z.array(z.string()).optional()
55
- });
56
- const KnipSchema = z.object({
57
- $schema: z.string().optional(),
58
- entry: z.array(z.string()).optional(),
59
- project: z.array(z.string()).optional(),
60
- ignore: z.array(z.string()).optional(),
61
- workspaces: z.record(z.string(), KnipWorkspaceConfig).optional()
62
- }).loose();
63
51
  /** Parse a JSONC string as a tsconfig.json. Returns a typed object with `{}` fallback on failure. */
64
52
  function parseTsconfig(raw) {
65
53
  const result = TsconfigSchema.safeParse(parse(raw));
@@ -70,11 +58,6 @@ function parseRenovateJson(raw) {
70
58
  const result = RenovateSchema.safeParse(parse(raw));
71
59
  return result.success ? result.data : {};
72
60
  }
73
- /** Parse a JSONC string as a knip.json. Returns a typed object with `{}` fallback on failure. */
74
- function parseKnipJson(raw) {
75
- const result = KnipSchema.safeParse(parse(raw));
76
- return result.success ? result.data : {};
77
- }
78
61
  /** Parse a JSON string as a package.json. Returns `undefined` on failure. */
79
62
  function parsePackageJson(raw) {
80
63
  try {
@@ -208,11 +191,11 @@ async function runInitPrompts(targetDir) {
208
191
  p.cancel("Cancelled.");
209
192
  process.exit(0);
210
193
  }
211
- const useLintRules = await p.confirm({
212
- message: "Include @bensandee/lint-rules?",
194
+ const useEslintPlugin = await p.confirm({
195
+ message: "Include @bensandee/eslint-plugin?",
213
196
  initialValue: true
214
197
  });
215
- if (isCancelled(useLintRules)) {
198
+ if (isCancelled(useEslintPlugin)) {
216
199
  p.cancel("Cancelled.");
217
200
  process.exit(0);
218
201
  }
@@ -361,7 +344,7 @@ async function runInitPrompts(targetDir) {
361
344
  name,
362
345
  isNew: !isExisting,
363
346
  structure,
364
- useLintRules,
347
+ useEslintPlugin,
365
348
  formatter,
366
349
  setupVitest,
367
350
  ci,
@@ -380,7 +363,7 @@ function buildDefaultConfig(targetDir, flags) {
380
363
  name: existingPkg?.name ?? path.basename(targetDir),
381
364
  isNew: !detected.hasPackageJson,
382
365
  structure: detected.hasPnpmWorkspace ? "monorepo" : "single",
383
- useLintRules: flags.lintRules ?? true,
366
+ useEslintPlugin: flags.eslintPlugin ?? true,
384
367
  formatter: detected.legacyConfigs.some((l) => l.tool === "prettier") ? "prettier" : "oxfmt",
385
368
  setupVitest: !detected.hasVitestConfig,
386
369
  ci: flags.noCi ? "none" : DEFAULT_CI,
@@ -412,17 +395,35 @@ function writeFile(targetDir, relativePath, content) {
412
395
  }
413
396
  /**
414
397
  * Create a GeneratorContext from a ProjectConfig and a conflict resolution handler.
398
+ * Returns the context and a list of files that were auto-archived before overwriting.
415
399
  */
416
400
  function createContext(config, confirmOverwrite) {
401
+ const archivedFiles = [];
417
402
  const pkgRaw = readFile(config.targetDir, "package.json");
418
403
  return {
419
- config,
420
- targetDir: config.targetDir,
421
- packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
422
- exists: (rel) => fileExists(config.targetDir, rel),
423
- read: (rel) => readFile(config.targetDir, rel),
424
- write: (rel, content) => writeFile(config.targetDir, rel, content),
425
- confirmOverwrite
404
+ ctx: {
405
+ config,
406
+ targetDir: config.targetDir,
407
+ packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
408
+ exists: (rel) => fileExists(config.targetDir, rel),
409
+ read: (rel) => readFile(config.targetDir, rel),
410
+ write: (rel, content) => {
411
+ if (!rel.startsWith(".tooling-archived/")) {
412
+ const existing = readFile(config.targetDir, rel);
413
+ if (existing !== void 0 && existing !== content) {
414
+ writeFile(config.targetDir, `.tooling-archived/${rel}`, existing);
415
+ archivedFiles.push(rel);
416
+ }
417
+ }
418
+ writeFile(config.targetDir, rel, content);
419
+ },
420
+ remove: (rel) => {
421
+ const fullPath = path.join(config.targetDir, rel);
422
+ if (existsSync(fullPath)) rmSync(fullPath);
423
+ },
424
+ confirmOverwrite
425
+ },
426
+ archivedFiles
426
427
  };
427
428
  }
428
429
 
@@ -436,7 +437,7 @@ const STANDARD_SCRIPTS_SINGLE = {
436
437
  lint: "oxlint",
437
438
  knip: "knip",
438
439
  check: "pnpm typecheck && pnpm build && pnpm lint && pnpm knip",
439
- prepare: "husky"
440
+ prepare: "lefthook install"
440
441
  };
441
442
  const STANDARD_SCRIPTS_MONOREPO = {
442
443
  build: "pnpm -r build",
@@ -445,11 +446,10 @@ const STANDARD_SCRIPTS_MONOREPO = {
445
446
  lint: "oxlint",
446
447
  knip: "knip",
447
448
  check: "pnpm typecheck && pnpm build && pnpm lint && pnpm knip",
448
- prepare: "husky"
449
+ prepare: "lefthook install"
449
450
  };
450
451
  /** DevDeps that belong in every project (single repo) or per-package (monorepo). */
451
452
  const PER_PACKAGE_DEV_DEPS = {
452
- "@tsconfig/strictest": "2.0.8",
453
453
  "@types/node": "25.3.2",
454
454
  tsdown: "0.20.3",
455
455
  typescript: "5.9.3",
@@ -457,9 +457,8 @@ const PER_PACKAGE_DEV_DEPS = {
457
457
  };
458
458
  /** DevDeps that belong at the root regardless of structure. */
459
459
  const ROOT_DEV_DEPS = {
460
- husky: "9.1.7",
461
460
  knip: "5.85.0",
462
- "lint-staged": "16.3.1",
461
+ lefthook: "2.1.2",
463
462
  oxlint: "1.50.0"
464
463
  };
465
464
  /**
@@ -527,7 +526,7 @@ async function generatePackageJson(ctx) {
527
526
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
528
527
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "latest";
529
528
  devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "latest";
530
- if (ctx.config.useLintRules) devDeps["@bensandee/lint-rules"] = isWorkspacePackage(ctx, "@bensandee/lint-rules") ? "workspace:*" : "latest";
529
+ if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "latest";
531
530
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
532
531
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
533
532
  addReleaseDeps(devDeps, ctx.config);
@@ -608,6 +607,7 @@ function generateMigratePrompt(results, config, detected) {
608
607
  const created = results.filter((r) => r.action === "created");
609
608
  const updated = results.filter((r) => r.action === "updated");
610
609
  const skipped = results.filter((r) => r.action === "skipped");
610
+ const archived = results.filter((r) => r.action === "archived");
611
611
  if (created.length > 0) {
612
612
  sections.push("**Created:**");
613
613
  for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
@@ -618,6 +618,11 @@ function generateMigratePrompt(results, config, detected) {
618
618
  for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
619
619
  sections.push("");
620
620
  }
621
+ if (archived.length > 0) {
622
+ sections.push("**Archived:**");
623
+ for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
624
+ sections.push("");
625
+ }
621
626
  if (skipped.length > 0) {
622
627
  sections.push("**Skipped (review these):**");
623
628
  for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
@@ -644,6 +649,18 @@ function generateMigratePrompt(results, config, detected) {
644
649
  }
645
650
  sections.push("");
646
651
  }
652
+ if (archived.length > 0) {
653
+ sections.push("### Review archived files");
654
+ sections.push("");
655
+ sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
656
+ sections.push("");
657
+ for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
658
+ sections.push("");
659
+ sections.push("1. Review the archived files for any custom configuration that should be preserved in the new files");
660
+ sections.push("2. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
661
+ sections.push("3. Delete the `.tooling-archived/` directory when migration is complete");
662
+ sections.push("");
663
+ }
647
664
  const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
648
665
  if (detected.hasLegacyOxlintJson) {
649
666
  sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
@@ -710,7 +727,7 @@ function generateMigratePrompt(results, config, detected) {
710
727
  sections.push("");
711
728
  sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
712
729
  sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
713
- sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.json`");
730
+ sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
714
731
  sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
715
732
  sections.push("");
716
733
  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.");
@@ -942,7 +959,7 @@ export default defineConfig({
942
959
  `;
943
960
  async function generateOxlint(ctx) {
944
961
  const filePath = "oxlint.config.ts";
945
- const content = ctx.config.useLintRules ? CONFIG_WITH_LINT_RULES : CONFIG_PRESET_ONLY;
962
+ const content = ctx.config.useEslintPlugin ? CONFIG_WITH_LINT_RULES : CONFIG_PRESET_ONLY;
946
963
  const existing = ctx.read(filePath);
947
964
  if (existing) {
948
965
  if (existing === content) return {
@@ -1050,7 +1067,8 @@ const STANDARD_ENTRIES = [
1050
1067
  ".env",
1051
1068
  ".env.*",
1052
1069
  "!.env.example",
1053
- ".tooling-migrate.md"
1070
+ ".tooling-migrate.md",
1071
+ ".tooling-archived/"
1054
1072
  ];
1055
1073
  /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
1056
1074
  function normalizeEntry(entry) {
@@ -1146,88 +1164,62 @@ async function generateCi(ctx) {
1146
1164
 
1147
1165
  //#endregion
1148
1166
  //#region src/generators/knip.ts
1149
- const KNIP_CONFIG_SINGLE = {
1150
- $schema: "https://unpkg.com/knip@latest/schema.json",
1151
- entry: ["src/index.ts"],
1152
- project: ["src/**/*.ts"],
1153
- ignore: ["dist/**"]
1154
- };
1155
- const KNIP_CONFIG_MONOREPO = {
1156
- $schema: "https://unpkg.com/knip@latest/schema.json",
1157
- workspaces: {
1158
- ".": {
1159
- entry: [],
1160
- project: []
1161
- },
1162
- "packages/*": {
1163
- entry: ["src/index.ts", "src/bin.ts"],
1164
- project: ["src/**/*.ts"],
1165
- ignore: ["dist/**"]
1166
- }
1167
- }
1168
- };
1167
+ const KNIP_CONFIG_SINGLE = `import type { KnipConfig } from "knip";
1168
+
1169
+ export default {
1170
+ entry: ["src/index.ts"],
1171
+ project: ["src/**/*.ts"],
1172
+ ignore: ["dist/**"],
1173
+ } satisfies KnipConfig;
1174
+ `;
1175
+ const KNIP_CONFIG_MONOREPO = `import type { KnipConfig } from "knip";
1176
+
1177
+ export default {
1178
+ workspaces: {
1179
+ ".": {
1180
+ entry: [],
1181
+ project: [],
1182
+ },
1183
+ "packages/*": {
1184
+ entry: ["src/index.ts", "src/bin.ts"],
1185
+ project: ["src/**/*.ts"],
1186
+ ignore: ["dist/**"],
1187
+ },
1188
+ },
1189
+ } satisfies KnipConfig;
1190
+ `;
1169
1191
  /** All known knip config file locations, in priority order. */
1170
1192
  const KNIP_CONFIG_PATHS = [
1193
+ "knip.config.ts",
1194
+ "knip.config.mts",
1171
1195
  "knip.json",
1172
1196
  "knip.jsonc",
1173
1197
  "knip.ts",
1174
- "knip.mts",
1175
- "knip.config.ts",
1176
- "knip.config.mts"
1198
+ "knip.mts"
1177
1199
  ];
1178
1200
  async function generateKnip(ctx) {
1179
- const filePath = "knip.json";
1201
+ const filePath = "knip.config.ts";
1180
1202
  const isMonorepo = ctx.config.structure === "monorepo";
1181
1203
  const existingPath = KNIP_CONFIG_PATHS.find((p) => ctx.exists(p));
1182
- if (existingPath === filePath) {
1183
- const existing = ctx.read(filePath);
1184
- if (existing) {
1185
- const parsed = parseKnipJson(existing);
1186
- const changes = [];
1187
- if (isMonorepo && !parsed.workspaces) {
1188
- parsed.workspaces = KNIP_CONFIG_MONOREPO.workspaces;
1189
- changes.push("added monorepo workspaces config");
1190
- }
1191
- if (!isMonorepo) {
1192
- if (!parsed.entry) {
1193
- parsed.entry = KNIP_CONFIG_SINGLE.entry;
1194
- changes.push("added entry patterns");
1195
- }
1196
- if (!parsed.project) {
1197
- parsed.project = KNIP_CONFIG_SINGLE.project;
1198
- changes.push("added project patterns");
1199
- }
1200
- }
1201
- if (changes.length === 0) return {
1202
- filePath,
1203
- action: "skipped",
1204
- description: "Already configured"
1205
- };
1206
- ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
1207
- return {
1208
- filePath,
1209
- action: "updated",
1210
- description: changes.join(", ")
1211
- };
1212
- }
1213
- }
1214
1204
  if (existingPath) return {
1215
1205
  filePath: existingPath,
1216
1206
  action: "skipped",
1217
- description: `Existing config found at ${existingPath}`
1207
+ description: existingPath === filePath ? "Already configured" : `Existing config found at ${existingPath}`
1218
1208
  };
1219
1209
  const config = isMonorepo ? KNIP_CONFIG_MONOREPO : KNIP_CONFIG_SINGLE;
1220
- ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
1210
+ ctx.write(filePath, config);
1221
1211
  return {
1222
1212
  filePath,
1223
1213
  action: "created",
1224
- description: "Generated knip.json for dead code analysis"
1214
+ description: "Generated knip.config.ts for dead code analysis"
1225
1215
  };
1226
1216
  }
1227
1217
 
1228
1218
  //#endregion
1229
1219
  //#region src/generators/renovate.ts
1230
- const SHARED_PRESET = "@bensandee/config";
1220
+ const SHARED_PRESET = "local>bensandee/tooling";
1221
+ /** Deprecated npm-based preset to migrate away from. */
1222
+ const LEGACY_PRESET = "@bensandee/config";
1231
1223
  /** All known renovate config file locations, in priority order. */
1232
1224
  const RENOVATE_CONFIG_PATHS = [
1233
1225
  "renovate.json",
@@ -1250,6 +1242,17 @@ async function generateRenovate(ctx) {
1250
1242
  if (existing) {
1251
1243
  const parsed = parseRenovateJson(existing);
1252
1244
  const existingExtends = parsed.extends ?? [];
1245
+ const legacyIndex = existingExtends.indexOf(LEGACY_PRESET);
1246
+ if (legacyIndex !== -1) {
1247
+ existingExtends[legacyIndex] = SHARED_PRESET;
1248
+ parsed.extends = existingExtends;
1249
+ ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
1250
+ return {
1251
+ filePath,
1252
+ action: "updated",
1253
+ description: `Migrated extends: ${LEGACY_PRESET} → ${SHARED_PRESET}`
1254
+ };
1255
+ }
1253
1256
  if (!existingExtends.includes(SHARED_PRESET)) {
1254
1257
  existingExtends.unshift(SHARED_PRESET);
1255
1258
  parsed.extends = existingExtends;
@@ -1339,6 +1342,8 @@ function buildSettings(ctx) {
1339
1342
  `Bash(${pm} view *)`,
1340
1343
  `Bash(${pm} list)`,
1341
1344
  `Bash(${pm} list *)`,
1345
+ `Bash(${pm} ls)`,
1346
+ `Bash(${pm} ls *)`,
1342
1347
  "Bash(npm view *)",
1343
1348
  "Bash(npm info *)",
1344
1349
  "Bash(npm show *)",
@@ -1378,6 +1383,8 @@ function buildSettings(ctx) {
1378
1383
  "Bash(head *)",
1379
1384
  "Bash(tail *)",
1380
1385
  "Bash(wc *)",
1386
+ "Bash(test *)",
1387
+ "Bash([ *)",
1381
1388
  "Bash(find *)",
1382
1389
  "Bash(which *)",
1383
1390
  "Bash(node -e *)",
@@ -1710,12 +1717,21 @@ async function generateReleaseCi(ctx) {
1710
1717
  }
1711
1718
 
1712
1719
  //#endregion
1713
- //#region src/generators/lint-staged.ts
1720
+ //#region src/generators/lefthook.ts
1714
1721
  function buildConfig(formatter) {
1715
- return `export default {\n "*": "${formatter === "prettier" ? "prettier --write" : "oxfmt"}",\n};\n`;
1716
- }
1717
- const HUSKY_PRE_COMMIT = "pnpm exec lint-staged\n";
1718
- /** All known lint-staged config file locations, in priority order. */
1722
+ return [
1723
+ "pre-commit:",
1724
+ " commands:",
1725
+ " lint:",
1726
+ " run: pnpm exec oxlint {staged_files}",
1727
+ " format:",
1728
+ ` run: ${formatter === "prettier" ? "pnpm exec prettier --write {staged_files}" : "pnpm exec oxfmt --no-error-on-unmatched-pattern {staged_files}"}`,
1729
+ " stage_fixed: true",
1730
+ ""
1731
+ ].join("\n");
1732
+ }
1733
+ const ARCHIVE_DIR = ".tooling-archived";
1734
+ /** All known lint-staged config file locations to archive. */
1719
1735
  const LINT_STAGED_CONFIG_PATHS = [
1720
1736
  "lint-staged.config.mjs",
1721
1737
  "lint-staged.config.js",
@@ -1727,37 +1743,71 @@ const LINT_STAGED_CONFIG_PATHS = [
1727
1743
  ".lintstagedrc.mjs",
1728
1744
  ".lintstagedrc.cjs"
1729
1745
  ];
1730
- async function generateLintStaged(ctx) {
1731
- const filePath = "lint-staged.config.mjs";
1732
- const huskyPath = ".husky/pre-commit";
1746
+ /** All known lefthook config file locations, in priority order. */
1747
+ const LEFTHOOK_CONFIG_PATHS = ["lefthook.yml", ".lefthook.yml"];
1748
+ async function generateLefthook(ctx) {
1749
+ const filePath = "lefthook.yml";
1733
1750
  const content = buildConfig(ctx.config.formatter);
1734
- if (ctx.read(huskyPath) !== HUSKY_PRE_COMMIT) ctx.write(huskyPath, HUSKY_PRE_COMMIT);
1735
- const existingPath = LINT_STAGED_CONFIG_PATHS.find((p) => ctx.exists(p));
1751
+ const results = [];
1752
+ const huskyPath = ".husky/pre-commit";
1753
+ const existingHusky = ctx.read(huskyPath);
1754
+ if (existingHusky !== void 0) {
1755
+ ctx.write(`${ARCHIVE_DIR}/${huskyPath}`, existingHusky);
1756
+ ctx.remove(huskyPath);
1757
+ results.push({
1758
+ filePath: huskyPath,
1759
+ action: "archived",
1760
+ description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
1761
+ });
1762
+ }
1763
+ for (const lsPath of LINT_STAGED_CONFIG_PATHS) {
1764
+ const existing = ctx.read(lsPath);
1765
+ if (existing !== void 0) {
1766
+ ctx.write(`${ARCHIVE_DIR}/${lsPath}`, existing);
1767
+ ctx.remove(lsPath);
1768
+ results.push({
1769
+ filePath: lsPath,
1770
+ action: "archived",
1771
+ description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
1772
+ });
1773
+ }
1774
+ }
1775
+ const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
1736
1776
  if (existingPath === filePath) {
1737
1777
  const existing = ctx.read(filePath);
1738
1778
  if (existing) {
1739
- if (existing === content) return {
1740
- filePath,
1741
- action: "skipped",
1742
- description: "Already configured"
1743
- };
1744
- if (await ctx.confirmOverwrite(filePath) === "skip") return {
1745
- filePath,
1746
- action: "skipped",
1747
- description: "Existing lint-staged config preserved"
1748
- };
1779
+ if (existing === content) {
1780
+ results.push({
1781
+ filePath,
1782
+ action: "skipped",
1783
+ description: "Already configured"
1784
+ });
1785
+ return results;
1786
+ }
1787
+ if (await ctx.confirmOverwrite(filePath) === "skip") {
1788
+ results.push({
1789
+ filePath,
1790
+ action: "skipped",
1791
+ description: "Existing lefthook config preserved"
1792
+ });
1793
+ return results;
1794
+ }
1749
1795
  }
1750
- } else if (existingPath) return {
1751
- filePath: existingPath,
1752
- action: "skipped",
1753
- description: `Existing config found at ${existingPath}`
1754
- };
1796
+ } else if (existingPath) {
1797
+ results.push({
1798
+ filePath: existingPath,
1799
+ action: "skipped",
1800
+ description: `Existing config found at ${existingPath}`
1801
+ });
1802
+ return results;
1803
+ }
1755
1804
  ctx.write(filePath, content);
1756
- return {
1805
+ results.push({
1757
1806
  filePath,
1758
1807
  action: existingPath === filePath ? "updated" : "created",
1759
- description: "Generated lint-staged config and husky pre-commit hook"
1760
- };
1808
+ description: "Generated lefthook pre-commit config"
1809
+ });
1810
+ return results;
1761
1811
  }
1762
1812
 
1763
1813
  //#endregion
@@ -1778,9 +1828,9 @@ const initCommand = defineCommand({
1778
1828
  alias: "y",
1779
1829
  description: "Accept all defaults (non-interactive)"
1780
1830
  },
1781
- "lint-rules": {
1831
+ "eslint-plugin": {
1782
1832
  type: "boolean",
1783
- description: "Include @bensandee/lint-rules (default: true)"
1833
+ description: "Include @bensandee/eslint-plugin (default: true)"
1784
1834
  },
1785
1835
  "no-ci": {
1786
1836
  type: "boolean",
@@ -1794,14 +1844,14 @@ const initCommand = defineCommand({
1794
1844
  async run({ args }) {
1795
1845
  const targetDir = path.resolve(args.dir ?? ".");
1796
1846
  await runInit(args.yes ? buildDefaultConfig(targetDir, {
1797
- lintRules: args["lint-rules"] === true ? true : void 0,
1847
+ eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
1798
1848
  noCi: args["no-ci"] === true ? true : void 0
1799
1849
  }) : await runInitPrompts(targetDir), args["no-prompt"] === true ? { noPrompt: true } : {});
1800
1850
  }
1801
1851
  });
1802
1852
  async function runInit(config, options = {}) {
1803
1853
  const detected = detectProject(config.targetDir);
1804
- const ctx = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
1854
+ const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
1805
1855
  const result = await p.select({
1806
1856
  message: `${relativePath} already exists. What do you want to do?`,
1807
1857
  options: [{
@@ -1824,7 +1874,7 @@ async function runInit(config, options = {}) {
1824
1874
  results.push(await generateTsdown(ctx));
1825
1875
  results.push(await generateOxlint(ctx));
1826
1876
  results.push(await generateFormatter(ctx));
1827
- results.push(await generateLintStaged(ctx));
1877
+ results.push(...await generateLefthook(ctx));
1828
1878
  results.push(await generateGitignore(ctx));
1829
1879
  results.push(await generateKnip(ctx));
1830
1880
  results.push(await generateRenovate(ctx));
@@ -1835,13 +1885,21 @@ async function runInit(config, options = {}) {
1835
1885
  results.push(await generateReleaseCi(ctx));
1836
1886
  const vitestResults = await generateVitest(ctx);
1837
1887
  results.push(...vitestResults);
1888
+ const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
1889
+ for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
1890
+ filePath: rel,
1891
+ action: "archived",
1892
+ description: `Original saved to .tooling-archived/${rel}`
1893
+ });
1838
1894
  s.stop("Done!");
1839
1895
  const created = results.filter((r) => r.action === "created");
1840
1896
  const updated = results.filter((r) => r.action === "updated");
1841
1897
  const skipped = results.filter((r) => r.action === "skipped");
1898
+ const archived = results.filter((r) => r.action === "archived");
1842
1899
  const summaryLines = [];
1843
1900
  if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
1844
1901
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
1902
+ if (archived.length > 0) summaryLines.push(`Archived: ${archived.map((r) => r.filePath).join(", ")}`);
1845
1903
  if (skipped.length > 0) summaryLines.push(`Skipped: ${skipped.map((r) => r.filePath).join(", ")}`);
1846
1904
  p.note(summaryLines.join("\n"), "Summary");
1847
1905
  if (!options.noPrompt) {
@@ -1981,10 +2039,6 @@ function createRealExecutor() {
1981
2039
  }
1982
2040
  };
1983
2041
  }
1984
- /** Check whether there are pending changeset files. */
1985
- function hasChangesets(executor, cwd) {
1986
- return executor.listChangesetFiles(cwd).length > 0;
1987
- }
1988
2042
  /** Parse "New tag:" lines from changeset publish output. */
1989
2043
  function parseNewTags(output) {
1990
2044
  const tags = [];
@@ -2066,6 +2120,22 @@ async function updatePr(executor, conn, prNumber, options) {
2066
2120
  });
2067
2121
  if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
2068
2122
  }
2123
+ /** Merge a pull request by number. */
2124
+ async function mergePr(executor, conn, prNumber, options) {
2125
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
2126
+ const res = await executor.fetch(url, {
2127
+ method: "POST",
2128
+ headers: {
2129
+ Authorization: `token ${conn.token}`,
2130
+ "Content-Type": "application/json"
2131
+ },
2132
+ body: JSON.stringify({
2133
+ Do: options?.method ?? "merge",
2134
+ delete_branch_after_merge: options?.deleteBranch ?? true
2135
+ })
2136
+ });
2137
+ if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
2138
+ }
2069
2139
  /** Check whether a Forgejo release already exists for a given tag. */
2070
2140
  async function findRelease(executor, conn, tag) {
2071
2141
  const encodedTag = encodeURIComponent(tag);
@@ -2093,6 +2163,21 @@ async function createRelease(executor, conn, tag) {
2093
2163
  if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
2094
2164
  }
2095
2165
 
2166
+ //#endregion
2167
+ //#region src/release/log.ts
2168
+ /** Log a debug message when verbose mode is enabled. */
2169
+ function debug(config, message) {
2170
+ if (config.verbose) p.log.info(`[debug] ${message}`);
2171
+ }
2172
+ /** Log the result of an exec call when verbose mode is enabled. */
2173
+ function debugExec(config, label, result) {
2174
+ if (!config.verbose) return;
2175
+ const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
2176
+ if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
2177
+ if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
2178
+ p.log.info(lines.join("\n"));
2179
+ }
2180
+
2096
2181
  //#endregion
2097
2182
  //#region src/release/version.ts
2098
2183
  const BRANCH = "changeset-release/main";
@@ -2165,11 +2250,20 @@ function buildPrContent(executor, cwd, packagesBefore) {
2165
2250
  async function runVersionMode(executor, config) {
2166
2251
  p.log.info("Changesets detected — versioning packages");
2167
2252
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
2168
- executor.exec("pnpm changeset version", { cwd: config.cwd });
2169
- executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd });
2253
+ debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
2254
+ const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
2255
+ debugExec(config, "pnpm changeset version", versionResult);
2256
+ if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
2257
+ debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
2170
2258
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
2259
+ debug(config, `PR title: ${title}`);
2171
2260
  executor.exec("git add -A", { cwd: config.cwd });
2172
- if (executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd }).exitCode !== 0) {
2261
+ const remainingChangesets = executor.listChangesetFiles(config.cwd);
2262
+ if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
2263
+ debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
2264
+ const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
2265
+ debugExec(config, "git commit", commitResult);
2266
+ if (commitResult.exitCode !== 0) {
2173
2267
  p.log.info("Nothing to commit after versioning");
2174
2268
  return {
2175
2269
  mode: "version",
@@ -2183,13 +2277,14 @@ async function runVersionMode(executor, config) {
2183
2277
  pr: "none"
2184
2278
  };
2185
2279
  }
2186
- executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd });
2280
+ debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
2187
2281
  const conn = {
2188
2282
  serverUrl: config.serverUrl,
2189
2283
  repository: config.repository,
2190
2284
  token: config.token
2191
2285
  };
2192
2286
  const existingPr = await findOpenPr(executor, conn, BRANCH);
2287
+ debug(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
2193
2288
  if (existingPr === null) {
2194
2289
  await createPr(executor, conn, {
2195
2290
  title,
@@ -2235,12 +2330,17 @@ async function retryAsync(fn) {
2235
2330
  async function runPublishMode(executor, config) {
2236
2331
  p.log.info("No changesets — publishing packages");
2237
2332
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
2333
+ debugExec(config, "pnpm changeset publish", publishResult);
2238
2334
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
2239
2335
  const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
2336
+ debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
2240
2337
  const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
2338
+ debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
2241
2339
  const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
2340
+ debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
2242
2341
  const remoteSet = new Set(remoteTags);
2243
2342
  const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
2343
+ debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
2244
2344
  if (config.dryRun) {
2245
2345
  if (tagsToPush.length === 0) {
2246
2346
  p.log.info("No packages were published");
@@ -2299,6 +2399,80 @@ async function runPublishMode(executor, config) {
2299
2399
  };
2300
2400
  }
2301
2401
 
2402
+ //#endregion
2403
+ //#region src/release/connection.ts
2404
+ const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
2405
+ /**
2406
+ * Resolve the hosting platform and connection details.
2407
+ *
2408
+ * Priority:
2409
+ * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, FORGEJO_TOKEN)
2410
+ * 2. `repository` field in package.json (server URL and owner/repo parsed from the URL)
2411
+ *
2412
+ * For Forgejo, FORGEJO_TOKEN is always required (either from env or explicitly).
2413
+ * If the repository URL hostname is `github.com`, returns `{ type: "github" }`.
2414
+ */
2415
+ function resolveConnection(cwd) {
2416
+ const serverUrl = process.env["FORGEJO_SERVER_URL"];
2417
+ const repository = process.env["FORGEJO_REPOSITORY"];
2418
+ const token = process.env["FORGEJO_TOKEN"];
2419
+ if (serverUrl && repository && token) return {
2420
+ type: "forgejo",
2421
+ conn: {
2422
+ serverUrl,
2423
+ repository,
2424
+ token
2425
+ }
2426
+ };
2427
+ const parsed = parseRepositoryUrl(cwd);
2428
+ if (parsed === null) {
2429
+ if (serverUrl) {
2430
+ if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2431
+ if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2432
+ }
2433
+ return { type: "github" };
2434
+ }
2435
+ if (parsed.hostname === "github.com") return { type: "github" };
2436
+ const resolvedToken = token;
2437
+ if (!resolvedToken) throw new FatalError("FORGEJO_TOKEN environment variable is required (server URL and repository were resolved from package.json)");
2438
+ return {
2439
+ type: "forgejo",
2440
+ conn: {
2441
+ serverUrl: serverUrl ?? `${parsed.protocol}//${parsed.hostname}`,
2442
+ repository: repository ?? parsed.repository,
2443
+ token: resolvedToken
2444
+ }
2445
+ };
2446
+ }
2447
+ function parseRepositoryUrl(cwd) {
2448
+ const pkgPath = path.join(cwd, "package.json");
2449
+ let raw;
2450
+ try {
2451
+ raw = readFileSync(pkgPath, "utf-8");
2452
+ } catch {
2453
+ return null;
2454
+ }
2455
+ const pkg = z.object({ repository: RepositorySchema.optional() }).safeParse(JSON.parse(raw));
2456
+ if (!pkg.success) return null;
2457
+ const repo = pkg.data.repository;
2458
+ if (!repo) return null;
2459
+ return parseGitUrl(typeof repo === "string" ? repo : repo.url);
2460
+ }
2461
+ function parseGitUrl(urlStr) {
2462
+ try {
2463
+ const url = new URL(urlStr);
2464
+ const pathname = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
2465
+ if (!pathname.includes("/")) return null;
2466
+ return {
2467
+ protocol: url.protocol,
2468
+ hostname: url.hostname,
2469
+ repository: pathname
2470
+ };
2471
+ } catch {
2472
+ return null;
2473
+ }
2474
+ }
2475
+
2302
2476
  //#endregion
2303
2477
  //#region src/commands/release-changesets.ts
2304
2478
  const releaseForgejoCommand = defineCommand({
@@ -2306,33 +2480,43 @@ const releaseForgejoCommand = defineCommand({
2306
2480
  name: "release:changesets",
2307
2481
  description: "Changesets version/publish for Forgejo CI"
2308
2482
  },
2309
- args: { "dry-run": {
2310
- type: "boolean",
2311
- description: "Skip push, API calls, and publishing side effects"
2312
- } },
2483
+ args: {
2484
+ "dry-run": {
2485
+ type: "boolean",
2486
+ description: "Skip push, API calls, and publishing side effects"
2487
+ },
2488
+ verbose: {
2489
+ type: "boolean",
2490
+ description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
2491
+ }
2492
+ },
2313
2493
  async run({ args }) {
2314
- if ((await runRelease(buildReleaseConfig({ dryRun: args["dry-run"] === true }), createRealExecutor())).mode === "none") process.exitCode = 0;
2494
+ if ((await runRelease(buildReleaseConfig({
2495
+ dryRun: args["dry-run"] === true,
2496
+ verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
2497
+ }), createRealExecutor())).mode === "none") process.exitCode = 0;
2315
2498
  }
2316
2499
  });
2317
- /** Build release config from environment variables and CLI flags. */
2500
+ /** Build release config from environment / package.json and CLI flags. */
2318
2501
  function buildReleaseConfig(flags) {
2319
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2320
- const repository = process.env["FORGEJO_REPOSITORY"];
2321
- const token = process.env["FORGEJO_TOKEN"];
2322
- if (!serverUrl) throw new FatalError("FORGEJO_SERVER_URL environment variable is required");
2323
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2324
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2502
+ const resolved = resolveConnection(process.cwd());
2503
+ if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
2325
2504
  return {
2326
- serverUrl,
2327
- repository,
2328
- token,
2505
+ ...resolved.conn,
2329
2506
  cwd: process.cwd(),
2330
- dryRun: flags.dryRun ?? false
2507
+ dryRun: flags.dryRun ?? false,
2508
+ verbose: flags.verbose ?? false
2331
2509
  };
2332
2510
  }
2333
2511
  /** Core release logic — testable with a mock executor. */
2334
2512
  async function runRelease(config, executor) {
2335
- if (hasChangesets(executor, config.cwd)) return runVersionMode(executor, config);
2513
+ const changesetFiles = executor.listChangesetFiles(config.cwd);
2514
+ debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
2515
+ if (changesetFiles.length > 0) {
2516
+ debug(config, "Entering version mode");
2517
+ return runVersionMode(executor, config);
2518
+ }
2519
+ debug(config, "Entering publish mode");
2336
2520
  return runPublishMode(executor, config);
2337
2521
  }
2338
2522
 
@@ -2350,21 +2534,17 @@ const releaseTriggerCommand = defineCommand({
2350
2534
  } },
2351
2535
  async run({ args }) {
2352
2536
  const ref = args.ref ?? "main";
2353
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2354
- if (serverUrl) await triggerForgejo(serverUrl, ref);
2537
+ const resolved = resolveConnection(process.cwd());
2538
+ if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
2355
2539
  else triggerGitHub(ref);
2356
2540
  }
2357
2541
  });
2358
- async function triggerForgejo(serverUrl, ref) {
2359
- const repository = process.env["FORGEJO_REPOSITORY"];
2360
- const token = process.env["FORGEJO_TOKEN"];
2361
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2362
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2363
- const url = `${serverUrl}/api/v1/repos/${repository}/actions/workflows/release.yml/dispatches`;
2542
+ async function triggerForgejo(conn, ref) {
2543
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/workflows/release.yml/dispatches`;
2364
2544
  const res = await fetch(url, {
2365
2545
  method: "POST",
2366
2546
  headers: {
2367
- Authorization: `token ${token}`,
2547
+ Authorization: `token ${conn.token}`,
2368
2548
  "Content-Type": "application/json"
2369
2549
  },
2370
2550
  body: JSON.stringify({ ref })
@@ -2390,18 +2570,10 @@ const createForgejoReleaseCommand = defineCommand({
2390
2570
  required: true
2391
2571
  } },
2392
2572
  async run({ args }) {
2393
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2394
- const repository = process.env["FORGEJO_REPOSITORY"];
2395
- const token = process.env["FORGEJO_TOKEN"];
2396
- if (!serverUrl) throw new FatalError("FORGEJO_SERVER_URL environment variable is required");
2397
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2398
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2573
+ const resolved = resolveConnection(process.cwd());
2574
+ if (resolved.type !== "forgejo") throw new FatalError("release:create-forgejo-release requires a Forgejo repository");
2399
2575
  const executor = createRealExecutor();
2400
- const conn = {
2401
- serverUrl,
2402
- repository,
2403
- token
2404
- };
2576
+ const conn = resolved.conn;
2405
2577
  if (await findRelease(executor, conn, args.tag)) {
2406
2578
  p.log.info(`Release for ${args.tag} already exists — skipping`);
2407
2579
  return;
@@ -2411,6 +2583,51 @@ const createForgejoReleaseCommand = defineCommand({
2411
2583
  }
2412
2584
  });
2413
2585
 
2586
+ //#endregion
2587
+ //#region src/commands/release-merge.ts
2588
+ const HEAD_BRANCH = "changeset-release/main";
2589
+ const releaseMergeCommand = defineCommand({
2590
+ meta: {
2591
+ name: "release:merge",
2592
+ description: "Merge the open changesets version PR"
2593
+ },
2594
+ args: { "dry-run": {
2595
+ type: "boolean",
2596
+ description: "Show what would be merged without actually merging"
2597
+ } },
2598
+ async run({ args }) {
2599
+ const dryRun = args["dry-run"] === true;
2600
+ const resolved = resolveConnection(process.cwd());
2601
+ if (resolved.type === "forgejo") await mergeForgejo(resolved.conn, dryRun);
2602
+ else mergeGitHub(dryRun);
2603
+ }
2604
+ });
2605
+ async function mergeForgejo(conn, dryRun) {
2606
+ const executor = createRealExecutor();
2607
+ const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
2608
+ if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
2609
+ if (dryRun) {
2610
+ p.log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
2611
+ return;
2612
+ }
2613
+ await mergePr(executor, conn, prNumber, {
2614
+ method: "merge",
2615
+ deleteBranch: true
2616
+ });
2617
+ p.log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
2618
+ }
2619
+ function mergeGitHub(dryRun) {
2620
+ const executor = createRealExecutor();
2621
+ if (dryRun) {
2622
+ const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
2623
+ if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
2624
+ p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
2625
+ return;
2626
+ }
2627
+ executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
2628
+ p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
2629
+ }
2630
+
2414
2631
  //#endregion
2415
2632
  //#region src/bin.ts
2416
2633
  runMain(defineCommand({
@@ -2424,7 +2641,8 @@ runMain(defineCommand({
2424
2641
  "repo:update": updateCommand,
2425
2642
  "release:changesets": releaseForgejoCommand,
2426
2643
  "release:trigger": releaseTriggerCommand,
2427
- "release:create-forgejo-release": createForgejoReleaseCommand
2644
+ "release:create-forgejo-release": createForgejoReleaseCommand,
2645
+ "release:merge": releaseMergeCommand
2428
2646
  }
2429
2647
  }));
2430
2648
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"
@@ -9,17 +9,13 @@
9
9
  "dist"
10
10
  ],
11
11
  "type": "module",
12
- "main": "./dist/index.mjs",
13
- "module": "./dist/index.mjs",
14
- "types": "./dist/index.d.mts",
15
12
  "imports": {
16
13
  "#src/*": "./src/*.ts"
17
14
  },
18
15
  "exports": {
19
- ".": {
20
- "types": "./dist/index.d.mts",
21
- "import": "./dist/index.mjs"
22
- }
16
+ ".": "./dist/index.mjs",
17
+ "./bin": "./dist/bin.mjs",
18
+ "./package.json": "./package.json"
23
19
  },
24
20
  "publishConfig": {
25
21
  "access": "public"
@@ -35,7 +31,7 @@
35
31
  "tsdown": "0.20.3",
36
32
  "typescript": "5.9.3",
37
33
  "vitest": "4.0.18",
38
- "@bensandee/config": "0.3.0"
34
+ "@bensandee/config": "0.5.0"
39
35
  },
40
36
  "scripts": {
41
37
  "build": "tsdown",
package/dist/bin.d.mts DELETED
@@ -1 +0,0 @@
1
- export { };
package/dist/index.d.mts DELETED
@@ -1,102 +0,0 @@
1
- import { z } from "zod";
2
-
3
- //#region src/utils/json.d.ts
4
- declare const PackageJsonSchema: z.ZodObject<{
5
- name: z.ZodOptional<z.ZodString>;
6
- version: z.ZodOptional<z.ZodString>;
7
- private: z.ZodOptional<z.ZodBoolean>;
8
- type: z.ZodOptional<z.ZodString>;
9
- scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
10
- dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
- devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
- bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
13
- exports: z.ZodOptional<z.ZodUnknown>;
14
- main: z.ZodOptional<z.ZodString>;
15
- types: z.ZodOptional<z.ZodString>;
16
- typings: z.ZodOptional<z.ZodString>;
17
- engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
18
- }, z.core.$loose>;
19
- type PackageJson = z.infer<typeof PackageJsonSchema>;
20
- //#endregion
21
- //#region src/types.d.ts
22
- type CiPlatform = "github" | "forgejo" | "none";
23
- type ReleaseStrategy = "release-it" | "commit-and-tag-version" | "changesets" | "none";
24
- /** User's answers from the interactive prompt or CLI flags. */
25
- interface ProjectConfig {
26
- /** Project name (from package.json name or user input) */
27
- name: string;
28
- /** Whether this is a new project or existing */
29
- isNew: boolean;
30
- /** Project structure */
31
- structure: "single" | "monorepo";
32
- /** Include @bensandee/lint-rules oxlint plugin */
33
- useLintRules: boolean;
34
- /** Formatter choice */
35
- formatter: "oxfmt" | "prettier";
36
- /** Set up vitest with a starter test */
37
- setupVitest: boolean;
38
- /** CI platform choice */
39
- ci: CiPlatform;
40
- /** Set up Renovate for automated dependency updates */
41
- setupRenovate: boolean;
42
- /** Release management strategy */
43
- releaseStrategy: ReleaseStrategy;
44
- /** Project type determines tsconfig base configuration */
45
- projectType: "default" | "node" | "react" | "library";
46
- /** Auto-detect and configure tsconfig bases for monorepo packages */
47
- detectPackageTypes: boolean;
48
- /** Target directory (default: cwd) */
49
- targetDir: string;
50
- }
51
- /** Result from a single generator: what file was written and how. */
52
- interface GeneratorResult {
53
- filePath: string;
54
- action: "created" | "updated" | "skipped";
55
- /** Human-readable description of what changed */
56
- description: string;
57
- }
58
- /** Context passed to each generator function. */
59
- interface GeneratorContext {
60
- config: ProjectConfig;
61
- /** Absolute path to target directory */
62
- targetDir: string;
63
- /** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
64
- packageJson: PackageJson | undefined;
65
- /** Check whether a file exists in the target directory */
66
- exists: (relativePath: string) => boolean;
67
- /** Read an existing file from the target directory, returns undefined if not found */
68
- read: (relativePath: string) => string | undefined;
69
- /** Write a file to the target directory (creating directories as needed) */
70
- write: (relativePath: string, content: string) => void;
71
- /** Prompt user for conflict resolution on non-mergeable files */
72
- confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
73
- }
74
- /** Generator function signature. */
75
- type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
76
- /** State detected from an existing project directory. */
77
- interface DetectedProjectState {
78
- hasPackageJson: boolean;
79
- hasTsconfig: boolean;
80
- hasOxlintConfig: boolean;
81
- /** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
82
- hasLegacyOxlintJson: boolean;
83
- hasGitignore: boolean;
84
- hasVitestConfig: boolean;
85
- hasTsdownConfig: boolean;
86
- hasPnpmWorkspace: boolean;
87
- hasKnipConfig: boolean;
88
- hasRenovateConfig: boolean;
89
- hasReleaseItConfig: boolean;
90
- hasCommitAndTagVersionConfig: boolean;
91
- hasChangesetsConfig: boolean;
92
- /** Legacy tooling configs found */
93
- legacyConfigs: LegacyConfig[];
94
- }
95
- declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
96
- type LegacyTool = (typeof LEGACY_TOOLS)[number];
97
- interface LegacyConfig {
98
- tool: LegacyTool;
99
- files: string[];
100
- }
101
- //#endregion
102
- export { type DetectedProjectState, type Generator, type GeneratorContext, type GeneratorResult, type LegacyConfig, type ProjectConfig };