Files
awesome-copilot/extensions/java-modernization-studio/doctor.mjs
T
github-actions[bot] 7646f29d36 chore: publish from main
2026-07-01 01:11:17 +00:00

413 lines
18 KiB
JavaScript

// doctor.mjs — Environment & workflow readiness checks for the App Modernization
// Cockpit. Probes the local CLI toolchain (JDK, Maven/Gradle, git, Docker, Azure
// CLI / azd, Node) and combines those results with repo facts from scan.mjs so a
// user can confirm their machine is ready *before* they start migrating — and get
// clear, actionable remediation when something is missing.
//
// Split into a pure builder (`buildDoctorReport`) and an impure runner
// (`runDoctor`) so the decision logic is unit-testable without shelling out.
import { execFile } from "node:child_process";
import { platform } from "node:os";
import { readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
// One probe per tool. Many CLIs print their version to stderr (notably `java`),
// so probes capture both streams.
export const TOOL_PROBES = [
{ key: "java", cmd: "java", args: ["-version"] },
{ key: "mvn", cmd: "mvn", args: ["-v"] },
{ key: "gradle", cmd: "gradle", args: ["-v"] },
{ key: "git", cmd: "git", args: ["--version"] },
{ key: "docker", cmd: "docker", args: ["--version"] },
{ key: "az", cmd: "az", args: ["--version"] },
{ key: "azd", cmd: "azd", args: ["version"] },
{ key: "node", cmd: "node", args: ["--version"] },
];
/** Friendly OS label for remediation hints. */
export function osLabel() {
const p = platform();
if (p === "darwin") return "macOS";
if (p === "win32") return "Windows";
if (p === "linux") return "Linux";
return p;
}
/** Extract a short version string from a tool's --version output. Pure. */
export function parseToolVersion(key, text) {
if (!text) return null;
const t = String(text);
if (key === "java") {
const m = t.match(/version\s+"?([\d._]+)"?/i);
if (m) return m[1];
}
if (key === "node") {
const m = t.match(/v?(\d+\.\d+\.\d+)/);
if (m) return m[1];
}
const g = t.match(/(\d+\.\d+(?:\.\d+)?)/);
if (g) return g[1];
const first = t.split(/\r?\n/)[0].trim();
return first ? first.slice(0, 40) : null;
}
function defaultExec(cmd, args) {
return makeExec(augmentedEnv(process.env, platform()))(cmd, args);
}
/**
* Build a child-process env whose PATH also covers the usual places dev tools
* live. A GUI-launched app (the Copilot app) inherits a minimal launchd PATH
* (/usr/bin:/bin:/usr/sbin:/sbin), so tools installed via Homebrew, SDKMAN,
* asdf, or a keg-only JDK aren't found even though the user's terminal sees
* them fine. Prepending these locations makes the probes match reality without
* touching the user's actual environment. Pure given an injected directory
* lister (so it's unit-testable without hitting the filesystem).
*/
export function augmentedEnv(env, plat, lister) {
const base = Object.assign({}, env);
if (plat === "win32") return base; // Windows PATH semantics differ; leave as-is.
const ls = lister || ((d) => { try { return readdirSync(d); } catch (e) { return []; } });
const extra = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"];
if (base.HOME) {
extra.push(base.HOME + "/.sdkman/candidates/java/current/bin");
extra.push(base.HOME + "/.asdf/shims");
}
if (base.JAVA_HOME) extra.push(base.JAVA_HOME + "/bin");
// Keg-only Homebrew JDKs aren't symlinked into .../bin, so enumerate them.
for (const root of ["/opt/homebrew/opt", "/usr/local/opt"]) {
for (const name of ls(root)) {
if (/^openjdk(@\d+)?$/.test(name)) extra.push(root + "/" + name + "/libexec/openjdk.jdk/Contents/Home/bin");
}
}
const seen = {};
const prefix = extra.filter((p) => { if (seen[p]) return false; seen[p] = 1; return true; });
base.PATH = prefix.join(":") + (base.PATH ? ":" + base.PATH : "");
return base;
}
/** Build an exec function bound to a specific environment. */
export function makeExec(env) {
return (cmd, args) =>
new Promise((resolve) => {
execFile(cmd, args, { timeout: 3500, windowsHide: true, env }, (error, stdout, stderr) => {
resolve({ error: error || null, stdout: String(stdout || ""), stderr: String(stderr || "") });
});
});
}
/**
* Expand a shell-style JAVA_HOME value from an rc assignment into a concrete path,
* WITHOUT executing anything. Handles surrounding quotes, an inline comment on
* unquoted values, and `~` / `$HOME` / `${HOME}` expansion. Returns null when the
* value still contains something only a shell could resolve (command substitution,
* other variables) — we'd rather report nothing than a bogus path.
*/
export function expandJavaHomeValue(raw, env) {
if (typeof raw !== "string") return null;
let v = raw.trim();
if (!v) return null;
if (v[0] !== '"' && v[0] !== "'") {
const c = v.indexOf(" #");
if (c >= 0) v = v.slice(0, c).trim();
}
if (v.length >= 2 && ((v[0] === '"' && v[v.length - 1] === '"') || (v[0] === "'" && v[v.length - 1] === "'"))) {
v = v.slice(1, -1);
}
const home = (env && env.HOME) || "";
if (v === "~") v = home;
else if (v.startsWith("~/")) v = home + v.slice(1);
v = v.replace(/\$\{HOME\}/g, home).replace(/\$HOME(?=\/|$)/g, home);
if (!v || /[`]|\$\(|\$\w/.test(v)) return null; // unresolved shell expansion
return v;
}
/**
* Statically parse a shell rc file's text for the last `JAVA_HOME=` assignment.
* Pure and side-effect-free. Returns the expanded path or null.
*/
export function parseJavaHomeFromRc(text, env) {
if (typeof text !== "string" || !text) return null;
const re = /^[ \t]*(?:export[ \t]+)?JAVA_HOME[ \t]*=[ \t]*(.+?)[ \t]*$/gm;
let m;
let last = null;
while ((m = re.exec(text)) !== null) {
const val = expandJavaHomeValue(m[1], env);
if (val) last = val; // later assignments win, mirroring shell evaluation order
}
return last;
}
/**
* Best-effort discovery of the JAVA_HOME the user's interactive shell is
* configured with, so the readiness report reflects the JDK their terminal
* actually uses (e.g. a pinned LTS) rather than whichever JDK happens to sort
* first on the augmented PATH.
*
* IMPORTANT: this only *reads and statically parses* the shell rc files — it never
* sources or executes them. That keeps the readiness probe honest with the UI
* promise ("only reads version numbers; nothing is installed or changed") and
* avoids running arbitrary user startup code. A JAVA_HOME defined via command
* substitution (e.g. `$(/usr/libexec/java_home)`) can't be resolved statically, so
* we simply fall back to PATH-based probing in that case. The rc reader is
* injectable for tests.
*/
export async function discoverJavaHome(env, plat, readRc) {
const e = env || {};
if (e.JAVA_HOME) return e.JAVA_HOME; // already explicit in this process
if (plat === "win32") return null; // not a $JAVA_HOME/rc world
const home = e.HOME || "";
if (!home) return null;
const shell = e.SHELL || (plat === "darwin" ? "/bin/zsh" : "/bin/bash");
const names = /zsh/.test(shell)
? [".zshenv", ".zprofile", ".zshrc"]
: /bash/.test(shell)
? [".bash_profile", ".bashrc", ".profile"]
: [".profile"];
const read =
readRc ||
(async (p) => {
try {
return await readFile(p, "utf8");
} catch {
return null;
}
});
let found = null;
for (const name of names) {
let text = null;
try {
text = await read(join(home, name));
} catch {
text = null;
}
const val = parseJavaHomeFromRc(text, e);
if (val) found = val; // later rc files (e.g. .zshrc) win
}
return found || null;
}
/** Probe a single tool. Returns { key, found, version }. */
export async function probeOne(def, exec) {
const run = exec || defaultExec;
let r;
try {
r = await run(def.cmd, def.args);
} catch (e) {
r = { error: e, stdout: "", stderr: "" };
}
const text = ((r.stdout || "") + "\n" + (r.stderr || "")).trim();
// ENOENT means the binary isn't on PATH at all.
if (r.error && r.error.code === "ENOENT") return { key: def.key, found: false, version: null };
if (r.error && !text) return { key: def.key, found: false, version: null };
const version = parseToolVersion(def.key, text);
if (r.error) {
// Non-zero exit. Genuine `--version` calls exit 0, so only trust this if a
// real version number came back. This rejects the macOS `java` stub, which
// exits 1 with "Unable to locate a Java Runtime" when no JDK is installed.
if (!(version && /\d/.test(version))) return { key: def.key, found: false, version: null };
}
return { key: def.key, found: true, version };
}
/** Probe every tool and build the readiness report. Impure (spawns processes). */
export async function runDoctor(scan, opts = {}) {
let exec = opts.exec;
if (!exec) {
const env = augmentedEnv(process.env, platform());
// Prefer the JDK the user's shell is configured with so the report matches
// their terminal (and so a dependency-pulled JDK doesn't shadow their pin).
// Static rc parse — no shell sourcing.
const jh = await discoverJavaHome(process.env, platform());
if (jh && !env.JAVA_HOME) {
env.JAVA_HOME = jh;
env.PATH = jh + "/bin:" + env.PATH;
}
exec = makeExec(env);
}
const probesArr = await Promise.all(TOOL_PROBES.map((d) => probeOne(d, exec)));
const probes = {};
for (const p of probesArr) probes[p.key] = p;
return buildDoctorReport({ probes, scan: scan || {} });
}
function envAction(tool, detail, fix) {
return { kind: "fix_env", payload: { tool, detail, fix }, label: "Help me set up " + tool };
}
/**
* Turn probe results + scan facts into grouped readiness checks. Pure.
* @returns {{ overall:"ready"|"caution"|"blocked", generatedAt:string, groups:Array }}
*/
export function buildDoctorReport({ probes, scan }) {
const get = (k) => (probes && probes[k]) || { found: false, version: null };
const a = (scan && scan.assessment) || {};
const buildTool = a.buildTool || null;
const buildRun = [];
// --- JDK -----------------------------------------------------------------
const java = get("java");
buildRun.push(
java.found
? { id: "jdk", label: "Java Development Kit", status: "ok", detail: "Java " + (java.version || "detected") }
: {
id: "jdk",
label: "Java Development Kit",
status: "fail",
detail: "java not found on PATH",
fix: "Install a JDK 17 or newer (Microsoft Build of OpenJDK or Eclipse Temurin) and make sure `java` is on your PATH.",
action: envAction("a JDK", "java not found on PATH", "Install JDK 17+ (Microsoft OpenJDK / Temurin) and add it to PATH."),
}
);
// --- Build tool ----------------------------------------------------------
if (buildTool === "Maven") {
const mvn = get("mvn");
const ok = mvn.found || a.hasMavenWrapper;
buildRun.push(
ok
? {
id: "build",
label: "Maven",
status: "ok",
detail: mvn.found ? "Maven " + (mvn.version || "detected") : "Using the project Maven wrapper (./mvnw)",
}
: {
id: "build",
label: "Maven",
status: "fail",
detail: "mvn not found and no ./mvnw wrapper in the repo",
fix: "Install Apache Maven, or add the Maven wrapper (`mvn -N wrapper:wrapper`) so the build runs anywhere.",
action: envAction("Maven", "mvn not found and no ./mvnw wrapper", "Install Apache Maven or add the ./mvnw wrapper."),
}
);
} else if (buildTool === "Gradle") {
const gr = get("gradle");
const ok = gr.found || a.hasGradleWrapper;
buildRun.push(
ok
? {
id: "build",
label: "Gradle",
status: "ok",
detail: gr.found ? "Gradle " + (gr.version || "detected") : "Using the project Gradle wrapper (./gradlew)",
}
: {
id: "build",
label: "Gradle",
status: "fail",
detail: "gradle not found and no ./gradlew wrapper in the repo",
fix: "Install Gradle, or add the Gradle wrapper (`gradle wrapper`) so the build runs anywhere.",
action: envAction("Gradle", "gradle not found and no ./gradlew wrapper", "Install Gradle or add the ./gradlew wrapper."),
}
);
} else {
buildRun.push({
id: "build",
label: "Build tool",
status: "info",
detail: "No Maven or Gradle build file detected yet.",
});
}
// --- git -----------------------------------------------------------------
const git = get("git");
buildRun.push(
git.found
? { id: "git", label: "Git", status: "ok", detail: git.version ? "git " + git.version : "installed" }
: {
id: "git",
label: "Git",
status: "fail",
detail: "git not found on PATH",
fix: "Install Git — the modernization workflow uses a branch and a pull request.",
action: envAction("Git", "git not found on PATH", "Install Git and ensure it is on PATH."),
}
);
// --- containerize / deploy (optional) ------------------------------------
const deploy = [];
const docker = get("docker");
if (docker.found) {
deploy.push({ id: "docker", label: "Docker", status: "ok", detail: docker.version ? "Docker " + docker.version : "installed" });
} else if (a.hasDockerfile) {
deploy.push({
id: "docker",
label: "Docker",
status: "warn",
detail: "A Dockerfile is present but Docker isn't installed.",
fix: "Install Docker Desktop (or the Docker engine) to build and run the container image locally.",
action: envAction("Docker", "Dockerfile present but Docker not installed", "Install Docker Desktop / engine."),
});
} else {
deploy.push({ id: "docker", label: "Docker", status: "info", detail: "Not installed — only needed to build container images." });
}
const az = get("az");
deploy.push(
az.found
? { id: "az", label: "Azure CLI", status: "ok", detail: az.version ? "az " + az.version : "installed" }
: { id: "az", label: "Azure CLI", status: "info", detail: "Optional — needed to provision Azure resources and set up passwordless auth." }
);
const azd = get("azd");
deploy.push(
azd.found
? { id: "azd", label: "Azure Developer CLI", status: "ok", detail: azd.version ? "azd " + azd.version : "installed" }
: { id: "azd", label: "Azure Developer CLI", status: "info", detail: "Optional — `azd` deploys the app to Azure once it's modernized." }
);
// --- workflow readiness (from scan, no probes) ---------------------------
const flow = [];
const g = scan && scan.git;
if (!g) {
flow.push({ id: "branch", label: "Source control", status: "info", detail: "Git branch state unavailable." });
} else if (g.isMigrationBranch) {
flow.push({ id: "branch", label: "Working branch", status: "ok", detail: "On modernization branch '" + g.branch + "'." });
} else {
flow.push({
id: "branch",
label: "Working branch",
status: "info",
detail: "On '" + (g.branch || "unknown") + "' — consider a dedicated modernization branch before making changes.",
});
}
const hasAssessment = !!(
(scan && scan.report && scan.report.findings && scan.report.findings.length) ||
(scan && scan.plan && scan.plan.exists) ||
(scan && scan.progress && scan.progress.exists)
);
flow.push(
hasAssessment
? { id: "assessed", label: "Assessment", status: "ok", detail: "Assessment artifacts found — you're underway." }
: {
id: "assessed",
label: "Assessment",
status: "warn",
detail: "No assessment yet. Start here to map your modernization work.",
action: { kind: "start_assessment", payload: {}, label: "Run assessment" },
}
);
const nSkills = (scan && scan.skills && scan.skills.length) || 0;
flow.push({
id: "skills",
label: "Custom skills",
status: "info",
detail: nSkills ? nSkills + " custom skill" + (nSkills > 1 ? "s" : "") + " discovered." : "No custom skills yet (optional).",
});
const groups = [
{ id: "buildRun", name: "Build & run", checks: buildRun },
{ id: "deploy", name: "Containerize & deploy to Azure (optional)", checks: deploy },
{ id: "flow", name: "Workflow readiness", checks: flow },
];
let overall = "ready";
const all = groups.reduce((acc, grp) => acc.concat(grp.checks), []);
if (all.some((c) => c.status === "fail")) overall = "blocked";
else if (all.some((c) => c.status === "warn")) overall = "caution";
return { overall, generatedAt: new Date().toISOString(), groups };
}