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.
@@ -0,0 +1,18 @@
1
+ import { runDiagnostics } from '../core/runner.js';
2
+ import { formatHuman, toJson, colorEnabled } from '../core/reporter.js';
3
+ import { hasFailures } from '../core/diagnostics.js';
4
+ import { EXIT } from '../core/exit-codes.js';
5
+ import { resolveProjectRoot } from '../utils/paths.js';
6
+
7
+ /** Shared handler for doctor, node, packages, env and services. Returns an exit code. */
8
+ export async function diagnoseCommand(categories, opts, { version }) {
9
+ const root = resolveProjectRoot(opts.cwd);
10
+ const report = await runDiagnostics(root, { categories, verbose: opts.verbose });
11
+ if (opts.json) {
12
+ process.stdout.write(toJson(report, { version }));
13
+ } else {
14
+ process.stdout.write(formatHuman(report, { verbose: opts.verbose, color: colorEnabled({ color: opts.color && !opts.ci }) }));
15
+ }
16
+ const failOn = opts.failOn ?? report.config.failOn ?? 'fail';
17
+ return hasFailures(report.results, failOn) ? EXIT.DIAGNOSTIC_FAILURE : EXIT.OK;
18
+ }
@@ -0,0 +1,83 @@
1
+ import { runDiagnostics } from '../core/runner.js';
2
+ import { EXIT } from '../core/exit-codes.js';
3
+ import { STATUS } from '../core/diagnostics.js';
4
+ import { collectFixes } from '../fixes/index.js';
5
+ import { describeListener } from '../checks/ports.js';
6
+ import { getPlatformAdapter } from '../platform/index.js';
7
+ import { resolveProjectRoot } from '../utils/paths.js';
8
+ import { isInteractive, askConfirm, askSelect } from '../utils/prompt.js';
9
+
10
+ export function formatFix(fix, index) {
11
+ const lines = [
12
+ `${index + 1}. ${fix.problem}`,
13
+ ` Why it matters: ${fix.why}`,
14
+ ` Proposed change: ${fix.change}`,
15
+ ` Modifies files: ${fix.modifiesFiles ? 'yes' : 'no'}`,
16
+ ` Terminates a process: ${fix.terminatesProcess ? 'yes' : 'no'}`,
17
+ ` Expected outcome: ${fix.expected}`,
18
+ ];
19
+ if (fix.preview) {
20
+ lines.push(' Preview:');
21
+ for (const line of fix.preview.trimEnd().split('\n')) lines.push(` ${line}`);
22
+ }
23
+ return lines.join('\n');
24
+ }
25
+
26
+ export async function fixCommand(opts, { out = process.stdout } = {}) {
27
+ const root = resolveProjectRoot(opts.cwd);
28
+ const adapter = getPlatformAdapter();
29
+ const { results } = await runDiagnostics(root, { adapter });
30
+ const fixes = collectFixes(results, root);
31
+ const write = (text = '') => out.write(`${text}\n`);
32
+
33
+ write('Dev Doctor fix');
34
+ if (fixes.length === 0) {
35
+ write('No automatic fixes available.');
36
+ const manual = [...new Set(results.filter((r) => r.hint && r.status === STATUS.FAIL).map((r) => r.hint))];
37
+ if (manual.length > 0) {
38
+ write('', 'Manual steps:');
39
+ manual.forEach((m, i) => write(` ${i + 1}. ${m}`));
40
+ }
41
+ return EXIT.OK;
42
+ }
43
+
44
+ write('');
45
+ fixes.forEach((fix, i) => write(formatFix(fix, i) + '\n'));
46
+ if (opts.dryRun) {
47
+ write('Dry run: no changes were made.');
48
+ return EXIT.OK;
49
+ }
50
+
51
+ const interactive = isInteractive({ ci: opts.ci });
52
+ if (!interactive && !opts.yes) {
53
+ write('Not running interactively; no changes were made. Re-run with --yes to apply these fixes.');
54
+ return EXIT.DIAGNOSTIC_FAILURE;
55
+ }
56
+
57
+ const ctx = {
58
+ adapter,
59
+ interactive,
60
+ // Interactive: terminatePortProcess asks its own confirmation. Non-interactive: --yes was given.
61
+ yes: !interactive,
62
+ confirm: askConfirm,
63
+ choose: (targets) => askSelect('Which process should be terminated?', targets.map((t) => ({ name: describeListener(t), value: t }))),
64
+ };
65
+
66
+ let failed = 0;
67
+ for (const fix of fixes) {
68
+ if (interactive && !fix.terminatesProcess && !(await askConfirm(`Apply: ${fix.change}?`))) {
69
+ write(`Skipped: ${fix.problem}`);
70
+ continue;
71
+ }
72
+ const applied = await fix.apply(ctx);
73
+ if (!applied.ok) {
74
+ failed++;
75
+ write(`FAIL ${applied.message}`);
76
+ continue;
77
+ }
78
+ const verified = await fix.verify(ctx);
79
+ if (!verified.ok) failed++;
80
+ write(`${verified.ok ? 'PASS' : 'FAIL'} ${applied.message}; ${verified.message}`);
81
+ }
82
+ return failed > 0 ? EXIT.DIAGNOSTIC_FAILURE : EXIT.OK;
83
+ }
@@ -0,0 +1,70 @@
1
+ import { inspectPorts, portResults, parsePort, DEFAULT_PORTS, describeListener, formatProcessDetails } from '../checks/ports.js';
2
+ import { STATUS, hasFailures } from '../core/diagnostics.js';
3
+ import { formatHuman, toJson, colorEnabled } from '../core/reporter.js';
4
+ import { terminatePortProcess, parsePid } from '../core/terminate.js';
5
+ import { portsWorkflow } from '../core/port-workflow.js';
6
+ import { EXIT } from '../core/exit-codes.js';
7
+ import { getPlatformAdapter } from '../platform/index.js';
8
+ import { loadConfig } from '../utils/config.js';
9
+ import { resolveProjectRoot } from '../utils/paths.js';
10
+ import { isInteractive, askConfirm, askSelect } from '../utils/prompt.js';
11
+
12
+ const isSuccess = (outcome) => outcome.status === 'released' || outcome.status === 'free';
13
+
14
+ export async function portsCommand(portArgs, opts, { version }) {
15
+ const root = resolveProjectRoot(opts.cwd);
16
+ const adapter = getPlatformAdapter();
17
+ // Never prompt when stdin/stderr are not terminals, in CI, or when JSON is requested.
18
+ const interactive = isInteractive({ ci: opts.ci }) && !opts.json;
19
+ // Human-readable progress goes to stderr when stdout carries JSON.
20
+ const log = (text) => (opts.json ? process.stderr : process.stdout).write(`${text}\n`);
21
+
22
+ const terminate = (port, pid) => terminatePortProcess(port, {
23
+ adapter,
24
+ pid,
25
+ projectRoot: root,
26
+ interactive,
27
+ yes: opts.yes,
28
+ force: opts.force,
29
+ confirm: askConfirm,
30
+ log,
31
+ choose: (targets) => askSelect(`Port ${port} has several listeners. Which process should be stopped?`, [
32
+ ...targets.map((t) => ({ name: describeListener(t), value: t })),
33
+ { name: 'Cancel', value: null },
34
+ ]),
35
+ });
36
+
37
+ if (opts.kill !== undefined) {
38
+ const port = parsePort(opts.kill);
39
+ const pid = opts.pid === undefined ? undefined : parsePid(opts.pid);
40
+ const outcome = await terminate(port, pid);
41
+ if (opts.json) process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
42
+ else log(outcome.message);
43
+ return isSuccess(outcome) ? EXIT.OK : EXIT.DIAGNOSTIC_FAILURE;
44
+ }
45
+
46
+ const explicit = portArgs.map(parsePort);
47
+ const config = loadConfig(root);
48
+ const ports = [...new Set(explicit.length > 0 ? explicit : (config.ports ?? DEFAULT_PORTS).map(parsePort))];
49
+ const inspections = await inspectPorts(ports, { adapter, describe: true, projectRoot: root });
50
+ const results = portResults(inspections, { occupiedStatus: explicit.length > 0 ? STATUS.FAIL : STATUS.WARN });
51
+ const report = { project: null, results };
52
+
53
+ if (opts.json) {
54
+ process.stdout.write(toJson(report, { version }));
55
+ return hasFailures(results) ? EXIT.DIAGNOSTIC_FAILURE : EXIT.OK;
56
+ }
57
+
58
+ process.stdout.write(formatHuman(report, { verbose: opts.verbose, color: colorEnabled({ color: opts.color && !opts.ci }) }));
59
+ // A single requested port gets a focused view of every listener.
60
+ if (ports.length === 1 && inspections[0].listeners.length > 0) {
61
+ for (const l of inspections[0].listeners) log(`\n${formatProcessDetails(ports[0], l).join('\n')}`);
62
+ }
63
+
64
+ if (interactive) {
65
+ log('');
66
+ await portsWorkflow(inspections, { select: askSelect, log, terminate });
67
+ }
68
+ // The exit code reflects the report above.
69
+ return hasFailures(results) ? EXIT.DIAGNOSTIC_FAILURE : EXIT.OK;
70
+ }
@@ -0,0 +1,24 @@
1
+ export const STATUS = Object.freeze({
2
+ PASS: 'pass',
3
+ WARN: 'warn',
4
+ FAIL: 'fail',
5
+ INFO: 'info',
6
+ });
7
+
8
+ /**
9
+ * A diagnostic result. Checks only return data; reporters decide how to show it.
10
+ * extra may hold: details (string[]), hint (string), fixId (string), fixData (object), data (object).
11
+ */
12
+ export function result(category, status, title, extra = {}) {
13
+ return { category, status, title, ...extra };
14
+ }
15
+
16
+ export function summarize(results) {
17
+ const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
18
+ for (const r of results) summary[r.status] += 1;
19
+ return summary;
20
+ }
21
+
22
+ export function hasFailures(results, failOn = 'fail') {
23
+ return results.some((r) => r.status === STATUS.FAIL || (failOn === 'warn' && r.status === STATUS.WARN));
24
+ }
@@ -0,0 +1,6 @@
1
+ // 0: no failures, 1: diagnostics found failures, 2: the tool itself could not run.
2
+ export const EXIT = Object.freeze({
3
+ OK: 0,
4
+ DIAGNOSTIC_FAILURE: 1,
5
+ ERROR: 2,
6
+ });
@@ -0,0 +1,39 @@
1
+ import { describeListener, formatProcessDetails } from '../checks/ports.js';
2
+
3
+ /**
4
+ * Interactive follow-up after a ports report: pick a process (when several), then Inspect / Stop / Skip.
5
+ * Only call this from an interactive terminal. `terminate(port, pid)` runs the safe termination flow.
6
+ * Returns the termination outcome, or null when nothing was stopped.
7
+ */
8
+ export async function portsWorkflow(inspections, { select, log, terminate }) {
9
+ const candidates = inspections.flatMap((i) => i.listeners.filter((l) => l.pid).map((listener) => ({ port: i.port, listener })));
10
+ if (candidates.length === 0) return null;
11
+
12
+ let chosen = candidates[0];
13
+ if (candidates.length > 1) {
14
+ chosen = await select('Several ports are occupied. Which process do you want to work with?', [
15
+ ...candidates.map((c) => ({ name: `Port ${c.port}: ${describeListener(c.listener)}`, value: c })),
16
+ { name: 'Skip', value: null },
17
+ ]);
18
+ if (!chosen) return null;
19
+ }
20
+
21
+ const { port, listener } = chosen;
22
+ for (;;) {
23
+ const action = await select(`Port ${port} is used by ${describeListener(listener)}. What do you want to do?`, [
24
+ { name: 'Inspect process', value: 'inspect' },
25
+ { name: 'Stop process', value: 'stop' },
26
+ { name: 'Skip', value: 'skip' },
27
+ ]);
28
+ if (action === 'inspect') {
29
+ log(formatProcessDetails(port, listener).join('\n'));
30
+ continue;
31
+ }
32
+ if (action === 'stop') {
33
+ const outcome = await terminate(port, listener.pid);
34
+ log(outcome.message);
35
+ return outcome;
36
+ }
37
+ return null;
38
+ }
39
+ }
@@ -0,0 +1,54 @@
1
+ import { Chalk } from 'chalk';
2
+ import { STATUS, summarize } from './diagnostics.js';
3
+ import { redact } from '../utils/redact.js';
4
+
5
+ export { redact };
6
+
7
+ const LABELS = { [STATUS.PASS]: 'PASS', [STATUS.WARN]: 'WARN', [STATUS.FAIL]: 'FAIL', [STATUS.INFO]: 'INFO' };
8
+
9
+ export function colorEnabled({ color = true, stream = process.stdout, env = process.env } = {}) {
10
+ if (!color || 'NO_COLOR' in env) return false;
11
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true;
12
+ return Boolean(stream.isTTY);
13
+ }
14
+
15
+ export function formatHuman(report, { verbose = false, color = false } = {}) {
16
+ const c = new Chalk({ level: color ? 1 : 0 });
17
+ const paint = { pass: c.green, warn: c.yellow, fail: c.red, info: c.cyan };
18
+ const lines = [c.bold('Dev Doctor')];
19
+ if (report.project) lines.push(`Project: ${report.project.name}${report.project.type !== 'Unknown' ? ` (${report.project.type})` : ''}`);
20
+
21
+ const categories = [...new Set(report.results.map((r) => r.category))];
22
+ for (const category of categories) {
23
+ lines.push('', c.bold(category));
24
+ for (const r of report.results.filter((x) => x.category === category)) {
25
+ lines.push(` ${paint[r.status](LABELS[r.status].padEnd(4))} ${redact(r.title)}`);
26
+ const showDetails = verbose || r.status === STATUS.FAIL;
27
+ if (showDetails) for (const d of r.details ?? []) lines.push(c.dim(` ${redact(d)}`));
28
+ }
29
+ }
30
+
31
+ const s = summarize(report.results);
32
+ lines.push('', c.bold('Summary'), ` ${s.fail} error${s.fail === 1 ? '' : 's'}, ${s.warn} warning${s.warn === 1 ? '' : 's'}, ${s.pass} passed`);
33
+
34
+ const steps = [...new Set(report.results.filter((r) => r.hint && (r.status === STATUS.FAIL || r.status === STATUS.WARN)).map((r) => r.hint))];
35
+ if (steps.length > 0) {
36
+ lines.push('', c.bold('Recommended next steps:'));
37
+ steps.forEach((step, i) => lines.push(` ${i + 1}. ${redact(step)}`));
38
+ }
39
+ return lines.join('\n') + '\n';
40
+ }
41
+
42
+ export function toJson(report, { version } = {}) {
43
+ const results = report.results.map(({ category, status, title, details, hint, fixId, data }) => ({
44
+ category, status, title, details: details ?? [], hint: hint ?? null, fixAvailable: Boolean(fixId), ...(data ? { data } : {}),
45
+ }));
46
+ const payload = {
47
+ tool: 'dev-doctor',
48
+ version,
49
+ project: report.project ? { name: report.project.name, type: report.project.type, root: report.project.root } : null,
50
+ summary: summarize(report.results),
51
+ results,
52
+ };
53
+ return redact(JSON.stringify(payload, null, 2)) + '\n';
54
+ }
@@ -0,0 +1,34 @@
1
+ import { STATUS } from './diagnostics.js';
2
+ import { loadProject, checkProject } from '../checks/project.js';
3
+ import { checkNode } from '../checks/node-version.js';
4
+ import { checkPackages } from '../checks/packages.js';
5
+ import { checkEnvironment } from '../checks/environment.js';
6
+ import { checkPorts, detectProjectPorts, DEFAULT_PORTS } from '../checks/ports.js';
7
+ import { checkServices, discoverServices } from '../checks/services.js';
8
+ import { getPlatformAdapter } from '../platform/index.js';
9
+ import { loadConfig } from '../utils/config.js';
10
+
11
+ export const ALL_CATEGORIES = ['project', 'node', 'packages', 'env', 'ports', 'services'];
12
+
13
+ /** Run the selected read-only checks. Never modifies anything. */
14
+ export async function runDiagnostics(root, { categories = ALL_CATEGORIES, verbose = false, adapter = getPlatformAdapter() } = {}) {
15
+ const config = loadConfig(root);
16
+ const project = loadProject(root);
17
+ const results = [];
18
+ const run = (name) => categories.includes(name);
19
+
20
+ if (run('project')) results.push(...checkProject(project));
21
+ if (run('node')) results.push(...(await checkNode(project)));
22
+ if (run('packages')) results.push(...(await checkPackages(project)));
23
+ if (run('env')) results.push(...(await checkEnvironment(root)));
24
+ if (run('ports')) {
25
+ const projectPorts = detectProjectPorts(project, config);
26
+ const ports = projectPorts.length > 0 ? projectPorts : DEFAULT_PORTS;
27
+ // Ports the project itself uses are failures when taken; generic common ports only warn.
28
+ const occupiedStatus = projectPorts.length > 0 ? STATUS.FAIL : STATUS.WARN;
29
+ results.push(...(await checkPorts(ports, { adapter, describe: verbose, occupiedStatus })));
30
+ }
31
+ if (run('services')) results.push(...(await checkServices(discoverServices(project, config))));
32
+
33
+ return { project, config, results };
34
+ }
@@ -0,0 +1,140 @@
1
+ import path from 'node:path';
2
+ import { inspectPort, describeListener, formatProcessDetails, parsePort } from '../checks/ports.js';
3
+ import { DevDoctorError } from '../utils/errors.js';
4
+
5
+ // Never terminate these, even when they hold a port.
6
+ const PROTECTED_NAMES = new Set([
7
+ 'launchd', 'kernel_task', 'windowserver', 'loginwindow', 'systemd', 'init', 'sshd', 'dockerd',
8
+ 'system', 'system idle process', 'smss.exe', 'csrss.exe', 'wininit.exe', 'winlogon.exe',
9
+ 'services.exe', 'lsass.exe', 'svchost.exe', 'explorer.exe', 'dwm.exe',
10
+ ]);
11
+
12
+ export function parsePid(value) {
13
+ const text = String(value).trim();
14
+ const pid = Number(text);
15
+ if (!/^\d+$/.test(text) || !Number.isSafeInteger(pid) || pid < 1 || pid > 0x7fffffff) {
16
+ throw new DevDoctorError(`Invalid process ID "${value}": expected a positive integer`);
17
+ }
18
+ return pid;
19
+ }
20
+
21
+ /** Returns a reason string if the process must not be terminated, otherwise null. */
22
+ export function killSafetyReason(listener, { currentPid = process.pid, parentPid = process.ppid } = {}) {
23
+ const { pid } = listener;
24
+ if (!Number.isInteger(pid) || pid <= 4) return 'it is a system process';
25
+ if (pid === currentPid) return 'it is dev-doctor itself';
26
+ if (pid === parentPid) return 'it is the shell or process that started dev-doctor';
27
+ const name = path.basename(String(listener.name ?? '')).toLowerCase();
28
+ if (PROTECTED_NAMES.has(name)) return `"${listener.name}" is a protected system process`;
29
+ return null;
30
+ }
31
+
32
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
+
34
+ /** Poll until the port is free or the target process has exited, or the timeout passes. */
35
+ async function waitAfterSignal(port, pid, deps, timeoutMs) {
36
+ const deadline = Date.now() + timeoutMs;
37
+ for (;;) {
38
+ const i = await inspectPort(port, deps);
39
+ if (i.state === 'free') return;
40
+ if (!(await deps.adapter.isRunning(pid))) return;
41
+ if (Date.now() >= deadline) return;
42
+ await sleep(200);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Terminate the process listening on a port, safely:
48
+ * - the target must be unambiguous (single listener, --pid, or an interactive choice) and pass safety rules;
49
+ * - explicit confirmation (or --yes when non-interactive);
50
+ * - re-check the target right before signalling; graceful first;
51
+ * - force only as a separate, explicit step (--force or a second confirmation) while the same PID still holds the port;
52
+ * - "released" is only reported after a fresh check finds the port free.
53
+ * Returns { port, pid, status: 'released'|'free'|'cancelled'|'refused'|'failed', message }.
54
+ */
55
+ export async function terminatePortProcess(portValue, options) {
56
+ const { adapter, bind, interactive, yes = false, force = false, confirm, choose, waitMs = 3000, safety, projectRoot } = options;
57
+ const log = options.log ?? (() => {});
58
+ const port = parsePort(portValue);
59
+ const requestedPid = options.pid === undefined || options.pid === null ? null : parsePid(options.pid);
60
+ const deps = { adapter, bind };
61
+ let target = null;
62
+ const done = (status, message) => ({ port, pid: target?.pid ?? requestedPid, status, message });
63
+
64
+ const inspection = await inspectPort(port, { ...deps, describe: true, projectRoot });
65
+ if (inspection.state === 'free') return done('free', `Port ${port} is already free; nothing was terminated`);
66
+ const candidates = inspection.listeners;
67
+ if (candidates.length === 0 || candidates.some((l) => !l.pid)) {
68
+ return done('refused', `Port ${port} is in use, but its owning process cannot be identified reliably${inspection.error ? ` (${inspection.error})` : ''}. Nothing was terminated; dev-doctor does not request elevated permissions.`);
69
+ }
70
+
71
+ if (requestedPid !== null) {
72
+ target = candidates.find((l) => l.pid === requestedPid) ?? null;
73
+ if (!target) return done('refused', `PID ${requestedPid} is not listening on port ${port} (listeners: ${candidates.map(describeListener).join(', ')}). Nothing was terminated.`);
74
+ } else if (candidates.length === 1) {
75
+ target = candidates[0];
76
+ } else if (!interactive) {
77
+ return done('refused', `Port ${port} is used by ${candidates.length} processes (${candidates.map(describeListener).join(', ')}). Re-run with --pid <pid> to choose one.`);
78
+ } else {
79
+ target = await choose(candidates);
80
+ if (!target) return done('cancelled', 'No process selected; nothing was terminated');
81
+ }
82
+
83
+ const label = describeListener(target);
84
+ const unsafe = killSafetyReason(target, safety);
85
+ if (unsafe) return done('refused', `Refusing to terminate ${label}: ${unsafe}`);
86
+
87
+ log(formatProcessDetails(port, target).join('\n'));
88
+ if (interactive && !yes) {
89
+ if (!(await confirm(`Stop ${label} listening on port ${port}?`))) return done('cancelled', 'Cancelled; nothing was terminated');
90
+ } else if (!interactive && !yes) {
91
+ return done('refused', `Not running interactively. Re-run with --yes to stop ${label}.`);
92
+ }
93
+
94
+ // The process may have exited, or the port changed hands, while we were asking.
95
+ const before = await inspectPort(port, deps);
96
+ if (!before.listeners.some((l) => l.pid === target.pid)) {
97
+ if (before.state === 'free') return done('free', `${label} exited before it was stopped; port ${port} is now free (verified)`);
98
+ return done('refused', `${label} is no longer listening on port ${port}; the port is now held by ${before.listeners.map(describeListener).join(', ') || 'an unidentified process'}. Nothing was terminated.`);
99
+ }
100
+
101
+ const verify = async (action) => {
102
+ const after = await inspectPort(port, deps);
103
+ if (after.state === 'free') return done('released', `${action} ${label}; port ${port} was released (verified by a fresh check)`);
104
+ const running = await adapter.isRunning(target.pid);
105
+ const holdsPort = after.listeners.some((l) => l.pid === target.pid);
106
+ if (!running || !holdsPort) {
107
+ const others = after.listeners.filter((l) => l.pid !== target.pid).map(describeListener).join(', ') || 'an unidentified process';
108
+ return done('failed', `${label} ${running ? 'is no longer listening' : 'exited'}, but port ${port} is still in use by ${others}. Nothing else was terminated.`);
109
+ }
110
+ return null; // still running and still holding the port
111
+ };
112
+
113
+ const graceful = await adapter.killProcess(target.pid, { force: false });
114
+ if (!graceful.ok && graceful.reason === 'permission') {
115
+ return done('failed', `Permission denied stopping ${label}. dev-doctor does not use sudo; stop it yourself if appropriate.`);
116
+ }
117
+ // On Windows, console processes cannot be asked to stop gracefully; they are still running and go to the force step.
118
+ const noGraceful = !graceful.ok && graceful.reason === 'graceful-unsupported';
119
+ if (!graceful.ok && !noGraceful && graceful.reason !== 'not-found') return done('failed', `Could not stop ${label}: ${graceful.message ?? graceful.reason}`);
120
+
121
+ if (!noGraceful) {
122
+ await waitAfterSignal(port, target.pid, deps, waitMs);
123
+ const afterGraceful = await verify(graceful.ok ? 'Stopped' : 'Process had already exited:');
124
+ if (afterGraceful) return afterGraceful;
125
+ }
126
+
127
+ // Same PID is still running and still holds the port: force only as an explicit, separate action.
128
+ const situation = noGraceful ? 'cannot be stopped gracefully (console process on Windows)' : 'is still running after a graceful stop request';
129
+ let allowForce = force;
130
+ if (!allowForce && interactive) allowForce = await confirm(`${label} ${situation}. Force terminate it?`);
131
+ if (!allowForce) {
132
+ return done('failed', `${label} ${situation}; port ${port} is still in use. Re-run with --force to force terminate it.`);
133
+ }
134
+
135
+ const forced = await adapter.killProcess(target.pid, { force: true });
136
+ if (!forced.ok && forced.reason === 'permission') return done('failed', `Permission denied force terminating ${label}.`);
137
+ if (!forced.ok && forced.reason !== 'not-found') return done('failed', `Could not force terminate ${label}: ${forced.message ?? forced.reason}`);
138
+ await waitAfterSignal(port, target.pid, deps, waitMs);
139
+ return (await verify('Force terminated')) ?? done('failed', `${label} is still running after force termination; port ${port} is still in use.`);
140
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import { safeProjectPath, readTextIfExists } from '../utils/paths.js';
3
+ import { checkEnvironment, isEnvGitignored } from '../checks/environment.js';
4
+ import { inspectPort } from '../checks/ports.js';
5
+ import { terminatePortProcess } from '../core/terminate.js';
6
+
7
+ /**
8
+ * Build the fixes offered for a set of diagnostic results.
9
+ * Each fix: { id, problem, why, change, preview, modifiesFiles, terminatesProcess, expected, apply(ctx), verify(ctx) }.
10
+ * apply returns { ok, message }; verify returns { ok, message }.
11
+ */
12
+ export function collectFixes(results, root) {
13
+ const fixes = [];
14
+ for (const r of results) {
15
+ if (r.fixId === 'create-env') fixes.push(createEnvFix(r, root));
16
+ else if (r.fixId === 'gitignore-env') fixes.push(gitignoreFix(r, root));
17
+ else if (r.fixId === 'kill-port') fixes.push(killPortFix(r));
18
+ }
19
+ return fixes;
20
+ }
21
+
22
+ function createEnvFix(r, root) {
23
+ const { template, keys } = r.fixData;
24
+ // Keys only, with empty values: never copy template values or invent credentials.
25
+ const content = `# Created by dev-doctor from ${template}. Fill in your own values.\n${keys.map((k) => `${k}=`).join('\n')}\n`;
26
+ return {
27
+ id: 'create-env',
28
+ problem: r.title,
29
+ why: 'Tools such as dotenv, Vite and Next.js load variables from .env; without it the app may fail to start.',
30
+ change: `Create .env with the ${keys.length} key names from ${template}, all with empty values`,
31
+ preview: content,
32
+ modifiesFiles: true,
33
+ terminatesProcess: false,
34
+ expected: '.env exists and lists every expected key; you still need to fill in the values',
35
+ async apply() {
36
+ const file = safeProjectPath(root, '.env');
37
+ try {
38
+ fs.writeFileSync(file, content, { flag: 'wx' }); // 'wx' fails if .env already exists: never overwrite.
39
+ } catch (error) {
40
+ if (error.code === 'EEXIST') return { ok: false, message: '.env already exists; it was not modified' };
41
+ throw error;
42
+ }
43
+ return { ok: true, message: 'Created .env' };
44
+ },
45
+ async verify() {
46
+ const results = await checkEnvironment(root);
47
+ const stillMissing = results.some((x) => x.fixId === 'create-env');
48
+ return stillMissing ? { ok: false, message: '.env is still missing' } : { ok: true, message: '.env now exists' };
49
+ },
50
+ };
51
+ }
52
+
53
+ function gitignoreFix(r, root) {
54
+ return {
55
+ id: 'gitignore-env',
56
+ problem: r.title,
57
+ why: 'Without a .gitignore entry, secrets in .env can be committed and pushed by accident.',
58
+ change: 'Append ".env" to .gitignore (created if missing)',
59
+ preview: '+ .env\n',
60
+ modifiesFiles: true,
61
+ terminatesProcess: false,
62
+ expected: 'git ignores .env (files already committed still need "git rm --cached .env")',
63
+ async apply() {
64
+ const file = safeProjectPath(root, '.gitignore');
65
+ const current = readTextIfExists(file) ?? '';
66
+ const prefix = current && !current.endsWith('\n') ? '\n' : '';
67
+ fs.appendFileSync(file, `${prefix}.env\n`);
68
+ return { ok: true, message: 'Added .env to .gitignore' };
69
+ },
70
+ async verify() {
71
+ const ok = isEnvGitignored(readTextIfExists(safeProjectPath(root, '.gitignore')));
72
+ return { ok, message: ok ? '.env is listed in .gitignore' : '.env is still not ignored' };
73
+ },
74
+ };
75
+ }
76
+
77
+ function killPortFix(r) {
78
+ const { port } = r.fixData;
79
+ return {
80
+ id: 'kill-port',
81
+ problem: r.title,
82
+ why: `Your dev server cannot start on port ${port} while another process is listening on it.`,
83
+ change: `Ask the process on port ${port} to stop (graceful first; force only if you confirm)`,
84
+ preview: null,
85
+ modifiesFiles: false,
86
+ terminatesProcess: true,
87
+ expected: `Port ${port} becomes available`,
88
+ async apply(ctx) {
89
+ const outcome = await terminatePortProcess(port, ctx);
90
+ return { ok: outcome.status === 'released' || outcome.status === 'free', message: outcome.message };
91
+ },
92
+ async verify(ctx) {
93
+ const i = await inspectPort(port, ctx);
94
+ return { ok: i.state === 'free', message: i.state === 'free' ? `Port ${port} is free` : `Port ${port} is still in use` };
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,25 @@
1
+ import { runCommand } from '../utils/process-info.js';
2
+ import { UtilityMissingError } from '../utils/errors.js';
3
+ import { lsofListeners, describeUnixProcess, killUnix, isRunningUnix } from './unix.js';
4
+
5
+ /** Working directory via `lsof -a -p PID -d cwd -Fn`; only visible for your own processes without root. */
6
+ export async function darwinCwd(pid, run = runCommand) {
7
+ const r = await run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
8
+ if (r.missing) return { reason: 'lsof not found' };
9
+ const line = r.stdout.split(/\r?\n/).find((l) => l.startsWith('n'));
10
+ return line ? { cwd: line.slice(1) } : { reason: 'not accessible (permission denied or another user)' };
11
+ }
12
+
13
+ export function createDarwinAdapter({ run = runCommand, kill } = {}) {
14
+ return {
15
+ platform: 'darwin',
16
+ async findListeners(port) {
17
+ const listeners = await lsofListeners(port, run);
18
+ if (listeners === null) throw new UtilityMissingError('lsof');
19
+ return listeners;
20
+ },
21
+ describeProcess: (pid) => describeUnixProcess(pid, { run, getCwd: (p) => darwinCwd(p, run) }),
22
+ isRunning: async (pid) => isRunningUnix(pid, kill),
23
+ killProcess: async (pid, { force = false } = {}) => killUnix(pid, force, kill),
24
+ };
25
+ }
@@ -0,0 +1,10 @@
1
+ import { createDarwinAdapter } from './darwin.js';
2
+ import { createLinuxAdapter } from './linux.js';
3
+ import { createWin32Adapter } from './win32.js';
4
+
5
+ export function getPlatformAdapter(platform = process.platform, options = {}) {
6
+ if (platform === 'win32') return createWin32Adapter(options);
7
+ if (platform === 'darwin') return createDarwinAdapter(options);
8
+ // Linux and other Unix-like systems.
9
+ return createLinuxAdapter(options);
10
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs/promises';
2
+ import { runCommand } from '../utils/process-info.js';
3
+ import { UtilityMissingError } from '../utils/errors.js';
4
+ import { lsofListeners, describeUnixProcess, killUnix, isRunningUnix } from './unix.js';
5
+
6
+ /** Working directory from /proc/PID/cwd. /proc/PID/environ is never read. */
7
+ export async function linuxCwd(pid, readlink = fs.readlink) {
8
+ try {
9
+ return { cwd: await readlink(`/proc/${pid}/cwd`) };
10
+ } catch (error) {
11
+ if (error.code === 'EACCES' || error.code === 'EPERM') return { reason: 'permission denied' };
12
+ if (error.code === 'ENOENT') return { reason: 'process has exited' };
13
+ return { reason: 'not available' };
14
+ }
15
+ }
16
+
17
+ /** Parse `ss -ltnpH` output. Process info is absent when the socket belongs to another user. */
18
+ export function parseSs(output) {
19
+ const listeners = [];
20
+ for (const line of output.split(/\r?\n/)) {
21
+ if (!line.trim()) continue;
22
+ const columns = line.trim().split(/\s+/);
23
+ const address = columns[3] ?? null;
24
+ const users = [...line.matchAll(/\("([^"]*)",pid=(\d+)/g)];
25
+ if (users.length === 0) {
26
+ listeners.push({ pid: null, name: null, addresses: [address] });
27
+ continue;
28
+ }
29
+ for (const [, name, pid] of users) listeners.push({ pid: Number(pid), name, addresses: [address] });
30
+ }
31
+ return listeners;
32
+ }
33
+
34
+ export function createLinuxAdapter({ run = runCommand, kill, readlink } = {}) {
35
+ return {
36
+ platform: 'linux',
37
+ async findListeners(port) {
38
+ const r = await run('ss', ['-ltnpH', `sport = :${port}`]);
39
+ if (!r.missing && r.ok) return parseSs(r.stdout);
40
+ const listeners = await lsofListeners(port, run);
41
+ if (listeners === null) throw new UtilityMissingError('ss or lsof');
42
+ return listeners;
43
+ },
44
+ describeProcess: (pid) => describeUnixProcess(pid, { run, getCwd: (p) => linuxCwd(p, readlink) }),
45
+ isRunning: async (pid) => isRunningUnix(pid, kill),
46
+ killProcess: async (pid, { force = false } = {}) => killUnix(pid, force, kill),
47
+ };
48
+ }