@rtorcato/js-tooling 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.
9
9
  [![Coverage](https://codecov.io/gh/rtorcato/js-tooling/branch/main/graph/badge.svg)](https://codecov.io/gh/rtorcato/js-tooling)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
11
11
 
12
- Most tooling libraries give you one piece — just TypeScript configs, or just an ESLint preset. **js-tooling** covers the entire lifecycle: TypeScript, Biome/ESLint, Vitest/Jest, Commitlint, Husky, Semantic Release, and GitHub Actions CI — all wired together. The interactive `setup` wizard scaffolds everything in one shot; `doctor` checks an existing project for drift.
12
+ Most tooling libraries give you one piece — just TypeScript configs, or just an ESLint preset. **js-tooling** covers the entire lifecycle: TypeScript, Biome/ESLint, Vitest/Jest, Commitlint, Husky, Semantic Release, GitHub Actions CI, and supply-chain security (Dependabot + CodeQL) — all wired together. The interactive `setup` wizard scaffolds everything in one shot; `doctor` checks an existing project for drift; `fix` applies the missing pieces incrementally.
13
13
 
14
14
  **[Full documentation →](https://rtorcato.github.io/js-tooling/)**
15
15
 
@@ -23,6 +23,8 @@ npx @rtorcato/js-tooling setup
23
23
 
24
24
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
25
25
 
26
+ **v2.4.0** — New `fix` command applies scaffolders for items `doctor` flags, with `--yes` and `--dry-run` flags. Drift never auto-overwrites — every existing file you'd lose is confirmed first. Doctor grew checks for `engines.node`, `.editorconfig`, `.nvmrc`, Husky, `lint-staged`, semantic-release, knip, GitHub Actions, GitLab CI, Dependabot, and CodeQL — plus a `Next steps:` footer that names the exact `fix` command to run for each finding. Setup wizard adds a "Include security automation?" prompt for Dependabot + CodeQL.
27
+
26
28
  **v2.0.0** — All 39 tool packages moved from `dependencies` to `peerDependencies`. Add them to your own `devDependencies`. Also ships: `doctor` subcommand, generator unit tests, Dependabot, CI matrix (Node 22 + 24).
27
29
 
28
30
  **v1.1.0** — Stricter commitlint limits, fix for CLI path resolution when copying configs.
@@ -1,6 +1,7 @@
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
5
  const PACKAGE = '@rtorcato/js-tooling';
5
6
  const NODE_MIN_MAJOR = 22;
6
7
  const NODE_LTS_REQUIREMENTS = {
@@ -393,6 +394,67 @@ async function checkGitHubActions(dir) {
393
394
  };
394
395
  }
395
396
  }
397
+ async function checkDependabot(dir) {
398
+ for (const candidate of ['.github/dependabot.yml', '.github/dependabot.yaml']) {
399
+ if (await fs.pathExists(path.join(dir, candidate))) {
400
+ return {
401
+ check: 'Dependabot',
402
+ status: 'ok',
403
+ detail: `${candidate} found`,
404
+ };
405
+ }
406
+ }
407
+ return {
408
+ check: 'Dependabot',
409
+ status: 'optional-missing',
410
+ detail: 'no .github/dependabot.yml',
411
+ hint: 'Run `npx @rtorcato/js-tooling fix dependabot` to scaffold weekly dep updates',
412
+ };
413
+ }
414
+ async function checkCodeQL(dir) {
415
+ const workflowsDir = path.join(dir, '.github', 'workflows');
416
+ if (!(await fs.pathExists(workflowsDir))) {
417
+ return {
418
+ check: 'CodeQL',
419
+ status: 'optional-missing',
420
+ detail: 'no .github/workflows/',
421
+ hint: 'Run `npx @rtorcato/js-tooling fix codeql` to scaffold CodeQL security scanning',
422
+ };
423
+ }
424
+ for (const candidate of ['codeql.yml', 'codeql.yaml']) {
425
+ if (await fs.pathExists(path.join(workflowsDir, candidate))) {
426
+ return {
427
+ check: 'CodeQL',
428
+ status: 'ok',
429
+ detail: `.github/workflows/${candidate} found`,
430
+ };
431
+ }
432
+ }
433
+ try {
434
+ const files = await fs.readdir(workflowsDir);
435
+ for (const f of files) {
436
+ if (!(f.endsWith('.yml') || f.endsWith('.yaml')))
437
+ continue;
438
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
439
+ if (/github\/codeql-action/.test(content)) {
440
+ return {
441
+ check: 'CodeQL',
442
+ status: 'ok',
443
+ detail: `codeql-action referenced in ${f}`,
444
+ };
445
+ }
446
+ }
447
+ }
448
+ catch {
449
+ // fall through to optional-missing
450
+ }
451
+ return {
452
+ check: 'CodeQL',
453
+ status: 'optional-missing',
454
+ detail: 'no codeql workflow found',
455
+ hint: 'Run `npx @rtorcato/js-tooling fix codeql` to scaffold CodeQL security scanning',
456
+ };
457
+ }
396
458
  async function checkGitLabCI(dir) {
397
459
  for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
398
460
  if (await fs.pathExists(path.join(dir, candidate))) {
@@ -427,6 +489,8 @@ export async function runDoctor(dir) {
427
489
  results.push(await checkSemanticRelease(targetDir, pkg));
428
490
  results.push(await checkKnip(targetDir, pkg));
429
491
  results.push(await checkGitHubActions(targetDir));
492
+ results.push(await checkDependabot(targetDir));
493
+ results.push(await checkCodeQL(targetDir));
430
494
  results.push(await checkGitLabCI(targetDir));
431
495
  return results;
432
496
  }
@@ -448,6 +512,30 @@ function statusLabel(status) {
448
512
  return chalk.gray('not configured');
449
513
  }
450
514
  }
515
+ const MAX_NEXT_STEP_SUGGESTIONS = 8;
516
+ export function nextStepSuggestions(results) {
517
+ const fixable = results.filter((r) => r.status === 'drift' || r.status === 'missing' || r.status === 'optional-missing');
518
+ const lines = [];
519
+ let overflow = 0;
520
+ for (const r of fixable) {
521
+ const target = getFixTargetForCheck(r.check);
522
+ if (!target)
523
+ continue;
524
+ if (lines.length >= MAX_NEXT_STEP_SUGGESTIONS) {
525
+ overflow++;
526
+ continue;
527
+ }
528
+ const verb = r.status === 'drift' ? 'align' : 'scaffold';
529
+ lines.push(`Run \`npx @rtorcato/js-tooling fix ${target}\` to ${verb} ${r.check}`);
530
+ }
531
+ if (overflow > 0) {
532
+ lines.push(`...and ${overflow} more — run \`npx @rtorcato/js-tooling fix\` to walk all findings`);
533
+ }
534
+ else if (lines.length > 0) {
535
+ lines.push('Run `npx @rtorcato/js-tooling fix` to walk all findings interactively');
536
+ }
537
+ return lines;
538
+ }
451
539
  export function summarize(results) {
452
540
  return {
453
541
  ok: results.filter((r) => r.status === 'ok').length,
@@ -474,6 +562,14 @@ export async function doctorCommand(options = {}) {
474
562
  const summary = summarize(results);
475
563
  console.log();
476
564
  console.log(` Summary: ${chalk.green(`${summary.ok} ok`)}, ${chalk.yellow(`${summary.drift} drift`)}, ${chalk.red(`${summary.missing} missing`)}, ${chalk.gray(`${summary.optionalMissing} not configured`)}\n`);
565
+ const suggestions = nextStepSuggestions(results);
566
+ if (suggestions.length > 0) {
567
+ console.log(chalk.bold(' Next steps:'));
568
+ for (const s of suggestions) {
569
+ console.log(` ${chalk.gray('-')} ${s}`);
570
+ }
571
+ console.log();
572
+ }
477
573
  }
478
574
  const summary = summarize(results);
479
575
  const exitCode = summary.drift > 0 || summary.missing > 0 ? 1 : 0;
@@ -0,0 +1,22 @@
1
+ export const FIX_TARGETS = {
2
+ 'package.json': 'package-json',
3
+ 'engines.node': 'engines',
4
+ EditorConfig: 'editorconfig',
5
+ 'Node version pin': 'nvmrc',
6
+ TypeScript: 'tsconfig',
7
+ Biome: 'biome',
8
+ ESLint: 'eslint',
9
+ Prettier: 'prettier',
10
+ Vitest: 'vitest',
11
+ Commitlint: 'commitlint',
12
+ Husky: 'husky',
13
+ 'lint-staged': 'husky',
14
+ 'semantic-release': 'semantic-release',
15
+ knip: 'knip',
16
+ 'GitHub Actions': 'github-actions',
17
+ Dependabot: 'dependabot',
18
+ CodeQL: 'codeql',
19
+ };
20
+ export function getFixTargetForCheck(checkName) {
21
+ return FIX_TARGETS[checkName] ?? null;
22
+ }
@@ -0,0 +1,399 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import inquirer from 'inquirer';
5
+ import { generateSemanticReleaseConfig } from '../generators/build.js';
6
+ import { generateCommitlintConfig, generateHuskyConfig } from '../generators/git.js';
7
+ import { generateGitHubActions } from '../generators/github-actions.js';
8
+ import { generateESLintConfig, generatePrettierConfig } from '../generators/linting.js';
9
+ import { ensureEnginesNode, generateEditorConfig, generateKnipConfig, generateNvmrc, } from '../generators/misc.js';
10
+ import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
11
+ import { generateVitestConfig } from '../generators/testing.js';
12
+ import { copyPreset } from '../utils/copy-preset.js';
13
+ import { runDoctor } from './doctor.js';
14
+ function inferProjectConfig(pkg) {
15
+ const deps = {
16
+ ...(pkg?.dependencies ?? {}),
17
+ ...(pkg?.devDependencies ?? {}),
18
+ };
19
+ let projectType = 'library';
20
+ if (deps.next)
21
+ projectType = 'nextjs-app';
22
+ else if (deps['react-dom'])
23
+ projectType = 'react-app';
24
+ return {
25
+ projectName: pkg?.name ?? 'project',
26
+ projectType,
27
+ typescript: { enabled: true, config: projectType === 'nextjs-app' ? 'next' : 'base' },
28
+ linting: {
29
+ tool: 'biome',
30
+ eslintConfig: projectType === 'nextjs-app' ? 'nextjs' : 'base',
31
+ },
32
+ formatting: { tool: 'biome' },
33
+ testing: { framework: 'vitest', environment: 'node' },
34
+ gitHooks: true,
35
+ commitLint: true,
36
+ semanticRelease: pkg?.private !== true,
37
+ securityAutomation: true,
38
+ bundler: 'tsup',
39
+ };
40
+ }
41
+ async function readPackageJson(dir) {
42
+ const filepath = path.join(dir, 'package.json');
43
+ if (!(await fs.pathExists(filepath)))
44
+ return null;
45
+ try {
46
+ return (await fs.readJson(filepath));
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ const FIXERS = [
53
+ {
54
+ target: 'biome',
55
+ description: 'Scaffold biome.json extending the @rtorcato/js-tooling preset',
56
+ appliesTo: ['Biome'],
57
+ outputs: ['biome.json'],
58
+ canFixDrift: true,
59
+ async run({ targetDir }) {
60
+ const result = await copyPreset('biome', targetDir);
61
+ return { filesWritten: [result.target] };
62
+ },
63
+ },
64
+ {
65
+ target: 'tsconfig',
66
+ description: 'Scaffold tsconfig.json extending the @rtorcato/js-tooling preset',
67
+ appliesTo: ['TypeScript'],
68
+ outputs: ['tsconfig.json'],
69
+ canFixDrift: true,
70
+ async run({ targetDir }) {
71
+ const result = await copyPreset('tsconfig', targetDir);
72
+ return { filesWritten: [result.target] };
73
+ },
74
+ },
75
+ {
76
+ target: 'eslint',
77
+ description: 'Scaffold eslint.config.mjs importing the @rtorcato/js-tooling preset',
78
+ appliesTo: ['ESLint'],
79
+ outputs: ['eslint.config.mjs'],
80
+ canFixDrift: true,
81
+ async run({ targetDir, pkg }) {
82
+ await generateESLintConfig(inferProjectConfig(pkg), targetDir);
83
+ return { filesWritten: ['eslint.config.mjs'] };
84
+ },
85
+ },
86
+ {
87
+ target: 'prettier',
88
+ description: 'Scaffold prettier.config.mjs re-exporting the preset',
89
+ appliesTo: ['Prettier'],
90
+ outputs: ['prettier.config.mjs'],
91
+ canFixDrift: true,
92
+ async run({ targetDir }) {
93
+ await generatePrettierConfig(targetDir);
94
+ return { filesWritten: ['prettier.config.mjs'] };
95
+ },
96
+ },
97
+ {
98
+ target: 'vitest',
99
+ description: 'Scaffold vitest.config.ts (preserves vitest.setup.ts if present)',
100
+ appliesTo: ['Vitest'],
101
+ outputs: ['vitest.config.ts'],
102
+ canFixDrift: true,
103
+ async run({ targetDir, pkg }) {
104
+ const setupPath = path.join(targetDir, 'vitest.setup.ts');
105
+ const hadSetup = await fs.pathExists(setupPath);
106
+ const savedSetup = hadSetup ? await fs.readFile(setupPath, 'utf-8') : null;
107
+ await generateVitestConfig(inferProjectConfig(pkg), targetDir);
108
+ if (hadSetup && savedSetup !== null) {
109
+ await fs.writeFile(setupPath, savedSetup);
110
+ }
111
+ return { filesWritten: ['vitest.config.ts'] };
112
+ },
113
+ },
114
+ {
115
+ target: 'commitlint',
116
+ description: 'Scaffold commitlint.config.mjs exporting the preset',
117
+ appliesTo: ['Commitlint'],
118
+ outputs: ['commitlint.config.mjs'],
119
+ canFixDrift: true,
120
+ async run({ targetDir }) {
121
+ await generateCommitlintConfig(targetDir);
122
+ return { filesWritten: ['commitlint.config.mjs'] };
123
+ },
124
+ },
125
+ {
126
+ target: 'husky',
127
+ description: 'Set up Husky + lint-staged (deep-merges existing lint-staged field)',
128
+ appliesTo: ['Husky', 'lint-staged'],
129
+ outputs: ['.husky/pre-commit', 'package.json (lint-staged field)'],
130
+ canFixDrift: true,
131
+ async run({ targetDir, pkg }) {
132
+ const pkgPath = path.join(targetDir, 'package.json');
133
+ const existingLintStaged = pkg?.['lint-staged'] ?? {};
134
+ await generateHuskyConfig(inferProjectConfig(pkg), targetDir);
135
+ const updated = (await fs.readJson(pkgPath));
136
+ const generated = updated['lint-staged'] ?? {};
137
+ updated['lint-staged'] = { ...generated, ...existingLintStaged };
138
+ await fs.writeJson(pkgPath, updated, { spaces: 2 });
139
+ return { filesWritten: ['.husky/pre-commit', 'package.json'] };
140
+ },
141
+ },
142
+ {
143
+ target: 'semantic-release',
144
+ description: 'Scaffold release.config.mjs (skipped on private packages)',
145
+ appliesTo: ['semantic-release'],
146
+ outputs: ['release.config.mjs'],
147
+ canFixDrift: true,
148
+ async run({ targetDir, pkg }) {
149
+ if (pkg?.private === true) {
150
+ console.log(chalk.gray(' skipping — package is private'));
151
+ return { filesWritten: [] };
152
+ }
153
+ await generateSemanticReleaseConfig(targetDir);
154
+ return { filesWritten: ['release.config.mjs'] };
155
+ },
156
+ },
157
+ {
158
+ target: 'github-actions',
159
+ description: 'Scaffold .github/workflows/ci.yml',
160
+ appliesTo: ['GitHub Actions'],
161
+ outputs: ['.github/workflows/ci.yml'],
162
+ canFixDrift: true,
163
+ async run({ targetDir, pkg }) {
164
+ await generateGitHubActions(inferProjectConfig(pkg), targetDir);
165
+ return { filesWritten: ['.github/workflows/ci.yml'] };
166
+ },
167
+ },
168
+ {
169
+ target: 'dependabot',
170
+ description: 'Scaffold .github/dependabot.yml (weekly npm + actions updates)',
171
+ appliesTo: ['Dependabot'],
172
+ outputs: ['.github/dependabot.yml'],
173
+ async run({ targetDir }) {
174
+ await generateDependabotConfig(targetDir);
175
+ return { filesWritten: ['.github/dependabot.yml'] };
176
+ },
177
+ },
178
+ {
179
+ target: 'codeql',
180
+ description: 'Scaffold .github/workflows/codeql.yml (security scanning)',
181
+ appliesTo: ['CodeQL'],
182
+ outputs: ['.github/workflows/codeql.yml'],
183
+ async run({ targetDir }) {
184
+ await generateCodeQLWorkflow(targetDir);
185
+ return { filesWritten: ['.github/workflows/codeql.yml'] };
186
+ },
187
+ },
188
+ {
189
+ target: 'editorconfig',
190
+ description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
191
+ appliesTo: ['EditorConfig'],
192
+ outputs: ['.editorconfig'],
193
+ canFixDrift: true,
194
+ async run({ targetDir }) {
195
+ await generateEditorConfig(targetDir);
196
+ return { filesWritten: ['.editorconfig'] };
197
+ },
198
+ },
199
+ {
200
+ target: 'nvmrc',
201
+ description: 'Scaffold .nvmrc pinned to Node 22',
202
+ appliesTo: ['Node version pin'],
203
+ outputs: ['.nvmrc'],
204
+ canFixDrift: true,
205
+ async run({ targetDir }) {
206
+ await generateNvmrc(targetDir);
207
+ return { filesWritten: ['.nvmrc'] };
208
+ },
209
+ },
210
+ {
211
+ target: 'engines',
212
+ description: 'Add engines.node to package.json (never overwrites)',
213
+ appliesTo: ['engines.node'],
214
+ outputs: ['package.json (engines.node field)'],
215
+ canFixDrift: true,
216
+ async run({ targetDir }) {
217
+ const result = await ensureEnginesNode(targetDir);
218
+ return { filesWritten: result === 'added' ? ['package.json'] : [] };
219
+ },
220
+ },
221
+ {
222
+ target: 'knip',
223
+ description: 'Scaffold knip.json with default entry/project globs',
224
+ appliesTo: ['knip'],
225
+ outputs: ['knip.json'],
226
+ canFixDrift: true,
227
+ async run({ targetDir }) {
228
+ await generateKnipConfig(targetDir);
229
+ return { filesWritten: ['knip.json'] };
230
+ },
231
+ },
232
+ {
233
+ target: 'package-json',
234
+ description: 'Add @rtorcato/js-tooling to devDependencies',
235
+ appliesTo: ['package.json'],
236
+ outputs: ['package.json (devDependencies)'],
237
+ canFixDrift: true,
238
+ async run({ targetDir, pkg }) {
239
+ const pkgPath = path.join(targetDir, 'package.json');
240
+ if (!pkg) {
241
+ console.log(chalk.yellow(' no package.json found — skipping'));
242
+ return { filesWritten: [] };
243
+ }
244
+ const updated = { ...pkg };
245
+ const devDeps = {
246
+ ...(updated.devDependencies ?? {}),
247
+ };
248
+ devDeps['@rtorcato/js-tooling'] = 'latest';
249
+ updated.devDependencies = devDeps;
250
+ await fs.writeJson(pkgPath, updated, { spaces: 2 });
251
+ console.log(chalk.dim(' reminder: run `pnpm install` to install the new dep'));
252
+ return { filesWritten: ['package.json'] };
253
+ },
254
+ },
255
+ ];
256
+ export function getFixers() {
257
+ return FIXERS;
258
+ }
259
+ function findFixer(target) {
260
+ const normalized = target.toLowerCase();
261
+ return FIXERS.find((f) => f.target.toLowerCase() === normalized);
262
+ }
263
+ function findFixerForCheck(checkName) {
264
+ return FIXERS.find((f) => f.appliesTo.includes(checkName));
265
+ }
266
+ function logTargets() {
267
+ console.log(chalk.gray('Available fix targets:'));
268
+ for (const f of FIXERS) {
269
+ console.log(` ${chalk.green('●')} ${chalk.bold(f.target)}: ${chalk.gray(f.description)}`);
270
+ }
271
+ }
272
+ async function applyFixer(fixer, result, targetDir, pkg, dryRun, silent) {
273
+ if (dryRun) {
274
+ if (!silent) {
275
+ console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
276
+ }
277
+ return { filesWritten: [], dryRun: true };
278
+ }
279
+ const { filesWritten } = await fixer.run({ targetDir, pkg, result });
280
+ if (!silent && filesWritten.length > 0) {
281
+ console.log(chalk.green(` ✅ wrote ${filesWritten.join(', ')}`));
282
+ }
283
+ return { filesWritten, dryRun: false };
284
+ }
285
+ async function confirmApply(fixer, result, assumeYes) {
286
+ if (assumeYes)
287
+ return true;
288
+ const isDrift = result.status === 'drift';
289
+ const message = isDrift
290
+ ? `⚠️ ${fixer.description} — overwrite existing file? user customizations will be lost`
291
+ : `Apply ${fixer.description}?`;
292
+ const { confirm } = await inquirer.prompt([
293
+ { type: 'confirm', name: 'confirm', message, default: !isDrift },
294
+ ]);
295
+ return confirm === true;
296
+ }
297
+ function recordFor(target, check, doctorStatus, status, filesWritten) {
298
+ return { target, check, status, doctorStatus, filesWritten };
299
+ }
300
+ export async function fixCommand(target, options = {}) {
301
+ const targetDir = path.resolve(options.directory ?? process.cwd());
302
+ const dryRun = options.dryRun === true;
303
+ const json = options.json === true;
304
+ // JSON mode implies --yes so prompts don't corrupt the output stream.
305
+ const assumeYes = options.yes === true || json;
306
+ const silent = json;
307
+ const pkg = await readPackageJson(targetDir);
308
+ const results = await runDoctor(targetDir);
309
+ const actions = [];
310
+ const emitJson = (resolvedTarget) => {
311
+ const payload = { directory: targetDir, target: resolvedTarget, actions };
312
+ console.log(JSON.stringify(payload, null, 2));
313
+ };
314
+ if (target) {
315
+ const fixer = findFixer(target);
316
+ if (!fixer) {
317
+ if (json) {
318
+ console.log(JSON.stringify({
319
+ directory: targetDir,
320
+ error: 'unknown-target',
321
+ target,
322
+ available: FIXERS.map((f) => f.target),
323
+ }, null, 2));
324
+ process.exit(1);
325
+ }
326
+ console.error(chalk.red(`\n❌ Unknown fix target: ${target}\n`));
327
+ logTargets();
328
+ console.log();
329
+ process.exit(1);
330
+ }
331
+ const result = results.find((r) => fixer.appliesTo.includes(r.check)) ??
332
+ { check: fixer.appliesTo[0] ?? fixer.target, status: 'missing', detail: '' };
333
+ if (result.status === 'ok') {
334
+ actions.push(recordFor(fixer.target, result.check, 'ok', 'already-ok', []));
335
+ if (json)
336
+ return emitJson(fixer.target);
337
+ console.log(chalk.green(`\n✅ ${result.check} is already configured\n`));
338
+ return;
339
+ }
340
+ if (!silent) {
341
+ console.log(chalk.cyan(`\n🔧 ${fixer.target} — ${chalk.bold(result.check)} is ${result.status}\n`));
342
+ }
343
+ const ok = await confirmApply(fixer, result, assumeYes);
344
+ if (!ok) {
345
+ actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
346
+ if (json)
347
+ return emitJson(fixer.target);
348
+ console.log(chalk.gray(' skipped\n'));
349
+ return;
350
+ }
351
+ const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
352
+ actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten));
353
+ if (json)
354
+ return emitJson(fixer.target);
355
+ console.log();
356
+ return;
357
+ }
358
+ const fixable = results.filter((r) => r.status !== 'ok');
359
+ if (fixable.length === 0) {
360
+ if (json)
361
+ return emitJson(null);
362
+ console.log(chalk.green('\n✅ All checks pass — nothing to fix\n'));
363
+ return;
364
+ }
365
+ if (!silent) {
366
+ console.log(chalk.cyan(`\n🔧 ${fixable.length} item(s) to address\n`));
367
+ }
368
+ let appliedCount = 0;
369
+ let skippedCount = 0;
370
+ let unsupportedCount = 0;
371
+ for (const result of fixable) {
372
+ const fixer = findFixerForCheck(result.check);
373
+ if (!fixer) {
374
+ actions.push(recordFor(null, result.check, result.status, 'unsupported', []));
375
+ if (!silent)
376
+ console.log(chalk.gray(` — ${result.check}: no fixer registered`));
377
+ unsupportedCount++;
378
+ continue;
379
+ }
380
+ if (!silent) {
381
+ console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
382
+ }
383
+ const ok = await confirmApply(fixer, result, assumeYes);
384
+ if (!ok) {
385
+ actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
386
+ if (!silent)
387
+ console.log(chalk.gray(' skipped'));
388
+ skippedCount++;
389
+ continue;
390
+ }
391
+ const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
392
+ actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten));
393
+ appliedCount++;
394
+ }
395
+ if (json)
396
+ return emitJson(null);
397
+ console.log();
398
+ console.log(` Summary: ${chalk.green(`${appliedCount} applied`)}, ${chalk.gray(`${skippedCount} skipped`)}, ${chalk.yellow(`${unsupportedCount} unsupported`)}\n`);
399
+ }
@@ -148,6 +148,12 @@ async function promptForConfig() {
148
148
  default: (answers) => answers.projectType === 'library',
149
149
  when: (answers) => answers.projectType === 'library',
150
150
  },
151
+ {
152
+ type: 'confirm',
153
+ name: 'securityAutomation',
154
+ message: '🛡️ Include security automation (Dependabot + CodeQL)?',
155
+ default: true,
156
+ },
151
157
  {
152
158
  type: 'list',
153
159
  name: 'bundler',
@@ -191,6 +197,7 @@ async function promptForConfig() {
191
197
  gitHooks: answers.gitHooks || false,
192
198
  commitLint: answers.commitLint || false,
193
199
  semanticRelease: answers.semanticRelease || false,
200
+ securityAutomation: answers.securityAutomation ?? false,
194
201
  bundler: answers.bundler || 'none',
195
202
  };
196
203
  }
@@ -213,6 +220,36 @@ function showNextSteps(config, _targetDir) {
213
220
  steps.forEach((step, index) => {
214
221
  console.log(` ${index + 1}. ${step}`);
215
222
  });
216
- console.log(chalk.dim('\n💡 All configuration files have been generated in your project directory.'));
223
+ const skipped = collectSkippedFixSuggestions(config);
224
+ if (skipped.length > 0) {
225
+ console.log(chalk.bold('\n💡 Want to add something you skipped?\n'));
226
+ for (const s of skipped) {
227
+ console.log(` ${chalk.gray('-')} ${s}`);
228
+ }
229
+ }
230
+ console.log(chalk.dim('\n📁 All configuration files have been generated in your project directory.'));
217
231
  console.log(chalk.dim(' You can modify them to suit your specific needs.\n'));
218
232
  }
233
+ function collectSkippedFixSuggestions(config) {
234
+ const suggestions = [];
235
+ if (!config.gitHooks) {
236
+ suggestions.push('Run `npx @rtorcato/js-tooling fix husky` to add git hooks later');
237
+ }
238
+ if (!config.commitLint) {
239
+ suggestions.push('Run `npx @rtorcato/js-tooling fix commitlint` to add conventional-commit linting');
240
+ }
241
+ if (!config.semanticRelease && config.projectType === 'library') {
242
+ suggestions.push('Run `npx @rtorcato/js-tooling fix semantic-release` to add automated releases');
243
+ }
244
+ if (!config.securityAutomation) {
245
+ suggestions.push('Run `npx @rtorcato/js-tooling fix dependabot` and `fix codeql` for security automation');
246
+ }
247
+ if (config.linting.tool === 'none') {
248
+ suggestions.push('Run `npx @rtorcato/js-tooling fix biome` or `fix eslint` to add linting');
249
+ }
250
+ if (config.testing.framework === 'none') {
251
+ suggestions.push('Run `npx @rtorcato/js-tooling fix vitest` to add a test runner');
252
+ }
253
+ suggestions.push('Run `npx @rtorcato/js-tooling doctor` any time to audit drift');
254
+ return suggestions;
255
+ }
@@ -82,7 +82,7 @@ export default defineConfig({
82
82
  `;
83
83
  await fs.writeFile(viteConfigPath, viteConfig);
84
84
  }
85
- async function generateSemanticReleaseConfig(targetDir) {
85
+ export async function generateSemanticReleaseConfig(targetDir) {
86
86
  const releaseConfigPath = path.join(targetDir, 'release.config.mjs');
87
87
  const releaseConfig = `export { default } from '@rtorcato/js-tooling/semantic-release/github'
88
88
  `;
@@ -10,7 +10,7 @@ export async function generateGitConfigs(config, targetDir) {
10
10
  // Generate .gitignore
11
11
  await generateGitignore(config, targetDir);
12
12
  }
13
- async function generateHuskyConfig(config, targetDir) {
13
+ export async function generateHuskyConfig(config, targetDir) {
14
14
  const huskyDir = path.join(targetDir, '.husky');
15
15
  await fs.ensureDir(huskyDir);
16
16
  // Pre-commit hook
@@ -52,7 +52,7 @@ npx --no -- commitlint --edit $1
52
52
  };
53
53
  await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
54
54
  }
55
- async function generateCommitlintConfig(targetDir) {
55
+ export async function generateCommitlintConfig(targetDir) {
56
56
  const commitlintConfigPath = path.join(targetDir, 'commitlint.config.mjs');
57
57
  const commitlintConfig = `export { default } from '@rtorcato/js-tooling/commitlint/config'
58
58
  `;
@@ -5,15 +5,20 @@ import { generateBuildConfigs } from './build.js';
5
5
  import { generateGitConfigs } from './git.js';
6
6
  import { generateGitHubActions } from './github-actions.js';
7
7
  import { generateLintingConfigs } from './linting.js';
8
+ import { generateMiscBaseline } from './misc.js';
8
9
  import { generatePackageJson } from './package-json.js';
9
10
  import { generateReadme } from './readme.js';
11
+ import { generateSecurityConfigs } from './security.js';
10
12
  import { generateTestingConfigs } from './testing.js';
11
13
  import { generateTSConfig } from './tsconfig.js';
12
14
  const __filename = fileURLToPath(import.meta.url);
13
15
  const __dirname = path.dirname(__filename);
14
16
  export async function generateConfigs(config, targetDir) {
15
- // Generate package.json
17
+ // Generate package.json (must run before generateMiscBaseline,
18
+ // which sets engines.node on the resulting file)
16
19
  await generatePackageJson(config, targetDir);
20
+ // Universal baseline: .editorconfig, .nvmrc, engines.node, knip.json
21
+ await generateMiscBaseline(targetDir);
17
22
  // Generate TypeScript configuration
18
23
  if (config.typescript.enabled) {
19
24
  await generateTSConfig(config, targetDir);
@@ -32,6 +37,10 @@ export async function generateConfigs(config, targetDir) {
32
37
  }
33
38
  // Generate GitHub Actions workflow
34
39
  await generateGitHubActions(config, targetDir);
40
+ // Generate security automation (Dependabot + CodeQL)
41
+ if (config.securityAutomation) {
42
+ await generateSecurityConfigs(targetDir);
43
+ }
35
44
  // Generate build configurations
36
45
  if (config.bundler !== 'none') {
37
46
  await generateBuildConfigs(config, targetDir);
@@ -14,7 +14,7 @@ export async function generateLintingConfigs(config, targetDir) {
14
14
  await generatePrettierConfig(targetDir);
15
15
  }
16
16
  }
17
- async function generateBiomeConfig(targetDir) {
17
+ export async function generateBiomeConfig(targetDir) {
18
18
  const biomeConfigPath = path.join(targetDir, 'biome.jsonc');
19
19
  const biomeConfig = {
20
20
  $schema: 'https://biomejs.dev/schemas/1.9.4/schema.json',
@@ -26,7 +26,7 @@ async function generateBiomeConfig(targetDir) {
26
26
  };
27
27
  await fs.writeJson(biomeConfigPath, biomeConfig, { spaces: 2 });
28
28
  }
29
- async function generateESLintConfig(config, targetDir) {
29
+ export async function generateESLintConfig(config, targetDir) {
30
30
  const eslintConfigPath = path.join(targetDir, 'eslint.config.mjs');
31
31
  const configType = config.linting.eslintConfig || 'base';
32
32
  const eslintConfig = `import { default as config } from '@rtorcato/js-tooling/eslint/${configType}'
@@ -35,7 +35,7 @@ export default config
35
35
  `;
36
36
  await fs.writeFile(eslintConfigPath, eslintConfig);
37
37
  }
38
- async function generatePrettierConfig(targetDir) {
38
+ export async function generatePrettierConfig(targetDir) {
39
39
  const prettierConfigPath = path.join(targetDir, 'prettier.config.mjs');
40
40
  const prettierConfig = `export { default } from '@rtorcato/js-tooling/prettier'
41
41
  `;
@@ -0,0 +1,52 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ const EDITORCONFIG_CONTENT = `root = true
4
+
5
+ [*]
6
+ charset = utf-8
7
+ end_of_line = lf
8
+ indent_style = tab
9
+ indent_size = 2
10
+ insert_final_newline = true
11
+ trim_trailing_whitespace = true
12
+
13
+ [*.md]
14
+ trim_trailing_whitespace = false
15
+
16
+ [*.{json,yml,yaml}]
17
+ indent_style = space
18
+ indent_size = 2
19
+ `;
20
+ const NVMRC_CONTENT = '22\n';
21
+ const KNIP_CONFIG = {
22
+ $schema: 'https://unpkg.com/knip@5/schema.json',
23
+ entry: ['src/index.ts'],
24
+ project: ['src/**/*.ts'],
25
+ };
26
+ export async function generateEditorConfig(targetDir) {
27
+ await fs.writeFile(path.join(targetDir, '.editorconfig'), EDITORCONFIG_CONTENT);
28
+ }
29
+ export async function generateNvmrc(targetDir) {
30
+ await fs.writeFile(path.join(targetDir, '.nvmrc'), NVMRC_CONTENT);
31
+ }
32
+ export async function ensureEnginesNode(targetDir, version = '>=22') {
33
+ const pkgPath = path.join(targetDir, 'package.json');
34
+ if (!(await fs.pathExists(pkgPath)))
35
+ return 'no-package-json';
36
+ const pkg = (await fs.readJson(pkgPath));
37
+ const engines = pkg.engines ?? {};
38
+ if (engines.node)
39
+ return 'already-set';
40
+ pkg.engines = { ...engines, node: version };
41
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
42
+ return 'added';
43
+ }
44
+ export async function generateKnipConfig(targetDir) {
45
+ await fs.writeJson(path.join(targetDir, 'knip.json'), KNIP_CONFIG, { spaces: 2 });
46
+ }
47
+ export async function generateMiscBaseline(targetDir) {
48
+ await generateEditorConfig(targetDir);
49
+ await generateNvmrc(targetDir);
50
+ await ensureEnginesNode(targetDir);
51
+ await generateKnipConfig(targetDir);
52
+ }
@@ -0,0 +1,73 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ export async function generateDependabotConfig(targetDir) {
4
+ await fs.ensureDir(path.join(targetDir, '.github'));
5
+ const filepath = path.join(targetDir, '.github', 'dependabot.yml');
6
+ const content = `version: 2
7
+ updates:
8
+ - package-ecosystem: "npm"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
12
+ open-pull-requests-limit: 10
13
+ versioning-strategy: "increase"
14
+ commit-message:
15
+ prefix: "chore(deps)"
16
+ include: "scope"
17
+
18
+ - package-ecosystem: "github-actions"
19
+ directory: "/"
20
+ schedule:
21
+ interval: "weekly"
22
+ commit-message:
23
+ prefix: "chore(ci)"
24
+ `;
25
+ await fs.writeFile(filepath, content);
26
+ }
27
+ export async function generateCodeQLWorkflow(targetDir) {
28
+ await fs.ensureDir(path.join(targetDir, '.github', 'workflows'));
29
+ const filepath = path.join(targetDir, '.github', 'workflows', 'codeql.yml');
30
+ const content = `name: CodeQL
31
+
32
+ on:
33
+ push:
34
+ branches: [main]
35
+ pull_request:
36
+ branches: [main]
37
+ schedule:
38
+ - cron: '0 6 * * 1'
39
+
40
+ jobs:
41
+ analyze:
42
+ name: Analyze
43
+ runs-on: ubuntu-latest
44
+ permissions:
45
+ actions: read
46
+ contents: read
47
+ security-events: write
48
+
49
+ strategy:
50
+ fail-fast: false
51
+ matrix:
52
+ language: [javascript-typescript]
53
+
54
+ steps:
55
+ - name: Checkout
56
+ uses: actions/checkout@v4
57
+
58
+ - name: Initialize CodeQL
59
+ uses: github/codeql-action/init@v3
60
+ with:
61
+ languages: \${{ matrix.language }}
62
+
63
+ - name: Perform CodeQL Analysis
64
+ uses: github/codeql-action/analyze@v3
65
+ with:
66
+ category: "/language:\${{ matrix.language }}"
67
+ `;
68
+ await fs.writeFile(filepath, content);
69
+ }
70
+ export async function generateSecurityConfigs(targetDir) {
71
+ await generateDependabotConfig(targetDir);
72
+ await generateCodeQLWorkflow(targetDir);
73
+ }
@@ -11,7 +11,7 @@ export async function generateTestingConfigs(config, targetDir) {
11
11
  await generatePlaywrightConfig(targetDir);
12
12
  }
13
13
  }
14
- async function generateVitestConfig(config, targetDir) {
14
+ export async function generateVitestConfig(config, targetDir) {
15
15
  const vitestConfigPath = path.join(targetDir, 'vitest.config.ts');
16
16
  const vitestConfig = `import { defineConfig } from 'vitest/config'
17
17
 
package/dist/cli/index.js CHANGED
@@ -5,7 +5,9 @@ import { Command } from 'commander';
5
5
  import fs from 'fs-extra';
6
6
  import packageJson from '../../package.json' with { type: 'json' };
7
7
  import { doctorCommand } from './commands/doctor.js';
8
+ import { fixCommand } from './commands/fix.js';
8
9
  import { setupProject } from './commands/setup.js';
10
+ import { copyPreset, PRESETS } from './utils/copy-preset.js';
9
11
  async function isSelfRepo(dir) {
10
12
  try {
11
13
  const pkg = await fs.readJson(path.join(dir, 'package.json'));
@@ -31,40 +33,20 @@ program
31
33
  .command('copy <config>')
32
34
  .description('📋 Copy a specific configuration file to current directory')
33
35
  .action(async (config) => {
34
- const availableConfigs = {
35
- biome: {
36
- source: 'tooling/biome/biome.json',
37
- target: 'biome.json',
38
- desc: 'Biome formatter and linter configuration',
39
- },
40
- tsconfig: {
41
- source: 'tooling/typescript/tsconfig.base.json',
42
- target: 'tsconfig.json',
43
- desc: 'TypeScript base configuration',
44
- },
45
- };
46
- if (!availableConfigs[config]) {
36
+ if (!(config in PRESETS)) {
47
37
  console.error(chalk.red(`\n❌ Unknown configuration: ${config}`));
48
38
  console.log(chalk.gray('Available configurations:'));
49
- Object.entries(availableConfigs).forEach(([key, { desc }]) => {
39
+ for (const [key, { desc }] of Object.entries(PRESETS)) {
50
40
  console.log(` ${chalk.green('●')} ${chalk.bold(key)}: ${chalk.gray(desc)}`);
51
- });
41
+ }
52
42
  console.log();
53
43
  process.exit(1);
54
44
  }
55
- const { source, target, desc } = availableConfigs[config];
56
45
  try {
57
- const fs = (await import('fs-extra')).default;
58
- const path = (await import('node:path')).default;
59
- // Get the package installation path - CLI is in dist/cli/index.js, need to go up 3 levels
60
- const cliFile = new URL(import.meta.url).pathname;
61
- const packagePath = path.dirname(path.dirname(path.dirname(cliFile)));
62
- const sourcePath = path.join(packagePath, source);
63
- const targetPath = path.join(process.cwd(), target);
64
- await fs.copy(sourcePath, targetPath);
65
- console.log(chalk.green(`\n✅ Copied ${desc}`));
66
- console.log(chalk.gray(` From: ${source}`));
67
- console.log(chalk.gray(` To: ${target}\n`));
46
+ const result = await copyPreset(config);
47
+ console.log(chalk.green(`\n✅ Copied ${result.desc}`));
48
+ console.log(chalk.gray(` From: ${result.source}`));
49
+ console.log(chalk.gray(` To: ${result.target}\n`));
68
50
  }
69
51
  catch (error) {
70
52
  console.error(chalk.red(`\n❌ Error copying configuration: ${error}\n`));
@@ -92,14 +74,21 @@ program
92
74
  { name: 'Playwright', desc: 'End-to-end testing configuration' },
93
75
  { name: 'Commitlint', desc: 'Conventional commit linting' },
94
76
  { name: 'Husky', desc: 'Git hooks for pre-commit validation' },
77
+ { name: 'lint-staged', desc: 'Run linters on staged files (pairs with Husky)' },
95
78
  { name: 'Semantic Release', desc: 'Automated versioning and publishing' },
96
79
  { name: 'tsup', desc: 'TypeScript bundler configuration' },
97
80
  { name: 'esbuild', desc: 'Fast JavaScript bundler configuration' },
81
+ { name: 'EditorConfig', desc: 'Cross-editor formatting consistency (.editorconfig)' },
82
+ { name: '.nvmrc', desc: 'Pin Node version per repository' },
83
+ { name: 'knip', desc: 'Find unused files, exports, and dependencies' },
84
+ { name: 'Dependabot', desc: 'Weekly automated dependency updates' },
85
+ { name: 'CodeQL', desc: 'GitHub security scanning workflow' },
98
86
  ];
99
87
  configs.forEach(({ name, desc }) => {
100
88
  console.log(` ${chalk.green('●')} ${chalk.bold(name)}: ${chalk.gray(desc)}`);
101
89
  });
102
- console.log(chalk.dim('\n💡 Run "js-tooling setup" to configure your project\n'));
90
+ console.log(chalk.dim('\n💡 Run `js-tooling setup` for a new project'));
91
+ console.log(chalk.dim(' or `js-tooling fix` to apply missing pieces to an existing one\n'));
103
92
  });
104
93
  program
105
94
  .command('doctor')
@@ -107,9 +96,22 @@ program
107
96
  .option('-d, --directory <path>', 'Target directory to diagnose', process.cwd())
108
97
  .option('--json', 'Emit machine-readable JSON output')
109
98
  .action(doctorCommand);
99
+ program
100
+ .command('fix [target]')
101
+ .description('🔧 Apply scaffolders for items doctor flagged')
102
+ .option('-d, --directory <path>', 'Target directory', process.cwd())
103
+ .option('--yes', 'Assume yes to all prompts (including drift overwrites)')
104
+ .option('--dry-run', 'Print what would change without writing files')
105
+ .option('--json', 'Emit machine-readable JSON output (implies --yes)')
106
+ .action((target, options) => fixCommand(target, {
107
+ directory: options.directory,
108
+ yes: options.yes,
109
+ dryRun: options.dryRun,
110
+ json: options.json,
111
+ }));
110
112
  program.hook('preAction', async (_, actionCommand) => {
111
113
  const name = actionCommand.name();
112
- if (name === 'setup' || name === 'doctor') {
114
+ if (name === 'setup' || name === 'doctor' || name === 'fix') {
113
115
  const dir = actionCommand.opts().directory ?? process.cwd();
114
116
  if (await isSelfRepo(dir)) {
115
117
  console.log(chalk.yellow('\n⚠️ This command cannot be run inside the @rtorcato/js-tooling repo itself.\n'));
@@ -0,0 +1,31 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ export const PRESETS = {
4
+ biome: {
5
+ source: 'tooling/biome/biome.json',
6
+ target: 'biome.json',
7
+ desc: 'Biome formatter and linter configuration',
8
+ },
9
+ tsconfig: {
10
+ source: 'tooling/typescript/tsconfig.base.json',
11
+ target: 'tsconfig.json',
12
+ desc: 'TypeScript base configuration',
13
+ },
14
+ };
15
+ export function getPackageRoot() {
16
+ const cliFile = new URL(import.meta.url).pathname;
17
+ return path.dirname(path.dirname(path.dirname(path.dirname(cliFile))));
18
+ }
19
+ export async function copyPreset(name, targetDir = process.cwd()) {
20
+ const preset = PRESETS[name];
21
+ const packageRoot = getPackageRoot();
22
+ const sourcePath = path.join(packageRoot, preset.source);
23
+ const targetPath = path.join(targetDir, preset.target);
24
+ await fs.copy(sourcePath, targetPath);
25
+ return {
26
+ source: preset.source,
27
+ target: preset.target,
28
+ targetPath,
29
+ desc: preset.desc,
30
+ };
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,3 +0,0 @@
1
- # `@turbo/eslint-config`
2
-
3
- Collection of internal eslint configurations.