@rtorcato/js-tooling 2.2.0 → 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 +288 -7
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
const NODE_MIN_MAJOR = 22;
|
|
6
6
|
const NODE_LTS_REQUIREMENTS = {
|
|
@@ -71,6 +71,7 @@ const FILE_CHECKS = [
|
|
|
71
71
|
expected: `imports "${PACKAGE}/prettier"`,
|
|
72
72
|
matcher: /@rtorcato\/js-tooling\/prettier/,
|
|
73
73
|
optional: true,
|
|
74
|
+
hint: `Re-export from "${PACKAGE}/prettier" in prettier.config.mjs`,
|
|
74
75
|
},
|
|
75
76
|
{
|
|
76
77
|
check: 'Vitest',
|
|
@@ -114,17 +115,29 @@ async function checkFile(dir, spec) {
|
|
|
114
115
|
hint: spec.hint,
|
|
115
116
|
};
|
|
116
117
|
}
|
|
117
|
-
async function
|
|
118
|
+
async function readPackageJson(dir) {
|
|
118
119
|
const filepath = path.join(dir, 'package.json');
|
|
119
|
-
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) {
|
|
120
131
|
return {
|
|
121
132
|
check: 'package.json',
|
|
122
133
|
status: 'missing',
|
|
123
134
|
detail: 'no package.json found',
|
|
124
135
|
};
|
|
125
136
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
137
|
+
const deps = {
|
|
138
|
+
...(pkg.dependencies ?? {}),
|
|
139
|
+
...(pkg.devDependencies ?? {}),
|
|
140
|
+
};
|
|
128
141
|
if (deps[PACKAGE]) {
|
|
129
142
|
return {
|
|
130
143
|
check: 'package.json',
|
|
@@ -139,14 +152,282 @@ async function checkPackageJson(dir) {
|
|
|
139
152
|
hint: `Run \`pnpm add -D ${PACKAGE}\``,
|
|
140
153
|
};
|
|
141
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
|
+
}
|
|
142
413
|
export async function runDoctor(dir) {
|
|
143
414
|
const targetDir = path.resolve(dir);
|
|
415
|
+
const pkg = await readPackageJson(targetDir);
|
|
144
416
|
const results = [];
|
|
145
417
|
results.push(evaluateNodeVersion(process.version));
|
|
146
|
-
results.push(
|
|
418
|
+
results.push(checkPackageJson(pkg));
|
|
419
|
+
results.push(checkEnginesNode(pkg));
|
|
420
|
+
results.push(await checkEditorConfig(targetDir));
|
|
421
|
+
results.push(await checkNodeVersionPin(targetDir));
|
|
147
422
|
for (const spec of FILE_CHECKS) {
|
|
148
423
|
results.push(await checkFile(targetDir, spec));
|
|
149
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));
|
|
150
431
|
return results;
|
|
151
432
|
}
|
|
152
433
|
const STATUS_ICONS = {
|
|
@@ -186,7 +467,7 @@ export async function doctorCommand(options = {}) {
|
|
|
186
467
|
for (const r of results) {
|
|
187
468
|
console.log(` ${STATUS_ICONS[r.status]} ${chalk.bold(r.check)} — ${statusLabel(r.status)}`);
|
|
188
469
|
console.log(` ${chalk.gray(r.detail)}`);
|
|
189
|
-
if (r.hint &&
|
|
470
|
+
if (r.hint && r.status !== 'ok') {
|
|
190
471
|
console.log(` ${chalk.dim('hint:')} ${chalk.dim(r.hint)}`);
|
|
191
472
|
}
|
|
192
473
|
}
|