@rtorcato/js-tooling 2.17.1 → 2.19.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/README.md CHANGED
@@ -17,12 +17,65 @@ Most tooling libraries give you one piece — just TypeScript configs, or just a
17
17
 
18
18
  **[Full documentation →](https://rtorcato.github.io/js-tooling/)**
19
19
 
20
- ## Quick start
20
+ ## Start a new project
21
+
22
+ Interactive wizard — answers every prompt, scaffolds the whole project:
21
23
 
22
24
  ```bash
23
25
  npx @rtorcato/js-tooling setup
24
26
  ```
25
27
 
28
+ Non-interactive — scaffold from a named preset in one shot (CI-friendly):
29
+
30
+ ```bash
31
+ npx @rtorcato/js-tooling setup --preset library -d ./my-lib --skip-install
32
+ # presets: library | web-app | node-api | nextjs-app | react-app
33
+ ```
34
+
35
+ Just one config file? Use `copy`:
36
+
37
+ ```bash
38
+ npx @rtorcato/js-tooling copy biome # → biome.json
39
+ npx @rtorcato/js-tooling copy tsconfig # → tsconfig.json
40
+ npx @rtorcato/js-tooling copy changesets # → .changeset/config.json
41
+ npx @rtorcato/js-tooling copy oxlint # → .oxlintrc.json
42
+ npx @rtorcato/js-tooling copy claude-skill # → .claude/skills/js-tooling.md
43
+ ```
44
+
45
+ **Already have a project?** Don't rerun `setup` — use `doctor` + `fix`:
46
+
47
+ ```bash
48
+ npx @rtorcato/js-tooling doctor # find what's missing or drifted
49
+ npx @rtorcato/js-tooling fix # apply scaffolders, prompting per item
50
+ ```
51
+
52
+ See the [Getting Started guide](https://rtorcato.github.io/js-tooling/guides/getting-started/) for the full walkthrough.
53
+
54
+ ## AI agent rules
55
+
56
+ The package ships rules that teach AI coding agents to drive the CLI
57
+ (`doctor` / `fix` / `setup`) non-interactively. Install for your agent — all
58
+ generated from one source, so guidance never drifts between them:
59
+
60
+ ```bash
61
+ npx @rtorcato/js-tooling fix claude-skill --yes # → .claude/skills/js-tooling.md
62
+ npx @rtorcato/js-tooling fix cursor-rules --yes # → .cursor/rules/js-tooling.mdc
63
+ npx @rtorcato/js-tooling fix copilot-instructions --yes # → .github/copilot-instructions.md
64
+ npx @rtorcato/js-tooling fix agents-md --yes # → AGENTS.md
65
+ ```
66
+
67
+ `copilot-instructions` and `agents-md` upsert a delimited block, so your own
68
+ content in those shared files is never clobbered. Re-running updates the block
69
+ in place on upgrade.
70
+
71
+ Prefer a symlink that auto-syncs the Claude skill on every upgrade?
72
+
73
+ ```bash
74
+ mkdir -p .claude/skills
75
+ ln -sf ../../node_modules/@rtorcato/js-tooling/tooling/claude/js-tooling.md \
76
+ .claude/skills/js-tooling.md
77
+ ```
78
+
26
79
  ## What's new
27
80
 
28
81
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
@@ -89,6 +89,24 @@ const FILE_CHECKS = [
89
89
  matcher: /@rtorcato\/js-tooling\/commitlint\/config/,
90
90
  optional: true,
91
91
  },
92
+ {
93
+ check: 'Oxlint',
94
+ candidates: ['.oxlintrc.json', 'oxlintrc.json'],
95
+ // Oxlint configs are project-owned (extends from npm packages isn't
96
+ // reliably supported), so any well-formed file counts as ok.
97
+ expected: 'is a valid Oxlint configuration',
98
+ matcher: /"(rules|plugins|categories|extends)"/,
99
+ optional: true,
100
+ hint: 'Run `npx @rtorcato/js-tooling copy oxlint` to scaffold',
101
+ },
102
+ {
103
+ check: 'Changesets',
104
+ candidates: ['.changeset/config.json'],
105
+ expected: 'is a valid Changesets configuration',
106
+ matcher: /"(changelog|access|baseBranch)"/,
107
+ optional: true,
108
+ hint: 'Run `npx @rtorcato/js-tooling copy changesets` to scaffold',
109
+ },
92
110
  ];
93
111
  async function checkFile(dir, spec) {
94
112
  for (const candidate of spec.candidates) {
@@ -451,7 +469,25 @@ async function checkSemanticRelease(dir, pkg) {
451
469
  break;
452
470
  }
453
471
  }
472
+ const hasChangesets = await fs.pathExists(path.join(dir, '.changeset', 'config.json'));
473
+ // Conflict: both semantic-release and Changesets configured.
474
+ if ((inPkg || configFile) && hasChangesets) {
475
+ return {
476
+ check: 'semantic-release',
477
+ status: 'drift',
478
+ detail: 'both semantic-release and Changesets are configured',
479
+ hint: 'Pick one release tool — remove either the semantic-release config or the .changeset/ directory',
480
+ };
481
+ }
454
482
  if (!inPkg && !configFile) {
483
+ // Changesets present — treat semantic-release as intentionally not used.
484
+ if (hasChangesets) {
485
+ return {
486
+ check: 'semantic-release',
487
+ status: 'ok',
488
+ detail: 'using Changesets (.changeset/config.json) instead',
489
+ };
490
+ }
455
491
  return {
456
492
  check: 'semantic-release',
457
493
  status: isPrivate ? 'optional-missing' : 'drift',
@@ -680,28 +716,28 @@ async function checkAreTheTypesWrong(_dir, pkg) {
680
716
  ...(pkg?.devDependencies ?? {}),
681
717
  };
682
718
  const scripts = pkg?.scripts ?? {};
683
- const hasDep = !!deps['are-the-types-wrong'];
684
- const hasScript = Object.values(scripts).some((s) => /\battw\b|are-the-types-wrong/.test(s));
719
+ const hasDep = !!deps['@arethetypeswrong/cli'];
720
+ const hasScript = Object.values(scripts).some((s) => /\battw\b/.test(s));
685
721
  if (hasDep && hasScript) {
686
722
  return {
687
723
  check: 'are-the-types-wrong',
688
724
  status: 'ok',
689
- detail: 'are-the-types-wrong installed and wired into a script',
725
+ detail: '@arethetypeswrong/cli installed and wired into a script',
690
726
  };
691
727
  }
692
728
  if (hasDep) {
693
729
  return {
694
730
  check: 'are-the-types-wrong',
695
731
  status: 'drift',
696
- detail: 'are-the-types-wrong installed but no script runs it',
697
- hint: 'Add `"attw": "attw --pack"` to package.json scripts and call it from your verify/CI chain',
732
+ detail: '@arethetypeswrong/cli installed but no script runs it',
733
+ hint: 'Run `npx @rtorcato/js-tooling fix attw` to add an `attw` script and wire it into verify',
698
734
  };
699
735
  }
700
736
  return {
701
737
  check: 'are-the-types-wrong',
702
738
  status: 'optional-missing',
703
- detail: 'are-the-types-wrong not configured',
704
- hint: 'Run `pnpm add -D are-the-types-wrong && attw --pack` to validate TypeScript exports before publishing',
739
+ detail: '@arethetypeswrong/cli not configured',
740
+ hint: 'Run `npx @rtorcato/js-tooling fix attw` to validate TypeScript exports before publishing',
705
741
  };
706
742
  }
707
743
  async function checkTreeshakeSetup(dir, pkg) {
@@ -1,7 +1,10 @@
1
1
  import path from 'node:path';
2
+ import os from 'node:os';
2
3
  import chalk from 'chalk';
4
+ import { createPatch } from 'diff';
3
5
  import fs from 'fs-extra';
4
6
  import inquirer from 'inquirer';
7
+ import { installAgentRules } from '../generators/agent-rules.js';
5
8
  import { generateSemanticReleaseConfig } from '../generators/build.js';
6
9
  import { generateCommitlintConfig, generateHuskyConfig, generatePrePushHook, } from '../generators/git.js';
7
10
  import { generateGitHubActions } from '../generators/github-actions.js';
@@ -57,6 +60,24 @@ async function readPackageJson(dir) {
57
60
  return null;
58
61
  }
59
62
  }
63
+ /** True when any (possibly nested) exports condition declares a `require`/CJS entry. */
64
+ function hasRequireCondition(exports) {
65
+ if (!exports || typeof exports !== 'object')
66
+ return false;
67
+ for (const value of Object.values(exports)) {
68
+ if (value && typeof value === 'object') {
69
+ if ('require' in value)
70
+ return true;
71
+ if (hasRequireCondition(value))
72
+ return true;
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+ /** ESM-only = `"type": "module"` and no CJS/`require` resolution in exports. */
78
+ function isEsmOnly(pkg) {
79
+ return pkg.type === 'module' && !hasRequireCondition(pkg.exports);
80
+ }
60
81
  const FIXERS = [
61
82
  {
62
83
  target: 'biome',
@@ -202,6 +223,28 @@ const FIXERS = [
202
223
  return { filesWritten: ['release.config.mjs'] };
203
224
  },
204
225
  },
226
+ {
227
+ target: 'changesets',
228
+ description: 'Scaffold .changeset/config.json (alternative to semantic-release)',
229
+ appliesTo: ['Changesets'],
230
+ outputs: ['.changeset/config.json'],
231
+ canFixDrift: true,
232
+ async run({ targetDir }) {
233
+ const result = await copyPreset('changesets', targetDir);
234
+ return { filesWritten: [result.target] };
235
+ },
236
+ },
237
+ {
238
+ target: 'oxlint',
239
+ description: 'Scaffold .oxlintrc.json (additive to Biome/ESLint)',
240
+ appliesTo: ['Oxlint'],
241
+ outputs: ['.oxlintrc.json'],
242
+ canFixDrift: true,
243
+ async run({ targetDir }) {
244
+ const result = await copyPreset('oxlint', targetDir);
245
+ return { filesWritten: [result.target] };
246
+ },
247
+ },
205
248
  {
206
249
  target: 'github-actions',
207
250
  description: 'Scaffold .github/workflows/ci.yml',
@@ -384,6 +427,86 @@ const FIXERS = [
384
427
  return { filesWritten };
385
428
  },
386
429
  },
430
+ {
431
+ target: 'attw',
432
+ description: 'Install @arethetypeswrong/cli + add an `attw` script (esm-only profile when applicable) and wire it into verify',
433
+ appliesTo: ['are-the-types-wrong'],
434
+ outputs: ['package.json (devDependencies + scripts.attw)'],
435
+ riskLevel: 'safe-merge',
436
+ canFixDrift: true,
437
+ async run({ targetDir, pkg }) {
438
+ const pkgPath = path.join(targetDir, 'package.json');
439
+ if (!pkg) {
440
+ console.log(chalk.yellow(' no package.json found — skipping'));
441
+ return { filesWritten: [] };
442
+ }
443
+ const updated = { ...pkg };
444
+ const devDeps = {
445
+ ...(updated.devDependencies ?? {}),
446
+ };
447
+ if (!devDeps['@arethetypeswrong/cli'])
448
+ devDeps['@arethetypeswrong/cli'] = '^0.18.2';
449
+ updated.devDependencies = devDeps;
450
+ const scripts = { ...(updated.scripts ?? {}) };
451
+ scripts.attw = isEsmOnly(pkg) ? 'attw --pack --profile esm-only' : 'attw --pack';
452
+ if (scripts.verify && !/\battw\b/.test(scripts.verify)) {
453
+ scripts.verify = `${scripts.verify} && pnpm attw`;
454
+ }
455
+ updated.scripts = scripts;
456
+ await fs.writeJson(pkgPath, updated, { spaces: 2 });
457
+ return { filesWritten: ['package.json'] };
458
+ },
459
+ },
460
+ {
461
+ target: 'claude-skill',
462
+ description: 'Install the js-tooling Claude Code skill into .claude/skills/',
463
+ appliesTo: ['Claude skill'],
464
+ outputs: ['.claude/skills/js-tooling.md'],
465
+ riskLevel: 'safe-add',
466
+ canFixDrift: true,
467
+ async run({ targetDir }) {
468
+ const result = await copyPreset('claude-skill', targetDir);
469
+ return { filesWritten: [result.target] };
470
+ },
471
+ },
472
+ {
473
+ target: 'cursor-rules',
474
+ description: 'Install the js-tooling rules for Cursor (.cursor/rules/js-tooling.mdc)',
475
+ appliesTo: ['Cursor rules'],
476
+ outputs: ['.cursor/rules/js-tooling.mdc'],
477
+ riskLevel: 'safe-add',
478
+ canFixDrift: true,
479
+ async run({ targetDir }) {
480
+ const written = await installAgentRules(targetDir, 'cursor');
481
+ return { filesWritten: [written] };
482
+ },
483
+ },
484
+ {
485
+ target: 'copilot-instructions',
486
+ description: 'Install the js-tooling rules for GitHub Copilot (.github/copilot-instructions.md)',
487
+ appliesTo: ['Copilot instructions'],
488
+ outputs: ['.github/copilot-instructions.md'],
489
+ // Upserts a delimited block — never clobbers the consumer's own instructions.
490
+ riskLevel: 'safe-merge',
491
+ canFixDrift: true,
492
+ async run({ targetDir }) {
493
+ const written = await installAgentRules(targetDir, 'copilot');
494
+ return { filesWritten: [written] };
495
+ },
496
+ },
497
+ {
498
+ target: 'agents-md',
499
+ description: 'Install the js-tooling rules into AGENTS.md (universal agent instructions)',
500
+ appliesTo: ['AGENTS.md rules'],
501
+ outputs: ['AGENTS.md'],
502
+ // Upserts a delimited block — never clobbers existing AGENTS.md content.
503
+ riskLevel: 'safe-merge',
504
+ canFixDrift: true,
505
+ async run({ targetDir }) {
506
+ const written = await installAgentRules(targetDir, 'agents-md');
507
+ return { filesWritten: [written] };
508
+ },
509
+ },
387
510
  {
388
511
  target: 'package-json',
389
512
  description: 'Add @rtorcato/js-tooling to devDependencies',
@@ -464,6 +587,117 @@ export function listFixers() {
464
587
  canFixDrift: f.canFixDrift ?? false,
465
588
  }));
466
589
  }
590
+ // Fixer outputs sometimes carry annotations like
591
+ // "package.json (lint-staged field)" — strip them to get a usable filesystem path.
592
+ function outputToRelativePath(output) {
593
+ return output.split(' ')[0] ?? output;
594
+ }
595
+ function shouldColorise() {
596
+ // Respect NO_COLOR (https://no-color.org) and chalk's own detection.
597
+ if (process.env.NO_COLOR && process.env.NO_COLOR !== '')
598
+ return false;
599
+ return chalk.level > 0;
600
+ }
601
+ function colorisePatch(patch) {
602
+ if (!shouldColorise())
603
+ return patch;
604
+ return patch
605
+ .split('\n')
606
+ .map((line) => {
607
+ if (line.startsWith('+++') || line.startsWith('---'))
608
+ return chalk.bold(line);
609
+ if (line.startsWith('@@'))
610
+ return chalk.cyan(line);
611
+ if (line.startsWith('+'))
612
+ return chalk.green(line);
613
+ if (line.startsWith('-'))
614
+ return chalk.red(line);
615
+ return line;
616
+ })
617
+ .join('\n');
618
+ }
619
+ /**
620
+ * Shadow-run a fixer in a temp copy of the target directory and return per-output
621
+ * diffs. We copy the real target into tmp so fixers that read existing state
622
+ * (e.g. husky reading package.json) still produce realistic output.
623
+ */
624
+ async function previewFixer(fixer, result, targetDir, pkg, lock) {
625
+ // Pick a tmp root that is NOT inside targetDir. macOS sometimes hands us a
626
+ // $TMPDIR that lives under the working dir (e.g. when the caller is itself
627
+ // running inside a tempdir tree), which would make fs.copy fail with
628
+ // "subdirectory of itself". Fall back to the parent of targetDir if so.
629
+ const resolvedTarget = path.resolve(targetDir);
630
+ let tmpRoot = path.resolve(os.tmpdir());
631
+ if (tmpRoot === resolvedTarget || tmpRoot.startsWith(resolvedTarget + path.sep)) {
632
+ tmpRoot = path.dirname(resolvedTarget);
633
+ }
634
+ const tmpDir = await fs.mkdtemp(path.join(tmpRoot, 'js-tooling-fix-preview-'));
635
+ try {
636
+ await fs.copy(targetDir, tmpDir, {
637
+ filter: (src) => {
638
+ const rel = path.relative(targetDir, src);
639
+ if (!rel)
640
+ return true;
641
+ const first = rel.split(path.sep)[0];
642
+ // Skip large/derived dirs that fixers never touch — keeps preview fast on
643
+ // big repos.
644
+ return first !== 'node_modules' && first !== 'dist' && first !== 'build' && first !== '.git';
645
+ },
646
+ });
647
+ await fixer.run({ targetDir: tmpDir, pkg, result, lock });
648
+ const previews = [];
649
+ const seen = new Set();
650
+ for (const output of fixer.outputs) {
651
+ const rel = outputToRelativePath(output);
652
+ if (seen.has(rel))
653
+ continue;
654
+ seen.add(rel);
655
+ const tmpPath = path.join(tmpDir, rel);
656
+ const realPath = path.join(targetDir, rel);
657
+ if (!(await fs.pathExists(tmpPath)))
658
+ continue;
659
+ const newContent = await fs.readFile(tmpPath, 'utf-8');
660
+ const existed = await fs.pathExists(realPath);
661
+ const oldContent = existed ? await fs.readFile(realPath, 'utf-8') : '';
662
+ if (newContent === oldContent) {
663
+ previews.push({ path: rel, kind: 'unchanged', patch: null });
664
+ continue;
665
+ }
666
+ const patch = createPatch(rel, oldContent, newContent, undefined, undefined, { context: 3 });
667
+ previews.push({
668
+ path: rel,
669
+ kind: existed ? 'modify' : 'create',
670
+ patch: colorisePatch(patch),
671
+ });
672
+ }
673
+ return previews;
674
+ }
675
+ finally {
676
+ await fs.remove(tmpDir).catch(() => {
677
+ // Best-effort cleanup; tmp dirs get GC'd by the OS eventually.
678
+ });
679
+ }
680
+ }
681
+ function printPreviews(previews) {
682
+ if (previews.length === 0) {
683
+ console.log(chalk.gray(' (no preview available — fixer produced no recognisable outputs)'));
684
+ return;
685
+ }
686
+ for (const p of previews) {
687
+ if (p.kind === 'unchanged') {
688
+ console.log(chalk.gray(` ${p.path} — unchanged`));
689
+ continue;
690
+ }
691
+ const label = p.kind === 'create' ? chalk.green('create') : chalk.yellow('modify');
692
+ console.log(` ${label} ${chalk.bold(p.path)}`);
693
+ if (p.patch) {
694
+ console.log(p.patch
695
+ .split('\n')
696
+ .map((l) => ` ${l}`)
697
+ .join('\n'));
698
+ }
699
+ }
700
+ }
467
701
  async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
468
702
  if (dryRun) {
469
703
  if (!silent) {
@@ -526,6 +760,8 @@ export async function fixCommand(target, options = {}) {
526
760
  // JSON mode implies --yes so prompts don't corrupt the output stream.
527
761
  const assumeYes = options.yes === true || json;
528
762
  const silent = json;
763
+ // Diff preview is interactive-only — suppress in JSON mode.
764
+ const showDiff = options.diff === true && !json;
529
765
  if (options.list) {
530
766
  const summary = listFixers();
531
767
  if (json) {
@@ -654,6 +890,10 @@ export async function fixCommand(target, options = {}) {
654
890
  console.log(chalk.cyan(`\n🔧 ${fixer.target} — ${chalk.bold(result.check)} is ${effectiveResult.status}\n`));
655
891
  }
656
892
  const conflict = noteLockConflict(result.check);
893
+ if (showDiff && (fixer.riskLevel ?? 'destructive') !== 'safe-add') {
894
+ const previews = await previewFixer(fixer, effectiveResult, targetDir, pkg, lock);
895
+ printPreviews(previews);
896
+ }
657
897
  const ok = await confirmApply(fixer, effectiveResult, assumeYes);
658
898
  if (!ok) {
659
899
  actions.push(recordFor(fixer.target, result.check, effectiveResult.status, 'skipped', [], conflict));
@@ -695,6 +935,10 @@ export async function fixCommand(target, options = {}) {
695
935
  console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
696
936
  }
697
937
  const conflict = noteLockConflict(result.check);
938
+ if (showDiff && (fixer.riskLevel ?? 'destructive') !== 'safe-add') {
939
+ const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
940
+ printPreviews(previews);
941
+ }
698
942
  const ok = await confirmApply(fixer, result, assumeYes);
699
943
  if (!ok) {
700
944
  actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
@@ -125,6 +125,8 @@ export const CONFIG_SCHEMA = {
125
125
  gitHooks: { type: 'boolean' },
126
126
  commitLint: { type: 'boolean' },
127
127
  semanticRelease: { type: 'boolean' },
128
+ changesets: { type: 'boolean' },
129
+ oxlint: { type: 'boolean' },
128
130
  securityAutomation: { type: 'boolean' },
129
131
  bundler: { type: 'string', enum: ['tsup', 'esbuild', 'vite', 'none'] },
130
132
  treeshakeCheck: { type: 'boolean' },
@@ -192,6 +194,10 @@ export function computeFileList(config) {
192
194
  files.push('vite.config.ts');
193
195
  if (config.semanticRelease)
194
196
  files.push('release.config.mjs');
197
+ if (config.changesets)
198
+ files.push('.changeset/config.json');
199
+ if (config.oxlint)
200
+ files.push('.oxlintrc.json');
195
201
  if (config.treeshakeCheck && config.projectType === 'library') {
196
202
  files.push('apps/treeshake-check/package.json', 'apps/treeshake-check/check.mjs', 'apps/treeshake-check/src/entry.ts');
197
203
  }
@@ -146,6 +146,13 @@ async function promptForConfig() {
146
146
  },
147
147
  when: (answers) => answers.lintingTool === 'eslint' || answers.lintingTool === 'both',
148
148
  },
149
+ {
150
+ type: 'confirm',
151
+ name: 'oxlint',
152
+ message: '🦀 Also run Oxlint alongside (50–100× faster than ESLint)?',
153
+ default: false,
154
+ when: (answers) => answers.lintingTool !== 'none',
155
+ },
149
156
  {
150
157
  type: 'list',
151
158
  name: 'testingFramework',
@@ -184,10 +191,15 @@ async function promptForConfig() {
184
191
  when: (answers) => answers.gitHooks,
185
192
  },
186
193
  {
187
- type: 'confirm',
188
- name: 'semanticRelease',
189
- message: '🚀 Set up semantic release for automated versioning?',
190
- default: (answers) => answers.projectType === 'library',
194
+ type: 'list',
195
+ name: 'releaseTool',
196
+ message: '🚀 Automated release tool?',
197
+ choices: [
198
+ { name: '📦 semantic-release (commit-message-driven)', value: 'semantic-release' },
199
+ { name: '📝 Changesets (changeset-file-driven, monorepo-friendly)', value: 'changesets' },
200
+ { name: '❌ None', value: 'none' },
201
+ ],
202
+ default: 'semantic-release',
191
203
  when: (answers) => answers.projectType === 'library',
192
204
  },
193
205
  {
@@ -245,7 +257,9 @@ async function promptForConfig() {
245
257
  },
246
258
  gitHooks: answers.gitHooks || false,
247
259
  commitLint: answers.commitLint || false,
248
- semanticRelease: answers.semanticRelease || false,
260
+ semanticRelease: answers.releaseTool === 'semantic-release',
261
+ changesets: answers.releaseTool === 'changesets',
262
+ oxlint: answers.oxlint || false,
249
263
  securityAutomation: answers.securityAutomation ?? false,
250
264
  bundler: answers.bundler || 'none',
251
265
  treeshakeCheck: answers.treeshakeCheck || false,
@@ -0,0 +1,67 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { getPackageRoot } from '../utils/copy-preset.js';
4
+ /**
5
+ * All agent rule files are generated from one source of truth — the shipped
6
+ * Claude skill — so the guidance never drifts between agents. Only the
7
+ * location and the frontmatter differ per agent.
8
+ */
9
+ const SOURCE = 'tooling/claude/js-tooling.md';
10
+ const BLOCK_START = '<!-- js-tooling:start -->';
11
+ const BLOCK_END = '<!-- js-tooling:end -->';
12
+ /** Read the shipped skill and split its frontmatter from the markdown body. */
13
+ async function readSkill() {
14
+ const raw = await fs.readFile(path.join(getPackageRoot(), SOURCE), 'utf8');
15
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
16
+ if (!match)
17
+ return { description: '', body: raw.trim() };
18
+ const description = (match[1].match(/^description:\s*(.+)$/m)?.[1] ?? '').trim();
19
+ return { description, body: match[2].trim() };
20
+ }
21
+ /**
22
+ * Upsert a delimited js-tooling block into a file that may already hold the
23
+ * consumer's own content (AGENTS.md, copilot-instructions). Replaces an
24
+ * existing block, appends if the file exists without one, creates otherwise.
25
+ * Never clobbers surrounding content.
26
+ */
27
+ async function upsertBlock(filePath, body) {
28
+ const block = `${BLOCK_START}\n${body}\n${BLOCK_END}`;
29
+ if (await fs.pathExists(filePath)) {
30
+ const existing = await fs.readFile(filePath, 'utf8');
31
+ const start = existing.indexOf(BLOCK_START);
32
+ const end = existing.indexOf(BLOCK_END);
33
+ if (start !== -1 && end !== -1) {
34
+ const next = existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length);
35
+ await fs.writeFile(filePath, next);
36
+ return;
37
+ }
38
+ await fs.writeFile(filePath, `${existing.trimEnd()}\n\n${block}\n`);
39
+ return;
40
+ }
41
+ await fs.ensureDir(path.dirname(filePath));
42
+ await fs.writeFile(filePath, `${block}\n`);
43
+ }
44
+ /** Install the js-tooling rules for one agent. Returns the written path (relative). */
45
+ export async function installAgentRules(targetDir, agent) {
46
+ const { description, body } = await readSkill();
47
+ switch (agent) {
48
+ case 'cursor': {
49
+ const rel = path.join('.cursor', 'rules', 'js-tooling.mdc');
50
+ const file = path.join(targetDir, rel);
51
+ const frontmatter = `---\ndescription: ${description}\nglobs:\nalwaysApply: false\n---\n\n`;
52
+ await fs.ensureDir(path.dirname(file));
53
+ await fs.writeFile(file, `${frontmatter}${body}\n`);
54
+ return rel;
55
+ }
56
+ case 'copilot': {
57
+ const rel = path.join('.github', 'copilot-instructions.md');
58
+ await upsertBlock(path.join(targetDir, rel), body);
59
+ return rel;
60
+ }
61
+ case 'agents-md': {
62
+ const rel = 'AGENTS.md';
63
+ await upsertBlock(path.join(targetDir, rel), body);
64
+ return rel;
65
+ }
66
+ }
67
+ }
@@ -14,6 +14,10 @@ export async function generateBuildConfigs(config, targetDir) {
14
14
  if (config.semanticRelease) {
15
15
  await generateSemanticReleaseConfig(targetDir);
16
16
  }
17
+ // Generate Changesets config (alternative to semantic-release)
18
+ if (config.changesets) {
19
+ await generateChangesetsConfig(targetDir);
20
+ }
17
21
  }
18
22
  async function generateTsupConfig(targetDir) {
19
23
  const tsupConfigPath = path.join(targetDir, 'tsup.config.ts');
@@ -73,3 +77,10 @@ export async function generateSemanticReleaseConfig(targetDir) {
73
77
  `;
74
78
  await fs.writeFile(releaseConfigPath, releaseConfig);
75
79
  }
80
+ export async function generateChangesetsConfig(targetDir) {
81
+ // Drop the canonical Changesets config into .changeset/config.json. The user
82
+ // owns this file once it's in their repo; subsequent `pnpm changeset` runs
83
+ // create per-change markdown files alongside it.
84
+ const { copyPreset } = await import('../utils/copy-preset.js');
85
+ await copyPreset('changesets', targetDir);
86
+ }
@@ -13,6 +13,17 @@ export async function generateLintingConfigs(config, targetDir) {
13
13
  if (config.linting.tool === 'eslint') {
14
14
  await generatePrettierConfig(targetDir);
15
15
  }
16
+ // Generate Oxlint config (additive — runs alongside Biome/ESLint)
17
+ if (config.oxlint) {
18
+ await generateOxlintConfig(targetDir);
19
+ }
20
+ }
21
+ export async function generateOxlintConfig(targetDir) {
22
+ // Oxlint's `extends` resolution from npm packages isn't reliably supported,
23
+ // so we copy the full preset rather than write a thin pointer file (same
24
+ // pattern as biome.jsonc — the user owns the file once it's in their repo).
25
+ const { copyPreset } = await import('../utils/copy-preset.js');
26
+ await copyPreset('oxlint', targetDir);
16
27
  }
17
28
  export async function generateBiomeConfig(targetDir) {
18
29
  const biomeConfigPath = path.join(targetDir, 'biome.jsonc');
package/dist/cli/index.js CHANGED
@@ -142,6 +142,18 @@ const TOOL_CATALOG = [
142
142
  ],
143
143
  fixTarget: 'semantic-release',
144
144
  },
145
+ {
146
+ name: 'Changesets',
147
+ description: 'Monorepo-friendly release tool (alternative to semantic-release)',
148
+ exports: ['@rtorcato/js-tooling/changesets'],
149
+ fixTarget: 'changesets',
150
+ },
151
+ {
152
+ name: 'Oxlint',
153
+ description: 'Rust-based linter (additive to Biome/ESLint)',
154
+ exports: ['@rtorcato/js-tooling/oxlint'],
155
+ fixTarget: 'oxlint',
156
+ },
145
157
  {
146
158
  name: 'tsup',
147
159
  description: 'TypeScript bundler configuration',
@@ -235,6 +247,7 @@ program
235
247
  .option('--json', 'Emit machine-readable JSON output (implies --yes)')
236
248
  .option('--list', 'List all registered fix targets and exit')
237
249
  .option('--resync', 'Re-scaffold every file recorded in .js-tooling.json')
250
+ .option('--diff', 'Show a unified diff of each change before confirming')
238
251
  .action((target, options) => fixCommand(target, {
239
252
  directory: options.directory,
240
253
  yes: options.yes,
@@ -242,6 +255,7 @@ program
242
255
  json: options.json,
243
256
  list: options.list,
244
257
  resync: options.resync,
258
+ diff: options.diff,
245
259
  }));
246
260
  program.hook('preAction', async (_, actionCommand) => {
247
261
  const name = actionCommand.name();
@@ -6,11 +6,26 @@ export const PRESETS = {
6
6
  target: 'biome.json',
7
7
  desc: 'Biome formatter and linter configuration',
8
8
  },
9
+ changesets: {
10
+ source: 'tooling/changesets/config.json',
11
+ target: '.changeset/config.json',
12
+ desc: 'Changesets release-tool configuration',
13
+ },
14
+ oxlint: {
15
+ source: 'tooling/oxlint/oxlintrc.json',
16
+ target: '.oxlintrc.json',
17
+ desc: 'Oxlint linter configuration (additive to Biome)',
18
+ },
9
19
  tsconfig: {
10
20
  source: 'tooling/typescript/tsconfig.base.json',
11
21
  target: 'tsconfig.json',
12
22
  desc: 'TypeScript base configuration',
13
23
  },
24
+ 'claude-skill': {
25
+ source: 'tooling/claude/js-tooling.md',
26
+ target: '.claude/skills/js-tooling.md',
27
+ desc: 'Claude Code skill for driving the js-tooling CLI',
28
+ },
14
29
  };
15
30
  export function getPackageRoot() {
16
31
  const cliFile = new URL(import.meta.url).pathname;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.17.1",
3
+ "version": "2.19.0",
4
4
  "description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -9,6 +9,7 @@
9
9
  "eslint"
10
10
  ],
11
11
  "author": "Richard Torcato",
12
+ "packageManager": "pnpm@11.1.3",
12
13
  "sideEffects": false,
13
14
  "license": "MIT",
14
15
  "repository": {
@@ -23,6 +24,8 @@
23
24
  "build": "pnpm build-cli",
24
25
  "build-cli": "rimraf ./dist/cli && tsc --project src/cli/tsconfig.json",
25
26
  "prepublishOnly": "./scripts/fix-bins.sh",
27
+ "dev": "pnpm --filter @rtorcato/docs dev",
28
+ "docs:build": "pnpm --filter @rtorcato/docs build",
26
29
  "==================== Common ====================": "",
27
30
  "lint": "pnpm exec biome lint --config-path=tooling/biome/biome.json src scripts",
28
31
  "format": "pnpm exec biome format --config-path=tooling/biome/biome.json src scripts",
@@ -70,9 +73,12 @@
70
73
  "tooling/tests/ssr-safety.d.mts",
71
74
  "tooling/tsup/index.ts",
72
75
  "tooling/biome/biome.json",
76
+ "tooling/changesets/config.json",
77
+ "tooling/oxlint/oxlintrc.json",
73
78
  "tooling/typedoc/typedoc.json",
74
79
  "tooling/semantic-release/*.mjs",
75
80
  "tooling/semantic-release/*.d.mts",
81
+ "tooling/claude/*.md",
76
82
  "README.md"
77
83
  ],
78
84
  "exports": {
@@ -143,6 +149,8 @@
143
149
  },
144
150
  "./tsup": "./tooling/tsup/index.ts",
145
151
  "./biome": "./tooling/biome/biome.json",
152
+ "./changesets": "./tooling/changesets/config.json",
153
+ "./oxlint": "./tooling/oxlint/oxlintrc.json",
146
154
  "./typedoc": "./tooling/typedoc/typedoc.json",
147
155
  "./semantic-release": {
148
156
  "types": "./tooling/semantic-release/index.d.mts",
@@ -168,11 +176,13 @@
168
176
  "dependencies": {
169
177
  "chalk": "^5.6.2",
170
178
  "commander": "^14.0.3",
179
+ "diff": "^9.0.0",
171
180
  "fs-extra": "^11.3.2",
172
181
  "inquirer": "^14.0.2"
173
182
  },
174
183
  "devDependencies": {
175
184
  "@biomejs/biome": "^2.4.16",
185
+ "@types/diff": "^8.0.0",
176
186
  "@commitlint/cli": "^20.1.0",
177
187
  "@commitlint/config-conventional": "^21.0.2",
178
188
  "@commitlint/types": "^20.0.0",
@@ -190,8 +200,8 @@
190
200
  "@total-typescript/ts-reset": "0.6.1",
191
201
  "@types/fs-extra": "^11.0.4",
192
202
  "@types/node": "^25.9.2",
193
- "@typescript-eslint/eslint-plugin": "^8.46.2",
194
- "@typescript-eslint/parser": "^8.46.2",
203
+ "@typescript-eslint/eslint-plugin": "^8.61.1",
204
+ "@typescript-eslint/parser": "^8.61.1",
195
205
  "@vitejs/plugin-react": "^5.1.0",
196
206
  "@vitest/coverage-v8": "^4.0.3",
197
207
  "commitizen": "^4.3.1",
@@ -199,9 +209,9 @@
199
209
  "cz-conventional-changelog": "^3.3.0",
200
210
  "esbuild": "^0.28.0",
201
211
  "esbuild-node-externals": "^1.22.0",
202
- "eslint": "9.38.0",
212
+ "eslint": "10.5.0",
203
213
  "eslint-plugin-import": "^2.32.0",
204
- "eslint-plugin-jest": "29.0.1",
214
+ "eslint-plugin-jest": "29.15.2",
205
215
  "husky": "^9.1.7",
206
216
  "is-ci": "^4.1.0",
207
217
  "jest": "^29.7.0",
@@ -214,8 +224,8 @@
214
224
  "ts-jest": "^29.4.11",
215
225
  "tsup": "8.5.1",
216
226
  "typescript": "^5.9.3",
217
- "typescript-eslint": "^8.60.0",
218
- "vitest": "4.0.3"
227
+ "typescript-eslint": "^8.61.1",
228
+ "vitest": "^4.1.8"
219
229
  },
220
230
  "peerDependencies": {
221
231
  "@biomejs/biome": "^2.0.0",
@@ -232,6 +242,7 @@
232
242
  "@semantic-release/github": "^12.0.8",
233
243
  "@semantic-release/npm": "^13.0.0",
234
244
  "@semantic-release/release-notes-generator": "^14.0.0",
245
+ "@size-limit/preset-small-lib": "^12.0.0",
235
246
  "@total-typescript/ts-reset": "^0.6.0",
236
247
  "@typescript-eslint/eslint-plugin": "^8.0.0",
237
248
  "@typescript-eslint/parser": "^8.0.0",
@@ -245,7 +256,6 @@
245
256
  "prettier": "^3.0.0",
246
257
  "semantic-release": "^25.0.0",
247
258
  "size-limit": "^12.0.0",
248
- "@size-limit/preset-small-lib": "^12.0.0",
249
259
  "ts-jest": "^29.0.0",
250
260
  "tsup": "^8.0.0",
251
261
  "typescript": ">=5.0.0",
@@ -0,0 +1,35 @@
1
+ # Changesets preset
2
+
3
+ Shared [Changesets](https://github.com/changesets/changesets) configuration for projects using `@rtorcato/js-tooling`.
4
+
5
+ Changesets is a **monorepo-friendly alternative to semantic-release**. The release workflow is the same shape (CI bumps versions, generates a changelog, publishes to npm), but the *intent* is captured in changeset markdown files at PR time rather than parsed from commit messages.
6
+
7
+ Pick one — Changesets or semantic-release — per repo. The `doctor` check flags repos configured for both.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ npx @rtorcato/js-tooling copy changesets
13
+ ```
14
+
15
+ This scaffolds `.changeset/config.json` from this preset. After that:
16
+
17
+ ```bash
18
+ pnpm changeset # interactive — author a changeset for the current change
19
+ pnpm changeset version # consume changesets, bump versions, write CHANGELOG.md
20
+ pnpm changeset publish # publish to npm
21
+ ```
22
+
23
+ CI typically runs the [Changesets release bot](https://github.com/changesets/action), which opens a "Version Packages" PR when changesets are present and publishes on merge.
24
+
25
+ ## Why pick this over semantic-release
26
+
27
+ - **Monorepos** — Changesets handles multi-package version bumps in a way semantic-release doesn't out of the box.
28
+ - **Explicit intent** — Authors declare a change's bump level in the changeset file rather than encoding it in commit message conventions, which is more forgiving for human commits.
29
+ - **Pre-release flows** — `--snapshot` and `pre enter <tag>` are first-class.
30
+
31
+ ## Why pick semantic-release instead
32
+
33
+ - **Single-package repos** — Less ceremony; nothing to author per change.
34
+ - **Strict conventional-commits discipline already in place.**
35
+ - **Existing CI built around the `npm run release` path.**
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "public",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": []
11
+ }
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: js-tooling
3
+ description: Use when auditing or fixing TypeScript/JavaScript project tooling in a repo that depends on @rtorcato/js-tooling, or scaffolding a new project with it. Triggers on "audit my tooling", "fix tooling drift", "is my tsconfig/biome/vitest config right", "set up CI/semantic-release/dependabot", "scaffold a TS library/web-app/node-api", "run doctor", "run fix", or "/js-tooling". Drives the `@rtorcato/js-tooling` CLI non-interactively (--json --yes). NOT for hand-editing configs the CLI owns — let the CLI scaffold them so they stay in sync with the presets.
4
+ ---
5
+
6
+ # js-tooling
7
+
8
+ `@rtorcato/js-tooling` is a single-package TS/JS tooling distribution: every preset
9
+ (TypeScript, Biome, ESLint, Prettier, Vitest/Jest, Commitlint, semantic-release,
10
+ tsup/esbuild/Vite/Playwright) plus a CLI to scaffold and audit. Prefer the CLI over
11
+ hand-editing the configs it owns — a manual edit drifts from the preset and `doctor`
12
+ will flag it.
13
+
14
+ Every command takes `--json` and a non-interactive mode; pair with `--yes` for
15
+ autonomous use. `--json` implies `--yes` (a prompt would corrupt the JSON).
16
+
17
+ Run via `npx @rtorcato/js-tooling <cmd>` (or the local `js-tooling` bin if installed).
18
+ `-d <dir>` targets a directory other than cwd.
19
+
20
+ ## The two workflows you'll use most
21
+
22
+ ### Audit → fix → confirm (existing repo)
23
+
24
+ ```bash
25
+ npx @rtorcato/js-tooling doctor --json # findings
26
+ npx @rtorcato/js-tooling fix --yes --json # apply every fixable finding
27
+ npx @rtorcato/js-tooling doctor --json # confirm clean
28
+ ```
29
+
30
+ `doctor` returns `{ directory, results: [{ check, status, detail, hint? }] }`.
31
+ Status is one of:
32
+ - `ok` — configured correctly, nothing to do.
33
+ - `drift` — file exists but doesn't extend our preset. `fix` defaults the overwrite
34
+ prompt to **No**; `--yes` is required to overwrite.
35
+ - `missing` — required and absent → fix it.
36
+ - `optional-missing` — opt-in tool not configured. Only fix if the user wants that tool.
37
+
38
+ `fix` returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`.
39
+
40
+ ### Targeted fix (one concern)
41
+
42
+ ```bash
43
+ npx @rtorcato/js-tooling list --json # enumerate targets
44
+ npx @rtorcato/js-tooling fix <target> --yes --json # e.g. biome, vitest, dependabot, attw
45
+ npx @rtorcato/js-tooling fix <target> --dry-run --json # preview writes
46
+ npx @rtorcato/js-tooling fix <target> --diff # unified diff before confirming
47
+ ```
48
+
49
+ `list --json` is the source of truth for valid targets — read it, don't guess.
50
+
51
+ ## Scaffolding a new project
52
+
53
+ ```bash
54
+ # Quick: from a named preset (library | web-app | node-api | nextjs-app | react-app)
55
+ npx @rtorcato/js-tooling setup --preset library -d ./my-lib --skip-install
56
+
57
+ # Full control: validate a config against the schema, preview, then write
58
+ npx @rtorcato/js-tooling setup --config-schema > project-config.schema.json
59
+ npx @rtorcato/js-tooling setup --config project.json --dry-run # preview file list
60
+ npx @rtorcato/js-tooling setup --config project.json -d ./my-lib --skip-install
61
+ ```
62
+
63
+ ## Drift policy (don't surprise the user)
64
+
65
+ - Safe-merge fixers (`engines`, `husky`, `package-json`) never overwrite — they add/merge.
66
+ - Drift on a config file (`biome`, `tsconfig`, …) is only overwritten with `--yes`.
67
+ Before overwriting drift the user wrote by hand, show `fix <target> --diff` first.
68
+ - `optional-missing` ≠ broken. Don't install opt-in tools (typedoc, size-limit,
69
+ treeshake-check, attw, codeql) unless the user asked for that capability.
70
+
71
+ ## Rules
72
+
73
+ - Let the CLI own its configs. If `doctor` says `drift`, fix via the CLI, don't hand-patch.
74
+ - Use `--json` whenever you'll parse the result; use `--dry-run`/`--diff` before any
75
+ destructive overwrite.
76
+ - After a `fix`, re-run `doctor` to confirm the finding cleared.
77
+
78
+ Full docs: https://rtorcato.github.io/js-tooling/guides/cli/
@@ -0,0 +1,25 @@
1
+ # Oxlint preset
2
+
3
+ Shared [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) configuration for projects using `@rtorcato/js-tooling`.
4
+
5
+ Oxlint is a Rust-based linter that's 50–100× faster than ESLint. It is intentionally **additive** to Biome — Biome handles formatting and the broad lint baseline, Oxlint adds a faster pass for the type-aware and import rules Biome doesn't cover yet.
6
+
7
+ ## Usage
8
+
9
+ ```bash
10
+ npx @rtorcato/js-tooling copy oxlint
11
+ ```
12
+
13
+ This drops `.oxlintrc.json` at the project root, extending the conventions in this preset. Run it with:
14
+
15
+ ```bash
16
+ pnpm oxlint
17
+ # or
18
+ npx oxlint
19
+ ```
20
+
21
+ ## Notes
22
+
23
+ - Oxlint shares its rule catalog with ESLint's plugins (`typescript`, `unicorn`, `oxc`, `import`), so most ESLint rules you know already work here.
24
+ - The preset disables Biome-overlapping rules to keep CI noise down — Biome stays the source of truth for formatting and the baseline lint set.
25
+ - For projects without Biome, you can run Oxlint standalone and re-enable the `style` and `pedantic` categories.
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
3
+ "categories": {
4
+ "correctness": "error",
5
+ "perf": "warn",
6
+ "suspicious": "warn",
7
+ "pedantic": "off",
8
+ "style": "off",
9
+ "restriction": "off",
10
+ "nursery": "off"
11
+ },
12
+ "plugins": ["typescript", "unicorn", "oxc", "import"],
13
+ "rules": {
14
+ "no-console": "warn",
15
+ "no-debugger": "error",
16
+ "no-empty": "warn",
17
+ "no-unused-vars": "off",
18
+ "@typescript-eslint/no-unused-vars": [
19
+ "warn",
20
+ { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
21
+ ],
22
+ "unicorn/filename-case": "off",
23
+ "unicorn/no-null": "off",
24
+ "unicorn/prevent-abbreviations": "off",
25
+ "import/no-default-export": "off"
26
+ },
27
+ "ignorePatterns": ["node_modules", "dist", "build", "coverage", ".next", "*.d.ts"]
28
+ }