@rtorcato/js-tooling 2.11.0 → 2.13.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.
@@ -630,6 +630,23 @@ function checkLockfile(lock) {
630
630
  detail: `.js-tooling.json v${lock.version} (written by ${lock.writtenBy})`,
631
631
  };
632
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
+ }
633
650
  async function checkGitLabCI(dir) {
634
651
  for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
635
652
  if (await fs.pathExists(path.join(dir, candidate))) {
@@ -672,6 +689,7 @@ export async function runDoctor(dir) {
672
689
  results.push(await checkDependabot(targetDir));
673
690
  results.push(await checkCodeQL(targetDir));
674
691
  results.push(await checkGitLabCI(targetDir));
692
+ results.push(await checkCodeowners(targetDir));
675
693
  results.push(await checkTreeshakeSetup(targetDir, pkg));
676
694
  // Lockfile-driven demotion: if the lock records an intentional opt-out for a
677
695
  // check that's currently optional-missing, demote it to ok with a clear detail.
@@ -20,6 +20,7 @@ export const FIX_TARGETS = {
20
20
  'GitHub Actions': 'github-actions',
21
21
  Dependabot: 'dependabot',
22
22
  CodeQL: 'codeql',
23
+ CODEOWNERS: 'codeowners',
23
24
  lockfile: 'lockfile',
24
25
  '.js-tooling.json': 'lockfile',
25
26
  };
@@ -6,7 +6,8 @@ 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
+ import { generateConfigs } from '../generators/index.js';
10
11
  import { composeVerifyScriptFromPkg } from '../generators/package-json.js';
11
12
  import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
12
13
  import { generateVitestConfig } from '../generators/testing.js';
@@ -15,6 +16,7 @@ import { copyPreset } from '../utils/copy-preset.js';
15
16
  import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } from '../utils/lockfile.js';
16
17
  import { runDoctor } from './doctor.js';
17
18
  import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
19
+ import { computeFileList } from './setup-presets.js';
18
20
  function inferProjectConfig(pkg) {
19
21
  const deps = {
20
22
  ...(pkg?.dependencies ?? {}),
@@ -229,6 +231,18 @@ const FIXERS = [
229
231
  return { filesWritten: ['.github/workflows/codeql.yml'] };
230
232
  },
231
233
  },
234
+ {
235
+ target: 'codeowners',
236
+ description: 'Scaffold .github/CODEOWNERS with commented examples',
237
+ appliesTo: ['CODEOWNERS'],
238
+ outputs: ['.github/CODEOWNERS'],
239
+ riskLevel: 'safe-add',
240
+ canFixDrift: false,
241
+ async run({ targetDir }) {
242
+ const written = await generateCodeowners(targetDir);
243
+ return { filesWritten: [written] };
244
+ },
245
+ },
232
246
  {
233
247
  target: 'editorconfig',
234
248
  description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
@@ -380,6 +394,16 @@ function logTargets() {
380
394
  console.log(` ${chalk.green('ā—')} ${chalk.bold(f.target)}: ${chalk.gray(f.description)}`);
381
395
  }
382
396
  }
397
+ export function listFixers() {
398
+ return FIXERS.map((f) => ({
399
+ target: f.target,
400
+ description: f.description,
401
+ appliesTo: f.appliesTo,
402
+ outputs: f.outputs,
403
+ riskLevel: f.riskLevel ?? 'destructive',
404
+ canFixDrift: f.canFixDrift ?? false,
405
+ }));
406
+ }
383
407
  async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
384
408
  if (dryRun) {
385
409
  if (!silent) {
@@ -442,6 +466,75 @@ export async function fixCommand(target, options = {}) {
442
466
  // JSON mode implies --yes so prompts don't corrupt the output stream.
443
467
  const assumeYes = options.yes === true || json;
444
468
  const silent = json;
469
+ if (options.list) {
470
+ const summary = listFixers();
471
+ if (json) {
472
+ console.log(JSON.stringify({ targets: summary }, null, 2));
473
+ return;
474
+ }
475
+ console.log(chalk.cyan('\nšŸ”§ Registered fix targets:\n'));
476
+ for (const f of summary) {
477
+ console.log(` ${chalk.green('ā—')} ${chalk.bold(f.target)}`);
478
+ console.log(` ${chalk.gray(f.description)}`);
479
+ console.log(` ${chalk.dim(`risk=${f.riskLevel}, drift=${f.canFixDrift ? 'yes' : 'no'}, outputs=${f.outputs.join(', ')}`)}`);
480
+ }
481
+ console.log();
482
+ return;
483
+ }
484
+ if (options.resync) {
485
+ if (target) {
486
+ console.error(chalk.red('\nāŒ --resync cannot be combined with a [target] argument\n'));
487
+ process.exit(1);
488
+ }
489
+ const resyncLock = await readLockfile(targetDir);
490
+ if (!resyncLock) {
491
+ if (json) {
492
+ console.log(JSON.stringify({ directory: targetDir, error: 'no-lockfile', hint: 'run `fix lockfile` first' }, null, 2));
493
+ }
494
+ else {
495
+ console.error(chalk.red(`\nāŒ No ${LOCKFILE_NAME} found — run \`fix lockfile\` first to record choices\n`));
496
+ }
497
+ process.exit(1);
498
+ }
499
+ const files = computeFileList(resyncLock.config);
500
+ if (!silent) {
501
+ console.log(chalk.cyan(`\nšŸ”„ Resync from ${LOCKFILE_NAME} (${files.length} files in scope)\n`));
502
+ }
503
+ if (dryRun) {
504
+ if (json) {
505
+ console.log(JSON.stringify({ directory: targetDir, mode: 'resync', dryRun: true, files }, null, 2));
506
+ }
507
+ else {
508
+ for (const f of files)
509
+ console.log(chalk.cyan(` [dry-run] would write: ${f}`));
510
+ console.log();
511
+ }
512
+ return;
513
+ }
514
+ if (!assumeYes) {
515
+ const { confirm } = await inquirer.prompt([
516
+ {
517
+ type: 'confirm',
518
+ name: 'confirm',
519
+ message: `Re-scaffold ${files.length} file(s) from ${LOCKFILE_NAME}? Generators preserve existing customizations where possible, but README.md will be rewritten.`,
520
+ default: false,
521
+ },
522
+ ]);
523
+ if (!confirm) {
524
+ console.log(chalk.gray(' skipped\n'));
525
+ return;
526
+ }
527
+ }
528
+ await generateConfigs(resyncLock.config, targetDir);
529
+ await writeLockfile(targetDir, resyncLock.config);
530
+ if (json) {
531
+ console.log(JSON.stringify({ directory: targetDir, mode: 'resync', dryRun: false, files }, null, 2));
532
+ }
533
+ else {
534
+ console.log(chalk.green(` āœ… resynced ${files.length} file(s)\n`));
535
+ }
536
+ return;
537
+ }
445
538
  const pkg = await readPackageJson(targetDir);
446
539
  const lock = await readLockfile(targetDir);
447
540
  const results = await runDoctor(targetDir);
@@ -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,22 @@ 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')
225
+ .option('--resync', 'Re-scaffold every file recorded in .js-tooling.json')
224
226
  .action((target, options) => fixCommand(target, {
225
227
  directory: options.directory,
226
228
  yes: options.yes,
227
229
  dryRun: options.dryRun,
228
230
  json: options.json,
231
+ list: options.list,
232
+ resync: options.resync,
229
233
  }));
230
234
  program.hook('preAction', async (_, actionCommand) => {
231
235
  const name = actionCommand.name();
232
236
  if (name === 'setup' || name === 'doctor' || name === 'fix') {
237
+ // `fix --list` is read-only and safe to run anywhere, including this repo.
238
+ if (name === 'fix' && actionCommand.opts().list)
239
+ return;
233
240
  const dir = actionCommand.opts().directory ?? process.cwd();
234
241
  if (await isSelfRepo(dir)) {
235
242
  console.log(chalk.yellow('\nāš ļø This command cannot be run inside the @rtorcato/js-tooling repo itself.\n'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
5
5
  "type": "module",
6
6
  "keywords": [