dev-env-doctor 0.0.0-stage → 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +303 -2
- package/bin/dev-doctor.js +4 -0
- package/package.json +49 -4
- package/src/checks/environment.js +212 -0
- package/src/checks/node-version.js +65 -0
- package/src/checks/packages.js +101 -0
- package/src/checks/ports.js +197 -0
- package/src/checks/project.js +69 -0
- package/src/checks/services.js +162 -0
- package/src/cli.js +81 -0
- package/src/commands/doctor.js +18 -0
- package/src/commands/fix.js +83 -0
- package/src/commands/ports.js +70 -0
- package/src/core/diagnostics.js +24 -0
- package/src/core/exit-codes.js +6 -0
- package/src/core/port-workflow.js +39 -0
- package/src/core/reporter.js +54 -0
- package/src/core/runner.js +34 -0
- package/src/core/terminate.js +140 -0
- package/src/fixes/index.js +97 -0
- package/src/platform/darwin.js +25 -0
- package/src/platform/index.js +10 -0
- package/src/platform/linux.js +48 -0
- package/src/platform/unix.js +79 -0
- package/src/platform/win32.js +96 -0
- package/src/utils/config.js +31 -0
- package/src/utils/env-parser.js +64 -0
- package/src/utils/errors.js +15 -0
- package/src/utils/paths.js +44 -0
- package/src/utils/process-info.js +34 -0
- package/src/utils/prompt.js +24 -0
- package/src/utils/redact.js +18 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import semver from 'semver';
|
|
3
|
+
import { result, STATUS } from '../core/diagnostics.js';
|
|
4
|
+
import { readTextIfExists } from '../utils/paths.js';
|
|
5
|
+
import { getToolVersion } from '../utils/process-info.js';
|
|
6
|
+
|
|
7
|
+
const CATEGORY = 'Node.js';
|
|
8
|
+
const ALIAS = /^(lts\/|lts$|node$|stable$|latest$|current$|system$)/i;
|
|
9
|
+
|
|
10
|
+
/** Compare the running Node.js version with a .nvmrc / .node-version value. */
|
|
11
|
+
export function checkVersionFile(file, value, current) {
|
|
12
|
+
const wanted = value.trim().replace(/^v/i, '');
|
|
13
|
+
if (!wanted) return result(CATEGORY, STATUS.WARN, `${file} is empty`);
|
|
14
|
+
if (ALIAS.test(wanted)) {
|
|
15
|
+
return result(CATEGORY, STATUS.INFO, `${file} uses alias "${wanted}"; cannot verify offline`);
|
|
16
|
+
}
|
|
17
|
+
const hint = `Switch Node.js versions, e.g. "nvm use" or "fnm use" (reads ${file}).`;
|
|
18
|
+
if (semver.valid(wanted)) {
|
|
19
|
+
if (semver.eq(current, wanted)) return result(CATEGORY, STATUS.PASS, `Node.js ${current} matches ${file} (${wanted})`);
|
|
20
|
+
if (semver.major(current) === semver.major(wanted)) {
|
|
21
|
+
return result(CATEGORY, STATUS.WARN, `Node.js ${current} differs from ${file} (${wanted})`, { hint });
|
|
22
|
+
}
|
|
23
|
+
return result(CATEGORY, STATUS.FAIL, `Node.js ${current} does not match ${file} (${wanted})`, { hint });
|
|
24
|
+
}
|
|
25
|
+
if (semver.validRange(wanted)) {
|
|
26
|
+
if (semver.satisfies(current, wanted)) return result(CATEGORY, STATUS.PASS, `Node.js ${current} matches ${file} (${wanted})`);
|
|
27
|
+
return result(CATEGORY, STATUS.FAIL, `Node.js ${current} does not match ${file} (${wanted})`, { hint });
|
|
28
|
+
}
|
|
29
|
+
return result(CATEGORY, STATUS.WARN, `${file} contains an unrecognized version "${wanted}"`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function checkEngines(range, current) {
|
|
33
|
+
if (!semver.validRange(range)) {
|
|
34
|
+
return result(CATEGORY, STATUS.WARN, `engines.node "${range}" is not a valid version range`);
|
|
35
|
+
}
|
|
36
|
+
if (semver.satisfies(current, range)) {
|
|
37
|
+
return result(CATEGORY, STATUS.PASS, `Node.js ${current} satisfies engines.node (${range})`);
|
|
38
|
+
}
|
|
39
|
+
return result(CATEGORY, STATUS.FAIL, `Node.js ${current} does not satisfy engines.node (${range})`, {
|
|
40
|
+
hint: `Install and use a Node.js version matching "${range}" (e.g. with nvm, fnm or volta).`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkNode(project, { nodeVersion = process.version, getVersion = getToolVersion } = {}) {
|
|
45
|
+
const current = semver.clean(nodeVersion) ?? nodeVersion;
|
|
46
|
+
const results = [result(CATEGORY, STATUS.INFO, `Node.js ${current}`)];
|
|
47
|
+
|
|
48
|
+
const npmVersion = await getVersion('npm');
|
|
49
|
+
results.push(npmVersion
|
|
50
|
+
? result(CATEGORY, STATUS.INFO, `npm ${npmVersion}`)
|
|
51
|
+
: result(CATEGORY, STATUS.WARN, 'npm was not found on PATH', { hint: 'Reinstall Node.js or fix your PATH so npm is available.' }));
|
|
52
|
+
|
|
53
|
+
const range = project.packageJson?.engines?.node;
|
|
54
|
+
if (typeof range === 'string') results.push(checkEngines(range, current));
|
|
55
|
+
|
|
56
|
+
for (const file of ['.nvmrc', '.node-version']) {
|
|
57
|
+
const text = readTextIfExists(path.join(project.root, file), 1024);
|
|
58
|
+
if (text !== null) results.push(checkVersionFile(file, text.split(/\r?\n/)[0], current));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof range !== 'string' && results.length === 2) {
|
|
62
|
+
results.push(result(CATEGORY, STATUS.INFO, 'No required Node.js version declared (engines.node, .nvmrc, .node-version)'));
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { result, STATUS } from '../core/diagnostics.js';
|
|
4
|
+
import { exists } from '../utils/paths.js';
|
|
5
|
+
import { getToolVersion } from '../utils/process-info.js';
|
|
6
|
+
|
|
7
|
+
const CATEGORY = 'Packages';
|
|
8
|
+
|
|
9
|
+
export const LOCKFILES = [
|
|
10
|
+
['package-lock.json', 'npm'],
|
|
11
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
12
|
+
['yarn.lock', 'yarn'],
|
|
13
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
14
|
+
['bun.lockb', 'bun'],
|
|
15
|
+
['bun.lock', 'bun'],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const KNOWN_MANAGERS = new Set(['npm', 'yarn', 'pnpm', 'bun']);
|
|
19
|
+
|
|
20
|
+
export function findLockfiles(root) {
|
|
21
|
+
return LOCKFILES.filter(([file]) => exists(path.join(root, file))).map(([file, manager]) => ({ file, manager }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mtime(file) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.statSync(file).mtimeMs;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function checkPackages(project, { getVersion = getToolVersion } = {}) {
|
|
33
|
+
const pkg = project.packageJson;
|
|
34
|
+
if (!pkg) return [];
|
|
35
|
+
const { root } = project;
|
|
36
|
+
const results = [];
|
|
37
|
+
|
|
38
|
+
const lockfiles = findLockfiles(root);
|
|
39
|
+
const lockManagers = [...new Set(lockfiles.map((l) => l.manager))];
|
|
40
|
+
const declared = typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : null;
|
|
41
|
+
const manager = (declared && KNOWN_MANAGERS.has(declared) ? declared : null) ?? (lockManagers.length === 1 ? lockManagers[0] : 'npm');
|
|
42
|
+
const install = `${manager} install`;
|
|
43
|
+
|
|
44
|
+
if (lockManagers.length > 1) {
|
|
45
|
+
results.push(result(CATEGORY, STATUS.WARN, 'Multiple lockfiles detected', {
|
|
46
|
+
details: lockfiles.map((l) => `${l.file} (${l.manager})`),
|
|
47
|
+
hint: `Agree on one package manager (likely ${manager}) and remove the other lockfiles yourself.`,
|
|
48
|
+
}));
|
|
49
|
+
} else if (lockfiles.length === 0) {
|
|
50
|
+
results.push(result(CATEGORY, STATUS.WARN, 'No lockfile found', { hint: `Run "${install}" and commit the generated lockfile.` }));
|
|
51
|
+
} else {
|
|
52
|
+
results.push(result(CATEGORY, STATUS.PASS, `Lockfile: ${lockfiles.map((l) => l.file).join(', ')} (${lockManagers[0]})`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (declared && lockManagers.length > 0 && !lockManagers.includes(declared)) {
|
|
56
|
+
results.push(result(CATEGORY, STATUS.WARN, `packageManager is "${declared}" but the lockfile belongs to ${lockManagers.join(', ')}`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const version = await getVersion(manager);
|
|
60
|
+
if (version) {
|
|
61
|
+
results.push(result(CATEGORY, STATUS.PASS, `${manager} ${version} is available`));
|
|
62
|
+
} else {
|
|
63
|
+
results.push(result(CATEGORY, STATUS.FAIL, `${manager} is used by this project but was not found`, {
|
|
64
|
+
hint: manager === 'npm' ? 'Reinstall Node.js to restore npm.' : `Install ${manager} (e.g. "corepack enable") and try again.`,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const names = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
|
|
69
|
+
if (names.length === 0) {
|
|
70
|
+
results.push(result(CATEGORY, STATUS.INFO, 'No dependencies declared'));
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
if (exists(path.join(root, '.pnp.cjs'))) {
|
|
74
|
+
results.push(result(CATEGORY, STATUS.INFO, "Yarn Plug'n'Play detected; node_modules checks skipped"));
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nodeModules = path.join(root, 'node_modules');
|
|
79
|
+
if (!exists(nodeModules)) {
|
|
80
|
+
results.push(result(CATEGORY, STATUS.FAIL, 'Dependencies are not installed (node_modules not found)', { hint: `Run "${install}".` }));
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const missing = names.filter((name) => !exists(path.join(nodeModules, name, 'package.json')));
|
|
85
|
+
if (missing.length > 0) {
|
|
86
|
+
results.push(result(CATEGORY, STATUS.FAIL, `${missing.length} of ${names.length} dependencies are not installed`, {
|
|
87
|
+
details: missing.slice(0, 10).concat(missing.length > 10 ? [`...and ${missing.length - 10} more`] : []),
|
|
88
|
+
hint: `Run "${install}".`,
|
|
89
|
+
}));
|
|
90
|
+
} else {
|
|
91
|
+
results.push(result(CATEGORY, STATUS.PASS, `All ${names.length} declared dependencies are installed`));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// npm writes node_modules/.package-lock.json on install; an older one means the lockfile changed since.
|
|
95
|
+
const lockTime = mtime(path.join(root, 'package-lock.json'));
|
|
96
|
+
const installTime = mtime(path.join(nodeModules, '.package-lock.json'));
|
|
97
|
+
if (lockTime && installTime && lockTime > installTime + 1000) {
|
|
98
|
+
results.push(result(CATEGORY, STATUS.WARN, 'package-lock.json changed since dependencies were last installed', { hint: 'Run "npm install".' }));
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { result, STATUS } from '../core/diagnostics.js';
|
|
4
|
+
import { DevDoctorError } from '../utils/errors.js';
|
|
5
|
+
import { parseEnv } from '../utils/env-parser.js';
|
|
6
|
+
import { readTextIfExists } from '../utils/paths.js';
|
|
7
|
+
import { redactCommandLine } from '../utils/redact.js';
|
|
8
|
+
import { allDependencies } from './project.js';
|
|
9
|
+
|
|
10
|
+
const CATEGORY = 'Ports';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_PORTS = [3000, 3001, 4200, 5000, 5173, 8000, 8080];
|
|
13
|
+
|
|
14
|
+
const FRAMEWORK_PORTS = [
|
|
15
|
+
['next', 3000], ['react-scripts', 3000], ['nuxt', 3000], ['vite', 5173], ['@angular/core', 4200], ['astro', 4321],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function parsePort(value) {
|
|
19
|
+
const text = String(value).trim();
|
|
20
|
+
const port = Number(text);
|
|
21
|
+
if (!/^\d+$/.test(text) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
22
|
+
throw new DevDoctorError(`Invalid port "${value}": expected an integer between 1 and 65535`);
|
|
23
|
+
}
|
|
24
|
+
return port;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Ports this project is likely to use: config, PORT in .env, script flags, framework defaults. */
|
|
28
|
+
export function detectProjectPorts(project, config = {}) {
|
|
29
|
+
const ports = new Set();
|
|
30
|
+
for (const p of config.ports ?? []) ports.add(parsePort(p));
|
|
31
|
+
|
|
32
|
+
const envText = readTextIfExists(path.join(project.root, '.env'));
|
|
33
|
+
const envPort = envText ? parseEnv(envText).entries.get('PORT')?.value : undefined;
|
|
34
|
+
if (envPort && /^\d+$/.test(envPort) && Number(envPort) <= 65535 && Number(envPort) > 0) ports.add(Number(envPort));
|
|
35
|
+
|
|
36
|
+
const pkg = project.packageJson;
|
|
37
|
+
for (const script of Object.values(pkg?.scripts ?? {})) {
|
|
38
|
+
if (typeof script !== 'string') continue;
|
|
39
|
+
for (const match of script.matchAll(/(?:--port[= ]|\s-p\s+)(\d{2,5})\b/g)) {
|
|
40
|
+
const port = Number(match[1]);
|
|
41
|
+
if (port > 0 && port <= 65535) ports.add(port);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const deps = allDependencies(pkg);
|
|
45
|
+
for (const [dep, port] of FRAMEWORK_PORTS) {
|
|
46
|
+
if (deps[dep]) {
|
|
47
|
+
ports.add(port);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...ports].sort((a, b) => a - b);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listenOnce(port, host) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const server = net.createServer();
|
|
57
|
+
server.unref();
|
|
58
|
+
server.once('error', (error) => resolve(error.code));
|
|
59
|
+
server.listen({ port, host, exclusive: true }, () => server.close(() => resolve('free')));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Try to bind the port. Returns 'free', 'in-use' or 'denied'. */
|
|
64
|
+
export async function bindTest(port) {
|
|
65
|
+
for (const host of ['127.0.0.1', '::']) {
|
|
66
|
+
const outcome = await listenOnce(port, host);
|
|
67
|
+
if (outcome === 'EADDRINUSE') return 'in-use';
|
|
68
|
+
if (outcome === 'EACCES') return 'denied';
|
|
69
|
+
// EADDRNOTAVAIL / EAFNOSUPPORT: that address family is unavailable; try the next one.
|
|
70
|
+
}
|
|
71
|
+
return 'free';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Merge IPv4/IPv6 entries of the same process. */
|
|
75
|
+
export function dedupeListeners(listeners) {
|
|
76
|
+
const byKey = new Map();
|
|
77
|
+
for (const l of listeners) {
|
|
78
|
+
const key = l.pid ?? 'unknown';
|
|
79
|
+
const existing = byKey.get(key);
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.addresses = [...new Set([...existing.addresses, ...l.addresses])];
|
|
82
|
+
existing.name ??= l.name;
|
|
83
|
+
} else {
|
|
84
|
+
byKey.set(key, { ...l, addresses: [...new Set(l.addresses)] });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...byKey.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isInside(dir, root) {
|
|
91
|
+
const rel = path.relative(root, dir);
|
|
92
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 'current' when the process runs in (or from) the project, 'other' when it clearly does not, else 'unknown'. */
|
|
96
|
+
export function projectAssociation(details, projectRoot) {
|
|
97
|
+
if (!projectRoot) return 'unknown';
|
|
98
|
+
if (details.cwd) return isInside(details.cwd, projectRoot) ? 'current' : 'other';
|
|
99
|
+
if (details.command && details.command.includes(projectRoot)) return 'current';
|
|
100
|
+
return 'unknown';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inspect one port: { port, state: 'free'|'in-use'|'denied', listeners, error? } */
|
|
104
|
+
export async function inspectPort(port, { adapter, bind = bindTest, describe = false, projectRoot = null } = {}) {
|
|
105
|
+
let listeners = [];
|
|
106
|
+
let error = null;
|
|
107
|
+
try {
|
|
108
|
+
listeners = dedupeListeners(await adapter.findListeners(port));
|
|
109
|
+
} catch (e) {
|
|
110
|
+
error = e.message;
|
|
111
|
+
}
|
|
112
|
+
if (describe) {
|
|
113
|
+
for (const l of listeners) {
|
|
114
|
+
if (!l.pid) continue;
|
|
115
|
+
const details = await adapter.describeProcess(l.pid);
|
|
116
|
+
// Command lines are redacted here so secrets never reach any reporter or JSON.
|
|
117
|
+
Object.assign(l, details, { name: l.name ?? details.name ?? null, command: redactCommandLine(details.command ?? null) });
|
|
118
|
+
l.project = projectAssociation(l, projectRoot);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (listeners.length > 0) return { port, state: 'in-use', listeners, error };
|
|
122
|
+
const state = await bind(port);
|
|
123
|
+
return { port, state, listeners: [], error };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function describeListener(l) {
|
|
127
|
+
if (!l.pid) return 'an inaccessible process';
|
|
128
|
+
return `${l.name ?? 'unknown process'} (PID ${l.pid})`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ASSOCIATION = { current: 'current project', other: 'another project or location', unknown: 'unknown' };
|
|
132
|
+
|
|
133
|
+
function field(l, name, value) {
|
|
134
|
+
if (value !== null && value !== undefined) return String(value);
|
|
135
|
+
return `unavailable${l.unavailable?.[name] ? ` (${l.unavailable[name]})` : ''}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Focused, multi-line description of one listener; missing data is labelled, never invented. */
|
|
139
|
+
export function formatProcessDetails(port, l) {
|
|
140
|
+
if (!l.pid) return [`Port ${port}: owning process is not accessible (PID unavailable)`];
|
|
141
|
+
const lines = [
|
|
142
|
+
`Port: ${port}`,
|
|
143
|
+
`PID: ${l.pid}`,
|
|
144
|
+
`Name: ${field(l, 'name', l.name)}`,
|
|
145
|
+
`Command: ${field(l, 'command', l.command)}`,
|
|
146
|
+
`Parent PID: ${field(l, 'ppid', l.ppid)}`,
|
|
147
|
+
`Working directory: ${field(l, 'cwd', l.cwd)}`,
|
|
148
|
+
`Project: ${ASSOCIATION[l.project] ?? 'unknown'}`,
|
|
149
|
+
`Listening on: ${l.addresses.join(', ') || 'unavailable'}`,
|
|
150
|
+
];
|
|
151
|
+
if (l.user) lines.splice(3, 0, `User: ${l.user}`);
|
|
152
|
+
if (l.exists === false) lines.push('Note: the process exited while it was being inspected');
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function portResults(inspections, { occupiedStatus = STATUS.FAIL } = {}) {
|
|
157
|
+
return inspections.map((i) => {
|
|
158
|
+
const details = [];
|
|
159
|
+
if (i.error) details.push(i.error);
|
|
160
|
+
for (const l of i.listeners) {
|
|
161
|
+
details.push(`PID ${l.pid ?? '?'} ${l.name ?? '?'} ${l.addresses.join(', ')}${l.user ? ` user: ${l.user}` : ''}`);
|
|
162
|
+
if (l.command) details.push(` ${l.command}`);
|
|
163
|
+
if (l.cwd) details.push(` cwd: ${l.cwd}${l.project ? ` (${ASSOCIATION[l.project]})` : ''}`);
|
|
164
|
+
}
|
|
165
|
+
const data = { port: i.port, state: i.state, listeners: i.listeners };
|
|
166
|
+
|
|
167
|
+
if (i.state === 'free') return result(CATEGORY, STATUS.PASS, `Port ${i.port} is available`, { details, data });
|
|
168
|
+
if (i.state === 'denied') {
|
|
169
|
+
return result(CATEGORY, STATUS.INFO, `Port ${i.port} needs elevated privileges to test`, { details, data });
|
|
170
|
+
}
|
|
171
|
+
const known = i.listeners.filter((l) => l.pid);
|
|
172
|
+
if (known.length === 0) {
|
|
173
|
+
return result(CATEGORY, occupiedStatus, `Port ${i.port} is in use (process details not accessible)`, {
|
|
174
|
+
details,
|
|
175
|
+
data,
|
|
176
|
+
hint: `Port ${i.port} is held by a process you cannot inspect; it may belong to another user or need elevated permissions.`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return result(CATEGORY, occupiedStatus, `Port ${i.port} is occupied by ${known.map(describeListener).join(', ')}`, {
|
|
180
|
+
details,
|
|
181
|
+
data,
|
|
182
|
+
hint: `Inspect the process using port ${i.port}, or stop it with "dev-doctor ports --kill ${i.port}".`,
|
|
183
|
+
// Only offer termination for ports the project needs, not for generic common ports.
|
|
184
|
+
...(occupiedStatus === STATUS.FAIL ? { fixId: 'kill-port', fixData: { port: i.port } } : {}),
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function inspectPorts(ports, options = {}) {
|
|
190
|
+
const inspections = [];
|
|
191
|
+
for (const port of ports) inspections.push(await inspectPort(port, options));
|
|
192
|
+
return inspections;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function checkPorts(ports, { occupiedStatus, ...options } = {}) {
|
|
196
|
+
return portResults(await inspectPorts(ports, options), { occupiedStatus });
|
|
197
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { result, STATUS } from '../core/diagnostics.js';
|
|
4
|
+
|
|
5
|
+
const CATEGORY = 'Project';
|
|
6
|
+
|
|
7
|
+
// Order matters: frameworks before the libraries they build on.
|
|
8
|
+
const PROJECT_TYPES = [
|
|
9
|
+
['next', 'Next.js'],
|
|
10
|
+
['nuxt', 'Nuxt'],
|
|
11
|
+
['@angular/core', 'Angular'],
|
|
12
|
+
['@sveltejs/kit', 'SvelteKit'],
|
|
13
|
+
['@remix-run/react', 'Remix'],
|
|
14
|
+
['astro', 'Astro'],
|
|
15
|
+
['vite', 'Vite'],
|
|
16
|
+
['react-scripts', 'Create React App'],
|
|
17
|
+
['@nestjs/core', 'NestJS'],
|
|
18
|
+
['express', 'Express'],
|
|
19
|
+
['fastify', 'Fastify'],
|
|
20
|
+
['react', 'React'],
|
|
21
|
+
['vue', 'Vue'],
|
|
22
|
+
['svelte', 'Svelte'],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function allDependencies(pkg) {
|
|
26
|
+
return { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function detectProjectType(pkg) {
|
|
30
|
+
if (!pkg) return 'Unknown';
|
|
31
|
+
const deps = allDependencies(pkg);
|
|
32
|
+
for (const [dep, type] of PROJECT_TYPES) {
|
|
33
|
+
if (deps[dep]) return type;
|
|
34
|
+
}
|
|
35
|
+
return 'Node.js';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Load package.json without executing anything. */
|
|
39
|
+
export function loadProject(root) {
|
|
40
|
+
const file = path.join(root, 'package.json');
|
|
41
|
+
const project = { root, name: path.basename(root), packageJson: null, packageJsonExists: false, packageJsonError: null, type: 'Unknown' };
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
text = fs.readFileSync(file, 'utf8');
|
|
45
|
+
} catch {
|
|
46
|
+
return project;
|
|
47
|
+
}
|
|
48
|
+
project.packageJsonExists = true;
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(text);
|
|
51
|
+
if (typeof pkg !== 'object' || pkg === null || Array.isArray(pkg)) throw new Error('package.json must contain a JSON object');
|
|
52
|
+
project.packageJson = pkg;
|
|
53
|
+
if (typeof pkg.name === 'string' && pkg.name) project.name = pkg.name;
|
|
54
|
+
project.type = detectProjectType(pkg);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
project.packageJsonError = error.message;
|
|
57
|
+
}
|
|
58
|
+
return project;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function checkProject(project) {
|
|
62
|
+
if (!project.packageJsonExists) {
|
|
63
|
+
return [result(CATEGORY, STATUS.FAIL, 'package.json not found', { hint: 'Run dev-doctor from your project root, or create package.json with "npm init".' })];
|
|
64
|
+
}
|
|
65
|
+
if (project.packageJsonError) {
|
|
66
|
+
return [result(CATEGORY, STATUS.FAIL, 'package.json is not valid JSON', { details: [project.packageJsonError], hint: 'Fix the syntax error in package.json.' })];
|
|
67
|
+
}
|
|
68
|
+
return [result(CATEGORY, STATUS.PASS, `package.json is valid (${project.type} project)`)];
|
|
69
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { result, STATUS } from '../core/diagnostics.js';
|
|
4
|
+
import { parseEnv } from '../utils/env-parser.js';
|
|
5
|
+
import { exists, readTextIfExists } from '../utils/paths.js';
|
|
6
|
+
import { runCommand } from '../utils/process-info.js';
|
|
7
|
+
import { allDependencies } from './project.js';
|
|
8
|
+
import { LOCAL_FILES } from './environment.js';
|
|
9
|
+
|
|
10
|
+
const CATEGORY = 'Services';
|
|
11
|
+
|
|
12
|
+
export const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
|
13
|
+
|
|
14
|
+
export const SERVICES = {
|
|
15
|
+
postgres: {
|
|
16
|
+
label: 'PostgreSQL', port: 5432, schemes: ['postgres:', 'postgresql:'], images: /postgres|postgis/i,
|
|
17
|
+
deps: ['pg', 'postgres', 'pg-promise'], urlKeys: ['DATABASE_URL', 'POSTGRES_URL', 'PG_URL'], hostKey: 'PGHOST', portKey: 'PGPORT',
|
|
18
|
+
},
|
|
19
|
+
mongodb: {
|
|
20
|
+
label: 'MongoDB', port: 27017, schemes: ['mongodb:', 'mongodb+srv:'], images: /mongo/i,
|
|
21
|
+
deps: ['mongodb', 'mongoose'], urlKeys: ['DATABASE_URL', 'MONGODB_URI', 'MONGODB_URL', 'MONGO_URL', 'MONGO_URI'], hostKey: 'MONGO_HOST', portKey: 'MONGO_PORT',
|
|
22
|
+
},
|
|
23
|
+
redis: {
|
|
24
|
+
label: 'Redis', port: 6379, schemes: ['redis:', 'rediss:'], images: /redis|valkey/i,
|
|
25
|
+
deps: ['redis', 'ioredis'], urlKeys: ['REDIS_URL'], hostKey: 'REDIS_HOST', portKey: 'REDIS_PORT',
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Merge local env files (later files win) and the process environment. Values are used only to find host/port. */
|
|
30
|
+
function loadEnvValues(root, env) {
|
|
31
|
+
const values = new Map();
|
|
32
|
+
for (const file of LOCAL_FILES) {
|
|
33
|
+
const text = readTextIfExists(path.join(root, file));
|
|
34
|
+
if (text === null) continue;
|
|
35
|
+
for (const [key, { value }] of parseEnv(text).entries) values.set(key, value);
|
|
36
|
+
}
|
|
37
|
+
for (const [key, value] of Object.entries(env)) values.set(key, value);
|
|
38
|
+
return values;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function endpointFromUrl(value, spec) {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(value);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!spec.schemes.includes(url.protocol)) return null;
|
|
49
|
+
if (url.protocol === 'mongodb+srv:') return { srv: true };
|
|
50
|
+
// Multi-host strings like "mongodb://a:1,b:2" are reduced to the first host by URL parsing.
|
|
51
|
+
return { host: url.hostname.replace(/^\[|\]$/g, '') || 'localhost', port: Number(url.port) || spec.port };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Work out which services the project appears to need and where they should be. */
|
|
55
|
+
export function discoverServices(project, config = {}, { env = process.env } = {}) {
|
|
56
|
+
const { root } = project;
|
|
57
|
+
const values = loadEnvValues(root, env);
|
|
58
|
+
const deps = allDependencies(project.packageJson);
|
|
59
|
+
const composeFile = COMPOSE_FILES.find((f) => exists(path.join(root, f))) ?? null;
|
|
60
|
+
const composeText = composeFile ? readTextIfExists(path.join(root, composeFile)) ?? '' : '';
|
|
61
|
+
const images = [...composeText.matchAll(/^\s*image:\s*["']?([^\s"'#]+)/gm)].map((m) => m[1]);
|
|
62
|
+
const serviceConfig = config.services ?? {};
|
|
63
|
+
|
|
64
|
+
const docker = { required: false, reasons: [] };
|
|
65
|
+
if (composeFile) docker.reasons.push(composeFile);
|
|
66
|
+
if (exists(path.join(root, 'Dockerfile'))) docker.reasons.push('Dockerfile');
|
|
67
|
+
docker.required = serviceConfig.docker === undefined ? docker.reasons.length > 0 : Boolean(serviceConfig.docker);
|
|
68
|
+
if (serviceConfig.docker) docker.reasons.push('.dev-doctor.json');
|
|
69
|
+
|
|
70
|
+
const services = [];
|
|
71
|
+
for (const [id, spec] of Object.entries(SERVICES)) {
|
|
72
|
+
const configured = serviceConfig[id];
|
|
73
|
+
if (configured === false) continue;
|
|
74
|
+
const reasons = [];
|
|
75
|
+
let endpoint = null;
|
|
76
|
+
|
|
77
|
+
if (configured && typeof configured === 'object') {
|
|
78
|
+
reasons.push('.dev-doctor.json');
|
|
79
|
+
endpoint = { host: configured.host ?? 'localhost', port: Number(configured.port) || spec.port };
|
|
80
|
+
}
|
|
81
|
+
for (const key of spec.urlKeys) {
|
|
82
|
+
const value = values.get(key);
|
|
83
|
+
const found = value ? endpointFromUrl(value, spec) : null;
|
|
84
|
+
if (found) {
|
|
85
|
+
reasons.push(key);
|
|
86
|
+
endpoint ??= found;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (values.get(spec.hostKey) || values.get(spec.portKey)) {
|
|
91
|
+
reasons.push(spec.hostKey);
|
|
92
|
+
endpoint ??= { host: values.get(spec.hostKey) || 'localhost', port: Number(values.get(spec.portKey)) || spec.port };
|
|
93
|
+
}
|
|
94
|
+
if (images.some((image) => spec.images.test(image))) reasons.push(composeFile);
|
|
95
|
+
const dep = spec.deps.find((d) => deps[d]);
|
|
96
|
+
if (dep) reasons.push(`dependency "${dep}"`);
|
|
97
|
+
|
|
98
|
+
if (reasons.length === 0) continue;
|
|
99
|
+
services.push({ id, label: spec.label, reasons, endpoint: endpoint ?? { host: 'localhost', port: spec.port } });
|
|
100
|
+
}
|
|
101
|
+
return { docker, services };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function tcpCheck(host, port, timeout = 2000) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const socket = net.connect({ host, port });
|
|
107
|
+
const finish = (ok) => {
|
|
108
|
+
socket.destroy();
|
|
109
|
+
resolve(ok);
|
|
110
|
+
};
|
|
111
|
+
socket.setTimeout(timeout, () => finish(false));
|
|
112
|
+
socket.once('connect', () => finish(true));
|
|
113
|
+
socket.once('error', () => finish(false));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function checkServices(discovery, { run = runCommand, tcp = tcpCheck } = {}) {
|
|
118
|
+
const results = [];
|
|
119
|
+
const { docker, services } = discovery;
|
|
120
|
+
const why = (reasons) => [`Detected from ${reasons.join(', ')}`];
|
|
121
|
+
|
|
122
|
+
const cli = await run('docker', ['--version'], { timeout: 5000 });
|
|
123
|
+
if (!cli.ok) {
|
|
124
|
+
if (docker.required) {
|
|
125
|
+
results.push(result(CATEGORY, STATUS.FAIL, 'Docker CLI not found', { details: why(docker.reasons), hint: 'Install Docker Desktop or Docker Engine.' }));
|
|
126
|
+
} else {
|
|
127
|
+
results.push(result(CATEGORY, STATUS.INFO, 'Docker CLI not found (not required by this project)'));
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
const daemon = await run('docker', ['info', '--format', '{{.ServerVersion}}'], { timeout: 8000 });
|
|
131
|
+
if (daemon.ok) {
|
|
132
|
+
results.push(result(CATEGORY, STATUS.PASS, `Docker daemon is running (${daemon.stdout.trim()})`));
|
|
133
|
+
} else {
|
|
134
|
+
results.push(result(CATEGORY, docker.required ? STATUS.FAIL : STATUS.WARN, 'Docker daemon is not running', {
|
|
135
|
+
details: docker.required ? why(docker.reasons) : [],
|
|
136
|
+
hint: 'Start Docker Desktop or the Docker service.',
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const service of services) {
|
|
142
|
+
const { endpoint } = service;
|
|
143
|
+
if (endpoint.srv) {
|
|
144
|
+
results.push(result(CATEGORY, STATUS.INFO, `${service.label} uses an SRV connection string; TCP check skipped`, { details: why(service.reasons) }));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const where = `${endpoint.host}:${endpoint.port}`;
|
|
148
|
+
if (await tcp(endpoint.host, endpoint.port)) {
|
|
149
|
+
results.push(result(CATEGORY, STATUS.PASS, `${service.label} is reachable at ${where}`, {
|
|
150
|
+
details: [...why(service.reasons), 'TCP connection only; authentication and queries were not verified'],
|
|
151
|
+
}));
|
|
152
|
+
} else {
|
|
153
|
+
results.push(result(CATEGORY, STATUS.WARN, `${service.label} is not reachable at ${where}`, {
|
|
154
|
+
details: why(service.reasons),
|
|
155
|
+
hint: `Verify ${service.label} is running if this project needs it${discovery.docker.reasons.some((r) => r.includes('compose')) ? ' (e.g. "docker compose up -d")' : ''}.`,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (services.length === 0) results.push(result(CATEGORY, STATUS.INFO, 'No database services detected'));
|
|
161
|
+
return results;
|
|
162
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { Command, Option, CommanderError } from 'commander';
|
|
3
|
+
import { diagnoseCommand } from './commands/doctor.js';
|
|
4
|
+
import { portsCommand } from './commands/ports.js';
|
|
5
|
+
import { fixCommand } from './commands/fix.js';
|
|
6
|
+
import { EXIT } from './core/exit-codes.js';
|
|
7
|
+
import { DevDoctorError } from './utils/errors.js';
|
|
8
|
+
|
|
9
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
10
|
+
|
|
11
|
+
function addCommonOptions(command) {
|
|
12
|
+
return command
|
|
13
|
+
.option('--cwd <path>', 'project directory to inspect', process.cwd())
|
|
14
|
+
.option('--json', 'print machine-readable JSON to stdout')
|
|
15
|
+
.option('-v, --verbose', 'show more details')
|
|
16
|
+
.option('--ci', 'CI mode: no prompts, no colors, non-zero exit on failures')
|
|
17
|
+
.addOption(new Option('--fail-on <level>', 'exit non-zero on this level or worse').choices(['fail', 'warn']))
|
|
18
|
+
.option('--no-color', 'disable colored output');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildProgram(setExit) {
|
|
22
|
+
const program = new Command();
|
|
23
|
+
program
|
|
24
|
+
.name('dev-doctor')
|
|
25
|
+
.description('Diagnose and safely fix common local development environment problems.')
|
|
26
|
+
.version(version, '-V, --version')
|
|
27
|
+
.exitOverride()
|
|
28
|
+
.addHelpText('after', '\nExit codes:\n 0 no failures\n 1 diagnostics found failures (or a requested action did not succeed)\n 2 dev-doctor could not run (invalid input or unexpected error)');
|
|
29
|
+
|
|
30
|
+
const diagnose = (categories) => async (opts) => setExit(await diagnoseCommand(categories, opts, { version }));
|
|
31
|
+
|
|
32
|
+
addCommonOptions(program.command('doctor', { isDefault: true }).description('run the full diagnostic suite (default)'))
|
|
33
|
+
.action(diagnose(['project', 'node', 'packages', 'env', 'ports', 'services']));
|
|
34
|
+
addCommonOptions(program.command('node').description('check the Node.js version against project requirements'))
|
|
35
|
+
.action(diagnose(['node']));
|
|
36
|
+
addCommonOptions(program.command('packages').description('check package.json, lockfiles and installed dependencies'))
|
|
37
|
+
.action(diagnose(['project', 'packages']));
|
|
38
|
+
addCommonOptions(program.command('env').description('compare .env files with .env.example and code references (keys only)'))
|
|
39
|
+
.action(diagnose(['env']));
|
|
40
|
+
addCommonOptions(program.command('services').description('check Docker and PostgreSQL/MongoDB/Redis TCP availability'))
|
|
41
|
+
.action(diagnose(['services']));
|
|
42
|
+
|
|
43
|
+
addCommonOptions(program.command('ports').description('show which processes are listening on ports'))
|
|
44
|
+
.argument('[ports...]', 'ports to check (default: common development ports)')
|
|
45
|
+
.option('--kill <port>', 'stop the process listening on <port> (asks for confirmation)')
|
|
46
|
+
.option('--pid <pid>', 'with --kill: the exact process to stop when several listen on the port')
|
|
47
|
+
.option('-y, --yes', 'confirm stopping without a prompt (required when non-interactive)')
|
|
48
|
+
.option('--force', 'with --kill: force terminate if the same process is still running after a graceful stop')
|
|
49
|
+
.action(async (ports, opts) => setExit(await portsCommand(ports, opts, { version })));
|
|
50
|
+
|
|
51
|
+
program.command('fix')
|
|
52
|
+
.description('diagnose, then preview and apply safe fixes with confirmation')
|
|
53
|
+
.option('--cwd <path>', 'project directory to inspect', process.cwd())
|
|
54
|
+
.option('--dry-run', 'only show the available fixes')
|
|
55
|
+
.option('-y, --yes', 'apply fixes without prompts (required when non-interactive)')
|
|
56
|
+
.option('--ci', 'CI mode: never prompt')
|
|
57
|
+
.action(async (opts) => setExit(await fixCommand(opts)));
|
|
58
|
+
|
|
59
|
+
return program;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function main(argv) {
|
|
63
|
+
const setExit = (code) => {
|
|
64
|
+
process.exitCode = code;
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
await buildProgram(setExit).parseAsync(argv);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof CommanderError) {
|
|
70
|
+
// Help and version exit with 0; usage errors (already printed by commander) with 2.
|
|
71
|
+
process.exitCode = error.exitCode === 0 ? EXIT.OK : EXIT.ERROR;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (error instanceof DevDoctorError) {
|
|
75
|
+
process.stderr.write(`dev-doctor: ${error.message}\n`);
|
|
76
|
+
} else {
|
|
77
|
+
process.stderr.write(`dev-doctor: unexpected error: ${error?.message ?? error}\n`);
|
|
78
|
+
}
|
|
79
|
+
process.exitCode = EXIT.ERROR;
|
|
80
|
+
}
|
|
81
|
+
}
|