mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 10:01:06 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
artifacts/
|
||||
@@ -0,0 +1,92 @@
|
||||
# Java Modernization Studio
|
||||
|
||||
An interactive GitHub Copilot **canvas** that turns the [GitHub Copilot App Modernization for Java](https://learn.microsoft.com/en-us/azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java) CLI workflow into a visible, steerable dashboard — assess a legacy Java app, drive a prioritized remediation plan, run validation gates, and dispatch Microsoft predefined tasks, all grounded in the repo's real artifacts.
|
||||
|
||||
The Copilot App Modernization for Java tooling stays the engine. This canvas is the cockpit on top of it: it reads what the workflow produces and turns each step into an agent-driving button.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Overview** — at-a-glance modernization status (phase, % complete, finding counts) scanned from the repo.
|
||||
- **Readiness (Environment Doctor)** — checks JDK, Maven (or `./mvnw`), Git, Docker, and Azure CLI on PATH and flags what's missing before you start.
|
||||
- **Assessment** — renders structured findings from `.appmod/assessment.json` (stack summary, severity-ordered findings, strengths), each with a one-click action.
|
||||
- **Plan & Progress** — renders `plan.md` / `progress.md` as live checklists (`- [ ]` / `- [x]`).
|
||||
- **Validation** — runs the workflow's quality gates (CVE validation, test generation) before and after changes.
|
||||
- **Tasks** — dispatches Microsoft predefined modernization tasks (managed identity for DB, secrets → Key Vault, message-broker → Service Bus, S3 → Blob, cache → Redis, Entra ID auth, and more) relevant to the detected stack.
|
||||
- **Summary** — surfaces `summary.md` when the run is complete.
|
||||
- **Autopilot** — an optional phase-ordered, hands-free loop that advances assessment → remediation → validation and updates the dashboard as the agent makes progress.
|
||||
|
||||
Buttons don't execute logic in the canvas — they dispatch a grounded prompt to your Copilot agent (action kinds: `run_task`, `generate_plan`, `run_cve`, `generate_tests`, `fix_finding`). The agent does the work; the canvas reflects the result.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **GitHub Copilot app** (the canvas host).
|
||||
- **GitHub Copilot App Modernization for Java** tooling — the underlying workflow this canvas drives.
|
||||
- **JDK 17+** and **Maven** (or a `./mvnw` wrapper) for the Java project you're modernizing.
|
||||
- *Optional:* **Azure CLI** (`az`) for cloud-readiness and Azure migration tasks; **Docker** for container checks.
|
||||
|
||||
## Install
|
||||
|
||||
This is an in-repo canvas extension. Copy the `java-modernization-studio/` folder into one of:
|
||||
|
||||
- `~/.copilot/extensions/` — **user scope** (just you), or
|
||||
- `.github/extensions/` — **project scope** (shared with your repo's team).
|
||||
|
||||
Then reload extensions (or restart the app) so Copilot discovers it. No build step is required — the Copilot CLI resolves `@github/copilot-sdk` automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
Point the canvas at a Java repository and let the agent drive it:
|
||||
|
||||
```text
|
||||
Open the Java Modernization Studio canvas for /path/to/my-java-app and run a readiness check.
|
||||
```
|
||||
|
||||
The canvas resolves the target repo from its `repoPath` input, falling back to the session's working directory. From there:
|
||||
|
||||
1. **Readiness first** — resolve any missing JDK/Maven/Azure CLI the Doctor flags.
|
||||
2. **Assess** — generate `.appmod/assessment.json` + a prioritized `plan.md` / `progress.md`.
|
||||
3. **Remediate** — work findings in severity order (P0 first), using task buttons and "Help me fix this".
|
||||
4. **Validate** — run the CVE and test-generation gates.
|
||||
5. **Ship** — when the work is genuinely complete, write `summary.md`.
|
||||
|
||||
### Suggested agent instructions
|
||||
|
||||
```text
|
||||
When a user modernizes a Java project with the Java Modernization Studio canvas:
|
||||
1) Open the canvas pointed at the repo (repoPath) and run the Environment Doctor first.
|
||||
2) Run an assessment; write findings to .appmod/assessment.json and a prioritized plan.md / progress.md.
|
||||
Start plan.md, progress.md, and summary.md with the exact first line <!-- appmod-cockpit --> so the
|
||||
canvas recognizes them as modernization artifacts.
|
||||
3) Work findings in severity order (P0 first); run the validation gates (CVE scan, test generation)
|
||||
before and after code changes.
|
||||
4) Keep plan.md / progress.md updated as - [ ] / - [x] checklists. Only write summary.md when the
|
||||
work is truly complete.
|
||||
```
|
||||
|
||||
## How it stays grounded
|
||||
|
||||
The canvas renders **real repo state**, never invented status:
|
||||
|
||||
- Structured findings come from `.appmod/assessment.json`.
|
||||
- Plan/progress/summary come from root `plan.md` / `progress.md` / `summary.md`.
|
||||
- To avoid mistaking an unrelated repo's `plan.md` for modernization output, root markdown is trusted **only** when `.appmod/` exists **or** the file's first line is the provenance marker `<!-- appmod-cockpit -->`.
|
||||
- Stack detection (build tool, Java version, framework, container) is parsed from `pom.xml` / Gradle / Dockerfile and drives which tasks are shown.
|
||||
|
||||
## Agent-callable actions
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `get_state` | Return the current modernization snapshot scanned from the repo (assessment, plan/progress, gates, tasks). |
|
||||
| `refresh` | Re-scan the repo and push a fresh snapshot to the open canvas. |
|
||||
|
||||
## Development
|
||||
|
||||
The grounding/parsing logic is pure and unit-tested independently of the canvas runtime:
|
||||
|
||||
```bash
|
||||
node --test test/cockpit.test.mjs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
@@ -0,0 +1,162 @@
|
||||
// autopilot.mjs — Sequencing engine for "Run on autopilot".
|
||||
//
|
||||
// Given the live plan, Autopilot drives the agent through the checklist one
|
||||
// step at a time: pick the next eligible step (respecting phase ordering),
|
||||
// hand it to the agent, wait for the turn to finish, re-scan, and repeat —
|
||||
// streaming progress to the canvas after every step.
|
||||
//
|
||||
// All I/O is injected (`snapshot`, `runTurn`, `buildStepPrompt`, `onProgress`)
|
||||
// so the loop is unit-testable without a live session or HTTP server. The pure
|
||||
// helpers below are the same selection logic the renderer uses for "Continue
|
||||
// here", which keeps the visible recommendation and the automated run in sync.
|
||||
|
||||
// How long to wait for a single step's turn to go idle. Generous: one
|
||||
// modernization step can involve edits, a build, and tests. The wait does not
|
||||
// abort in-flight agent work; it only bounds how long Autopilot blocks before
|
||||
// treating the step as failed.
|
||||
export const AUTOPILOT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export const AUTOPILOT_MAX_STEPS = 25;
|
||||
|
||||
/** The checklist Autopilot follows: progress.md when present, else plan.md. */
|
||||
export function currentSteps(state) {
|
||||
if (!state) return [];
|
||||
if (state.progress && state.progress.steps && state.progress.steps.length) return state.progress.steps;
|
||||
if (state.plan && state.plan.steps) return state.plan.steps;
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Stable identity for a step across re-scans (phase + title). */
|
||||
export function stepKey(step) {
|
||||
if (!step) return "";
|
||||
return (step.section || "") + "::" + (step.title || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The next step Autopilot should run: the first not-done step in the active
|
||||
* phase, falling back to the first not-done step overall. Mirrors the renderer's
|
||||
* "Continue here" so the automated run never jumps ahead of the safe next step.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function selectNextStep(state) {
|
||||
const steps = currentSteps(state);
|
||||
if (!steps.length) return null;
|
||||
const ord = (state && state.ordering) || { activeRank: null };
|
||||
const inPhase = steps.find(
|
||||
(x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank)
|
||||
);
|
||||
return inPhase || steps.find((x) => x.status !== "done") || null;
|
||||
}
|
||||
|
||||
/** Whether the given step is now checked off in a freshly scanned state. */
|
||||
export function isStepDone(state, step) {
|
||||
const k = stepKey(step);
|
||||
const match = currentSteps(state).find((s) => stepKey(s) === k);
|
||||
return !!(match && match.status === "done");
|
||||
}
|
||||
|
||||
/** Construct the mutable, serializable run record broadcast to the canvas. */
|
||||
export function makeRun({ scope, maxSteps, startRank } = {}) {
|
||||
return {
|
||||
running: true,
|
||||
cancelled: false,
|
||||
scope: scope === "all" ? "all" : "phase",
|
||||
maxSteps: maxSteps || AUTOPILOT_MAX_STEPS,
|
||||
startRank: startRank == null ? null : startRank,
|
||||
status: "running",
|
||||
current: null,
|
||||
completed: [],
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the run to completion. Mutates `run` in place and calls
|
||||
* `deps.onProgress(run, state|null)` whenever something changes so the caller
|
||||
* can broadcast. Resolves with the final `run`.
|
||||
*
|
||||
* @param {object} run from makeRun()
|
||||
* @param {{
|
||||
* snapshot: () => Promise<object>,
|
||||
* runTurn: (prompt: string) => Promise<any>,
|
||||
* buildStepPrompt: (step: object) => string,
|
||||
* onProgress: (run: object, state: object|null) => void,
|
||||
* log?: Function,
|
||||
* }} deps
|
||||
*/
|
||||
export async function runAutopilot(run, deps) {
|
||||
const onProgress = deps.onProgress || (() => {});
|
||||
let lastKey = null;
|
||||
try {
|
||||
while (true) {
|
||||
if (run.cancelled) {
|
||||
run.status = "cancelled";
|
||||
break;
|
||||
}
|
||||
if (run.completed.length >= run.maxSteps) {
|
||||
run.status = "capped";
|
||||
break;
|
||||
}
|
||||
const state = await deps.snapshot();
|
||||
const step = selectNextStep(state);
|
||||
if (!step) {
|
||||
run.status = "completed";
|
||||
break;
|
||||
}
|
||||
// Phase scope: stop once the active phase advances past where we began.
|
||||
if (run.scope === "phase" && run.startRank != null && step.rank != null && step.rank > run.startRank) {
|
||||
run.status = "phase_done";
|
||||
break;
|
||||
}
|
||||
const key = stepKey(step);
|
||||
// Selecting the same step twice running means the previous attempt did
|
||||
// not check it off — the agent is stuck or waiting on a decision. Stop
|
||||
// and hand control back rather than loop on it.
|
||||
if (key === lastKey) {
|
||||
run.status = "stuck";
|
||||
run.stuck = step.title;
|
||||
break;
|
||||
}
|
||||
lastKey = key;
|
||||
|
||||
run.current = { title: step.title, section: step.section || null };
|
||||
onProgress(run, state);
|
||||
if (deps.log) deps.log("Autopilot → " + step.title, { ephemeral: true });
|
||||
|
||||
let stepError = null;
|
||||
try {
|
||||
await deps.runTurn(deps.buildStepPrompt(step));
|
||||
} catch (e) {
|
||||
stepError = (e && e.message) || String(e);
|
||||
}
|
||||
|
||||
const after = await deps.snapshot();
|
||||
const done = isStepDone(after, step);
|
||||
run.completed.push({
|
||||
title: step.title,
|
||||
section: step.section || null,
|
||||
done,
|
||||
error: stepError,
|
||||
at: Date.now(),
|
||||
});
|
||||
run.current = null;
|
||||
onProgress(run, after);
|
||||
|
||||
if (stepError) {
|
||||
run.status = "error";
|
||||
run.error = stepError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
run.status = "error";
|
||||
run.error = (e && e.message) || String(e);
|
||||
} finally {
|
||||
if (run.status === "running") run.status = "completed";
|
||||
run.running = false;
|
||||
run.finishedAt = Date.now();
|
||||
onProgress(run, null);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "appmod-cockpit",
|
||||
"name": "Java Modernization Studio",
|
||||
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Ayan Gupta",
|
||||
"url": "https://github.com/ayangupt"
|
||||
},
|
||||
"keywords": [
|
||||
"app-modernization",
|
||||
"assessment-dashboard",
|
||||
"azure-migration",
|
||||
"java-modernization",
|
||||
"legacy-java",
|
||||
"modernization-cockpit",
|
||||
"validation-gates"
|
||||
],
|
||||
"screenshots": {
|
||||
"icon": {
|
||||
"path": "assets/preview.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
"gallery": {
|
||||
"path": "assets/preview.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// catalog.mjs — Static catalog of GitHub Copilot App Modernization for Java
|
||||
// predefined tasks and supported Java upgrade paths. Sourced from Microsoft Learn:
|
||||
// /azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java-predefined-tasks
|
||||
// /azure/developer/github-copilot-app-modernization/tools
|
||||
//
|
||||
// `detect` lists dependency-signature keys (see scan.mjs DEP_SIGNATURES). When a
|
||||
// repo's build files match one of those keys, the task is flagged "relevant".
|
||||
|
||||
export const PREDEFINED_TASKS = [
|
||||
{
|
||||
id: "rabbitmq-to-servicebus",
|
||||
name: "RabbitMQ to Azure Service Bus",
|
||||
category: "Messaging",
|
||||
summary:
|
||||
"Convert RabbitMQ usage (Spring AMQP, Spring JMS, or Jakarta EE over AMQP) to Azure Service Bus, preserving messaging semantics with secure auth by default.",
|
||||
detect: ["rabbitmq"],
|
||||
},
|
||||
{
|
||||
id: "activemq-to-servicebus",
|
||||
name: "ActiveMQ to Azure Service Bus",
|
||||
category: "Messaging",
|
||||
summary:
|
||||
"Convert ActiveMQ producers, consumers, connection factories, and queue/topic interactions to Azure Service Bus equivalents.",
|
||||
detect: ["activemq"],
|
||||
},
|
||||
{
|
||||
id: "aws-sqs-to-servicebus",
|
||||
name: "AWS SQS to Azure Service Bus",
|
||||
category: "Messaging",
|
||||
summary:
|
||||
"Translate AWS SQS queue operations and message handling to Azure Service Bus, preserving at-least-once delivery, batching, and visibility-timeout behaviors.",
|
||||
detect: ["awsSqs"],
|
||||
},
|
||||
{
|
||||
id: "managed-identity-db",
|
||||
name: "Managed Identities for Database migration",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Prepare your codebase for Managed Identity authentication when moving from a local database to Azure SQL, MySQL, PostgreSQL, or Cosmos DB.",
|
||||
detect: ["jdbc"],
|
||||
},
|
||||
{
|
||||
id: "managed-identity-credentials",
|
||||
name: "Managed Identities for Credential Migration",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Replace connection strings / shared access signatures for messaging services (Event Hubs, Service Bus) with Azure Managed Identity authentication.",
|
||||
detect: ["rabbitmq", "activemq", "jms"],
|
||||
},
|
||||
{
|
||||
id: "secrets-to-keyvault",
|
||||
name: "Secrets & Certificate Management to Azure Key Vault",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Move hardcoded secrets and local TLS/mTLS certificates (Java KeyStores) to Azure Key Vault, retrieving them at runtime via the JCA provider.",
|
||||
detect: ["keystore"],
|
||||
},
|
||||
{
|
||||
id: "crypto-to-keyvault",
|
||||
name: "Cryptography operations to Azure Key Vault",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Migrate local signing, verification, encryption, and decryption to Azure Key Vault so keys never leave the vault.",
|
||||
detect: ["crypto"],
|
||||
},
|
||||
{
|
||||
id: "aws-secrets-to-keyvault",
|
||||
name: "AWS Secret Manager to Azure Key Vault",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Reconfigure secret creation, retrieval, update, and deletion from AWS Secret Manager to Azure Key Vault.",
|
||||
detect: ["awsSecrets"],
|
||||
},
|
||||
{
|
||||
id: "entra-id-auth",
|
||||
name: "User authentication to Microsoft Entra ID",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Transition LDAP-based user authentication to Microsoft Entra ID authentication.",
|
||||
detect: ["ldap"],
|
||||
},
|
||||
{
|
||||
id: "aws-s3-to-blob",
|
||||
name: "AWS S3 to Azure Storage Blob",
|
||||
category: "Storage",
|
||||
summary:
|
||||
"Convert code that interacts with AWS S3 into Azure Storage Blob logic while maintaining the same semantics.",
|
||||
detect: ["awsS3"],
|
||||
},
|
||||
{
|
||||
id: "file-io-to-fileshare",
|
||||
name: "Local file I/O to Azure Storage File share mounts",
|
||||
category: "Storage",
|
||||
summary:
|
||||
"Convert local file reads/writes to unified mount-path access so an Azure Storage File share can persist data across replicas.",
|
||||
detect: [],
|
||||
},
|
||||
{
|
||||
id: "logging-to-console",
|
||||
name: "Logging to local file → console (Azure Monitor)",
|
||||
category: "Observability",
|
||||
summary:
|
||||
"Convert file-based logging to console-based logging so Azure hosting integrates it with Azure Monitor automatically.",
|
||||
detect: ["filelog"],
|
||||
},
|
||||
{
|
||||
id: "javamail-to-acs",
|
||||
name: "Java Mail to Azure Communication Services",
|
||||
category: "Email",
|
||||
summary:
|
||||
"Convert SMTP-based mail sending to Azure Communication Services, which works in Azure hosting environments that block port 25.",
|
||||
detect: ["javamail"],
|
||||
},
|
||||
{
|
||||
id: "databases-to-azure",
|
||||
name: "On-prem databases to Azure database offerings",
|
||||
category: "Database",
|
||||
summary:
|
||||
"Migrate Oracle, IBM Db2, Informix, or Sybase ASE to Azure Database for PostgreSQL or Azure SQL with passwordless Entra ID auth, reconciling SQL dialect differences.",
|
||||
detect: ["oracle", "db2", "sybase", "informix"],
|
||||
},
|
||||
{
|
||||
id: "cache-to-redis",
|
||||
name: "Cache solutions to Azure Managed Redis",
|
||||
category: "Cache",
|
||||
summary:
|
||||
"Migrate in-memory or distributed caches (Infinispan, SwarmCache, Memcached, etc.) to Azure Managed Redis with passwordless Entra ID auth.",
|
||||
detect: ["cache"],
|
||||
},
|
||||
{
|
||||
id: "ant-eclipse-to-maven",
|
||||
name: "Ant / Eclipse project to Maven",
|
||||
category: "Build",
|
||||
summary:
|
||||
"Convert Ant or Eclipse IDE projects to Maven so the project builds consistently from any environment.",
|
||||
detect: ["ant"],
|
||||
},
|
||||
];
|
||||
|
||||
// Java runtime upgrade ladder targeted by App Modernization (8 → 11 → 17 → 21 → 25),
|
||||
// with particular focus on Spring Boot apps.
|
||||
export const JAVA_UPGRADE_TARGETS = [11, 17, 21, 25];
|
||||
|
||||
// Day-to-day Java utilities you can invoke in Copilot Chat with the `#` prefix.
|
||||
export const APPMOD_TOOLS = {
|
||||
cve: {
|
||||
id: "appmod-validate-cves-for-java",
|
||||
label: "Validate CVEs",
|
||||
summary:
|
||||
"Scan the project for known Java CVEs and validate that critical vulnerabilities are addressed.",
|
||||
},
|
||||
tests: {
|
||||
id: "appmod-generate-tests-for-java",
|
||||
label: "Generate unit tests",
|
||||
summary: "Use AI code understanding to generate unit tests for the Java code.",
|
||||
},
|
||||
};
|
||||
|
||||
// The five validation gates App Modernization runs after code transformation.
|
||||
export const VALIDATION_GATES = [
|
||||
{ key: "build", label: "Build" },
|
||||
{ key: "tests", label: "Unit Tests" },
|
||||
{ key: "cve", label: "CVE Check" },
|
||||
{ key: "consistency", label: "Consistency" },
|
||||
{ key: "completeness", label: "Completeness" },
|
||||
];
|
||||
|
||||
/** Return the catalog with a `relevant` flag set per task based on detected deps. */
|
||||
export function catalogWithRelevance(detectedKeys) {
|
||||
const set = new Set(detectedKeys || []);
|
||||
return PREDEFINED_TASKS.map((t) => ({
|
||||
...t,
|
||||
relevant: t.detect.some((d) => set.has(d)),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// extension.mjs — Java Modernization Studio (user-scoped canvas). Wiring only.
|
||||
//
|
||||
// Declares the canvas and bridges the SDK to the testable modules:
|
||||
// scan.mjs repo + markdown parsing -> grounded state
|
||||
// catalog.mjs Microsoft predefined task catalog
|
||||
// prompts.mjs action -> crafted agent prompt
|
||||
// server.mjs per-instance loopback HTTP server + action dispatch
|
||||
// renderer.mjs cockpit UI
|
||||
//
|
||||
// Button clicks POST /action; agent actions are forwarded to session.send. When a
|
||||
// turn finishes (session.idle) every open cockpit re-scans and pushes fresh state.
|
||||
|
||||
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
|
||||
import { basename } from "node:path";
|
||||
import { scanRepo } from "./scan.mjs";
|
||||
import { createInstanceServer, pushState, resolveRepoPath } from "./server.mjs";
|
||||
import { AUTOPILOT_TURN_TIMEOUT_MS } from "./autopilot.mjs";
|
||||
|
||||
const instances = new Map(); // instanceId -> rec { server, url, repoPath, sseClients }
|
||||
let sessionRef = null;
|
||||
let lastWorkingDir = null;
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [
|
||||
createCanvas({
|
||||
id: "appmod-cockpit",
|
||||
displayName: "Java Modernization Studio",
|
||||
description:
|
||||
"Drive the GitHub Copilot App Modernization for Java workflow: assessment, plan/progress, validation gates, and task/skill runs grounded in the repo's real artifacts.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
repoPath: { type: "string", description: "Absolute path to the Java repo to inspect." },
|
||||
initialTab: { type: "string", enum: ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"] },
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
name: "get_state",
|
||||
description:
|
||||
"Return the current modernization state snapshot scanned from the repo (assessment, plan/progress, gates, skills, tasks).",
|
||||
handler: async (ctx) => {
|
||||
const rec = instances.get(ctx.instanceId);
|
||||
const repoPath = rec ? rec.repoPath : resolveRepoPath(ctx, lastWorkingDir);
|
||||
return await scanRepo(repoPath);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refresh",
|
||||
description: "Re-scan the repo and push a fresh snapshot to the open cockpit.",
|
||||
handler: async (ctx) => {
|
||||
const rec = instances.get(ctx.instanceId);
|
||||
if (!rec) throw new CanvasError("not_open", "Cockpit instance is not open.");
|
||||
await pushState(rec, logFn);
|
||||
return { ok: true, repoPath: rec.repoPath };
|
||||
},
|
||||
},
|
||||
],
|
||||
open: async (ctx) => {
|
||||
const repoPath = resolveRepoPath(ctx, lastWorkingDir);
|
||||
if (repoPath) lastWorkingDir = repoPath; // never clobber a good target with null
|
||||
const initialTab = ctx.input && ctx.input.initialTab ? ctx.input.initialTab : "overview";
|
||||
let rec = instances.get(ctx.instanceId);
|
||||
if (!rec) {
|
||||
rec = await createInstanceServer({
|
||||
instanceId: ctx.instanceId,
|
||||
repoPath,
|
||||
initialTab,
|
||||
sendPrompt: (prompt) => sessionRef.send({ prompt }),
|
||||
runTurn: (prompt) => sessionRef.sendAndWait({ prompt }, AUTOPILOT_TURN_TIMEOUT_MS),
|
||||
log: logFn,
|
||||
});
|
||||
instances.set(ctx.instanceId, rec);
|
||||
} else if (rec.repoPath !== repoPath) {
|
||||
rec.repoPath = repoPath; // refresh target on re-open
|
||||
rec.doctor = null; // env readiness depends on repo facts — recompute for the new target
|
||||
}
|
||||
return {
|
||||
title: "Java Modernization Studio",
|
||||
status: repoPath ? basename(repoPath) : "no repo",
|
||||
url: rec.url,
|
||||
};
|
||||
},
|
||||
onClose: async (ctx) => {
|
||||
const rec = instances.get(ctx.instanceId);
|
||||
if (rec) {
|
||||
instances.delete(ctx.instanceId);
|
||||
for (const res of rec.sseClients) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => rec.server.close(() => resolve()));
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sessionRef = session;
|
||||
function logFn(message, opts) {
|
||||
try {
|
||||
return sessionRef.log(message, opts);
|
||||
} catch {
|
||||
/* logging is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
if (session.workspacePath && !lastWorkingDir) lastWorkingDir = session.workspacePath;
|
||||
|
||||
// A finished turn may have changed plan.md/progress.md/summary.md or the working
|
||||
// tree — re-scan and push fresh state to every open cockpit.
|
||||
session.on("session.idle", async () => {
|
||||
for (const rec of instances.values()) {
|
||||
await pushState(rec, logFn);
|
||||
}
|
||||
});
|
||||
|
||||
await session.log("Java Modernization Studio ready", { ephemeral: true });
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "java-modernization-studio",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"author": {
|
||||
"name": "Ayan Gupta",
|
||||
"url": "https://github.com/ayangupt"
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.4"
|
||||
},
|
||||
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
|
||||
"keywords": [
|
||||
"java-modernization",
|
||||
"app-modernization",
|
||||
"azure-migration",
|
||||
"legacy-java",
|
||||
"assessment-dashboard",
|
||||
"validation-gates",
|
||||
"modernization-cockpit"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// prompts.mjs — Maps each cockpit button action to a crafted agent instruction.
|
||||
// Pure (no I/O, no session) so it can be unit-tested in isolation.
|
||||
|
||||
import { PREDEFINED_TASKS } from "./catalog.mjs";
|
||||
|
||||
export const APPMOD_PREAMBLE =
|
||||
"You are running the GitHub Copilot App Modernization for Java workflow. " +
|
||||
"Keep plan.md and progress.md up to date as a checklist (use - [ ] / - [x]) and, when the work is complete, write summary.md. " +
|
||||
"Start each of these files (plan.md, progress.md, summary.md) with the exact line <!-- appmod-cockpit --> as the very first line so the cockpit recognizes them as modernization artifacts.";
|
||||
|
||||
export const ACTION_LABELS = {
|
||||
start_assessment: "Assessment",
|
||||
generate_plan: "Generate plan",
|
||||
run_task: "Run task",
|
||||
run_skill: "Run skill",
|
||||
run_cve: "CVE scan",
|
||||
generate_tests: "Generate tests",
|
||||
run_build_tests: "Build & tests",
|
||||
run_consistency: "Consistency check",
|
||||
run_completeness: "Completeness check",
|
||||
create_skill: "Create skill",
|
||||
open_pr: "Open PR",
|
||||
fix_finding: "Resolve finding",
|
||||
work_step: "Work on step",
|
||||
fix_env: "Environment setup",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the agent prompt for a cockpit action.
|
||||
* @returns {string|null} prompt text, or null for unknown actions / unknown task ids.
|
||||
*/
|
||||
export function buildPrompt(kind, payload, repoPath) {
|
||||
const p = payload || {};
|
||||
switch (kind) {
|
||||
case "start_assessment":
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nRun an App Modernization assessment of this Java project at " + repoPath + ". " +
|
||||
"Report the current Java runtime, build tool, framework versions, outdated/vulnerable dependencies, " +
|
||||
"and cloud-readiness issues for Azure. Summarize findings and write a prioritized plan.md of remediation steps. Do not change code yet." +
|
||||
"\n\nAlso write the findings as structured JSON to .appmod/assessment.json so the cockpit can render them. Use this shape:\n" +
|
||||
"{\n" +
|
||||
' "generatedAt": "<ISO 8601 timestamp>",\n' +
|
||||
' "headline": "<one-line stack summary>",\n' +
|
||||
' "summary": "<2-3 sentence plain-language verdict>",\n' +
|
||||
' "stack": { "buildTool": "", "java": "", "framework": "", "database": "", "container": "" },\n' +
|
||||
' "findings": [\n' +
|
||||
' { "id": "kebab-id", "severity": "P0|P1|P2|P3", "title": "", "detail": "", "files": ["path"],\n' +
|
||||
' "action": { "kind": "run_task|generate_plan|run_cve|generate_tests|fix_finding", "payload": {}, "label": "" } }\n' +
|
||||
" ],\n" +
|
||||
' "strengths": ["<things already done well>"]\n' +
|
||||
"}\n" +
|
||||
"Order findings by severity (P0 first). For action.kind, use run_task with payload {\"taskId\":\"...\"} when a Microsoft predefined task fits, " +
|
||||
"generate_plan/run_cve/generate_tests when those fit, otherwise use fix_finding (the cockpit turns it into a 'Help me fix this' button). Omit action only if there is genuinely nothing to do."
|
||||
);
|
||||
case "generate_plan":
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nCreate a step-by-step modernization plan to upgrade this project to Java " + (p.targetJava || 21) + ". " +
|
||||
"Cover build-file changes, removed/replaced APIs, dependency upgrades, and required test updates. " +
|
||||
"Write the plan as a checklist in plan.md and create an empty progress.md mirroring those steps. Do not change code yet."
|
||||
);
|
||||
case "run_task": {
|
||||
const t = PREDEFINED_TASKS.find((x) => x.id === p.taskId);
|
||||
if (!t) return null;
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nApply the App Modernization predefined task \"" + t.name + "\": " + t.summary + "\n" +
|
||||
"Steps: (1) record the plan in plan.md and progress.md; (2) check out a migration branch; " +
|
||||
"(3) make the code changes for this task; (4) validate by building, running unit tests, and a CVE check; " +
|
||||
"(5) write a summary.md of what changed. Pause for my confirmation before committing."
|
||||
);
|
||||
}
|
||||
case "run_skill":
|
||||
if (!p.folder) return null;
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nRun the custom modernization skill in .github/skills/" + p.folder + "/SKILL.md. " +
|
||||
"Follow its instructions and referenced resources exactly, updating plan.md and progress.md as you go, " +
|
||||
"and write summary.md when finished."
|
||||
);
|
||||
case "run_cve":
|
||||
return "Check this Java project for known CVE issues using #appmod-validate-cves-for-java and report which dependencies need upgrading, then update progress.md.";
|
||||
case "generate_tests":
|
||||
return "Generate unit tests for this Java project using #appmod-generate-tests-for-java, then run them and record the result in progress.md.";
|
||||
case "run_build_tests":
|
||||
return "Build this project and run its unit tests. Report failures with root-cause analysis and update the Build and Unit Tests entries in progress.md.";
|
||||
case "run_consistency":
|
||||
return "Run a consistency check comparing the modernized code against the original behavior (APIs, SQL, wire format). List any behavioral differences and update progress.md.";
|
||||
case "run_completeness":
|
||||
return "Run a completeness check: verify every step in plan.md has been implemented. List anything missing and update progress.md.";
|
||||
case "create_skill":
|
||||
return (
|
||||
"Help me create a custom App Modernization skill. Create .github/skills/<skill-name>/SKILL.md with Skill Name, " +
|
||||
"Description, and Content sections following the Agent Skills specification, plus any resource files it references. Ask me what migration this skill should capture."
|
||||
);
|
||||
case "open_pr":
|
||||
return "Summarize the modernization changes on the current branch and open a pull request with a clear title and a body that lists the plan steps completed and validation results.";
|
||||
case "work_step": {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) return null;
|
||||
const phase = p.section ? " (phase: " + p.section + ")" : "";
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nWork on this single step from the modernization checklist" + phase + ":\n\"" + title + "\"\n\n" +
|
||||
"Do just this step: tell me what you'll change, make the code/config changes, then check it off in progress.md " +
|
||||
"(change its - [ ] to - [x]) and add a one-line note of what changed. If the step needs a decision from me or is " +
|
||||
"ambiguous, ask before proceeding. Pause for my confirmation before committing."
|
||||
);
|
||||
}
|
||||
case "auto_step": {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) return null;
|
||||
const phase = p.section ? " (phase: " + p.section + ")" : "";
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nYou are running in AUTOPILOT, working the modernization checklist on your own. " +
|
||||
"Do exactly ONE step now" + phase + ":\n\"" + title + "\"\n\n" +
|
||||
"For this single step: briefly state what you will change, make the necessary code/config changes, " +
|
||||
"then check it off in progress.md (change its - [ ] to - [x]) and add a one-line note of what changed. " +
|
||||
"Do NOT commit, and do NOT start any other step. " +
|
||||
"If this step genuinely needs a decision from me, or is blocked or ambiguous, do NOT guess: leave it unchecked, " +
|
||||
"add a short note in progress.md describing exactly what you need, and stop so I can take over."
|
||||
);
|
||||
}
|
||||
case "fix_finding": {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) return null;
|
||||
const detail = (p.detail || "").trim();
|
||||
const files = Array.isArray(p.files) && p.files.length ? "\nAffected files: " + p.files.join(", ") + "." : "";
|
||||
const sev = p.severity ? " (" + p.severity + ")" : "";
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nHelp me resolve this App Modernization assessment finding" + sev + ":\n" +
|
||||
title + (detail ? " — " + detail : "") + "." + files +
|
||||
"\n\nExplain the fix, make the change, and update the matching checklist items in plan.md and progress.md. " +
|
||||
"Pause for my confirmation before committing."
|
||||
);
|
||||
}
|
||||
case "fix_env": {
|
||||
const tool = (p.tool || "").trim();
|
||||
if (!tool) return null;
|
||||
const detail = p.detail ? "\nWhat the readiness check found: " + p.detail + "." : "";
|
||||
const fix = p.fix ? "\nSuggested fix: " + p.fix : "";
|
||||
return (
|
||||
"I'm preparing my local environment for the GitHub Copilot App Modernization for Java workflow.\n" +
|
||||
"The readiness check flagged a missing or misconfigured tool: " + tool + "." + detail + fix +
|
||||
"\n\nWalk me through installing and configuring " + tool + " on my machine, including the exact commands for my OS " +
|
||||
"and how to verify it's on PATH at the right version for this project. This is environment setup only — do not modify my application code."
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
// scan.mjs — Grounds the cockpit in the real repository. Reads App Modernization
|
||||
// artifacts (plan.md / progress.md / summary.md), discovers custom skills under
|
||||
// .github/skills/, extracts assessment facts from Maven/Gradle build files, and
|
||||
// reports git branch state. Everything degrades gracefully when files are absent.
|
||||
|
||||
import { readFile, readdir, stat, open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { catalogWithRelevance, VALIDATION_GATES } from "./catalog.mjs";
|
||||
|
||||
// Dependency signatures: substring(s) searched in build files -> catalog `detect` key.
|
||||
const DEP_SIGNATURES = {
|
||||
rabbitmq: ["rabbitmq", "spring-rabbit", "amqp-client", "spring-amqp", "starter-amqp", "amqp"],
|
||||
activemq: ["activemq"],
|
||||
jms: ["javax.jms", "jakarta.jms", "spring-jms"],
|
||||
awsS3: ["aws-java-sdk-s3", "s3", "software.amazon.awssdk"],
|
||||
awsSqs: ["aws-java-sdk-sqs", "sqs"],
|
||||
awsSecrets: ["secretsmanager"],
|
||||
javamail: ["javax.mail", "jakarta.mail", "com.sun.mail", "spring-boot-starter-mail"],
|
||||
ldap: ["spring-ldap", "spring-security-ldap", "unboundid-ldapsdk", "ldaptive"],
|
||||
cache: ["infinispan", "swarmcache", "memcached", "spymemcached", "ehcache"],
|
||||
oracle: ["ojdbc", "oracle"],
|
||||
db2: ["db2jcc", "com.ibm.db2"],
|
||||
sybase: ["jconn", "sybase"],
|
||||
informix: ["informix"],
|
||||
jdbc: ["jdbc", "hikari", "mysql", "postgresql", "mssql-jdbc", "sqlserver"],
|
||||
keystore: ["keystore", ".jks", "javax.net.ssl"],
|
||||
crypto: ["javax.crypto", "java.security", "bouncycastle", "bcprov"],
|
||||
filelog: ["fileappender", "rollingfileappender", "logback", "log4j"],
|
||||
};
|
||||
|
||||
// Cap how much of any single file we read. Build files and modernization docs are
|
||||
// tiny; an unbounded read on a pathological repo (a giant generated XML, a vendored
|
||||
// blob) would waste memory for no parsing benefit. Truncating is safe — every
|
||||
// parser here scans for early markers/coordinates.
|
||||
const MAX_READ_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function execGit(args, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
execFile("git", args, { cwd, timeout: 4000 }, (err, stdout) => {
|
||||
if (err) resolve(null);
|
||||
else resolve(String(stdout).trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readText(path) {
|
||||
try {
|
||||
const st = await stat(path);
|
||||
if (!st.isFile()) return null;
|
||||
if (st.size > MAX_READ_BYTES) {
|
||||
const fh = await open(path, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const { bytesRead } = await fh.read(buf, 0, MAX_READ_BYTES, 0);
|
||||
return buf.toString("utf8", 0, bytesRead);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isDirectory(path) {
|
||||
try {
|
||||
return (await stat(path)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit provenance marker the cockpit writes at the top of artifacts it owns,
|
||||
// so a root plan/progress/summary self-identifies as modernization state even
|
||||
// when there is no .appmod/ directory yet.
|
||||
const APPMOD_MARKER_RE = /<!--\s*appmod-cockpit\b/i;
|
||||
function hasMarker(md) {
|
||||
return typeof md === "string" && APPMOD_MARKER_RE.test(md.slice(0, 2000));
|
||||
}
|
||||
|
||||
// The assessment.json is written by an LLM step, so its shape can drift: severities
|
||||
// may be lowercase or word-form, `files` may be a bare string, findings may be
|
||||
// missing, and the top level might not even be an object. Normalize defensively so
|
||||
// the renderer always gets P0–P3 severities and array `files`, and nothing is
|
||||
// silently dropped or used to build an invalid CSS class.
|
||||
const SEVERITY_ALIASES = {
|
||||
p0: "P0", p1: "P1", p2: "P2", p3: "P3",
|
||||
critical: "P0", blocker: "P0", severe: "P0",
|
||||
high: "P1", major: "P1",
|
||||
medium: "P2", moderate: "P2", warning: "P2", warn: "P2",
|
||||
low: "P3", minor: "P3", info: "P3", informational: "P3", note: "P3",
|
||||
};
|
||||
function normalizeSeverity(sev) {
|
||||
if (typeof sev !== "string") return "P3";
|
||||
return SEVERITY_ALIASES[sev.trim().toLowerCase()] || "P3";
|
||||
}
|
||||
function normalizeFinding(f, idx) {
|
||||
if (!f || typeof f !== "object" || Array.isArray(f)) return null;
|
||||
let files = [];
|
||||
if (typeof f.files === "string") files = f.files ? [f.files] : [];
|
||||
else if (Array.isArray(f.files)) files = f.files.filter((x) => typeof x === "string");
|
||||
const title =
|
||||
(typeof f.title === "string" && f.title.trim()) ||
|
||||
(typeof f.id === "string" && f.id.trim()) ||
|
||||
"Untitled finding";
|
||||
const out = {
|
||||
id: typeof f.id === "string" && f.id ? f.id : "finding-" + (idx + 1),
|
||||
severity: normalizeSeverity(f.severity),
|
||||
title,
|
||||
detail: typeof f.detail === "string" ? f.detail : "",
|
||||
files,
|
||||
};
|
||||
if (f.action && typeof f.action === "object" && !Array.isArray(f.action)) out.action = f.action;
|
||||
return out;
|
||||
}
|
||||
function normalizeReport(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const findingsIn = Array.isArray(raw.findings) ? raw.findings : [];
|
||||
const findings = [];
|
||||
findingsIn.forEach((f, i) => {
|
||||
const n = normalizeFinding(f, i);
|
||||
if (n) findings.push(n);
|
||||
});
|
||||
const out = { ...raw, findings };
|
||||
if (raw.strengths != null && !Array.isArray(raw.strengths)) {
|
||||
out.strengths = typeof raw.strengths === "string" && raw.strengths ? [raw.strengths] : [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripMd(s) {
|
||||
return String(s)
|
||||
.replace(/[*_`~]+/g, "")
|
||||
.replace(/^\s*#+\s*/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Infer a status from inline markers / emojis in a line of text.
|
||||
function statusFromText(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (/[✅✔]|\bdone\b|\bcompleted?\b|\bpassed?\b/.test(t)) return "done";
|
||||
if (/[❌✖]|\bfailed?\b|\berror\b|\bblocked\b/.test(t)) return "failed";
|
||||
if (/[⏳🔄]|in progress|in-progress|ongoing|running|wip/.test(t)) return "in_progress";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse GitHub-style task list items: "- [ ] ...", "- [x] ...", "- [/] ...".
|
||||
// Each item also carries the nearest preceding heading as its `section`.
|
||||
function parseChecklist(md) {
|
||||
const items = [];
|
||||
let section = null;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||||
if (h) { section = stripMd(h[1]); continue; }
|
||||
const m = line.match(/^\s*[-*]\s+\[([ xX/~\-])\]\s+(.*\S)\s*$/);
|
||||
if (!m) continue;
|
||||
const mark = m[1].toLowerCase();
|
||||
let status = "pending";
|
||||
if (mark === "x") status = "done";
|
||||
else if (mark === "/" || mark === "~" || mark === "-") status = "in_progress";
|
||||
const inline = statusFromText(m[2]);
|
||||
if (inline) status = inline;
|
||||
items.push({ title: stripMd(m[2]), status, section });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Fallbacks when there are no checkbox items: numbered steps, then H2/H3 headings.
|
||||
function parseLooseSteps(md) {
|
||||
const numbered = [];
|
||||
let section = null;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||||
if (h) { section = stripMd(h[1]); continue; }
|
||||
const m = line.match(/^\s*\d+[.)]\s+(.*\S)\s*$/);
|
||||
if (m) numbered.push({ title: stripMd(m[1]), status: statusFromText(m[1]) || "pending", section });
|
||||
}
|
||||
if (numbered.length) return numbered;
|
||||
|
||||
const headings = [];
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*#{2,4}\s+(.*\S)\s*$/);
|
||||
if (m) headings.push({ title: stripMd(m[1]), status: statusFromText(m[1]) || "pending", section: null });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function parseSteps(md) {
|
||||
if (!md) return [];
|
||||
const checklist = parseChecklist(md);
|
||||
return checklist.length ? checklist : parseLooseSteps(md);
|
||||
}
|
||||
|
||||
// Return the steps under the first heading matching `headingRe`, up to the next
|
||||
// heading, or null if there is no such section. Used to scope the validation
|
||||
// gates to an explicit "Validation gates" section so unrelated checklist items
|
||||
// that merely mention "build"/"vulnerable"/etc. don't hijack a gate's status.
|
||||
function sectionSteps(md, headingRe) {
|
||||
if (!md) return null;
|
||||
let inSection = false;
|
||||
const buf = [];
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||||
if (h) {
|
||||
if (inSection) break; // next heading closes the section
|
||||
if (headingRe.test(h[1])) inSection = true;
|
||||
continue;
|
||||
}
|
||||
if (inSection) buf.push(line);
|
||||
}
|
||||
if (!inSection) return null;
|
||||
const steps = parseSteps(buf.join("\n"));
|
||||
return steps.length ? steps : null;
|
||||
}
|
||||
|
||||
function percentDone(steps) {
|
||||
if (!steps.length) return 0;
|
||||
let score = 0;
|
||||
for (const s of steps) {
|
||||
if (s.status === "done") score += 1;
|
||||
else if (s.status === "in_progress") score += 0.5;
|
||||
}
|
||||
return Math.round((score / steps.length) * 100);
|
||||
}
|
||||
|
||||
// Rank a phase/section heading into the canonical modernization order so the UI
|
||||
// can recommend (and gently gate) the sequence: assessment -> P0 -> P1 -> P2 ->
|
||||
// P3 -> validation. Unknown sections return null (never gated).
|
||||
function phaseRankFromName(name) {
|
||||
if (!name) return null;
|
||||
const n = String(name).toLowerCase();
|
||||
if (/assessment/.test(n)) return 0;
|
||||
const m = n.match(/\bp([0-3])\b/);
|
||||
if (m) return 1 + Number(m[1]);
|
||||
if (/validation|gates|sign-?off/.test(n)) return 5;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Summarize phase ordering from the checklist: which phase is the earliest one
|
||||
// with unfinished work (the "active" phase the user should be in), plus a
|
||||
// per-phase done/total roll-up. Steps in phases ranked after `activeRank` are
|
||||
// "ahead of sequence" and the UI flags them so users don't jump the gun.
|
||||
function computeOrdering(steps) {
|
||||
const phases = [];
|
||||
const byKey = {};
|
||||
for (const st of steps) {
|
||||
const key = st.section || "Steps";
|
||||
if (!byKey[key]) {
|
||||
byKey[key] = { name: key, rank: st.rank == null ? null : st.rank, total: 0, done: 0 };
|
||||
phases.push(byKey[key]);
|
||||
}
|
||||
byKey[key].total++;
|
||||
if (st.status === "done") byKey[key].done++;
|
||||
}
|
||||
let activeRank = null;
|
||||
let activePhase = null;
|
||||
for (const st of steps) {
|
||||
if (st.status !== "done" && st.rank != null) {
|
||||
if (activeRank == null || st.rank < activeRank) {
|
||||
activeRank = st.rank;
|
||||
activePhase = st.section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { activeRank, activePhase, phases };
|
||||
}
|
||||
|
||||
// Derive the five validation gates from progress/summary step titles.
|
||||
function deriveGates(steps, summaryText) {
|
||||
const patterns = {
|
||||
build: /\bbuild\b|compil/i,
|
||||
tests: /unit test|\btests?\b|junit/i,
|
||||
cve: /\bcve\b|vulnerab|security scan/i,
|
||||
consistency: /consistenc/i,
|
||||
completeness: /completenes/i,
|
||||
};
|
||||
const gates = {};
|
||||
for (const g of VALIDATION_GATES) {
|
||||
let status = "not_run";
|
||||
for (const s of steps) {
|
||||
if (patterns[g.key].test(s.title)) {
|
||||
status = s.status; // done | in_progress | failed | pending
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (status === "not_run" && summaryText) {
|
||||
const line = summaryText
|
||||
.split(/\r?\n/)
|
||||
.find((l) => patterns[g.key].test(l));
|
||||
if (line) status = statusFromText(line) || "in_progress";
|
||||
}
|
||||
gates[g.key] = status;
|
||||
}
|
||||
return gates;
|
||||
}
|
||||
|
||||
async function discoverSkills(repoPath) {
|
||||
const skillsDir = join(repoPath, ".github", "skills");
|
||||
const out = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(skillsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const md = await readText(join(skillsDir, e.name, "SKILL.md"));
|
||||
let name = e.name;
|
||||
let description = "";
|
||||
if (md) {
|
||||
const nameM = md.match(/(?:^|\n)#+\s*(.+)|name:\s*(.+)/i);
|
||||
if (nameM) name = stripMd(nameM[1] || nameM[2] || e.name);
|
||||
const descM = md.match(/description:\s*(.+)/i);
|
||||
if (descM) description = stripMd(descM[1]);
|
||||
}
|
||||
out.push({ folder: e.name, name, description, hasSkillMd: !!md });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectJavaVersion(pom, gradle) {
|
||||
const text = `${pom || ""}\n${gradle || ""}`;
|
||||
const grab = (re) => {
|
||||
const m = text.match(re);
|
||||
return m ? m[1].replace(/^1\./, "") : null;
|
||||
};
|
||||
return (
|
||||
grab(/<maven\.compiler\.release>\s*([\d.]+)\s*</) ||
|
||||
grab(/<maven\.compiler\.source>\s*([\d.]+)\s*</) ||
|
||||
grab(/<java\.version>\s*([\d.]+)\s*</) ||
|
||||
grab(/<source>\s*([\d.]+)\s*</) ||
|
||||
grab(/<release>\s*([\d.]+)\s*</) ||
|
||||
grab(/sourceCompatibility\s*=?\s*['"]?(?:JavaVersion\.VERSION_)?([\d.]+)/) ||
|
||||
grab(/languageVersion[^)]*?(\d+)/) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Match a dependency signature against build text. Plain substring matching makes
|
||||
// short alpha tokens dangerous ("ant" used to hit "important/constant", and a bare
|
||||
// token could land inside an unrelated word). We instead require the signature to
|
||||
// begin a *token* — i.e. be preceded by a word boundary — which rejects mid-word
|
||||
// matches while still allowing versioned/suffixed coordinates (ojdbc -> "ojdbc8",
|
||||
// hikari -> "hikaricp", s3 -> "...-s3"). Signatures that intentionally start with a
|
||||
// separator (e.g. ".jks") are matched as plain substrings.
|
||||
function signatureMatches(text, sig) {
|
||||
const s = sig.toLowerCase();
|
||||
if (!s) return false;
|
||||
const alnum = (c) => (c >= "a" && c <= "z") || (c >= "0" && c <= "9");
|
||||
if (!alnum(s[0])) return text.includes(s);
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const i = text.indexOf(s, from);
|
||||
if (i < 0) return false;
|
||||
const before = i === 0 ? "" : text[i - 1];
|
||||
if (before === "" || !alnum(before)) return true; // signature begins a token
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function detectDependencies(buildText) {
|
||||
const text = (buildText || "").toLowerCase();
|
||||
const keys = [];
|
||||
for (const [key, sigs] of Object.entries(DEP_SIGNATURES)) {
|
||||
if (sigs.some((s) => signatureMatches(text, s))) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const DOCKERFILE_RE = /^(dockerfile|containerfile)(\.[\w.-]+)?$/i;
|
||||
|
||||
async function dirHasDockerfile(dir) {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
return entries.some((e) => (e.isFile() || e.isSymbolicLink()) && DOCKERFILE_RE.test(e.name));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findDockerfile(repoPath) {
|
||||
// Case-insensitive, and also accept Containerfile (Podman) / Dockerfile.<tag>.
|
||||
if (await dirHasDockerfile(repoPath)) return true;
|
||||
try {
|
||||
const entries = await readdir(repoPath, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && !e.name.startsWith(".")) {
|
||||
if (await dirHasDockerfile(join(repoPath, e.name))) return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Directories we never descend into when unioning module build files.
|
||||
const SKIP_DIRS = new Set(["node_modules", "target", "build", "dist", "out", "bin", ".git"]);
|
||||
|
||||
async function buildAssessment(repoPath) {
|
||||
const rootPom = await readText(join(repoPath, "pom.xml"));
|
||||
let rootGradle = await readText(join(repoPath, "build.gradle"));
|
||||
if (!rootGradle) rootGradle = await readText(join(repoPath, "build.gradle.kts"));
|
||||
const antBuild = await readText(join(repoPath, "build.xml"));
|
||||
|
||||
// Union one level of modules so multi-module Maven, Gradle subprojects, and
|
||||
// monorepos report the real Java version / dependencies — not just whatever
|
||||
// the (often dependency-free) aggregator root pom declares.
|
||||
const childPoms = [];
|
||||
const childGradles = [];
|
||||
try {
|
||||
const entries = await readdir(repoPath, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
||||
const cp = await readText(join(repoPath, e.name, "pom.xml"));
|
||||
if (cp) childPoms.push(cp);
|
||||
let cg = await readText(join(repoPath, e.name, "build.gradle"));
|
||||
if (!cg) cg = await readText(join(repoPath, e.name, "build.gradle.kts"));
|
||||
if (cg) childGradles.push(cg);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const allPom = [rootPom, ...childPoms].filter(Boolean).join("\n");
|
||||
const allGradle = [rootGradle, ...childGradles].filter(Boolean).join("\n");
|
||||
const buildText = `${allPom}\n${allGradle}\n${antBuild || ""}`;
|
||||
const hasMaven = !!(rootPom || childPoms.length);
|
||||
const hasGradle = !!(rootGradle || childGradles.length);
|
||||
const buildTool = hasMaven ? "Maven" : hasGradle ? "Gradle" : antBuild ? "Ant" : null;
|
||||
const springBoot = /spring-boot/i.test(buildText);
|
||||
const springVersion = (() => {
|
||||
const m = buildText.match(/spring-boot[^>]*?<version>\s*([\d.]+[\w.-]*)\s*</i);
|
||||
return m ? m[1] : null;
|
||||
})();
|
||||
|
||||
const detectedKeys = detectDependencies(buildText);
|
||||
// Ant is identified by its build file, not a content substring (avoids the
|
||||
// old "ant" false positives), so flag it from the resolved build tool.
|
||||
if (buildTool === "Ant" && !detectedKeys.includes("ant")) detectedKeys.push("ant");
|
||||
|
||||
return {
|
||||
buildTool,
|
||||
javaVersion: detectJavaVersion(allPom, allGradle),
|
||||
springBoot,
|
||||
springVersion,
|
||||
hasDockerfile: await findDockerfile(repoPath),
|
||||
hasMavenWrapper:
|
||||
(await exists(join(repoPath, "mvnw"))) || (await exists(join(repoPath, "mvnw.cmd"))),
|
||||
hasGradleWrapper:
|
||||
(await exists(join(repoPath, "gradlew"))) || (await exists(join(repoPath, "gradlew.bat"))),
|
||||
detectedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a repo and produce the full cockpit state snapshot.
|
||||
* @param {string} repoPath absolute path to the repo working directory
|
||||
* @param {{ includeGit?: boolean }} [opts]
|
||||
*/
|
||||
export async function scanRepo(repoPath, opts = {}) {
|
||||
const includeGit = opts.includeGit !== false;
|
||||
if (!repoPath || !(await isDirectory(repoPath))) {
|
||||
return { ok: false, repoPath: repoPath || null, error: "repo path not available" };
|
||||
}
|
||||
|
||||
// Modernization docs. Prefer the namespaced .appmod/ copies; the root-level
|
||||
// files (plan.md / progress.md / summary.md) use very common filenames, so we
|
||||
// trust them as workflow state only when the provenance gate below passes.
|
||||
const [apPlan, apProgress, apSummary, rootPlan, rootProgress, rootSummary] = await Promise.all([
|
||||
readText(join(repoPath, ".appmod", "plan.md")),
|
||||
readText(join(repoPath, ".appmod", "progress.md")),
|
||||
readText(join(repoPath, ".appmod", "summary.md")),
|
||||
readText(join(repoPath, "plan.md")),
|
||||
readText(join(repoPath, "progress.md")),
|
||||
readText(join(repoPath, "summary.md")),
|
||||
]);
|
||||
|
||||
// Structured assessment report (written by the assessment step). Optional,
|
||||
// and the strongest provenance signal that this repo is running the workflow.
|
||||
let report = null;
|
||||
const reportRaw = await readText(join(repoPath, ".appmod", "assessment.json"));
|
||||
if (reportRaw) {
|
||||
try {
|
||||
report = normalizeReport(JSON.parse(reportRaw));
|
||||
} catch {
|
||||
report = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Provenance gate. A .appmod/ artifact (assessment.json or a namespaced doc)
|
||||
// means the modernization workflow is active in this repo, so the root docs
|
||||
// are ours. Otherwise a root doc is trusted only if it carries the explicit
|
||||
// <!-- appmod-cockpit --> marker. This stops an unrelated summary.md from
|
||||
// flipping a foreign repo to "completed", or a stray plan.md from injecting
|
||||
// fake steps into the progress/ordering/autopilot machinery.
|
||||
const hasAppmodArtifact =
|
||||
reportRaw != null || apPlan != null || apProgress != null || apSummary != null;
|
||||
const trusted = (root) => hasAppmodArtifact || hasMarker(root);
|
||||
const planMd = apPlan || (trusted(rootPlan) ? rootPlan : null);
|
||||
const progressMd = apProgress || (trusted(rootProgress) ? rootProgress : null);
|
||||
const summaryMd = apSummary || (trusted(rootSummary) ? rootSummary : null);
|
||||
|
||||
const assessment = await buildAssessment(repoPath);
|
||||
const skills = await discoverSkills(repoPath);
|
||||
|
||||
const planSteps = parseSteps(planMd);
|
||||
const progressSteps = parseSteps(progressMd);
|
||||
// Tag each step with its canonical phase rank for ordering guidance.
|
||||
for (const arr of [planSteps, progressSteps]) {
|
||||
for (const st of arr) st.rank = phaseRankFromName(st.section);
|
||||
}
|
||||
// Progress is the source of truth for status; fall back to plan steps.
|
||||
const steps = progressSteps.length ? progressSteps : planSteps;
|
||||
const percent = percentDone(steps);
|
||||
const ordering = computeOrdering(steps);
|
||||
// Prefer an explicit "Validation"/"gates" section for gate status; fall back
|
||||
// to scanning all steps when no such section exists.
|
||||
const gateSteps =
|
||||
sectionSteps(progressMd, /validation|gates/i) ||
|
||||
sectionSteps(summaryMd, /validation|gates/i) ||
|
||||
steps;
|
||||
const gates = deriveGates(gateSteps, summaryMd);
|
||||
|
||||
let status = "not_started";
|
||||
if (summaryMd) status = "completed";
|
||||
else if (planMd || progressMd) status = "in_progress";
|
||||
|
||||
let git = null;
|
||||
if (includeGit) {
|
||||
const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], repoPath);
|
||||
const dirty = await execGit(["status", "--porcelain"], repoPath);
|
||||
git = {
|
||||
branch,
|
||||
isMigrationBranch: !!branch && /moderniz|migrat|upgrade|appmod/i.test(branch),
|
||||
dirty: dirty === null ? null : dirty.length > 0,
|
||||
changedFiles: dirty ? dirty.split(/\r?\n/).filter(Boolean).length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
repoPath,
|
||||
scannedAt: new Date().toISOString(),
|
||||
status,
|
||||
percent,
|
||||
assessment,
|
||||
tasks: catalogWithRelevance(assessment.detectedKeys),
|
||||
skills,
|
||||
git,
|
||||
plan: { exists: !!planMd, steps: planSteps },
|
||||
progress: { exists: !!progressMd, steps: progressSteps },
|
||||
summary: { exists: !!summaryMd, markdown: summaryMd || "" },
|
||||
report,
|
||||
ordering,
|
||||
gates,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// server.mjs — Per-instance loopback HTTP server + action dispatch for the cockpit.
|
||||
// Decoupled from the SDK: agent messaging is injected as `sendPrompt`, so this
|
||||
// module can be exercised by tests without a live session.
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { scanRepo } from "./scan.mjs";
|
||||
import { renderHtml } from "./renderer.mjs";
|
||||
import { buildPrompt, ACTION_LABELS } from "./prompts.mjs";
|
||||
import { runDoctor } from "./doctor.mjs";
|
||||
import { makeRun, runAutopilot, AUTOPILOT_MAX_STEPS } from "./autopilot.mjs";
|
||||
|
||||
/** Resolve which repo path an open/action context refers to, by precedence. */
|
||||
export function resolveRepoPath(ctx, lastWorkingDir) {
|
||||
const fromInput =
|
||||
ctx && ctx.input && typeof ctx.input.repoPath === "string" ? ctx.input.repoPath : null;
|
||||
const fromSession =
|
||||
ctx && ctx.session && ctx.session.workingDirectory ? ctx.session.workingDirectory : null;
|
||||
// No process.cwd() fallback: that resolves to the extension's own directory,
|
||||
// not the user's repo, and would make the cockpit scan unrelated files. When
|
||||
// nothing resolves we return null and the UI shows a "repo not available"
|
||||
// state instead of plausible-but-wrong data.
|
||||
return fromInput || fromSession || lastWorkingDir || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the repo and attach the environment readiness report. Tool probing is
|
||||
* expensive (spawns several processes), so the doctor result is cached on the
|
||||
* instance record and only recomputed on first build or an explicit recheck —
|
||||
* not on every per-turn refresh. Probing only runs when `rec.runDoctor` is wired
|
||||
* (real instances), so unit tests stay fast and deterministic.
|
||||
*/
|
||||
export async function buildState(rec, { recheckEnv = false } = {}) {
|
||||
const state = await scanRepo(rec.repoPath);
|
||||
if (state && state.ok && typeof rec.runDoctor === "function") {
|
||||
if (recheckEnv || rec.doctor == null) {
|
||||
try {
|
||||
rec.doctor = await rec.runDoctor(state);
|
||||
} catch {
|
||||
rec.doctor = rec.doctor || null;
|
||||
}
|
||||
}
|
||||
state.doctor = rec.doctor;
|
||||
} else if (state && state.ok) {
|
||||
state.doctor = rec.doctor || null;
|
||||
}
|
||||
if (state && state.ok) state.autopilot = rec.autopilot || null;
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Write an SSE payload to every connected client of an instance. */
|
||||
export function broadcast(rec, payload) {
|
||||
const data = "data: " + JSON.stringify(payload) + "\n\n";
|
||||
for (const res of rec.sseClients) {
|
||||
try {
|
||||
res.write(data);
|
||||
} catch {
|
||||
// Client disconnected uncleanly: drop it so we don't keep throwing on
|
||||
// every future broadcast (dead clients would otherwise leak in the Set).
|
||||
rec.sseClients.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-scan the repo and push the fresh snapshot to connected clients. */
|
||||
export async function pushState(rec, log, opts) {
|
||||
let state;
|
||||
try {
|
||||
state = await buildState(rec, opts);
|
||||
} catch (e) {
|
||||
if (log) log("appmod-cockpit scan failed: " + e.message, { level: "error" });
|
||||
return null;
|
||||
}
|
||||
broadcast(rec, { type: "state", state });
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Clamp a requested step budget into a sane range. */
|
||||
function clampSteps(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return AUTOPILOT_MAX_STEPS;
|
||||
return Math.max(1, Math.min(50, Math.round(v)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off an Autopilot run for this instance. Returns immediately; the run loop
|
||||
* streams progress to connected clients over SSE. The run promise is parked on
|
||||
* the record so it is not garbage-collected mid-flight.
|
||||
* @param {object} rec instance record
|
||||
* @param {object} payload { scope?: "phase"|"all", maxSteps?, force? }
|
||||
* @param {{ runTurn:(p:string)=>Promise<any>, log?:Function }} deps
|
||||
*/
|
||||
export async function startAutopilot(rec, payload, deps) {
|
||||
if (rec.autopilot && rec.autopilot.running) {
|
||||
return { ok: false, error: "Autopilot is already running." };
|
||||
}
|
||||
if (!deps || typeof deps.runTurn !== "function") {
|
||||
return { ok: false, error: "This session can't drive Autopilot." };
|
||||
}
|
||||
const p = payload || {};
|
||||
const start = await buildState(rec);
|
||||
if (!start || !start.ok) {
|
||||
return { ok: false, error: "Can't read the repo to start Autopilot." };
|
||||
}
|
||||
if (start.doctor && start.doctor.overall === "blocked" && !p.force) {
|
||||
return { ok: false, error: "Your environment isn't ready. Open Readiness and install the missing tools first." };
|
||||
}
|
||||
const scope = p.scope === "all" ? "all" : "phase";
|
||||
const startRank = start.ordering ? start.ordering.activeRank : null;
|
||||
const run = makeRun({ scope, maxSteps: clampSteps(p.maxSteps), startRank });
|
||||
rec.autopilot = run;
|
||||
broadcast(rec, { type: "autopilot", autopilot: run });
|
||||
|
||||
const runDeps = {
|
||||
snapshot: () => buildState(rec),
|
||||
runTurn: deps.runTurn,
|
||||
buildStepPrompt: (step) => buildPrompt("auto_step", { title: step.title, section: step.section }, rec.repoPath),
|
||||
onProgress: (r, state) => {
|
||||
if (state) broadcast(rec, { type: "state", state });
|
||||
else broadcast(rec, { type: "autopilot", autopilot: r });
|
||||
},
|
||||
log: deps.log,
|
||||
};
|
||||
rec.autopilotPromise = runAutopilot(run, runDeps)
|
||||
.then(() => pushState(rec, deps.log))
|
||||
.catch((e) => {
|
||||
if (deps.log) deps.log("Autopilot run failed: " + e.message, { level: "error" });
|
||||
});
|
||||
return { ok: true, message: "Autopilot started", autopilot: run };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a cockpit action.
|
||||
* @param {object} rec instance record ({ repoPath, sseClients, ... })
|
||||
* @param {string} kind action kind
|
||||
* @param {object} payload action payload
|
||||
* @param {{ sendPrompt:(p:string)=>Promise<void>, runTurn?:Function, log?:Function }} deps
|
||||
*/
|
||||
export async function dispatchAction(rec, kind, payload, deps) {
|
||||
if (kind === "refresh") {
|
||||
const state = await pushState(rec, deps && deps.log);
|
||||
return { ok: true, message: "Refreshed", state };
|
||||
}
|
||||
if (kind === "recheck_env") {
|
||||
const state = await pushState(rec, deps && deps.log, { recheckEnv: true });
|
||||
return { ok: true, message: "Re-checked environment", state };
|
||||
}
|
||||
if (kind === "autopilot_start") {
|
||||
return await startAutopilot(rec, payload, deps);
|
||||
}
|
||||
if (kind === "autopilot_stop") {
|
||||
if (rec.autopilot && rec.autopilot.running) {
|
||||
rec.autopilot.cancelled = true;
|
||||
broadcast(rec, { type: "autopilot", autopilot: rec.autopilot });
|
||||
return { ok: true, message: "Stopping after the current step finishes…" };
|
||||
}
|
||||
return { ok: false, error: "Autopilot isn't running." };
|
||||
}
|
||||
if (kind === "autopilot_dismiss") {
|
||||
if (rec.autopilot && !rec.autopilot.running) {
|
||||
rec.autopilot = null;
|
||||
await pushState(rec, deps && deps.log);
|
||||
}
|
||||
return { ok: true, message: "Dismissed" };
|
||||
}
|
||||
const prompt = buildPrompt(kind, payload, rec.repoPath);
|
||||
if (!prompt) return { ok: false, error: "Unknown action: " + kind };
|
||||
if (!deps || typeof deps.sendPrompt !== "function") {
|
||||
return { ok: false, error: "Session not ready" };
|
||||
}
|
||||
const label = ACTION_LABELS[kind] || kind;
|
||||
try {
|
||||
await deps.sendPrompt(prompt);
|
||||
if (deps.log) deps.log("Java Modernization Studio → " + label, { ephemeral: true });
|
||||
return { ok: true, label, message: "Sent to the agent: " + label, prompt };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Cap the request body the /action endpoint will buffer. Actions carry tiny JSON
|
||||
// payloads; anything large is a bug or abuse, so we reject rather than buffer it.
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
function readBody(req, max = MAX_BODY_BYTES) {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
let size = 0;
|
||||
let done = false;
|
||||
req.on("data", (c) => {
|
||||
if (done) return;
|
||||
size += c.length;
|
||||
if (size > max) {
|
||||
done = true;
|
||||
resolve({ body: "", tooLarge: true });
|
||||
if (typeof req.destroy === "function") req.destroy();
|
||||
return;
|
||||
}
|
||||
data += c;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve({ body: data, tooLarge: false });
|
||||
}
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve({ body: "", tooLarge: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request handler for an instance. Exposed for tests so routes can be
|
||||
* exercised without binding a socket.
|
||||
*/
|
||||
export function makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }) {
|
||||
return async function handler(req, res) {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
try {
|
||||
// Per-instance secret: real instances (createInstanceServer) mint a token
|
||||
// that the host embeds in the iframe URL. Reject any loopback request that
|
||||
// doesn't present it, so other local processes can't read repo state or
|
||||
// dispatch agent actions just by guessing the random port. When no token is
|
||||
// set (direct makeHandler unit tests) the guard is a no-op.
|
||||
if (rec.token) {
|
||||
const provided = url.searchParams.get("token");
|
||||
if (provided !== rec.token) {
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "forbidden" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderHtml({ instanceId, initialTab, token: rec.token }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/state") {
|
||||
const state = await buildState(rec);
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(state));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("retry: 3000\n\n");
|
||||
rec.sseClients.add(res);
|
||||
req.on("close", () => rec.sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/action") {
|
||||
const { body, tooLarge } = await readBody(req);
|
||||
if (tooLarge) {
|
||||
res.statusCode = 413;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "request body too large" }));
|
||||
return;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body || "{}");
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed.kind !== "string" || !parsed.kind) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "missing or invalid 'kind'" }));
|
||||
return;
|
||||
}
|
||||
const result = await dispatchAction(rec, parsed.kind, parsed.payload, { sendPrompt, runTurn, log });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
} catch (e) {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
}
|
||||
res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback server for one canvas instance.
|
||||
* @returns {Promise<object>} rec with { server, url, repoPath, sseClients }
|
||||
*/
|
||||
export async function createInstanceServer({ instanceId, repoPath, initialTab, sendPrompt, runTurn, log, runDoctor: runDoctorImpl }) {
|
||||
const rec = { server: null, url: "", repoPath, sseClients: new Set(), doctor: null, autopilot: null, token: randomBytes(16).toString("hex") };
|
||||
// Wire the environment doctor (injectable for tests); real instances probe the
|
||||
// local toolchain, cached and only recomputed on explicit recheck.
|
||||
rec.runDoctor = typeof runDoctorImpl === "function" ? runDoctorImpl : (scan) => runDoctor(scan, {});
|
||||
const handler = makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log });
|
||||
const server = createServer((req, res) => {
|
||||
// The handler is async: if it rejects, surface a 500 and log it rather than
|
||||
// letting it become an unhandled rejection that can crash the extension.
|
||||
handler(req, res).catch((err) => {
|
||||
if (log) log("Java Modernization Studio server error: " + (err && err.message ? err.message : err), { level: "error" });
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "internal error" }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
} catch {
|
||||
/* response already torn down */
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
rec.server = server;
|
||||
rec.url = "http://127.0.0.1:" + port + "/?token=" + rec.token;
|
||||
return rec;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user