@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.
@@ -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 checkPackageJson(dir) {
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 pkg = await fs.readJson(filepath);
127
- const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
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(await checkPackageJson(targetDir));
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 && (r.status === 'drift' || r.status === 'missing')) {
470
+ if (r.hint && r.status !== 'ok') {
190
471
  console.log(` ${chalk.dim('hint:')} ${chalk.dim(r.hint)}`);
191
472
  }
192
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.2.0",
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": [