Files
Ayan Gupta 65ba0b3922 Add Java Modernization Studio canvas extension (#2156)
* Add Java Modernization Studio canvas extension

A canvas extension that drives the GitHub Copilot App Modernization for Java workflow from an interactive dashboard: environment readiness checks, repo assessment, prioritized plan/progress, validation gates (CVE scan, test generation), and one-click predefined-task runs — all grounded in the repo's real artifacts (.appmod/assessment.json, plan.md, progress.md). Includes a node:test suite (109 tests) for the grounding logic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

* Secure cockpit loopback server with a per-instance token

Address Copilot review feedback on the Java Modernization Studio canvas:

- Mint a per-instance secret in createInstanceServer, embed it in the
  iframe URL, and validate it on every loopback request (/, /state,
  /events, /action). Other local processes can no longer read repo
  state or dispatch agent actions just by guessing the random port.
  Mirrors the existing diagram-viewer token pattern; the client echoes
  the token from its boot payload. The guard is a no-op when no token is
  set, so direct makeHandler unit tests are unaffected.
- Handle async request-handler rejections in createServer with a .catch
  that returns 500 and logs, instead of leaking an unhandled rejection
  that could destabilize the extension process.
- Tests: token guard 403s unauthenticated /, /state, /events and
  /action and allows valid-token requests; the end-to-end test asserts
  the tokenized URL and a tokenless 403.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CI: add preview screenshot and clear codespell hits

Add the required assets/preview.png screenshot for the canvas-extension
validator, and resolve two codespell findings in the cockpit:
rename the planSim helper's `nd` variable to `notDone` and reword a
catalog comment to avoid the `invokable`/`invocable` flag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden cockpit loopback server per Copilot review

Address the second-round Copilot review comments on the loopback server:

- broadcast(): drop an SSE client whose write() throws instead of keeping
  it in the Set, so a uncleanly-disconnected client can't cause repeated
  exceptions or leak dead entries on every subsequent broadcast.
- POST /action: treat a malformed JSON body or a missing/invalid "kind"
  as a 400 client error (with an application/json body) instead of a 200
  carrying { ok:false, error:"Unknown action: undefined" }.
- Handler catch: set Content-Type: application/json on the 500 error
  response so it is consistent with the other JSON routes.

Adds four tests covering dead-client eviction, the two 400 paths, and the
JSON-typed 500. Full suite: 115 passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

* Add canvas.json gallery metadata for Java Modernization Studio

Aaron requested a canvas.json so the awesome-copilot website/gallery can
list this extension. Generated via `npm run website:data`
(writePerExtensionCanvasManifests), which derives id/name/description/
version/keywords/screenshots from package.json + the createCanvas source +
assets, and carries the author through. Output committed verbatim so a CI
regeneration produces no diff.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

* Add author tag to package.json

Declare the author in the package manifest (object form, matching the
author already carried in canvas.json so it includes the profile URL).
The published gallery sources author from canvas.json; this adds the
conventional npm author tag to package.json for completeness. Verified
`npm run website:data` still emits the author on the extensions record
and leaves canvas.json byte-identical.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

---------

Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 11:10:53 +10: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 };
}