@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.
@@ -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 checkPackageJson(dir) {
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 pkg = await fs.readJson(filepath);
88
- const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
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(await checkPackageJson(targetDir));
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 && (r.status === 'drift' || r.status === 'missing')) {
470
+ if (r.hint && r.status !== 'ok') {
150
471
  console.log(` ${chalk.dim('hint:')} ${chalk.dim(r.hint)}`);
151
472
  }
152
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.1.2",
3
+ "version": "2.3.0",
4
4
  "description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
5
5
  "type": "module",
6
6
  "keywords": [