@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 +54 -1
- package/dist/cli/commands/doctor.js +43 -7
- package/dist/cli/commands/fix.js +244 -0
- package/dist/cli/commands/setup-presets.js +6 -0
- package/dist/cli/commands/setup.js +19 -5
- package/dist/cli/generators/agent-rules.js +67 -0
- package/dist/cli/generators/build.js +11 -0
- package/dist/cli/generators/linting.js +11 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/utils/copy-preset.js +15 -0
- package/package.json +18 -8
- package/tooling/changesets/README.md +35 -0
- package/tooling/changesets/config.json +11 -0
- package/tooling/claude/js-tooling.md +78 -0
- package/tooling/oxlint/README.md +25 -0
- package/tooling/oxlint/oxlintrc.json +28 -0
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
|
-
##
|
|
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['
|
|
684
|
-
const hasScript = Object.values(scripts).some((s) => /\battw\b
|
|
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: '
|
|
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: '
|
|
697
|
-
hint: '
|
|
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: '
|
|
704
|
-
hint: 'Run `
|
|
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) {
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -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: '
|
|
188
|
-
name: '
|
|
189
|
-
message: '🚀
|
|
190
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
194
|
-
"@typescript-eslint/parser": "^8.
|
|
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": "
|
|
212
|
+
"eslint": "10.5.0",
|
|
203
213
|
"eslint-plugin-import": "^2.32.0",
|
|
204
|
-
"eslint-plugin-jest": "29.
|
|
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.
|
|
218
|
-
"vitest": "4.
|
|
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
|
+
}
|