@rtorcato/js-tooling 2.2.0 ā 2.4.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 +384 -7
- package/dist/cli/commands/fix-targets.js +22 -0
- package/dist/cli/commands/fix.js +354 -0
- package/dist/cli/commands/setup.js +7 -0
- package/dist/cli/generators/build.js +1 -1
- package/dist/cli/generators/git.js +2 -2
- package/dist/cli/generators/index.js +10 -1
- package/dist/cli/generators/linting.js +3 -3
- package/dist/cli/generators/misc.js +52 -0
- package/dist/cli/generators/security.js +73 -0
- package/dist/cli/generators/testing.js +1 -1
- package/dist/cli/index.js +21 -28
- package/dist/cli/utils/copy-preset.js +31 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import chalk from 'chalk';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
|
-
import
|
|
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 = {
|
|
@@ -71,6 +72,7 @@ const FILE_CHECKS = [
|
|
|
71
72
|
expected: `imports "${PACKAGE}/prettier"`,
|
|
72
73
|
matcher: /@rtorcato\/js-tooling\/prettier/,
|
|
73
74
|
optional: true,
|
|
75
|
+
hint: `Re-export from "${PACKAGE}/prettier" in prettier.config.mjs`,
|
|
74
76
|
},
|
|
75
77
|
{
|
|
76
78
|
check: 'Vitest',
|
|
@@ -114,17 +116,29 @@ async function checkFile(dir, spec) {
|
|
|
114
116
|
hint: spec.hint,
|
|
115
117
|
};
|
|
116
118
|
}
|
|
117
|
-
async function
|
|
119
|
+
async function readPackageJson(dir) {
|
|
118
120
|
const filepath = path.join(dir, 'package.json');
|
|
119
|
-
if (!(await fs.pathExists(filepath)))
|
|
121
|
+
if (!(await fs.pathExists(filepath)))
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
return (await fs.readJson(filepath));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function checkPackageJson(pkg) {
|
|
131
|
+
if (!pkg) {
|
|
120
132
|
return {
|
|
121
133
|
check: 'package.json',
|
|
122
134
|
status: 'missing',
|
|
123
135
|
detail: 'no package.json found',
|
|
124
136
|
};
|
|
125
137
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
138
|
+
const deps = {
|
|
139
|
+
...(pkg.dependencies ?? {}),
|
|
140
|
+
...(pkg.devDependencies ?? {}),
|
|
141
|
+
};
|
|
128
142
|
if (deps[PACKAGE]) {
|
|
129
143
|
return {
|
|
130
144
|
check: 'package.json',
|
|
@@ -139,14 +153,345 @@ async function checkPackageJson(dir) {
|
|
|
139
153
|
hint: `Run \`pnpm add -D ${PACKAGE}\``,
|
|
140
154
|
};
|
|
141
155
|
}
|
|
156
|
+
function checkEnginesNode(pkg) {
|
|
157
|
+
if (!pkg) {
|
|
158
|
+
return {
|
|
159
|
+
check: 'engines.node',
|
|
160
|
+
status: 'missing',
|
|
161
|
+
detail: 'no package.json',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const engines = pkg.engines ?? {};
|
|
165
|
+
if (!engines.node) {
|
|
166
|
+
return {
|
|
167
|
+
check: 'engines.node',
|
|
168
|
+
status: 'drift',
|
|
169
|
+
detail: 'engines.node not set in package.json',
|
|
170
|
+
hint: `Add \`"engines": { "node": ">=${NODE_MIN_MAJOR}" }\` to package.json`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
check: 'engines.node',
|
|
175
|
+
status: 'ok',
|
|
176
|
+
detail: `engines.node = ${engines.node}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
async function checkEditorConfig(dir) {
|
|
180
|
+
const exists = await fs.pathExists(path.join(dir, '.editorconfig'));
|
|
181
|
+
return {
|
|
182
|
+
check: 'EditorConfig',
|
|
183
|
+
status: exists ? 'ok' : 'optional-missing',
|
|
184
|
+
detail: exists ? '.editorconfig found' : 'no .editorconfig',
|
|
185
|
+
hint: exists ? undefined : 'Add an .editorconfig for cross-editor formatting consistency',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async function checkNodeVersionPin(dir) {
|
|
189
|
+
for (const candidate of ['.nvmrc', '.node-version']) {
|
|
190
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
191
|
+
return {
|
|
192
|
+
check: 'Node version pin',
|
|
193
|
+
status: 'ok',
|
|
194
|
+
detail: `${candidate} found`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
check: 'Node version pin',
|
|
200
|
+
status: 'optional-missing',
|
|
201
|
+
detail: 'no .nvmrc / .node-version',
|
|
202
|
+
hint: 'Add .nvmrc to pin Node version per repo (e.g. `echo 22 > .nvmrc`)',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
async function checkHusky(dir, pkg) {
|
|
206
|
+
const huskyDir = await fs.pathExists(path.join(dir, '.husky'));
|
|
207
|
+
const scripts = pkg?.scripts ?? {};
|
|
208
|
+
const prepareScript = scripts.prepare ?? '';
|
|
209
|
+
const hasHookScript = /\bhusky\b/.test(prepareScript);
|
|
210
|
+
if (huskyDir && hasHookScript) {
|
|
211
|
+
return {
|
|
212
|
+
check: 'Husky',
|
|
213
|
+
status: 'ok',
|
|
214
|
+
detail: '.husky/ directory and prepare script configured',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (huskyDir || hasHookScript) {
|
|
218
|
+
return {
|
|
219
|
+
check: 'Husky',
|
|
220
|
+
status: 'drift',
|
|
221
|
+
detail: huskyDir
|
|
222
|
+
? '.husky/ exists but no `prepare: husky` script'
|
|
223
|
+
: '`prepare: husky` set but no .husky/ directory',
|
|
224
|
+
hint: 'Run `pnpm exec husky init` to scaffold both halves',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
check: 'Husky',
|
|
229
|
+
status: 'optional-missing',
|
|
230
|
+
detail: 'husky not configured',
|
|
231
|
+
hint: 'Run `pnpm add -D husky && pnpm exec husky init` to enable git hooks',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const LINT_STAGED_FILES = [
|
|
235
|
+
'.lintstagedrc',
|
|
236
|
+
'.lintstagedrc.json',
|
|
237
|
+
'.lintstagedrc.yaml',
|
|
238
|
+
'.lintstagedrc.yml',
|
|
239
|
+
'.lintstagedrc.js',
|
|
240
|
+
'.lintstagedrc.cjs',
|
|
241
|
+
'.lintstagedrc.mjs',
|
|
242
|
+
'lint-staged.config.js',
|
|
243
|
+
'lint-staged.config.cjs',
|
|
244
|
+
'lint-staged.config.mjs',
|
|
245
|
+
];
|
|
246
|
+
async function checkLintStaged(dir, pkg) {
|
|
247
|
+
const inPkg = pkg ? 'lint-staged' in pkg : false;
|
|
248
|
+
let inFile = null;
|
|
249
|
+
for (const candidate of LINT_STAGED_FILES) {
|
|
250
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
251
|
+
inFile = candidate;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (inPkg || inFile) {
|
|
256
|
+
return {
|
|
257
|
+
check: 'lint-staged',
|
|
258
|
+
status: 'ok',
|
|
259
|
+
detail: inPkg ? '`lint-staged` field in package.json' : `${inFile} found`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
check: 'lint-staged',
|
|
264
|
+
status: 'optional-missing',
|
|
265
|
+
detail: 'lint-staged not configured',
|
|
266
|
+
hint: 'Add a `lint-staged` field to package.json and wire it into the husky pre-commit hook',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const KNIP_FILES = [
|
|
270
|
+
'knip.json',
|
|
271
|
+
'knip.jsonc',
|
|
272
|
+
'knip.ts',
|
|
273
|
+
'knip.config.ts',
|
|
274
|
+
'knip.config.js',
|
|
275
|
+
'knip.config.mjs',
|
|
276
|
+
];
|
|
277
|
+
async function checkKnip(dir, pkg) {
|
|
278
|
+
const inPkg = pkg ? 'knip' in pkg : false;
|
|
279
|
+
let inFile = null;
|
|
280
|
+
for (const candidate of KNIP_FILES) {
|
|
281
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
282
|
+
inFile = candidate;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (inPkg || inFile) {
|
|
287
|
+
return {
|
|
288
|
+
check: 'knip',
|
|
289
|
+
status: 'ok',
|
|
290
|
+
detail: inPkg ? '`knip` field in package.json' : `${inFile} found`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
check: 'knip',
|
|
295
|
+
status: 'optional-missing',
|
|
296
|
+
detail: 'knip not configured',
|
|
297
|
+
hint: 'Add `knip` to detect unused files, deps, and exports',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const SEMANTIC_RELEASE_FILES = [
|
|
301
|
+
'.releaserc',
|
|
302
|
+
'.releaserc.json',
|
|
303
|
+
'.releaserc.yaml',
|
|
304
|
+
'.releaserc.yml',
|
|
305
|
+
'.releaserc.js',
|
|
306
|
+
'.releaserc.cjs',
|
|
307
|
+
'release.config.js',
|
|
308
|
+
'release.config.cjs',
|
|
309
|
+
'release.config.mjs',
|
|
310
|
+
];
|
|
311
|
+
async function checkSemanticRelease(dir, pkg) {
|
|
312
|
+
const isPrivate = pkg?.private === true;
|
|
313
|
+
const inPkg = pkg ? 'release' in pkg : false;
|
|
314
|
+
let configFile = null;
|
|
315
|
+
let configContent = null;
|
|
316
|
+
for (const candidate of SEMANTIC_RELEASE_FILES) {
|
|
317
|
+
const filepath = path.join(dir, candidate);
|
|
318
|
+
if (await fs.pathExists(filepath)) {
|
|
319
|
+
configFile = candidate;
|
|
320
|
+
try {
|
|
321
|
+
configContent = await fs.readFile(filepath, 'utf-8');
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
configContent = '';
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!inPkg && !configFile) {
|
|
330
|
+
return {
|
|
331
|
+
check: 'semantic-release',
|
|
332
|
+
status: isPrivate ? 'optional-missing' : 'drift',
|
|
333
|
+
detail: isPrivate
|
|
334
|
+
? 'semantic-release not configured (package is private)'
|
|
335
|
+
: 'semantic-release not configured',
|
|
336
|
+
hint: isPrivate
|
|
337
|
+
? undefined
|
|
338
|
+
: `Extend "${PACKAGE}/semantic-release" or "${PACKAGE}/semantic-release/github" in a release config`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const presetRegex = /@rtorcato\/js-tooling\/semantic-release/;
|
|
342
|
+
const pkgReleaseStr = inPkg ? JSON.stringify(pkg?.release ?? '') : '';
|
|
343
|
+
const usesPreset = (configContent && presetRegex.test(configContent)) || presetRegex.test(pkgReleaseStr);
|
|
344
|
+
if (usesPreset) {
|
|
345
|
+
return {
|
|
346
|
+
check: 'semantic-release',
|
|
347
|
+
status: 'ok',
|
|
348
|
+
detail: configFile
|
|
349
|
+
? `${configFile} extends ${PACKAGE}/semantic-release`
|
|
350
|
+
: `release field extends ${PACKAGE}/semantic-release`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
check: 'semantic-release',
|
|
355
|
+
status: 'drift',
|
|
356
|
+
detail: configFile
|
|
357
|
+
? `${configFile} does not extend ${PACKAGE}/semantic-release`
|
|
358
|
+
: '`release` field does not extend our preset',
|
|
359
|
+
hint: `Extend "${PACKAGE}/semantic-release" or "${PACKAGE}/semantic-release/github"`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
async function checkGitHubActions(dir) {
|
|
363
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
364
|
+
if (!(await fs.pathExists(workflowsDir))) {
|
|
365
|
+
return {
|
|
366
|
+
check: 'GitHub Actions',
|
|
367
|
+
status: 'optional-missing',
|
|
368
|
+
detail: 'no .github/workflows/',
|
|
369
|
+
hint: 'Run `npx @rtorcato/js-tooling setup` to scaffold a CI workflow',
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const files = await fs.readdir(workflowsDir);
|
|
374
|
+
const workflows = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
375
|
+
if (workflows.length === 0) {
|
|
376
|
+
return {
|
|
377
|
+
check: 'GitHub Actions',
|
|
378
|
+
status: 'optional-missing',
|
|
379
|
+
detail: '.github/workflows/ is empty',
|
|
380
|
+
hint: 'Add a workflow file (e.g. ci.yml) under .github/workflows/',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
check: 'GitHub Actions',
|
|
385
|
+
status: 'ok',
|
|
386
|
+
detail: `${workflows.length} workflow${workflows.length === 1 ? '' : 's'} in .github/workflows/`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return {
|
|
391
|
+
check: 'GitHub Actions',
|
|
392
|
+
status: 'optional-missing',
|
|
393
|
+
detail: 'unable to read .github/workflows/',
|
|
394
|
+
};
|
|
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
|
+
}
|
|
458
|
+
async function checkGitLabCI(dir) {
|
|
459
|
+
for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
|
|
460
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
461
|
+
return {
|
|
462
|
+
check: 'GitLab CI',
|
|
463
|
+
status: 'ok',
|
|
464
|
+
detail: `${candidate} found`,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
check: 'GitLab CI',
|
|
470
|
+
status: 'optional-missing',
|
|
471
|
+
detail: 'no .gitlab-ci.yml',
|
|
472
|
+
hint: 'Add a .gitlab-ci.yml if this repo is hosted on GitLab',
|
|
473
|
+
};
|
|
474
|
+
}
|
|
142
475
|
export async function runDoctor(dir) {
|
|
143
476
|
const targetDir = path.resolve(dir);
|
|
477
|
+
const pkg = await readPackageJson(targetDir);
|
|
144
478
|
const results = [];
|
|
145
479
|
results.push(evaluateNodeVersion(process.version));
|
|
146
|
-
results.push(
|
|
480
|
+
results.push(checkPackageJson(pkg));
|
|
481
|
+
results.push(checkEnginesNode(pkg));
|
|
482
|
+
results.push(await checkEditorConfig(targetDir));
|
|
483
|
+
results.push(await checkNodeVersionPin(targetDir));
|
|
147
484
|
for (const spec of FILE_CHECKS) {
|
|
148
485
|
results.push(await checkFile(targetDir, spec));
|
|
149
486
|
}
|
|
487
|
+
results.push(await checkHusky(targetDir, pkg));
|
|
488
|
+
results.push(await checkLintStaged(targetDir, pkg));
|
|
489
|
+
results.push(await checkSemanticRelease(targetDir, pkg));
|
|
490
|
+
results.push(await checkKnip(targetDir, pkg));
|
|
491
|
+
results.push(await checkGitHubActions(targetDir));
|
|
492
|
+
results.push(await checkDependabot(targetDir));
|
|
493
|
+
results.push(await checkCodeQL(targetDir));
|
|
494
|
+
results.push(await checkGitLabCI(targetDir));
|
|
150
495
|
return results;
|
|
151
496
|
}
|
|
152
497
|
const STATUS_ICONS = {
|
|
@@ -167,6 +512,30 @@ function statusLabel(status) {
|
|
|
167
512
|
return chalk.gray('not configured');
|
|
168
513
|
}
|
|
169
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
|
+
}
|
|
170
539
|
export function summarize(results) {
|
|
171
540
|
return {
|
|
172
541
|
ok: results.filter((r) => r.status === 'ok').length,
|
|
@@ -186,13 +555,21 @@ export async function doctorCommand(options = {}) {
|
|
|
186
555
|
for (const r of results) {
|
|
187
556
|
console.log(` ${STATUS_ICONS[r.status]} ${chalk.bold(r.check)} ā ${statusLabel(r.status)}`);
|
|
188
557
|
console.log(` ${chalk.gray(r.detail)}`);
|
|
189
|
-
if (r.hint &&
|
|
558
|
+
if (r.hint && r.status !== 'ok') {
|
|
190
559
|
console.log(` ${chalk.dim('hint:')} ${chalk.dim(r.hint)}`);
|
|
191
560
|
}
|
|
192
561
|
}
|
|
193
562
|
const summary = summarize(results);
|
|
194
563
|
console.log();
|
|
195
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
|
+
}
|
|
196
573
|
}
|
|
197
574
|
const summary = summarize(results);
|
|
198
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,354 @@
|
|
|
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) {
|
|
273
|
+
if (dryRun) {
|
|
274
|
+
console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
|
|
275
|
+
return { applied: true, written: [] };
|
|
276
|
+
}
|
|
277
|
+
const { filesWritten } = await fixer.run({ targetDir, pkg, result });
|
|
278
|
+
if (filesWritten.length > 0) {
|
|
279
|
+
console.log(chalk.green(` ā
wrote ${filesWritten.join(', ')}`));
|
|
280
|
+
}
|
|
281
|
+
return { applied: true, written: filesWritten };
|
|
282
|
+
}
|
|
283
|
+
async function confirmApply(fixer, result, assumeYes) {
|
|
284
|
+
if (assumeYes)
|
|
285
|
+
return true;
|
|
286
|
+
const isDrift = result.status === 'drift';
|
|
287
|
+
const message = isDrift
|
|
288
|
+
? `ā ļø ${fixer.description} ā overwrite existing file? user customizations will be lost`
|
|
289
|
+
: `Apply ${fixer.description}?`;
|
|
290
|
+
const { confirm } = await inquirer.prompt([
|
|
291
|
+
{ type: 'confirm', name: 'confirm', message, default: !isDrift },
|
|
292
|
+
]);
|
|
293
|
+
return confirm === true;
|
|
294
|
+
}
|
|
295
|
+
export async function fixCommand(target, options = {}) {
|
|
296
|
+
const targetDir = path.resolve(options.directory ?? process.cwd());
|
|
297
|
+
const assumeYes = options.yes === true;
|
|
298
|
+
const dryRun = options.dryRun === true;
|
|
299
|
+
const pkg = await readPackageJson(targetDir);
|
|
300
|
+
const results = await runDoctor(targetDir);
|
|
301
|
+
if (target) {
|
|
302
|
+
const fixer = findFixer(target);
|
|
303
|
+
if (!fixer) {
|
|
304
|
+
console.error(chalk.red(`\nā Unknown fix target: ${target}\n`));
|
|
305
|
+
logTargets();
|
|
306
|
+
console.log();
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
const result = results.find((r) => fixer.appliesTo.includes(r.check)) ??
|
|
310
|
+
{ check: fixer.appliesTo[0] ?? fixer.target, status: 'missing', detail: '' };
|
|
311
|
+
if (result.status === 'ok') {
|
|
312
|
+
console.log(chalk.green(`\nā
${result.check} is already configured\n`));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
console.log(chalk.cyan(`\nš§ ${fixer.target} ā ${chalk.bold(result.check)} is ${result.status}\n`));
|
|
316
|
+
const ok = await confirmApply(fixer, result, assumeYes);
|
|
317
|
+
if (!ok) {
|
|
318
|
+
console.log(chalk.gray(' skipped\n'));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
await applyFixer(fixer, result, targetDir, pkg, dryRun);
|
|
322
|
+
console.log();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// No target ā walk all non-ok results
|
|
326
|
+
const fixable = results.filter((r) => r.status !== 'ok');
|
|
327
|
+
if (fixable.length === 0) {
|
|
328
|
+
console.log(chalk.green('\nā
All checks pass ā nothing to fix\n'));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
console.log(chalk.cyan(`\nš§ ${fixable.length} item(s) to address\n`));
|
|
332
|
+
let applied = 0;
|
|
333
|
+
let skipped = 0;
|
|
334
|
+
let unsupported = 0;
|
|
335
|
+
for (const result of fixable) {
|
|
336
|
+
const fixer = findFixerForCheck(result.check);
|
|
337
|
+
if (!fixer) {
|
|
338
|
+
console.log(chalk.gray(` ā ${result.check}: no fixer registered`));
|
|
339
|
+
unsupported++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
console.log(` ${chalk.bold(result.check)} (${result.status}) ā ${fixer.target}`);
|
|
343
|
+
const ok = await confirmApply(fixer, result, assumeYes);
|
|
344
|
+
if (!ok) {
|
|
345
|
+
console.log(chalk.gray(' skipped'));
|
|
346
|
+
skipped++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
await applyFixer(fixer, result, targetDir, pkg, dryRun);
|
|
350
|
+
applied++;
|
|
351
|
+
}
|
|
352
|
+
console.log();
|
|
353
|
+
console.log(` Summary: ${chalk.green(`${applied} applied`)}, ${chalk.gray(`${skipped} skipped`)}, ${chalk.yellow(`${unsupported} unsupported`)}\n`);
|
|
354
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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`));
|
|
@@ -107,9 +89,20 @@ program
|
|
|
107
89
|
.option('-d, --directory <path>', 'Target directory to diagnose', process.cwd())
|
|
108
90
|
.option('--json', 'Emit machine-readable JSON output')
|
|
109
91
|
.action(doctorCommand);
|
|
92
|
+
program
|
|
93
|
+
.command('fix [target]')
|
|
94
|
+
.description('š§ Apply scaffolders for items doctor flagged')
|
|
95
|
+
.option('-d, --directory <path>', 'Target directory', process.cwd())
|
|
96
|
+
.option('--yes', 'Assume yes to all prompts (including drift overwrites)')
|
|
97
|
+
.option('--dry-run', 'Print what would change without writing files')
|
|
98
|
+
.action((target, options) => fixCommand(target, {
|
|
99
|
+
directory: options.directory,
|
|
100
|
+
yes: options.yes,
|
|
101
|
+
dryRun: options.dryRun,
|
|
102
|
+
}));
|
|
110
103
|
program.hook('preAction', async (_, actionCommand) => {
|
|
111
104
|
const name = actionCommand.name();
|
|
112
|
-
if (name === 'setup' || name === 'doctor') {
|
|
105
|
+
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
|
113
106
|
const dir = actionCommand.opts().directory ?? process.cwd();
|
|
114
107
|
if (await isSelfRepo(dir)) {
|
|
115
108
|
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
|
+
}
|