@rtorcato/js-tooling 2.10.0 ā 2.12.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/cli/commands/doctor.js +60 -1
- package/dist/cli/commands/fix-targets.js +95 -0
- package/dist/cli/commands/fix.js +101 -15
- package/dist/cli/commands/setup-presets.js +1 -1
- package/dist/cli/commands/setup.js +3 -0
- package/dist/cli/generators/misc.js +20 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/utils/lockfile.js +54 -0
- package/package.json +2 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
import {
|
|
4
|
+
import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
5
|
+
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
5
6
|
const PACKAGE = '@rtorcato/js-tooling';
|
|
6
7
|
const NODE_MIN_MAJOR = 22;
|
|
7
8
|
const NODE_LTS_REQUIREMENTS = {
|
|
@@ -606,6 +607,46 @@ async function checkTreeshakeSetup(dir, pkg) {
|
|
|
606
607
|
hint: 'Run `npx @rtorcato/js-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
|
|
607
608
|
};
|
|
608
609
|
}
|
|
610
|
+
function checkLockfile(lock) {
|
|
611
|
+
if (!lock) {
|
|
612
|
+
return {
|
|
613
|
+
check: 'lockfile',
|
|
614
|
+
status: 'optional-missing',
|
|
615
|
+
detail: 'no .js-tooling.json ā doctor cannot tell intentional opt-outs from drift',
|
|
616
|
+
hint: 'Run `npx @rtorcato/js-tooling fix lockfile` to record current choices',
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (lock.version > LOCKFILE_VERSION) {
|
|
620
|
+
return {
|
|
621
|
+
check: 'lockfile',
|
|
622
|
+
status: 'drift',
|
|
623
|
+
detail: `.js-tooling.json version ${lock.version} is newer than this CLI supports (v${LOCKFILE_VERSION})`,
|
|
624
|
+
hint: 'Upgrade @rtorcato/js-tooling to a release that supports this lockfile version',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
check: 'lockfile',
|
|
629
|
+
status: 'ok',
|
|
630
|
+
detail: `.js-tooling.json v${lock.version} (written by ${lock.writtenBy})`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async function checkCodeowners(dir) {
|
|
634
|
+
for (const candidate of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
635
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
636
|
+
return {
|
|
637
|
+
check: 'CODEOWNERS',
|
|
638
|
+
status: 'ok',
|
|
639
|
+
detail: `${candidate} found`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
check: 'CODEOWNERS',
|
|
645
|
+
status: 'optional-missing',
|
|
646
|
+
detail: 'no CODEOWNERS file',
|
|
647
|
+
hint: 'Run `npx @rtorcato/js-tooling fix codeowners` to scaffold .github/CODEOWNERS',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
609
650
|
async function checkGitLabCI(dir) {
|
|
610
651
|
for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
|
|
611
652
|
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
@@ -626,9 +667,11 @@ async function checkGitLabCI(dir) {
|
|
|
626
667
|
export async function runDoctor(dir) {
|
|
627
668
|
const targetDir = path.resolve(dir);
|
|
628
669
|
const pkg = await readPackageJson(targetDir);
|
|
670
|
+
const lock = await readLockfile(targetDir);
|
|
629
671
|
const results = [];
|
|
630
672
|
results.push(evaluateNodeVersion(process.version));
|
|
631
673
|
results.push(checkPackageJson(pkg));
|
|
674
|
+
results.push(checkLockfile(lock));
|
|
632
675
|
results.push(checkEnginesNode(pkg));
|
|
633
676
|
results.push(await checkEditorConfig(targetDir));
|
|
634
677
|
results.push(await checkNodeVersionPin(targetDir));
|
|
@@ -646,7 +689,23 @@ export async function runDoctor(dir) {
|
|
|
646
689
|
results.push(await checkDependabot(targetDir));
|
|
647
690
|
results.push(await checkCodeQL(targetDir));
|
|
648
691
|
results.push(await checkGitLabCI(targetDir));
|
|
692
|
+
results.push(await checkCodeowners(targetDir));
|
|
649
693
|
results.push(await checkTreeshakeSetup(targetDir, pkg));
|
|
694
|
+
// Lockfile-driven demotion: if the lock records an intentional opt-out for a
|
|
695
|
+
// check that's currently optional-missing, demote it to ok with a clear detail.
|
|
696
|
+
if (lock) {
|
|
697
|
+
return results.map((r) => {
|
|
698
|
+
if (r.status !== 'optional-missing')
|
|
699
|
+
return r;
|
|
700
|
+
if (!declinedInLock(lock, r.check))
|
|
701
|
+
return r;
|
|
702
|
+
return {
|
|
703
|
+
check: r.check,
|
|
704
|
+
status: 'ok',
|
|
705
|
+
detail: 'intentionally declined (.js-tooling.json)',
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
}
|
|
650
709
|
return results;
|
|
651
710
|
}
|
|
652
711
|
const STATUS_ICONS = {
|
|
@@ -20,7 +20,102 @@ export const FIX_TARGETS = {
|
|
|
20
20
|
'GitHub Actions': 'github-actions',
|
|
21
21
|
Dependabot: 'dependabot',
|
|
22
22
|
CodeQL: 'codeql',
|
|
23
|
+
CODEOWNERS: 'codeowners',
|
|
24
|
+
lockfile: 'lockfile',
|
|
25
|
+
'.js-tooling.json': 'lockfile',
|
|
23
26
|
};
|
|
24
27
|
export function getFixTargetForCheck(checkName) {
|
|
25
28
|
return FIX_TARGETS[checkName] ?? null;
|
|
26
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* For a given doctor check name, returns true when the lockfile records that
|
|
32
|
+
* the user intentionally opted out of the tool that check covers. Used by
|
|
33
|
+
* doctor to demote `optional-missing` to `ok` and by fix to print a conflict
|
|
34
|
+
* warning before overriding the recorded choice.
|
|
35
|
+
*/
|
|
36
|
+
export function declinedInLock(lock, checkName) {
|
|
37
|
+
if (!lock)
|
|
38
|
+
return false;
|
|
39
|
+
const c = lock.config;
|
|
40
|
+
switch (checkName) {
|
|
41
|
+
case 'TypeScript':
|
|
42
|
+
return c.typescript?.enabled === false;
|
|
43
|
+
case 'Biome':
|
|
44
|
+
return c.linting?.tool !== 'biome' && c.linting?.tool !== 'both';
|
|
45
|
+
case 'ESLint':
|
|
46
|
+
return c.linting?.tool !== 'eslint' && c.linting?.tool !== 'both';
|
|
47
|
+
case 'Prettier':
|
|
48
|
+
return c.formatting?.tool !== 'prettier';
|
|
49
|
+
case 'Vitest':
|
|
50
|
+
return c.testing?.framework !== 'vitest';
|
|
51
|
+
case 'Commitlint':
|
|
52
|
+
return c.commitLint === false;
|
|
53
|
+
case 'Husky':
|
|
54
|
+
case 'lint-staged':
|
|
55
|
+
case 'Husky pre-push':
|
|
56
|
+
return c.gitHooks === false;
|
|
57
|
+
case 'verify script':
|
|
58
|
+
// Verify is derived from other tools; only "declined" if none of typecheck/lint/test are enabled.
|
|
59
|
+
return (c.typescript?.enabled === false &&
|
|
60
|
+
c.linting?.tool === 'none' &&
|
|
61
|
+
c.testing?.framework === 'none');
|
|
62
|
+
case 'semantic-release':
|
|
63
|
+
return c.semanticRelease === false;
|
|
64
|
+
case 'Dependabot':
|
|
65
|
+
case 'CodeQL':
|
|
66
|
+
return c.securityAutomation === false;
|
|
67
|
+
default:
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* When a fixer is about to scaffold a tool, return the patch to apply to the
|
|
73
|
+
* lockfile's recorded choices so intent stays in sync with reality. Returns
|
|
74
|
+
* null when the target either doesn't change any recorded choice (e.g. the
|
|
75
|
+
* `verify` fixer is derived, or `engines` writes a universal field) or when
|
|
76
|
+
* the lockfile already reflects the change.
|
|
77
|
+
*/
|
|
78
|
+
export function lockfilePatchForTarget(target, lock) {
|
|
79
|
+
const c = lock.config;
|
|
80
|
+
switch (target) {
|
|
81
|
+
case 'biome':
|
|
82
|
+
if (c.linting.tool === 'biome' || c.linting.tool === 'both')
|
|
83
|
+
return null;
|
|
84
|
+
return {
|
|
85
|
+
linting: { tool: 'biome' },
|
|
86
|
+
formatting: { tool: 'biome' },
|
|
87
|
+
};
|
|
88
|
+
case 'eslint':
|
|
89
|
+
if (c.linting.tool === 'eslint' || c.linting.tool === 'both')
|
|
90
|
+
return null;
|
|
91
|
+
return {
|
|
92
|
+
linting: { tool: 'eslint', eslintConfig: c.linting.eslintConfig ?? 'base' },
|
|
93
|
+
formatting: { tool: 'prettier' },
|
|
94
|
+
};
|
|
95
|
+
case 'prettier':
|
|
96
|
+
if (c.formatting.tool === 'prettier')
|
|
97
|
+
return null;
|
|
98
|
+
return { formatting: { tool: 'prettier' } };
|
|
99
|
+
case 'vitest':
|
|
100
|
+
if (c.testing.framework === 'vitest')
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
testing: { framework: 'vitest', environment: c.testing.environment ?? 'node' },
|
|
104
|
+
};
|
|
105
|
+
case 'commitlint':
|
|
106
|
+
return c.commitLint ? null : { commitLint: true };
|
|
107
|
+
case 'husky':
|
|
108
|
+
return c.gitHooks ? null : { gitHooks: true };
|
|
109
|
+
case 'semantic-release':
|
|
110
|
+
return c.semanticRelease ? null : { semanticRelease: true };
|
|
111
|
+
case 'dependabot':
|
|
112
|
+
case 'codeql':
|
|
113
|
+
return c.securityAutomation ? null : { securityAutomation: true };
|
|
114
|
+
case 'tsconfig':
|
|
115
|
+
return c.typescript.enabled ? null : { typescript: { enabled: true, config: 'base' } };
|
|
116
|
+
case 'treeshake-check':
|
|
117
|
+
return c.treeshakeCheck ? null : { treeshakeCheck: true };
|
|
118
|
+
default:
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -6,13 +6,15 @@ import { generateSemanticReleaseConfig } from '../generators/build.js';
|
|
|
6
6
|
import { generateCommitlintConfig, generateHuskyConfig, generatePrePushHook, } from '../generators/git.js';
|
|
7
7
|
import { generateGitHubActions } from '../generators/github-actions.js';
|
|
8
8
|
import { generateESLintConfig, generatePrettierConfig } from '../generators/linting.js';
|
|
9
|
-
import { ensureEnginesNode, generateEditorConfig, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, } from '../generators/misc.js';
|
|
9
|
+
import { ensureEnginesNode, generateCodeowners, generateEditorConfig, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, } from '../generators/misc.js';
|
|
10
10
|
import { composeVerifyScriptFromPkg } from '../generators/package-json.js';
|
|
11
|
-
import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
|
|
12
11
|
import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
|
|
13
12
|
import { generateVitestConfig } from '../generators/testing.js';
|
|
13
|
+
import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
|
|
14
14
|
import { copyPreset } from '../utils/copy-preset.js';
|
|
15
|
+
import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } from '../utils/lockfile.js';
|
|
15
16
|
import { runDoctor } from './doctor.js';
|
|
17
|
+
import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
|
|
16
18
|
function inferProjectConfig(pkg) {
|
|
17
19
|
const deps = {
|
|
18
20
|
...(pkg?.dependencies ?? {}),
|
|
@@ -227,6 +229,18 @@ const FIXERS = [
|
|
|
227
229
|
return { filesWritten: ['.github/workflows/codeql.yml'] };
|
|
228
230
|
},
|
|
229
231
|
},
|
|
232
|
+
{
|
|
233
|
+
target: 'codeowners',
|
|
234
|
+
description: 'Scaffold .github/CODEOWNERS with commented examples',
|
|
235
|
+
appliesTo: ['CODEOWNERS'],
|
|
236
|
+
outputs: ['.github/CODEOWNERS'],
|
|
237
|
+
riskLevel: 'safe-add',
|
|
238
|
+
canFixDrift: false,
|
|
239
|
+
async run({ targetDir }) {
|
|
240
|
+
const written = await generateCodeowners(targetDir);
|
|
241
|
+
return { filesWritten: [written] };
|
|
242
|
+
},
|
|
243
|
+
},
|
|
230
244
|
{
|
|
231
245
|
target: 'editorconfig',
|
|
232
246
|
description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
|
|
@@ -344,6 +358,23 @@ const FIXERS = [
|
|
|
344
358
|
return { filesWritten: ['package.json'] };
|
|
345
359
|
},
|
|
346
360
|
},
|
|
361
|
+
{
|
|
362
|
+
target: 'lockfile',
|
|
363
|
+
description: `Scaffold ${LOCKFILE_NAME} recording current tool choices`,
|
|
364
|
+
appliesTo: ['lockfile'],
|
|
365
|
+
outputs: [LOCKFILE_NAME],
|
|
366
|
+
riskLevel: 'safe-add',
|
|
367
|
+
canFixDrift: false,
|
|
368
|
+
async run({ targetDir, pkg }) {
|
|
369
|
+
if (!pkg) {
|
|
370
|
+
console.log(chalk.yellow(' no package.json found ā skipping'));
|
|
371
|
+
return { filesWritten: [] };
|
|
372
|
+
}
|
|
373
|
+
const config = inferProjectConfig(pkg);
|
|
374
|
+
await writeLockfile(targetDir, config);
|
|
375
|
+
return { filesWritten: [LOCKFILE_NAME] };
|
|
376
|
+
},
|
|
377
|
+
},
|
|
347
378
|
];
|
|
348
379
|
export function getFixers() {
|
|
349
380
|
return FIXERS;
|
|
@@ -361,17 +392,37 @@ function logTargets() {
|
|
|
361
392
|
console.log(` ${chalk.green('ā')} ${chalk.bold(f.target)}: ${chalk.gray(f.description)}`);
|
|
362
393
|
}
|
|
363
394
|
}
|
|
364
|
-
|
|
395
|
+
export function listFixers() {
|
|
396
|
+
return FIXERS.map((f) => ({
|
|
397
|
+
target: f.target,
|
|
398
|
+
description: f.description,
|
|
399
|
+
appliesTo: f.appliesTo,
|
|
400
|
+
outputs: f.outputs,
|
|
401
|
+
riskLevel: f.riskLevel ?? 'destructive',
|
|
402
|
+
canFixDrift: f.canFixDrift ?? false,
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
|
|
365
406
|
if (dryRun) {
|
|
366
407
|
if (!silent) {
|
|
367
408
|
console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
|
|
368
409
|
}
|
|
369
410
|
return { filesWritten: [], dryRun: true };
|
|
370
411
|
}
|
|
371
|
-
const { filesWritten } = await fixer.run({ targetDir, pkg, result });
|
|
412
|
+
const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock });
|
|
372
413
|
if (!silent && filesWritten.length > 0) {
|
|
373
414
|
console.log(chalk.green(` ā
wrote ${filesWritten.join(', ')}`));
|
|
374
415
|
}
|
|
416
|
+
// Auto-resync the lockfile when a fix changes a recorded choice.
|
|
417
|
+
if (lock && fixer.target !== 'lockfile') {
|
|
418
|
+
const patch = lockfilePatchForTarget(fixer.target, lock);
|
|
419
|
+
if (patch) {
|
|
420
|
+
const ok = await updateLockfileConfig(targetDir, patch);
|
|
421
|
+
if (ok && !silent) {
|
|
422
|
+
console.log(chalk.dim(` ā» ${LOCKFILE_NAME} updated to reflect the new choice`));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
375
426
|
return { filesWritten, dryRun: false };
|
|
376
427
|
}
|
|
377
428
|
function promptMessageFor(fixer, result) {
|
|
@@ -400,8 +451,11 @@ async function confirmApply(fixer, result, assumeYes) {
|
|
|
400
451
|
]);
|
|
401
452
|
return confirm === true;
|
|
402
453
|
}
|
|
403
|
-
function recordFor(target, check, doctorStatus, status, filesWritten) {
|
|
404
|
-
|
|
454
|
+
function recordFor(target, check, doctorStatus, status, filesWritten, lockfileConflict = false) {
|
|
455
|
+
const base = { target, check, status, doctorStatus, filesWritten };
|
|
456
|
+
if (lockfileConflict)
|
|
457
|
+
base.lockfileConflict = true;
|
|
458
|
+
return base;
|
|
405
459
|
}
|
|
406
460
|
export async function fixCommand(target, options = {}) {
|
|
407
461
|
const targetDir = path.resolve(options.directory ?? process.cwd());
|
|
@@ -410,9 +464,34 @@ export async function fixCommand(target, options = {}) {
|
|
|
410
464
|
// JSON mode implies --yes so prompts don't corrupt the output stream.
|
|
411
465
|
const assumeYes = options.yes === true || json;
|
|
412
466
|
const silent = json;
|
|
467
|
+
if (options.list) {
|
|
468
|
+
const summary = listFixers();
|
|
469
|
+
if (json) {
|
|
470
|
+
console.log(JSON.stringify({ targets: summary }, null, 2));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
console.log(chalk.cyan('\nš§ Registered fix targets:\n'));
|
|
474
|
+
for (const f of summary) {
|
|
475
|
+
console.log(` ${chalk.green('ā')} ${chalk.bold(f.target)}`);
|
|
476
|
+
console.log(` ${chalk.gray(f.description)}`);
|
|
477
|
+
console.log(` ${chalk.dim(`risk=${f.riskLevel}, drift=${f.canFixDrift ? 'yes' : 'no'}, outputs=${f.outputs.join(', ')}`)}`);
|
|
478
|
+
}
|
|
479
|
+
console.log();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
413
482
|
const pkg = await readPackageJson(targetDir);
|
|
483
|
+
const lock = await readLockfile(targetDir);
|
|
414
484
|
const results = await runDoctor(targetDir);
|
|
415
485
|
const actions = [];
|
|
486
|
+
const noteLockConflict = (check) => {
|
|
487
|
+
if (!lock)
|
|
488
|
+
return false;
|
|
489
|
+
const conflict = declinedInLock(lock, check);
|
|
490
|
+
if (conflict && !silent) {
|
|
491
|
+
console.log(chalk.yellow(` ā ${LOCKFILE_NAME} says this tool was declined ā applying anyway will update the lockfile to reflect the new choice.`));
|
|
492
|
+
}
|
|
493
|
+
return conflict;
|
|
494
|
+
};
|
|
416
495
|
const emitJson = (resolvedTarget) => {
|
|
417
496
|
const payload = { directory: targetDir, target: resolvedTarget, actions };
|
|
418
497
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -436,7 +515,12 @@ export async function fixCommand(target, options = {}) {
|
|
|
436
515
|
}
|
|
437
516
|
const result = results.find((r) => fixer.appliesTo.includes(r.check)) ??
|
|
438
517
|
{ check: fixer.appliesTo[0] ?? fixer.target, status: 'missing', detail: '' };
|
|
439
|
-
|
|
518
|
+
// A check that's `ok` because the lockfile records an opt-out should still be
|
|
519
|
+
// fixable when the user explicitly targets it ā treat it as optional-missing
|
|
520
|
+
// so the override + lockfile resync paths run.
|
|
521
|
+
const lockfileDemoted = lock !== null && declinedInLock(lock, result.check);
|
|
522
|
+
const effectiveResult = result.status === 'ok' && lockfileDemoted ? { ...result, status: 'optional-missing' } : result;
|
|
523
|
+
if (effectiveResult.status === 'ok') {
|
|
440
524
|
actions.push(recordFor(fixer.target, result.check, 'ok', 'already-ok', []));
|
|
441
525
|
if (json)
|
|
442
526
|
return emitJson(fixer.target);
|
|
@@ -444,18 +528,19 @@ export async function fixCommand(target, options = {}) {
|
|
|
444
528
|
return;
|
|
445
529
|
}
|
|
446
530
|
if (!silent) {
|
|
447
|
-
console.log(chalk.cyan(`\nš§ ${fixer.target} ā ${chalk.bold(result.check)} is ${
|
|
531
|
+
console.log(chalk.cyan(`\nš§ ${fixer.target} ā ${chalk.bold(result.check)} is ${effectiveResult.status}\n`));
|
|
448
532
|
}
|
|
449
|
-
const
|
|
533
|
+
const conflict = noteLockConflict(result.check);
|
|
534
|
+
const ok = await confirmApply(fixer, effectiveResult, assumeYes);
|
|
450
535
|
if (!ok) {
|
|
451
|
-
actions.push(recordFor(fixer.target, result.check,
|
|
536
|
+
actions.push(recordFor(fixer.target, result.check, effectiveResult.status, 'skipped', [], conflict));
|
|
452
537
|
if (json)
|
|
453
538
|
return emitJson(fixer.target);
|
|
454
539
|
console.log(chalk.gray(' skipped\n'));
|
|
455
540
|
return;
|
|
456
541
|
}
|
|
457
|
-
const outcome = await applyFixer(fixer,
|
|
458
|
-
actions.push(recordFor(fixer.target, result.check,
|
|
542
|
+
const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent);
|
|
543
|
+
actions.push(recordFor(fixer.target, result.check, effectiveResult.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
|
|
459
544
|
if (json)
|
|
460
545
|
return emitJson(fixer.target);
|
|
461
546
|
console.log();
|
|
@@ -486,16 +571,17 @@ export async function fixCommand(target, options = {}) {
|
|
|
486
571
|
if (!silent) {
|
|
487
572
|
console.log(` ${chalk.bold(result.check)} (${result.status}) ā ${fixer.target}`);
|
|
488
573
|
}
|
|
574
|
+
const conflict = noteLockConflict(result.check);
|
|
489
575
|
const ok = await confirmApply(fixer, result, assumeYes);
|
|
490
576
|
if (!ok) {
|
|
491
|
-
actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
|
|
577
|
+
actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
|
|
492
578
|
if (!silent)
|
|
493
579
|
console.log(chalk.gray(' skipped'));
|
|
494
580
|
skippedCount++;
|
|
495
581
|
continue;
|
|
496
582
|
}
|
|
497
|
-
const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
|
|
498
|
-
actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten));
|
|
583
|
+
const outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent);
|
|
584
|
+
actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
|
|
499
585
|
appliedCount++;
|
|
500
586
|
}
|
|
501
587
|
if (json)
|
|
@@ -151,7 +151,7 @@ export function validateProjectConfig(input) {
|
|
|
151
151
|
return { valid: errors.length === 0, errors };
|
|
152
152
|
}
|
|
153
153
|
export function computeFileList(config) {
|
|
154
|
-
const files = ['package.json'];
|
|
154
|
+
const files = ['package.json', '.js-tooling.json'];
|
|
155
155
|
files.push('.editorconfig', '.nvmrc', 'knip.json');
|
|
156
156
|
if (config.typescript.enabled) {
|
|
157
157
|
files.push('tsconfig.json', 'reset.d.ts');
|
|
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import inquirer from 'inquirer';
|
|
5
5
|
import { generateConfigs } from '../generators/index.js';
|
|
6
6
|
import { installDependencies } from '../utils/install.js';
|
|
7
|
+
import { LOCKFILE_NAME, writeLockfile } from '../utils/lockfile.js';
|
|
7
8
|
import { buildPresetConfig, computeFileList, CONFIG_SCHEMA, PRESET_NAMES, validateProjectConfig, } from './setup-presets.js';
|
|
8
9
|
async function resolveConfig(options) {
|
|
9
10
|
if (options.config && options.preset) {
|
|
@@ -55,6 +56,7 @@ export async function setupProject(options) {
|
|
|
55
56
|
}
|
|
56
57
|
console.log(chalk.cyan('\nš Generating configuration files...\n'));
|
|
57
58
|
await generateConfigs(config, targetDir);
|
|
59
|
+
await writeLockfile(targetDir, config);
|
|
58
60
|
if (!options.skipInstall) {
|
|
59
61
|
console.log(chalk.cyan('\nš¦ Installing dependencies...\n'));
|
|
60
62
|
await installDependencies(config, targetDir);
|
|
@@ -264,6 +266,7 @@ function showNextSteps(config, _targetDir) {
|
|
|
264
266
|
if (config.gitHooks) {
|
|
265
267
|
steps.push('šŖ Commit your changes to test the git hooks');
|
|
266
268
|
}
|
|
269
|
+
steps.push(`š ${LOCKFILE_NAME} records your setup choices ā doctor uses it to suppress intentional opt-outs`);
|
|
267
270
|
steps.push('š Check the generated README.md for more details');
|
|
268
271
|
steps.forEach((step, index) => {
|
|
269
272
|
console.log(` ${index + 1}. ${step}`);
|
|
@@ -30,6 +30,20 @@ const SIZE_LIMIT_CONFIG = [
|
|
|
30
30
|
limit: '10 kB',
|
|
31
31
|
},
|
|
32
32
|
];
|
|
33
|
+
const CODEOWNERS_CONTENT = `# .github/CODEOWNERS
|
|
34
|
+
# Each line is a file pattern followed by one or more owners.
|
|
35
|
+
# Owners can be GitHub usernames (@user) or team names (@org/team).
|
|
36
|
+
# Order matters ā the last matching pattern wins.
|
|
37
|
+
# See https://docs.github.com/articles/about-code-owners/
|
|
38
|
+
#
|
|
39
|
+
# Examples:
|
|
40
|
+
# * @your-username
|
|
41
|
+
# /src/api/ @backend-team
|
|
42
|
+
# /docs/ @docs-team @your-username
|
|
43
|
+
# *.md @docs-team
|
|
44
|
+
|
|
45
|
+
* @TODO-owner
|
|
46
|
+
`;
|
|
33
47
|
export async function generateEditorConfig(targetDir) {
|
|
34
48
|
await fs.writeFile(path.join(targetDir, '.editorconfig'), EDITORCONFIG_CONTENT);
|
|
35
49
|
}
|
|
@@ -54,6 +68,12 @@ export async function generateKnipConfig(targetDir) {
|
|
|
54
68
|
export async function generateSizeLimitConfig(targetDir) {
|
|
55
69
|
await fs.writeJson(path.join(targetDir, '.size-limit.json'), SIZE_LIMIT_CONFIG, { spaces: 2 });
|
|
56
70
|
}
|
|
71
|
+
export async function generateCodeowners(targetDir) {
|
|
72
|
+
const filepath = path.join(targetDir, '.github', 'CODEOWNERS');
|
|
73
|
+
await fs.ensureDir(path.dirname(filepath));
|
|
74
|
+
await fs.writeFile(filepath, CODEOWNERS_CONTENT);
|
|
75
|
+
return '.github/CODEOWNERS';
|
|
76
|
+
}
|
|
57
77
|
export async function generateMiscBaseline(targetDir) {
|
|
58
78
|
await generateEditorConfig(targetDir);
|
|
59
79
|
await generateNvmrc(targetDir);
|
package/dist/cli/index.js
CHANGED
|
@@ -221,15 +221,20 @@ program
|
|
|
221
221
|
.option('--yes', 'Assume yes to all prompts (including drift overwrites)')
|
|
222
222
|
.option('--dry-run', 'Print what would change without writing files')
|
|
223
223
|
.option('--json', 'Emit machine-readable JSON output (implies --yes)')
|
|
224
|
+
.option('--list', 'List all registered fix targets and exit')
|
|
224
225
|
.action((target, options) => fixCommand(target, {
|
|
225
226
|
directory: options.directory,
|
|
226
227
|
yes: options.yes,
|
|
227
228
|
dryRun: options.dryRun,
|
|
228
229
|
json: options.json,
|
|
230
|
+
list: options.list,
|
|
229
231
|
}));
|
|
230
232
|
program.hook('preAction', async (_, actionCommand) => {
|
|
231
233
|
const name = actionCommand.name();
|
|
232
234
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
|
235
|
+
// `fix --list` is read-only and safe to run anywhere, including this repo.
|
|
236
|
+
if (name === 'fix' && actionCommand.opts().list)
|
|
237
|
+
return;
|
|
233
238
|
const dir = actionCommand.opts().directory ?? process.cwd();
|
|
234
239
|
if (await isSelfRepo(dir)) {
|
|
235
240
|
console.log(chalk.yellow('\nā ļø This command cannot be run inside the @rtorcato/js-tooling repo itself.\n'));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import packageJson from '../../../package.json' with { type: 'json' };
|
|
4
|
+
import { validateProjectConfig } from '../commands/setup-presets.js';
|
|
5
|
+
export const LOCKFILE_NAME = '.js-tooling.json';
|
|
6
|
+
export const LOCKFILE_VERSION = 1;
|
|
7
|
+
const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/js-tooling/schemas/lockfile.json';
|
|
8
|
+
export async function readLockfile(dir) {
|
|
9
|
+
const filepath = path.join(dir, LOCKFILE_NAME);
|
|
10
|
+
if (!(await fs.pathExists(filepath)))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const raw = (await fs.readJson(filepath));
|
|
14
|
+
if (typeof raw !== 'object' || raw === null)
|
|
15
|
+
return null;
|
|
16
|
+
const obj = raw;
|
|
17
|
+
if (typeof obj.version !== 'number')
|
|
18
|
+
return null;
|
|
19
|
+
if (typeof obj.config !== 'object' || obj.config === null)
|
|
20
|
+
return null;
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function writeLockfile(dir, config) {
|
|
28
|
+
const { valid, errors } = validateProjectConfig(config);
|
|
29
|
+
if (!valid) {
|
|
30
|
+
throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
|
|
31
|
+
}
|
|
32
|
+
const filepath = path.join(dir, LOCKFILE_NAME);
|
|
33
|
+
const lockfile = {
|
|
34
|
+
$schema: LOCKFILE_SCHEMA_URL,
|
|
35
|
+
version: LOCKFILE_VERSION,
|
|
36
|
+
config,
|
|
37
|
+
writtenBy: `@rtorcato/js-tooling@${packageJson.version}`,
|
|
38
|
+
writtenAt: new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
await fs.writeJson(filepath, lockfile, { spaces: 2 });
|
|
41
|
+
return filepath;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Patch a subset of a lockfile's config in place, preserving everything else.
|
|
45
|
+
* Returns true when the file was rewritten, false when no lockfile exists.
|
|
46
|
+
*/
|
|
47
|
+
export async function updateLockfileConfig(dir, patch) {
|
|
48
|
+
const existing = await readLockfile(dir);
|
|
49
|
+
if (!existing)
|
|
50
|
+
return false;
|
|
51
|
+
const merged = { ...existing.config, ...patch };
|
|
52
|
+
await writeLockfile(dir, merged);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtorcato/js-tooling",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -188,7 +188,7 @@
|
|
|
188
188
|
"commitizen": "^4.3.1",
|
|
189
189
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
190
190
|
"cz-conventional-changelog": "^3.3.0",
|
|
191
|
-
"esbuild": "^0.
|
|
191
|
+
"esbuild": "^0.28.0",
|
|
192
192
|
"esbuild-node-externals": "^1.22.0",
|
|
193
193
|
"eslint": "9.38.0",
|
|
194
194
|
"eslint-plugin-import": "^2.32.0",
|