@litodocs/cli 0.5.2 → 0.7.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.
@@ -0,0 +1,118 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Lito Template Manifest",
4
+ "description": "Configuration schema for Lito documentation templates",
5
+ "type": "object",
6
+ "properties": {
7
+ "name": {
8
+ "type": "string",
9
+ "description": "Template name"
10
+ },
11
+ "version": {
12
+ "type": "string",
13
+ "description": "Template version (semver)"
14
+ },
15
+ "framework": {
16
+ "type": "string",
17
+ "enum": ["astro", "react", "next", "vue", "nuxt", "svelte"],
18
+ "description": "The framework this template uses"
19
+ },
20
+ "frameworkConfig": {
21
+ "type": "object",
22
+ "description": "Custom framework configuration overrides",
23
+ "properties": {
24
+ "contentDir": {
25
+ "type": "string",
26
+ "description": "Directory where documentation content is placed"
27
+ },
28
+ "publicDir": {
29
+ "type": "string",
30
+ "description": "Directory for static public assets"
31
+ },
32
+ "configFile": {
33
+ "type": "string",
34
+ "description": "Main framework config file name"
35
+ },
36
+ "layoutInjection": {
37
+ "type": "boolean",
38
+ "description": "Whether to inject layout frontmatter into markdown files"
39
+ },
40
+ "layoutPath": {
41
+ "type": "string",
42
+ "description": "Path to the markdown layout component"
43
+ },
44
+ "useMDX": {
45
+ "type": "boolean",
46
+ "description": "Whether the template uses MDX for markdown processing"
47
+ }
48
+ }
49
+ },
50
+ "commands": {
51
+ "type": "object",
52
+ "description": "Custom build/dev commands",
53
+ "properties": {
54
+ "dev": {
55
+ "type": "array",
56
+ "items": [
57
+ { "type": "string", "description": "Binary name" },
58
+ { "type": "array", "items": { "type": "string" }, "description": "Arguments" }
59
+ ],
60
+ "description": "Dev server command [binary, [args]]"
61
+ },
62
+ "build": {
63
+ "type": "array",
64
+ "items": [
65
+ { "type": "string", "description": "Binary name" },
66
+ { "type": "array", "items": { "type": "string" }, "description": "Arguments" }
67
+ ],
68
+ "description": "Build command [binary, [args]]"
69
+ }
70
+ }
71
+ },
72
+ "features": {
73
+ "type": "object",
74
+ "description": "Template feature flags",
75
+ "properties": {
76
+ "search": {
77
+ "type": "boolean",
78
+ "default": true,
79
+ "description": "Whether template supports search"
80
+ },
81
+ "i18n": {
82
+ "type": "boolean",
83
+ "default": false,
84
+ "description": "Whether template supports internationalization"
85
+ },
86
+ "versioning": {
87
+ "type": "boolean",
88
+ "default": false,
89
+ "description": "Whether template supports doc versioning"
90
+ },
91
+ "darkMode": {
92
+ "type": "boolean",
93
+ "default": true,
94
+ "description": "Whether template supports dark mode"
95
+ },
96
+ "apiDocs": {
97
+ "type": "boolean",
98
+ "default": false,
99
+ "description": "Whether template supports OpenAPI documentation"
100
+ }
101
+ }
102
+ },
103
+ "requiredFiles": {
104
+ "type": "array",
105
+ "items": { "type": "string" },
106
+ "description": "List of files required for this template to work"
107
+ },
108
+ "author": {
109
+ "type": "string",
110
+ "description": "Template author"
111
+ },
112
+ "repository": {
113
+ "type": "string",
114
+ "description": "Git repository URL"
115
+ }
116
+ },
117
+ "required": ["framework"]
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litodocs/cli",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Beautiful documentation sites from Markdown. Fast, simple, and open-source.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -3,6 +3,11 @@ import pc from "picocolors";
3
3
  import { buildCommand } from "./commands/build.js";
4
4
  import { devCommand } from "./commands/dev.js";
5
5
  import { ejectCommand } from "./commands/eject.js";
6
+ import { initCommand } from "./commands/init.js";
7
+ import { validateCommand } from "./commands/validate.js";
8
+ import { previewCommand } from "./commands/preview.js";
9
+ import { doctorCommand } from "./commands/doctor.js";
10
+ import { infoCommand } from "./commands/info.js";
6
11
  import {
7
12
  templateListCommand,
8
13
  templateCacheCommand,
@@ -20,7 +25,7 @@ export async function cli() {
20
25
  .description(
21
26
  "Beautiful documentation sites from Markdown. Fast, simple, and open-source."
22
27
  )
23
- .version("0.5.2");
28
+ .version("0.6.0");
24
29
 
25
30
  program
26
31
  .command("build")
@@ -95,6 +100,49 @@ export async function cli() {
95
100
  .option("--refresh", "Force re-download template (bypass cache)", false)
96
101
  .action(ejectCommand);
97
102
 
103
+ // Initialize a new project
104
+ program
105
+ .command("init")
106
+ .description("Initialize a new documentation project")
107
+ .option("-o, --output <path>", "Output directory for the project")
108
+ .option("-n, --name <name>", "Project name")
109
+ .option("-t, --template <name>", "Template to use", "default")
110
+ .option("--sample", "Create sample documentation pages", true)
111
+ .action(initCommand);
112
+
113
+ // Validate configuration
114
+ program
115
+ .command("validate")
116
+ .description("Validate docs-config.json configuration")
117
+ .option("-i, --input <path>", "Path to the docs folder")
118
+ .option("-q, --quiet", "Quiet mode for CI (exit code only)")
119
+ .action(validateCommand);
120
+
121
+ // Preview production build
122
+ program
123
+ .command("preview")
124
+ .description("Preview production build locally")
125
+ .option("-i, --input <path>", "Path to docs folder (will build if no dist exists)")
126
+ .option("-o, --output <path>", "Path to built site", "./dist")
127
+ .option("-t, --template <name>", "Template to use if building", "default")
128
+ .option("-b, --base-url <url>", "Base URL for the site", "/")
129
+ .option("-p, --port <number>", "Port for preview server", "4321")
130
+ .action(previewCommand);
131
+
132
+ // Doctor - diagnose issues
133
+ program
134
+ .command("doctor")
135
+ .description("Diagnose common issues with your docs project")
136
+ .option("-i, --input <path>", "Path to the docs folder")
137
+ .action(doctorCommand);
138
+
139
+ // Info - show project information
140
+ program
141
+ .command("info")
142
+ .description("Show project information and statistics")
143
+ .option("-i, --input <path>", "Path to the docs folder")
144
+ .action(infoCommand);
145
+
98
146
  // Template management commands
99
147
  const templateCmd = program
100
148
  .command("template")
@@ -5,10 +5,10 @@ import pc from 'picocolors';
5
5
  import { scaffoldProject, cleanupProject } from '../core/scaffold.js';
6
6
  import { syncDocs } from '../core/sync.js';
7
7
  import { generateConfig } from '../core/config.js';
8
- import { runAstroBuild } from '../core/astro.js';
9
8
  import { syncDocsConfig } from '../core/config-sync.js';
10
9
  import { copyOutput } from '../core/output.js';
11
10
  import { getTemplatePath } from '../core/template-fetcher.js';
11
+ import { detectFramework, runFrameworkBuild, getOutputDir, needsSearchIndex } from '../core/framework-runner.js';
12
12
 
13
13
  export async function buildCommand(options) {
14
14
  try {
@@ -29,28 +29,33 @@ export async function buildCommand(options) {
29
29
  const templatePath = await getTemplatePath(options.template, options.refresh);
30
30
  s.stop(templatePath ? `Using template: ${pc.cyan(templatePath)}` : 'Using bundled template');
31
31
 
32
- // Step 1: Scaffold temporary Astro project
33
- s.start('Setting up Astro project...');
32
+ // Step 1: Scaffold temporary project
33
+ s.start('Setting up project...');
34
34
  const projectDir = await scaffoldProject(templatePath);
35
- s.stop('Astro project scaffolded');
35
+ s.stop('Project scaffolded');
36
+
37
+ // Step 1.5: Detect framework
38
+ s.start('Detecting framework...');
39
+ const frameworkConfig = await detectFramework(projectDir);
40
+ s.stop(`Using framework: ${pc.cyan(frameworkConfig.name)}`);
36
41
 
37
42
  // Step 2: Prepare project (Install dependencies, Sync docs, Generate navigation)
38
43
  const { installDependencies, runBinary } = await import('../core/package-manager.js');
39
44
  s.start('Preparing project (installing dependencies, syncing files)...');
40
-
45
+
41
46
  const userConfigPath = resolve(options.input, 'docs-config.json');
42
47
 
43
48
  await Promise.all([
44
49
  installDependencies(projectDir, { silent: true }),
45
- syncDocs(inputPath, projectDir),
50
+ syncDocs(inputPath, projectDir, frameworkConfig),
46
51
  syncDocsConfig(projectDir, inputPath, userConfigPath)
47
52
  ]);
48
53
 
49
54
  s.stop('Project prepared (dependencies installed, docs synced, navigation generated)');
50
55
 
51
- // Step 4: Generate config
52
- s.start('Generating Astro configuration...');
53
- await generateConfig(projectDir, options);
56
+ // Step 4: Generate config (framework-aware)
57
+ s.start(`Generating ${frameworkConfig.name} configuration...`);
58
+ await generateConfig(projectDir, options, frameworkConfig);
54
59
  s.stop('Configuration generated');
55
60
 
56
61
  // Step 4.5: Configure for provider
@@ -61,20 +66,24 @@ export async function buildCommand(options) {
61
66
  s.stop(`Configured for ${options.provider}`);
62
67
  }
63
68
 
64
- // Step 5: Build with Astro
65
- s.start('Building site with Astro...');
66
- await runAstroBuild(projectDir);
69
+ // Step 5: Build with framework
70
+ s.start(`Building site with ${frameworkConfig.name}...`);
71
+ await runFrameworkBuild(projectDir, frameworkConfig);
67
72
  s.stop('Site built successfully');
68
73
 
69
- // Step 5.5: Generate Pagefind search index
70
- s.start('Generating search index...');
71
- await runBinary(projectDir, 'pagefind', ['--site', 'dist']);
72
- s.stop('Search index generated');
74
+ // Step 5.5: Generate Pagefind search index (only for static frameworks)
75
+ if (needsSearchIndex(frameworkConfig)) {
76
+ const outputDir = getOutputDir(frameworkConfig);
77
+ s.start('Generating search index...');
78
+ await runBinary(projectDir, 'pagefind', ['--site', outputDir]);
79
+ s.stop('Search index generated');
80
+ }
73
81
 
74
82
  // Step 6: Copy output
75
83
  const outputPath = resolve(options.output);
84
+ const frameworkOutputDir = getOutputDir(frameworkConfig);
76
85
  s.start(`Copying output to ${pc.cyan(outputPath)}...`);
77
- await copyOutput(projectDir, outputPath);
86
+ await copyOutput(projectDir, outputPath, frameworkOutputDir);
78
87
  s.stop('Output copied');
79
88
 
80
89
  // Cleanup temp directory
@@ -6,9 +6,9 @@ import chokidar from 'chokidar';
6
6
  import { scaffoldProject, cleanupProject } from '../core/scaffold.js';
7
7
  import { syncDocs } from '../core/sync.js';
8
8
  import { generateConfig } from '../core/config.js';
9
- import { runAstroDev } from '../core/astro.js';
10
9
  import { syncDocsConfig } from '../core/config-sync.js';
11
10
  import { getTemplatePath } from '../core/template-fetcher.js';
11
+ import { detectFramework, runFrameworkDev } from '../core/framework-runner.js';
12
12
 
13
13
  export async function devCommand(options) {
14
14
  try {
@@ -29,10 +29,15 @@ export async function devCommand(options) {
29
29
  const templatePath = await getTemplatePath(options.template, options.refresh);
30
30
  s.stop(templatePath ? `Using template: ${pc.cyan(templatePath)}` : 'Using bundled template');
31
31
 
32
- // Step 1: Scaffold temporary Astro project
33
- s.start('Setting up Astro project...');
32
+ // Step 1: Scaffold temporary project
33
+ s.start('Setting up project...');
34
34
  const projectDir = await scaffoldProject(templatePath);
35
- s.stop('Astro project scaffolded');
35
+ s.stop('Project scaffolded');
36
+
37
+ // Step 1.5: Detect framework
38
+ s.start('Detecting framework...');
39
+ const frameworkConfig = await detectFramework(projectDir);
40
+ s.stop(`Using framework: ${pc.cyan(frameworkConfig.name)}`);
36
41
 
37
42
  // Register cleanup handlers
38
43
  const cleanup = async () => {
@@ -52,15 +57,15 @@ export async function devCommand(options) {
52
57
 
53
58
  await Promise.all([
54
59
  installDependencies(projectDir, { silent: true }),
55
- syncDocs(inputPath, projectDir),
60
+ syncDocs(inputPath, projectDir, frameworkConfig),
56
61
  syncDocsConfig(projectDir, inputPath, userConfigPath)
57
62
  ]);
58
63
 
59
64
  s.stop('Project prepared (dependencies installed, docs synced, navigation generated)');
60
65
 
61
- // Step 4: Generate config
62
- s.start('Generating Astro configuration...');
63
- await generateConfig(projectDir, options);
66
+ // Step 4: Generate config (framework-aware)
67
+ s.start(`Generating ${frameworkConfig.name} configuration...`);
68
+ await generateConfig(projectDir, options, frameworkConfig);
64
69
  s.stop('Configuration generated');
65
70
 
66
71
  // Step 4: Setup file watcher with debouncing
@@ -82,7 +87,7 @@ export async function devCommand(options) {
82
87
 
83
88
  try {
84
89
  await Promise.all([
85
- syncDocs(inputPath, projectDir),
90
+ syncDocs(inputPath, projectDir, frameworkConfig),
86
91
  syncDocsConfig(projectDir, inputPath, userConfigPath)
87
92
  ]);
88
93
  log.success('Documentation and config re-synced');
@@ -115,9 +120,9 @@ export async function devCommand(options) {
115
120
  debouncedSync();
116
121
  });
117
122
 
118
- // Step 5: Start Astro dev server
119
- note(`Starting Astro dev server at http://localhost:${options.port}`, 'Dev Server');
120
- await runAstroDev(projectDir, options.port);
123
+ // Step 5: Start framework dev server
124
+ note(`Starting ${frameworkConfig.name} dev server at http://localhost:${options.port}`, 'Dev Server');
125
+ await runFrameworkDev(projectDir, frameworkConfig, options.port);
121
126
 
122
127
  } catch (error) {
123
128
  if (isCancel(error)) {
@@ -0,0 +1,311 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { resolve, join, extname } from 'path';
3
+ import { intro, outro, log, spinner } from '@clack/prompts';
4
+ import pc from 'picocolors';
5
+ import { validateConfig } from '../core/config-validator.js';
6
+ import { TEMPLATE_REGISTRY } from '../core/template-registry.js';
7
+
8
+ /**
9
+ * Check result types
10
+ */
11
+ const CHECK_PASS = 'pass';
12
+ const CHECK_WARN = 'warn';
13
+ const CHECK_FAIL = 'fail';
14
+
15
+ /**
16
+ * Doctor command - Diagnose common issues
17
+ */
18
+ export async function doctorCommand(options) {
19
+ try {
20
+ const inputPath = options.input ? resolve(options.input) : process.cwd();
21
+
22
+ console.clear();
23
+ intro(pc.inverse(pc.cyan(' Lito - Doctor ')));
24
+
25
+ log.message(pc.dim(`Checking: ${inputPath}`));
26
+ log.message('');
27
+
28
+ const checks = [];
29
+
30
+ // Check 1: Directory exists
31
+ checks.push(await checkDirectoryExists(inputPath));
32
+
33
+ // Check 2: docs-config.json exists
34
+ checks.push(await checkConfigExists(inputPath));
35
+
36
+ // Check 3: Config is valid JSON
37
+ const configCheck = await checkConfigValid(inputPath);
38
+ checks.push(configCheck);
39
+
40
+ // Check 4: Config passes validation
41
+ if (configCheck.status === CHECK_PASS) {
42
+ checks.push(await checkConfigSchema(inputPath));
43
+ }
44
+
45
+ // Check 5: Content files exist
46
+ checks.push(await checkContentFiles(inputPath));
47
+
48
+ // Check 6: Check for common issues
49
+ checks.push(await checkCommonIssues(inputPath));
50
+
51
+ // Check 7: Template cache
52
+ checks.push(await checkTemplateCache());
53
+
54
+ // Check 8: Node.js version
55
+ checks.push(await checkNodeVersion());
56
+
57
+ // Print results
58
+ log.message('');
59
+ log.message(pc.bold('Diagnostic Results:'));
60
+ log.message('');
61
+
62
+ let hasErrors = false;
63
+ let hasWarnings = false;
64
+
65
+ for (const check of checks) {
66
+ const icon =
67
+ check.status === CHECK_PASS
68
+ ? pc.green('✓')
69
+ : check.status === CHECK_WARN
70
+ ? pc.yellow('!')
71
+ : pc.red('✗');
72
+
73
+ log.message(` ${icon} ${check.name}`);
74
+
75
+ if (check.message) {
76
+ log.message(` ${pc.dim(check.message)}`);
77
+ }
78
+
79
+ if (check.status === CHECK_FAIL) hasErrors = true;
80
+ if (check.status === CHECK_WARN) hasWarnings = true;
81
+ }
82
+
83
+ log.message('');
84
+
85
+ // Summary
86
+ if (hasErrors) {
87
+ log.error(pc.red('Some checks failed. Please fix the issues above.'));
88
+ process.exit(1);
89
+ } else if (hasWarnings) {
90
+ log.warn(pc.yellow('Some warnings found. Your project should still work.'));
91
+ outro(pc.yellow('Doctor completed with warnings'));
92
+ } else {
93
+ log.success(pc.green('All checks passed!'));
94
+ outro(pc.green('Your project looks healthy! 🎉'));
95
+ }
96
+ } catch (error) {
97
+ log.error(pc.red(error.message));
98
+ process.exit(1);
99
+ }
100
+ }
101
+
102
+ async function checkDirectoryExists(inputPath) {
103
+ if (!existsSync(inputPath)) {
104
+ return {
105
+ name: 'Directory exists',
106
+ status: CHECK_FAIL,
107
+ message: `Directory not found: ${inputPath}`,
108
+ };
109
+ }
110
+
111
+ const stat = statSync(inputPath);
112
+ if (!stat.isDirectory()) {
113
+ return {
114
+ name: 'Directory exists',
115
+ status: CHECK_FAIL,
116
+ message: `Path is not a directory: ${inputPath}`,
117
+ };
118
+ }
119
+
120
+ return {
121
+ name: 'Directory exists',
122
+ status: CHECK_PASS,
123
+ };
124
+ }
125
+
126
+ async function checkConfigExists(inputPath) {
127
+ const configPath = join(inputPath, 'docs-config.json');
128
+
129
+ if (!existsSync(configPath)) {
130
+ return {
131
+ name: 'docs-config.json exists',
132
+ status: CHECK_FAIL,
133
+ message: `Run 'lito init' to create a configuration file`,
134
+ };
135
+ }
136
+
137
+ return {
138
+ name: 'docs-config.json exists',
139
+ status: CHECK_PASS,
140
+ };
141
+ }
142
+
143
+ async function checkConfigValid(inputPath) {
144
+ const configPath = join(inputPath, 'docs-config.json');
145
+
146
+ if (!existsSync(configPath)) {
147
+ return {
148
+ name: 'Config is valid JSON',
149
+ status: CHECK_WARN,
150
+ message: 'Skipped (no config file)',
151
+ };
152
+ }
153
+
154
+ try {
155
+ JSON.parse(readFileSync(configPath, 'utf-8'));
156
+ return {
157
+ name: 'Config is valid JSON',
158
+ status: CHECK_PASS,
159
+ };
160
+ } catch (e) {
161
+ return {
162
+ name: 'Config is valid JSON',
163
+ status: CHECK_FAIL,
164
+ message: e.message,
165
+ };
166
+ }
167
+ }
168
+
169
+ async function checkConfigSchema(inputPath) {
170
+ const configPath = join(inputPath, 'docs-config.json');
171
+
172
+ try {
173
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
174
+ const result = validateConfig(config, inputPath, { silent: true });
175
+
176
+ if (!result.valid) {
177
+ return {
178
+ name: 'Config passes validation',
179
+ status: CHECK_FAIL,
180
+ message: result.errors.map((e) => e.message).join('; '),
181
+ };
182
+ }
183
+
184
+ return {
185
+ name: 'Config passes validation',
186
+ status: CHECK_PASS,
187
+ };
188
+ } catch (e) {
189
+ return {
190
+ name: 'Config passes validation',
191
+ status: CHECK_WARN,
192
+ message: 'Could not validate',
193
+ };
194
+ }
195
+ }
196
+
197
+ async function checkContentFiles(inputPath) {
198
+ try {
199
+ const files = readdirSync(inputPath);
200
+ const contentFiles = files.filter((f) => {
201
+ const ext = extname(f).toLowerCase();
202
+ return ['.md', '.mdx'].includes(ext);
203
+ });
204
+
205
+ if (contentFiles.length === 0) {
206
+ return {
207
+ name: 'Content files exist',
208
+ status: CHECK_WARN,
209
+ message: 'No .md or .mdx files found in root directory',
210
+ };
211
+ }
212
+
213
+ return {
214
+ name: 'Content files exist',
215
+ status: CHECK_PASS,
216
+ message: `Found ${contentFiles.length} content file(s)`,
217
+ };
218
+ } catch (e) {
219
+ return {
220
+ name: 'Content files exist',
221
+ status: CHECK_WARN,
222
+ message: 'Could not read directory',
223
+ };
224
+ }
225
+ }
226
+
227
+ async function checkCommonIssues(inputPath) {
228
+ const issues = [];
229
+
230
+ // Check for node_modules in docs folder (shouldn't be there)
231
+ if (existsSync(join(inputPath, 'node_modules'))) {
232
+ issues.push('node_modules folder found in docs (should be removed)');
233
+ }
234
+
235
+ // Check for package.json in docs folder (might cause conflicts)
236
+ if (existsSync(join(inputPath, 'package.json'))) {
237
+ issues.push('package.json in docs folder (may cause conflicts)');
238
+ }
239
+
240
+ // Check for .git in docs folder
241
+ if (existsSync(join(inputPath, '.git'))) {
242
+ issues.push('.git folder in docs (unusual structure)');
243
+ }
244
+
245
+ if (issues.length > 0) {
246
+ return {
247
+ name: 'No common issues',
248
+ status: CHECK_WARN,
249
+ message: issues.join('; '),
250
+ };
251
+ }
252
+
253
+ return {
254
+ name: 'No common issues',
255
+ status: CHECK_PASS,
256
+ };
257
+ }
258
+
259
+ async function checkTemplateCache() {
260
+ const cacheDir = join(process.env.HOME || '~', '.lito', 'templates');
261
+
262
+ if (!existsSync(cacheDir)) {
263
+ return {
264
+ name: 'Template cache',
265
+ status: CHECK_PASS,
266
+ message: 'No cached templates',
267
+ };
268
+ }
269
+
270
+ try {
271
+ const cached = readdirSync(cacheDir);
272
+ return {
273
+ name: 'Template cache',
274
+ status: CHECK_PASS,
275
+ message: `${cached.length} template(s) cached`,
276
+ };
277
+ } catch (e) {
278
+ return {
279
+ name: 'Template cache',
280
+ status: CHECK_WARN,
281
+ message: 'Could not read cache directory',
282
+ };
283
+ }
284
+ }
285
+
286
+ async function checkNodeVersion() {
287
+ const version = process.version;
288
+ const major = parseInt(version.slice(1).split('.')[0], 10);
289
+
290
+ if (major < 18) {
291
+ return {
292
+ name: 'Node.js version',
293
+ status: CHECK_FAIL,
294
+ message: `Node.js 18+ required (found ${version})`,
295
+ };
296
+ }
297
+
298
+ if (major < 20) {
299
+ return {
300
+ name: 'Node.js version',
301
+ status: CHECK_WARN,
302
+ message: `Node.js 20+ recommended (found ${version})`,
303
+ };
304
+ }
305
+
306
+ return {
307
+ name: 'Node.js version',
308
+ status: CHECK_PASS,
309
+ message: version,
310
+ };
311
+ }