mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 10:01:06 +00:00
413 lines
18 KiB
JavaScript
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 };
|
|
}
|