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,79 @@
1
+ import { runCommand } from '../utils/process-info.js';
2
+
3
+ /** Parse `lsof -Fpcn` output into listeners. */
4
+ export function parseLsof(output) {
5
+ const listeners = [];
6
+ let current = null;
7
+ for (const line of output.split(/\r?\n/)) {
8
+ const field = line[0];
9
+ const value = line.slice(1);
10
+ if (field === 'p') {
11
+ current = { pid: Number(value), name: null, addresses: [] };
12
+ listeners.push(current);
13
+ } else if (field === 'c' && current) {
14
+ current.name = value;
15
+ } else if (field === 'n' && current) {
16
+ current.addresses.push(value);
17
+ }
18
+ }
19
+ return listeners;
20
+ }
21
+
22
+ export async function lsofListeners(port, run = runCommand) {
23
+ const r = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn']);
24
+ if (r.missing) return null;
25
+ // lsof exits with 1 when nothing matches.
26
+ return r.stdout.trim() ? parseLsof(r.stdout) : [];
27
+ }
28
+
29
+ /** Parse `ps -o ppid=,user=,command=` output. */
30
+ export function parsePs(output) {
31
+ const match = /^\s*(\d+)\s+(\S+)\s*(.*)$/.exec(output.trim());
32
+ if (!match) return null;
33
+ return { ppid: Number(match[1]), user: match[2], command: match[3].trim() || null };
34
+ }
35
+
36
+ /**
37
+ * Process details on Unix. Every field may be null; `unavailable` says why.
38
+ * getCwd(pid) returns { cwd } or { reason }.
39
+ */
40
+ export async function describeUnixProcess(pid, { run = runCommand, getCwd } = {}) {
41
+ const details = { exists: true, ppid: null, user: null, command: null, cwd: null, unavailable: {} };
42
+ const r = await run('ps', ['-o', 'ppid=,user=,command=', '-p', String(pid)]);
43
+ const parsed = r.ok ? parsePs(r.stdout) : null;
44
+ if (r.missing) {
45
+ details.unavailable.command = 'ps not found';
46
+ } else if (!parsed) {
47
+ // ps exits non-zero when the PID does not exist.
48
+ return { ...details, exists: false, unavailable: { command: 'process has exited' } };
49
+ } else {
50
+ Object.assign(details, parsed);
51
+ if (!parsed.command) details.unavailable.command = 'not accessible';
52
+ }
53
+ const cwd = await getCwd(pid);
54
+ if (cwd.cwd) details.cwd = cwd.cwd;
55
+ else details.unavailable.cwd = cwd.reason;
56
+ return details;
57
+ }
58
+
59
+ /** Signal 0 checks existence without affecting the process. EPERM means it exists but belongs to someone else. */
60
+ export function isRunningUnix(pid, kill = process.kill.bind(process)) {
61
+ try {
62
+ kill(pid, 0);
63
+ return true;
64
+ } catch (error) {
65
+ return error.code === 'EPERM';
66
+ }
67
+ }
68
+
69
+ /** Send a signal; returns { ok } or { ok: false, reason: 'permission' | 'not-found' | 'error' }. */
70
+ export function killUnix(pid, force, kill = process.kill.bind(process)) {
71
+ try {
72
+ kill(pid, force ? 'SIGKILL' : 'SIGTERM');
73
+ return { ok: true };
74
+ } catch (error) {
75
+ if (error.code === 'EPERM') return { ok: false, reason: 'permission' };
76
+ if (error.code === 'ESRCH') return { ok: false, reason: 'not-found' };
77
+ return { ok: false, reason: 'error', message: error.message };
78
+ }
79
+ }
@@ -0,0 +1,96 @@
1
+ import { runCommand } from '../utils/process-info.js';
2
+ import { UtilityMissingError } from '../utils/errors.js';
3
+
4
+ /**
5
+ * Parse `netstat -ano` output. Listening sockets have a foreign port of 0;
6
+ * this avoids depending on the localized "LISTENING" state text.
7
+ */
8
+ export function parseNetstat(output, port) {
9
+ const listeners = [];
10
+ for (const line of output.split(/\r?\n/)) {
11
+ const parts = line.trim().split(/\s+/);
12
+ if (parts.length < 5 || parts[0].toUpperCase() !== 'TCP') continue;
13
+ const [, local, foreign] = parts;
14
+ const pid = Number(parts[parts.length - 1]);
15
+ if (!local.endsWith(`:${port}`) || !foreign.endsWith(':0') || !Number.isInteger(pid)) continue;
16
+ listeners.push({ pid, name: null, addresses: [local] });
17
+ }
18
+ return listeners;
19
+ }
20
+
21
+ /** Parse `tasklist /FO CSV /NH` output and return the image name. */
22
+ export function parseTasklist(output) {
23
+ const match = /^"([^"]+)","(\d+)"/m.exec(output);
24
+ return match ? match[1] : null;
25
+ }
26
+
27
+ // The PID is passed through an environment variable and cast to [int], so it is never interpolated into the script.
28
+ const DESCRIBE_SCRIPT = [
29
+ '$p = [int]$env:DEV_DOCTOR_PID',
30
+ '$x = Get-CimInstance Win32_Process -Filter "ProcessId = $p"',
31
+ 'if ($x) { $x | Select-Object ProcessId, ParentProcessId, Name, CommandLine | ConvertTo-Json -Compress }',
32
+ ].join('; ');
33
+
34
+ /** Parse the JSON printed by DESCRIBE_SCRIPT. */
35
+ export function parseWin32Process(output) {
36
+ const text = output.trim();
37
+ if (!text) return null;
38
+ try {
39
+ const data = JSON.parse(text);
40
+ return { ppid: Number.isInteger(data.ParentProcessId) ? data.ParentProcessId : null, name: data.Name ?? null, command: data.CommandLine || null };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function createWin32Adapter({ run = runCommand } = {}) {
47
+ return {
48
+ platform: 'win32',
49
+ async findListeners(port) {
50
+ const r = await run('netstat', ['-ano']);
51
+ if (r.missing) throw new UtilityMissingError('netstat');
52
+ const listeners = parseNetstat(r.stdout, port);
53
+ for (const listener of listeners) {
54
+ const t = await run('tasklist', ['/FI', `PID eq ${listener.pid}`, '/FO', 'CSV', '/NH']);
55
+ if (t.ok) listener.name = parseTasklist(t.stdout);
56
+ }
57
+ return listeners;
58
+ },
59
+ async describeProcess(pid) {
60
+ const details = { exists: true, ppid: null, user: null, command: null, cwd: null, unavailable: { cwd: 'not available on Windows', user: 'not collected on Windows' } };
61
+ const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', DESCRIBE_SCRIPT], { timeout: 10000, env: { DEV_DOCTOR_PID: String(pid) } });
62
+ if (r.missing) {
63
+ details.unavailable.command = 'PowerShell not found';
64
+ return details;
65
+ }
66
+ const parsed = r.ok ? parseWin32Process(r.stdout) : null;
67
+ if (r.ok && !parsed) return { ...details, exists: false, unavailable: { ...details.unavailable, command: 'process has exited' } };
68
+ if (!parsed) {
69
+ details.unavailable.command = 'not available';
70
+ return details;
71
+ }
72
+ details.ppid = parsed.ppid;
73
+ details.command = parsed.command;
74
+ // CommandLine is empty for elevated or other users' processes.
75
+ if (!parsed.command) details.unavailable.command = 'not accessible (permission denied)';
76
+ return details;
77
+ },
78
+ async isRunning(pid) {
79
+ const t = await run('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH']);
80
+ return t.ok && parseTasklist(t.stdout) !== null;
81
+ },
82
+ async killProcess(pid, { force = false } = {}) {
83
+ const args = ['/PID', String(pid)];
84
+ if (force) args.push('/F');
85
+ const r = await run('taskkill', args);
86
+ if (r.ok) return { ok: true };
87
+ if (r.missing) return { ok: false, reason: 'error', message: 'taskkill not found' };
88
+ const text = `${r.stdout} ${r.stderr}`;
89
+ if (/access is denied/i.test(text)) return { ok: false, reason: 'permission' };
90
+ // Console programs (node.exe, python.exe, ...) have no window to close, so taskkill without /F cannot stop them.
91
+ if (!force && /forcefully|\/F option/i.test(text)) return { ok: false, reason: 'graceful-unsupported' };
92
+ if (/not found/i.test(text)) return { ok: false, reason: 'not-found' };
93
+ return { ok: false, reason: 'error', message: text.trim() };
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,31 @@
1
+ import path from 'node:path';
2
+ import { readTextIfExists } from './paths.js';
3
+ import { DevDoctorError } from './errors.js';
4
+
5
+ export const CONFIG_FILE = '.dev-doctor.json';
6
+
7
+ /**
8
+ * Optional project config:
9
+ * { "ports": [3000], "failOn": "fail" | "warn",
10
+ * "services": { "docker": true, "postgres": { "host": "localhost", "port": 5432 }, "redis": false } }
11
+ */
12
+ export function loadConfig(root) {
13
+ const text = readTextIfExists(path.join(root, CONFIG_FILE));
14
+ if (text === null) return {};
15
+ let config;
16
+ try {
17
+ config = JSON.parse(text);
18
+ } catch {
19
+ throw new DevDoctorError(`${CONFIG_FILE} is not valid JSON`);
20
+ }
21
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
22
+ throw new DevDoctorError(`${CONFIG_FILE} must contain a JSON object`);
23
+ }
24
+ if (config.ports !== undefined && !Array.isArray(config.ports)) {
25
+ throw new DevDoctorError(`${CONFIG_FILE}: "ports" must be an array`);
26
+ }
27
+ if (config.failOn !== undefined && !['fail', 'warn'].includes(config.failOn)) {
28
+ throw new DevDoctorError(`${CONFIG_FILE}: "failOn" must be "fail" or "warn"`);
29
+ }
30
+ return config;
31
+ }
@@ -0,0 +1,64 @@
1
+ const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/;
2
+
3
+ /**
4
+ * Parse dotenv-style text. Returns entries (key -> { value, line }) and issues.
5
+ * Issues only contain line numbers and messages, never file content.
6
+ */
7
+ export function parseEnv(text) {
8
+ const entries = new Map();
9
+ const issues = [];
10
+ const lines = String(text).split(/\r?\n/);
11
+
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const lineNo = i + 1;
14
+ let line = lines[i].trim();
15
+ if (line === '' || line.startsWith('#')) continue;
16
+ line = line.replace(/^export\s+/, '');
17
+
18
+ const match = KEY_LINE.exec(line);
19
+ if (!match) {
20
+ issues.push({ line: lineNo, message: 'line could not be parsed as KEY=value' });
21
+ continue;
22
+ }
23
+ const key = match[1];
24
+ let raw = match[2];
25
+ let value;
26
+
27
+ const quote = raw[0];
28
+ if (quote === '"' || quote === "'" || quote === '`') {
29
+ let end = raw.indexOf(quote, 1);
30
+ if (end !== -1) {
31
+ value = raw.slice(1, end);
32
+ } else {
33
+ // Multi-line quoted value: read until the closing quote.
34
+ const parts = [raw.slice(1)];
35
+ let closed = false;
36
+ while (i + 1 < lines.length) {
37
+ i++;
38
+ const next = lines[i];
39
+ end = next.indexOf(quote);
40
+ if (end !== -1) {
41
+ parts.push(next.slice(0, end));
42
+ closed = true;
43
+ break;
44
+ }
45
+ parts.push(next);
46
+ }
47
+ if (!closed) {
48
+ issues.push({ line: lineNo, message: `unterminated quoted value for ${key}` });
49
+ continue;
50
+ }
51
+ value = parts.join('\n');
52
+ }
53
+ } else {
54
+ const comment = raw.search(/\s#/);
55
+ if (comment !== -1) raw = raw.slice(0, comment);
56
+ value = raw.trim();
57
+ }
58
+
59
+ if (entries.has(key)) issues.push({ line: lineNo, message: `${key} is defined more than once` });
60
+ entries.set(key, { value, line: lineNo });
61
+ }
62
+
63
+ return { entries, issues };
64
+ }
@@ -0,0 +1,15 @@
1
+ /** An expected error caused by bad input or environment; shown without a stack trace. */
2
+ export class DevDoctorError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'DevDoctorError';
6
+ }
7
+ }
8
+
9
+ export class UtilityMissingError extends Error {
10
+ constructor(utility) {
11
+ super(`Required system utility "${utility}" was not found`);
12
+ this.name = 'UtilityMissingError';
13
+ this.utility = utility;
14
+ }
15
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DevDoctorError } from './errors.js';
4
+
5
+ export function resolveProjectRoot(cwd = process.cwd()) {
6
+ const root = path.resolve(cwd);
7
+ let stat;
8
+ try {
9
+ stat = fs.statSync(root);
10
+ } catch {
11
+ throw new DevDoctorError(`Directory not found: ${root}`);
12
+ }
13
+ if (!stat.isDirectory()) throw new DevDoctorError(`Not a directory: ${root}`);
14
+ return root;
15
+ }
16
+
17
+ /** Resolve a path inside the project root; refuse anything that escapes it. */
18
+ export function safeProjectPath(root, relativePath) {
19
+ const target = path.resolve(root, relativePath);
20
+ const rel = path.relative(root, target);
21
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
22
+ throw new DevDoctorError(`Refusing to access a path outside the project: ${relativePath}`);
23
+ }
24
+ return target;
25
+ }
26
+
27
+ export function exists(file) {
28
+ try {
29
+ fs.accessSync(file);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export function readTextIfExists(file, maxBytes = 1024 * 1024) {
37
+ try {
38
+ const stat = fs.statSync(file);
39
+ if (!stat.isFile() || stat.size > maxBytes) return null;
40
+ return fs.readFileSync(file, 'utf8');
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
@@ -0,0 +1,34 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ /**
4
+ * Run a command with an argument array (no shell) and a timeout. Never throws.
5
+ * The shell option exists only for Windows .cmd shims and must be used with fixed arguments.
6
+ */
7
+ export function runCommand(command, args = [], { timeout = 5000, shell = false, env } = {}) {
8
+ return new Promise((resolve) => {
9
+ execFile(command, args, { timeout, shell, env: env ? { ...process.env, ...env } : undefined, windowsHide: true, maxBuffer: 5 * 1024 * 1024 }, (error, stdout, stderr) => {
10
+ if (!error) {
11
+ resolve({ ok: true, code: 0, stdout: String(stdout), stderr: String(stderr), missing: false, timedOut: false });
12
+ return;
13
+ }
14
+ resolve({
15
+ ok: false,
16
+ code: typeof error.code === 'number' ? error.code : null,
17
+ stdout: String(stdout ?? ''),
18
+ stderr: String(stderr ?? ''),
19
+ missing: error.code === 'ENOENT',
20
+ timedOut: Boolean(error.killed),
21
+ });
22
+ });
23
+ });
24
+ }
25
+
26
+ /** Get a tool's version, e.g. `npm --version`. Returns null when it is not available. */
27
+ export async function getToolVersion(tool, { run = runCommand, platform = process.platform } = {}) {
28
+ // Package managers are .cmd shims on Windows and need a shell; the arguments are constant.
29
+ const r = platform === 'win32'
30
+ ? await run(`${tool} --version`, [], { timeout: 8000, shell: true })
31
+ : await run(tool, ['--version'], { timeout: 8000 });
32
+ if (!r.ok) return null;
33
+ return r.stdout.trim().split(/\r?\n/)[0] || null;
34
+ }
@@ -0,0 +1,24 @@
1
+ import { confirm, select } from '@inquirer/prompts';
2
+
3
+ export function isInteractive({ ci = false } = {}) {
4
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY) && !ci && !process.env.CI;
5
+ }
6
+
7
+ // Prompts are written to stderr so stdout stays clean (e.g. for --json).
8
+ const context = { output: process.stderr };
9
+
10
+ export async function askConfirm(message, defaultValue = false) {
11
+ try {
12
+ return await confirm({ message, default: defaultValue }, context);
13
+ } catch {
14
+ return false; // Ctrl+C or closed input counts as "no".
15
+ }
16
+ }
17
+
18
+ export async function askSelect(message, choices) {
19
+ try {
20
+ return await select({ message, choices }, context);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
@@ -0,0 +1,18 @@
1
+ const SECRET_WORDS = 'password|passwd|pwd|token|secret|api[-_]?key|apikey|auth|credential|access[-_]?key|private[-_]?key';
2
+
3
+ /** Mask credentials embedded in URLs (scheme://user:pass@host). */
4
+ export function redact(text) {
5
+ return String(text).replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1***@');
6
+ }
7
+
8
+ /**
9
+ * Mask common secrets in a process command line: URL credentials, --password=x / --token x style flags,
10
+ * SECRET_KEY=x style assignments and Bearer/Basic tokens. Over-redaction is preferred to leaking.
11
+ */
12
+ export function redactCommandLine(command) {
13
+ if (command === null || command === undefined) return command;
14
+ return redact(command)
15
+ .replace(new RegExp(`(--?[A-Za-z0-9_-]*(?:${SECRET_WORDS})[A-Za-z0-9_-]*)(=|\\s+)("[^"]*"|'[^']*'|\\S+)`, 'gi'), '$1$2***')
16
+ .replace(new RegExp(`\\b([A-Za-z0-9_]*(?:${SECRET_WORDS})[A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\\s*]\\S*)`, 'gi'), '$1=***')
17
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 ***');
18
+ }