@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.
@@ -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 { getFixTargetForCheck } from './fix-targets.js';
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
+ }
@@ -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
- async function applyFixer(fixer, result, targetDir, pkg, dryRun, silent) {
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
- return { target, check, status, doctorStatus, filesWritten };
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
- if (result.status === 'ok') {
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 ${result.status}\n`));
531
+ console.log(chalk.cyan(`\nšŸ”§ ${fixer.target} — ${chalk.bold(result.check)} is ${effectiveResult.status}\n`));
448
532
  }
449
- const ok = await confirmApply(fixer, result, assumeYes);
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, result.status, 'skipped', []));
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, result, targetDir, pkg, dryRun, silent);
458
- actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten));
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.10.0",
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.25.11",
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",