@rtorcato/js-tooling 2.1.2 → 2.3.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 +328 -7
- package/package.json +1 -1
|
@@ -1,7 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import chalk from 'chalk';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
|
-
import path from 'node:path';
|
|
4
4
|
const PACKAGE = '@rtorcato/js-tooling';
|
|
5
|
+
const NODE_MIN_MAJOR = 22;
|
|
6
|
+
const NODE_LTS_REQUIREMENTS = {
|
|
7
|
+
22: { minor: 22, patch: 2 },
|
|
8
|
+
24: { minor: 15, patch: 0 },
|
|
9
|
+
};
|
|
10
|
+
function parseNodeVersion(version) {
|
|
11
|
+
const clean = version.replace(/^v/, '').split('-')[0] ?? '';
|
|
12
|
+
const [maj, min, pat] = clean.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
13
|
+
return [maj ?? 0, min ?? 0, pat ?? 0];
|
|
14
|
+
}
|
|
15
|
+
export function evaluateNodeVersion(version) {
|
|
16
|
+
const [major, minor, patch] = parseNodeVersion(version);
|
|
17
|
+
const display = `v${major}.${minor}.${patch}`;
|
|
18
|
+
if (major < NODE_MIN_MAJOR) {
|
|
19
|
+
return {
|
|
20
|
+
check: 'Node',
|
|
21
|
+
status: 'missing',
|
|
22
|
+
detail: `${display} is below required Node ${NODE_MIN_MAJOR}+`,
|
|
23
|
+
hint: `Install Node ${NODE_MIN_MAJOR} LTS or newer (https://nodejs.org)`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const lts = NODE_LTS_REQUIREMENTS[major];
|
|
27
|
+
if (lts) {
|
|
28
|
+
const meets = minor > lts.minor || (minor === lts.minor && patch >= lts.patch);
|
|
29
|
+
if (!meets) {
|
|
30
|
+
return {
|
|
31
|
+
check: 'Node',
|
|
32
|
+
status: 'drift',
|
|
33
|
+
detail: `${display} — npm may emit EBADENGINE warnings from transitive deps`,
|
|
34
|
+
hint: `Upgrade to Node ${major}.${lts.minor}.${lts.patch}+ (or 26+) to silence transitive engine warnings`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
check: 'Node',
|
|
40
|
+
status: 'ok',
|
|
41
|
+
detail: display,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
5
44
|
const FILE_CHECKS = [
|
|
6
45
|
{
|
|
7
46
|
check: 'TypeScript',
|
|
@@ -32,6 +71,7 @@ const FILE_CHECKS = [
|
|
|
32
71
|
expected: `imports "${PACKAGE}/prettier"`,
|
|
33
72
|
matcher: /@rtorcato\/js-tooling\/prettier/,
|
|
34
73
|
optional: true,
|
|
74
|
+
hint: `Re-export from "${PACKAGE}/prettier" in prettier.config.mjs`,
|
|
35
75
|
},
|
|
36
76
|
{
|
|
37
77
|
check: 'Vitest',
|
|
@@ -75,17 +115,29 @@ async function checkFile(dir, spec) {
|
|
|
75
115
|
hint: spec.hint,
|
|
76
116
|
};
|
|
77
117
|
}
|
|
78
|
-
async function
|
|
118
|
+
async function readPackageJson(dir) {
|
|
79
119
|
const filepath = path.join(dir, 'package.json');
|
|
80
|
-
if (!(await fs.pathExists(filepath)))
|
|
120
|
+
if (!(await fs.pathExists(filepath)))
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
return (await fs.readJson(filepath));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function checkPackageJson(pkg) {
|
|
130
|
+
if (!pkg) {
|
|
81
131
|
return {
|
|
82
132
|
check: 'package.json',
|
|
83
133
|
status: 'missing',
|
|
84
134
|
detail: 'no package.json found',
|
|
85
135
|
};
|
|
86
136
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
137
|
+
const deps = {
|
|
138
|
+
...(pkg.dependencies ?? {}),
|
|
139
|
+
...(pkg.devDependencies ?? {}),
|
|
140
|
+
};
|
|
89
141
|
if (deps[PACKAGE]) {
|
|
90
142
|
return {
|
|
91
143
|
check: 'package.json',
|
|
@@ -100,13 +152,282 @@ async function checkPackageJson(dir) {
|
|
|
100
152
|
hint: `Run \`pnpm add -D ${PACKAGE}\``,
|
|
101
153
|
};
|
|
102
154
|
}
|
|
155
|
+
function checkEnginesNode(pkg) {
|
|
156
|
+
if (!pkg) {
|
|
157
|
+
return {
|
|
158
|
+
check: 'engines.node',
|
|
159
|
+
status: 'missing',
|
|
160
|
+
detail: 'no package.json',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const engines = pkg.engines ?? {};
|
|
164
|
+
if (!engines.node) {
|
|
165
|
+
return {
|
|
166
|
+
check: 'engines.node',
|
|
167
|
+
status: 'drift',
|
|
168
|
+
detail: 'engines.node not set in package.json',
|
|
169
|
+
hint: `Add \`"engines": { "node": ">=${NODE_MIN_MAJOR}" }\` to package.json`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
check: 'engines.node',
|
|
174
|
+
status: 'ok',
|
|
175
|
+
detail: `engines.node = ${engines.node}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function checkEditorConfig(dir) {
|
|
179
|
+
const exists = await fs.pathExists(path.join(dir, '.editorconfig'));
|
|
180
|
+
return {
|
|
181
|
+
check: 'EditorConfig',
|
|
182
|
+
status: exists ? 'ok' : 'optional-missing',
|
|
183
|
+
detail: exists ? '.editorconfig found' : 'no .editorconfig',
|
|
184
|
+
hint: exists ? undefined : 'Add an .editorconfig for cross-editor formatting consistency',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function checkNodeVersionPin(dir) {
|
|
188
|
+
for (const candidate of ['.nvmrc', '.node-version']) {
|
|
189
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
190
|
+
return {
|
|
191
|
+
check: 'Node version pin',
|
|
192
|
+
status: 'ok',
|
|
193
|
+
detail: `${candidate} found`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
check: 'Node version pin',
|
|
199
|
+
status: 'optional-missing',
|
|
200
|
+
detail: 'no .nvmrc / .node-version',
|
|
201
|
+
hint: 'Add .nvmrc to pin Node version per repo (e.g. `echo 22 > .nvmrc`)',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async function checkHusky(dir, pkg) {
|
|
205
|
+
const huskyDir = await fs.pathExists(path.join(dir, '.husky'));
|
|
206
|
+
const scripts = pkg?.scripts ?? {};
|
|
207
|
+
const prepareScript = scripts.prepare ?? '';
|
|
208
|
+
const hasHookScript = /\bhusky\b/.test(prepareScript);
|
|
209
|
+
if (huskyDir && hasHookScript) {
|
|
210
|
+
return {
|
|
211
|
+
check: 'Husky',
|
|
212
|
+
status: 'ok',
|
|
213
|
+
detail: '.husky/ directory and prepare script configured',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (huskyDir || hasHookScript) {
|
|
217
|
+
return {
|
|
218
|
+
check: 'Husky',
|
|
219
|
+
status: 'drift',
|
|
220
|
+
detail: huskyDir
|
|
221
|
+
? '.husky/ exists but no `prepare: husky` script'
|
|
222
|
+
: '`prepare: husky` set but no .husky/ directory',
|
|
223
|
+
hint: 'Run `pnpm exec husky init` to scaffold both halves',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
check: 'Husky',
|
|
228
|
+
status: 'optional-missing',
|
|
229
|
+
detail: 'husky not configured',
|
|
230
|
+
hint: 'Run `pnpm add -D husky && pnpm exec husky init` to enable git hooks',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const LINT_STAGED_FILES = [
|
|
234
|
+
'.lintstagedrc',
|
|
235
|
+
'.lintstagedrc.json',
|
|
236
|
+
'.lintstagedrc.yaml',
|
|
237
|
+
'.lintstagedrc.yml',
|
|
238
|
+
'.lintstagedrc.js',
|
|
239
|
+
'.lintstagedrc.cjs',
|
|
240
|
+
'.lintstagedrc.mjs',
|
|
241
|
+
'lint-staged.config.js',
|
|
242
|
+
'lint-staged.config.cjs',
|
|
243
|
+
'lint-staged.config.mjs',
|
|
244
|
+
];
|
|
245
|
+
async function checkLintStaged(dir, pkg) {
|
|
246
|
+
const inPkg = pkg ? 'lint-staged' in pkg : false;
|
|
247
|
+
let inFile = null;
|
|
248
|
+
for (const candidate of LINT_STAGED_FILES) {
|
|
249
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
250
|
+
inFile = candidate;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (inPkg || inFile) {
|
|
255
|
+
return {
|
|
256
|
+
check: 'lint-staged',
|
|
257
|
+
status: 'ok',
|
|
258
|
+
detail: inPkg ? '`lint-staged` field in package.json' : `${inFile} found`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
check: 'lint-staged',
|
|
263
|
+
status: 'optional-missing',
|
|
264
|
+
detail: 'lint-staged not configured',
|
|
265
|
+
hint: 'Add a `lint-staged` field to package.json and wire it into the husky pre-commit hook',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const KNIP_FILES = [
|
|
269
|
+
'knip.json',
|
|
270
|
+
'knip.jsonc',
|
|
271
|
+
'knip.ts',
|
|
272
|
+
'knip.config.ts',
|
|
273
|
+
'knip.config.js',
|
|
274
|
+
'knip.config.mjs',
|
|
275
|
+
];
|
|
276
|
+
async function checkKnip(dir, pkg) {
|
|
277
|
+
const inPkg = pkg ? 'knip' in pkg : false;
|
|
278
|
+
let inFile = null;
|
|
279
|
+
for (const candidate of KNIP_FILES) {
|
|
280
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
281
|
+
inFile = candidate;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (inPkg || inFile) {
|
|
286
|
+
return {
|
|
287
|
+
check: 'knip',
|
|
288
|
+
status: 'ok',
|
|
289
|
+
detail: inPkg ? '`knip` field in package.json' : `${inFile} found`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
check: 'knip',
|
|
294
|
+
status: 'optional-missing',
|
|
295
|
+
detail: 'knip not configured',
|
|
296
|
+
hint: 'Add `knip` to detect unused files, deps, and exports',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const SEMANTIC_RELEASE_FILES = [
|
|
300
|
+
'.releaserc',
|
|
301
|
+
'.releaserc.json',
|
|
302
|
+
'.releaserc.yaml',
|
|
303
|
+
'.releaserc.yml',
|
|
304
|
+
'.releaserc.js',
|
|
305
|
+
'.releaserc.cjs',
|
|
306
|
+
'release.config.js',
|
|
307
|
+
'release.config.cjs',
|
|
308
|
+
'release.config.mjs',
|
|
309
|
+
];
|
|
310
|
+
async function checkSemanticRelease(dir, pkg) {
|
|
311
|
+
const isPrivate = pkg?.private === true;
|
|
312
|
+
const inPkg = pkg ? 'release' in pkg : false;
|
|
313
|
+
let configFile = null;
|
|
314
|
+
let configContent = null;
|
|
315
|
+
for (const candidate of SEMANTIC_RELEASE_FILES) {
|
|
316
|
+
const filepath = path.join(dir, candidate);
|
|
317
|
+
if (await fs.pathExists(filepath)) {
|
|
318
|
+
configFile = candidate;
|
|
319
|
+
try {
|
|
320
|
+
configContent = await fs.readFile(filepath, 'utf-8');
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
configContent = '';
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!inPkg && !configFile) {
|
|
329
|
+
return {
|
|
330
|
+
check: 'semantic-release',
|
|
331
|
+
status: isPrivate ? 'optional-missing' : 'drift',
|
|
332
|
+
detail: isPrivate
|
|
333
|
+
? 'semantic-release not configured (package is private)'
|
|
334
|
+
: 'semantic-release not configured',
|
|
335
|
+
hint: isPrivate
|
|
336
|
+
? undefined
|
|
337
|
+
: `Extend "${PACKAGE}/semantic-release" or "${PACKAGE}/semantic-release/github" in a release config`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const presetRegex = /@rtorcato\/js-tooling\/semantic-release/;
|
|
341
|
+
const pkgReleaseStr = inPkg ? JSON.stringify(pkg?.release ?? '') : '';
|
|
342
|
+
const usesPreset = (configContent && presetRegex.test(configContent)) || presetRegex.test(pkgReleaseStr);
|
|
343
|
+
if (usesPreset) {
|
|
344
|
+
return {
|
|
345
|
+
check: 'semantic-release',
|
|
346
|
+
status: 'ok',
|
|
347
|
+
detail: configFile
|
|
348
|
+
? `${configFile} extends ${PACKAGE}/semantic-release`
|
|
349
|
+
: `release field extends ${PACKAGE}/semantic-release`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
check: 'semantic-release',
|
|
354
|
+
status: 'drift',
|
|
355
|
+
detail: configFile
|
|
356
|
+
? `${configFile} does not extend ${PACKAGE}/semantic-release`
|
|
357
|
+
: '`release` field does not extend our preset',
|
|
358
|
+
hint: `Extend "${PACKAGE}/semantic-release" or "${PACKAGE}/semantic-release/github"`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function checkGitHubActions(dir) {
|
|
362
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
363
|
+
if (!(await fs.pathExists(workflowsDir))) {
|
|
364
|
+
return {
|
|
365
|
+
check: 'GitHub Actions',
|
|
366
|
+
status: 'optional-missing',
|
|
367
|
+
detail: 'no .github/workflows/',
|
|
368
|
+
hint: 'Run `npx @rtorcato/js-tooling setup` to scaffold a CI workflow',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const files = await fs.readdir(workflowsDir);
|
|
373
|
+
const workflows = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
374
|
+
if (workflows.length === 0) {
|
|
375
|
+
return {
|
|
376
|
+
check: 'GitHub Actions',
|
|
377
|
+
status: 'optional-missing',
|
|
378
|
+
detail: '.github/workflows/ is empty',
|
|
379
|
+
hint: 'Add a workflow file (e.g. ci.yml) under .github/workflows/',
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
check: 'GitHub Actions',
|
|
384
|
+
status: 'ok',
|
|
385
|
+
detail: `${workflows.length} workflow${workflows.length === 1 ? '' : 's'} in .github/workflows/`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return {
|
|
390
|
+
check: 'GitHub Actions',
|
|
391
|
+
status: 'optional-missing',
|
|
392
|
+
detail: 'unable to read .github/workflows/',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function checkGitLabCI(dir) {
|
|
397
|
+
for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
|
|
398
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
399
|
+
return {
|
|
400
|
+
check: 'GitLab CI',
|
|
401
|
+
status: 'ok',
|
|
402
|
+
detail: `${candidate} found`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
check: 'GitLab CI',
|
|
408
|
+
status: 'optional-missing',
|
|
409
|
+
detail: 'no .gitlab-ci.yml',
|
|
410
|
+
hint: 'Add a .gitlab-ci.yml if this repo is hosted on GitLab',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
103
413
|
export async function runDoctor(dir) {
|
|
104
414
|
const targetDir = path.resolve(dir);
|
|
415
|
+
const pkg = await readPackageJson(targetDir);
|
|
105
416
|
const results = [];
|
|
106
|
-
results.push(
|
|
417
|
+
results.push(evaluateNodeVersion(process.version));
|
|
418
|
+
results.push(checkPackageJson(pkg));
|
|
419
|
+
results.push(checkEnginesNode(pkg));
|
|
420
|
+
results.push(await checkEditorConfig(targetDir));
|
|
421
|
+
results.push(await checkNodeVersionPin(targetDir));
|
|
107
422
|
for (const spec of FILE_CHECKS) {
|
|
108
423
|
results.push(await checkFile(targetDir, spec));
|
|
109
424
|
}
|
|
425
|
+
results.push(await checkHusky(targetDir, pkg));
|
|
426
|
+
results.push(await checkLintStaged(targetDir, pkg));
|
|
427
|
+
results.push(await checkSemanticRelease(targetDir, pkg));
|
|
428
|
+
results.push(await checkKnip(targetDir, pkg));
|
|
429
|
+
results.push(await checkGitHubActions(targetDir));
|
|
430
|
+
results.push(await checkGitLabCI(targetDir));
|
|
110
431
|
return results;
|
|
111
432
|
}
|
|
112
433
|
const STATUS_ICONS = {
|
|
@@ -146,7 +467,7 @@ export async function doctorCommand(options = {}) {
|
|
|
146
467
|
for (const r of results) {
|
|
147
468
|
console.log(` ${STATUS_ICONS[r.status]} ${chalk.bold(r.check)} — ${statusLabel(r.status)}`);
|
|
148
469
|
console.log(` ${chalk.gray(r.detail)}`);
|
|
149
|
-
if (r.hint &&
|
|
470
|
+
if (r.hint && r.status !== 'ok') {
|
|
150
471
|
console.log(` ${chalk.dim('hint:')} ${chalk.dim(r.hint)}`);
|
|
151
472
|
}
|
|
152
473
|
}
|