Add modernization workflow canvas (#2758)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marco Antonio Silva
2026-09-14 14:29:03 +10:00
committed by GitHub
co-authored by Copilot
parent 7568a482ce
commit 5e59c61ead
12 changed files with 799 additions and 0 deletions
+6
View File
@@ -1170,6 +1170,12 @@
"ref": "v0.0.179" "ref": "v0.0.179"
} }
}, },
{
"name": "modernization-workflow",
"source": "plugins/modernization-workflow",
"description": "Visualize and guide the GitHub Copilot Modernize CLI Assess, Plan, and Execute workflow for .NET, Java, and C++ application repositories.",
"version": "1.0.0"
},
{ {
"name": "modernize-dotnet", "name": "modernize-dotnet",
"description": "AI-powered .NET modernization and upgrade assistant. Helps upgrade .NET Framework and .NET applications to the latest versions of .NET.", "description": "AI-powered .NET modernization and upgrade assistant. Helps upgrade .NET Framework and .NET applications to the latest versions of .NET.",
+1
View File
@@ -76,6 +76,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t
| [jupyter-notebooks](../plugins/jupyter-notebooks/README.md) | Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas. | 1 items | data-analysis, interactive-notebooks, jupyter, python | | [jupyter-notebooks](../plugins/jupyter-notebooks/README.md) | Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas. | 1 items | data-analysis, interactive-notebooks, jupyter, python |
| [kotlin-mcp-development](../plugins/kotlin-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | kotlin, mcp, model-context-protocol, kotlin-multiplatform, server-development, ktor | | [kotlin-mcp-development](../plugins/kotlin-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | kotlin, mcp, model-context-protocol, kotlin-multiplatform, server-development, ktor |
| [mcp-m365-copilot](../plugins/mcp-m365-copilot/README.md) | Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot | 4 items | mcp, m365-copilot, declarative-agents, api-plugins, model-context-protocol, adaptive-cards | | [mcp-m365-copilot](../plugins/mcp-m365-copilot/README.md) | Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot | 4 items | mcp, m365-copilot, declarative-agents, api-plugins, model-context-protocol, adaptive-cards |
| [modernization-workflow](../plugins/modernization-workflow/README.md) | Visualize and guide the GitHub Copilot Modernize CLI Assess, Plan, and Execute workflow for .NET, Java, and C++ application repositories. | 1 items | app-modernization, assessment-dashboard, azure-migration, cpp-modernization, dotnet-modernization, java-modernization, modernization-canvas, modernize-cli |
| [napkin](../plugins/napkin/README.md) | Visual whiteboard collaboration for Copilot CLI. Opens an interactive whiteboard in your browser where you can draw, sketch, and add sticky notes — then share everything back with Copilot. Copilot sees your drawings and responds with analysis, suggestions, and ideas. | 1 items | whiteboard, visual, collaboration, brainstorming, non-technical, drawing, sticky-notes, accessibility, copilot-cli, ux | | [napkin](../plugins/napkin/README.md) | Visual whiteboard collaboration for Copilot CLI. Opens an interactive whiteboard in your browser where you can draw, sketch, and add sticky notes — then share everything back with Copilot. Copilot sees your drawings and responds with analysis, suggestions, and ideas. | 1 items | whiteboard, visual, collaboration, brainstorming, non-technical, drawing, sticky-notes, accessibility, copilot-cli, ux |
| [noob-mode](../plugins/noob-mode/README.md) | Plain-English translation layer for non-technical Copilot CLI users. Translates every approval prompt, error message, and technical output into clear, jargon-free English with color-coded risk indicators. | 1 items | accessibility, plain-english, non-technical, beginner, translation, copilot-cli, ux | | [noob-mode](../plugins/noob-mode/README.md) | Plain-English translation layer for non-technical Copilot CLI users. Translates every approval prompt, error message, and technical output into clear, jargon-free English with color-coded risk indicators. | 1 items | accessibility, plain-english, non-technical, beginner, translation, copilot-cli, ux |
| [openapi-to-application-csharp-dotnet](../plugins/openapi-to-application-csharp-dotnet/README.md) | Generate production-ready .NET applications from OpenAPI specifications. Includes ASP.NET Core project scaffolding, controller generation, entity framework integration, and C# best practices. | 2 items | openapi, code-generation, api, csharp, dotnet, aspnet | | [openapi-to-application-csharp-dotnet](../plugins/openapi-to-application-csharp-dotnet/README.md) | Generate production-ready .NET applications from OpenAPI specifications. Includes ASP.NET Core project scaffolding, controller generation, entity framework integration, and C# best practices. | 2 items | openapi, code-generation, api, csharp, dotnet, aspnet |
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 EMEA App GBB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,26 @@
# Modernization Workflow canvas
An interactive GitHub Copilot CLI canvas that visualizes the documented
Modernize CLI lifecycle:
1. **Assess** the application and generate evidence.
2. **Plan** a reviewable modernization strategy and task list.
3. **Execute** transformations with build, security, and result validation.
The canvas detects .NET, Java, and C++ repository signals, generates commands
from the current workflow configuration, and refreshes progress from artifacts
under `.github/modernize/`.
## Canvas ID
`modernization-workflow`
## Agent actions
- `get_workflow_state`
- `configure_workflow`
- `set_step_status`
- `refresh_from_artifacts`
Workflow state is stored in `.github/modernize/canvas-<stateId>.json` in the
application repository so progress survives canvas and CLI restarts.
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

@@ -0,0 +1,4 @@
{
"name": "modernization-workflow",
"version": 1
}
@@ -0,0 +1,167 @@
import { basename } from "node:path";
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
import { createInstanceServer, pushState } from "./lib/server.mjs";
import {
configureWorkflow,
getWorkflowState,
refreshWorkflow,
resolveWorkspace,
setStepStatus,
} from "./lib/state.mjs";
const instances = new Map();
let sessionRef;
let workingDirectory = process.cwd();
function instance(ctx) {
const value = instances.get(ctx.instanceId);
if (!value) throw new CanvasError("not_open", "Open the Modernization Workflow canvas first.");
return value;
}
async function updateInstance(rec, operation) {
const state = await operation(rec.workspace, rec.stateId);
await pushState(rec, state);
return state;
}
const session = await joinSession({
canvases: [
createCanvas({
id: "modernization-workflow",
displayName: "Modernization Workflow",
description: "Track and guide a Modernize CLI Assess, Plan, and Execute workflow from real repository artifacts.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
workspace: { type: "string", description: "Absolute local application repository path." },
stateId: {
type: "string",
pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
description: "Stable workflow identifier used for persisted state.",
},
language: { type: "string", enum: ["auto", "dotnet", "java", "cpp"] },
source: { type: "string" },
goal: { type: "string" },
upgradeTarget: { type: "string" },
planName: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
},
},
actions: [
{
name: "get_workflow_state",
description: "Return the current workflow configuration, generated commands, artifacts, and step progress.",
handler: async (ctx) => {
const rec = instance(ctx);
return getWorkflowState(rec.workspace, rec.stateId);
},
},
{
name: "configure_workflow",
description: "Configure the source, language, modernization goal, and plan name.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
language: { type: "string", enum: ["auto", "dotnet", "java", "cpp"] },
source: { type: "string", minLength: 1 },
goal: { type: "string", minLength: 1 },
upgradeTarget: { type: "string" },
planName: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
delegate: { type: "string", enum: ["local", "cloud"] },
},
},
handler: async (ctx) => {
const rec = instance(ctx);
return updateInstance(rec, (workspace, stateId) =>
configureWorkflow(workspace, stateId, ctx.input ?? {}));
},
},
{
name: "set_step_status",
description: "Set an Assess, Plan, or Execute step to pending, active, complete, or blocked.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["step", "status"],
properties: {
step: { type: "string", enum: ["assess", "plan", "execute"] },
status: { type: "string", enum: ["pending", "active", "complete", "blocked"] },
},
},
handler: async (ctx) => {
const rec = instance(ctx);
return updateInstance(rec, (workspace, stateId) =>
setStepStatus(workspace, stateId, ctx.input.step, ctx.input.status));
},
},
{
name: "refresh_from_artifacts",
description: "Re-scan the repository and update progress from Modernize CLI output artifacts.",
handler: async (ctx) => {
const rec = instance(ctx);
return updateInstance(rec, refreshWorkflow);
},
},
],
open: async (ctx) => {
const workspace = resolveWorkspace(ctx.input?.workspace, workingDirectory);
const stateId = ctx.input?.stateId ?? "default";
let rec = instances.get(ctx.instanceId);
if (rec && (rec.workspace !== workspace || rec.stateId !== stateId)) {
for (const client of rec.clients) client.end();
await new Promise((resolve) => rec.server.close(resolve));
instances.delete(ctx.instanceId);
rec = null;
}
if (!rec) {
rec = await createInstanceServer({
workspace,
stateId,
initialConfig: ctx.input ?? {},
onConfigure: (input) => configureWorkflow(workspace, stateId, input),
onSetStep: (step, status) => setStepStatus(workspace, stateId, step, status),
onRefresh: () => refreshWorkflow(workspace, stateId),
});
instances.set(ctx.instanceId, rec);
}
return {
title: "Modernization Workflow",
status: basename(workspace),
url: rec.url,
};
},
onClose: async (ctx) => {
const rec = instances.get(ctx.instanceId);
if (!rec) return;
instances.delete(ctx.instanceId);
for (const client of rec.clients) client.end();
await new Promise((resolve) => rec.server.close(resolve));
},
}),
],
hooks: {
onSessionStart: async (input) => {
if (input.workingDirectory) workingDirectory = input.workingDirectory;
return {
additionalContext:
"When a user is modernizing an application, use the Modernization Workflow canvas to make Assess, Plan, and Execute progress visible. Refresh it after Modernize CLI commands change repository artifacts.",
};
},
},
});
sessionRef = session;
session.on("session.idle", async () => {
for (const rec of instances.values()) {
const state = await refreshWorkflow(rec.workspace, rec.stateId);
await pushState(rec, state);
}
});
await session.log("Modernization Workflow canvas ready", { ephemeral: true });
@@ -0,0 +1,190 @@
function serialize(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
export function renderHtml(initialState, token) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Modernization Workflow</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--background-color-default, #0d1117);
color: var(--text-color-default, #f0f6fc);
font: var(--text-body-medium, 14px)/var(--leading-body-medium, 20px) var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
}
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.shell { min-height: 100vh; }
header {
padding: 28px 32px 26px;
background: linear-gradient(125deg, #071a2d 0%, #0c3452 55%, #0d4757 100%);
color: #fff; border-bottom: 1px solid rgba(255,255,255,.14);
}
.eyebrow { display:flex; align-items:center; gap:9px; text-transform:uppercase; letter-spacing:.12em; font-size:11px; font-weight:700; color:#7ee7d8; }
.mark { width:9px; height:9px; border-radius:50%; background:#2bd4bd; box-shadow:0 0 0 5px rgba(43,212,189,.13); }
h1 { margin: 12px 0 7px; font-size: var(--text-title-large, 28px); line-height: 1.15; letter-spacing:-.03em; }
.subtitle { margin:0; max-width:720px; color:#bdd3e0; }
.header-row { display:flex; justify-content:space-between; align-items:end; gap:20px; }
.progress-wrap { min-width:180px; text-align:right; }
.progress-value { font-size:26px; font-weight:700; }
.progress-track { height:7px; margin-top:7px; border-radius:8px; background:rgba(255,255,255,.16); overflow:hidden; }
.progress-bar { height:100%; background:#2bd4bd; transition:width .25s ease; }
main { padding:24px 32px 40px; max-width:1280px; margin:auto; }
.notice { display:none; margin-bottom:18px; padding:11px 13px; border:1px solid #9a6700; border-radius:8px; background:rgba(187,128,9,.12); color:var(--text-color-default,#f0f6fc); }
.grid { display:grid; grid-template-columns:minmax(0, 1.45fr) minmax(280px, .75fr); gap:20px; }
.panel { background:var(--background-color-muted,#161b22); border:1px solid var(--border-color-default,#30363d); border-radius:12px; overflow:hidden; }
.panel-head { padding:16px 18px; border-bottom:1px solid var(--border-color-default,#30363d); display:flex; justify-content:space-between; align-items:center; gap:12px; }
.panel-head h2 { margin:0; font-size:16px; }
.panel-body { padding:18px; }
.workflow { display:grid; gap:12px; }
.step { border:1px solid var(--border-color-default,#30363d); border-radius:10px; padding:16px; background:var(--background-color-default,#0d1117); }
.step-top { display:flex; gap:12px; align-items:flex-start; }
.number { flex:0 0 32px; height:32px; display:grid; place-items:center; border-radius:50%; background:#173e54; color:#9ce7e0; font-weight:700; }
.step.complete .number { background:#1f6f58; color:#fff; }
.step.active { border-color:#2bd4bd; box-shadow:0 0 0 1px rgba(43,212,189,.22); }
.step h3 { margin:1px 0 3px; font-size:15px; }
.step p { margin:0; color:var(--text-color-muted,#8b949e); font-size:12px; }
.status { margin-left:auto; padding:3px 8px; border-radius:99px; font-size:11px; text-transform:capitalize; border:1px solid var(--border-color-default,#30363d); }
.status.complete { color:#56d6a1; border-color:#238636; background:rgba(35,134,54,.12); }
.status.active { color:#7ee7d8; border-color:#2bd4bd; }
.status.blocked { color:#ffb8b0; border-color:#f85149; }
.command { display:flex; align-items:center; gap:10px; margin:13px 0 10px 44px; padding:10px 11px; border-radius:7px; background:#050b10; border:1px solid #26323d; }
code { flex:1; min-width:0; overflow:auto; color:#d8f4f0; font:12px/18px var(--font-mono, Consolas, monospace); white-space:nowrap; }
.copy, .small-btn { flex:0 0 auto; border:1px solid var(--border-color-default,#30363d); background:var(--background-color-muted,#161b22); color:inherit; border-radius:6px; padding:6px 9px; }
.actions { margin-left:44px; display:flex; gap:7px; flex-wrap:wrap; }
.primary { border-color:#238636; background:#238636; color:#fff; }
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
label { display:grid; gap:5px; color:var(--text-color-muted,#8b949e); font-size:12px; }
label.wide { grid-column:1/-1; }
input, select, textarea { width:100%; border:1px solid var(--border-color-default,#30363d); border-radius:7px; padding:8px 9px; background:var(--background-color-default,#0d1117); color:var(--text-color-default,#f0f6fc); }
textarea { min-height:65px; resize:vertical; }
.save-row { display:flex; justify-content:flex-end; margin-top:12px; }
.meta { display:grid; gap:10px; margin:0; }
.meta div { display:grid; grid-template-columns:105px 1fr; gap:10px; }
.meta dt { color:var(--text-color-muted,#8b949e); }
.meta dd { margin:0; overflow-wrap:anywhere; }
.artifact-list { margin:0; padding:0; list-style:none; display:grid; gap:7px; }
.artifact-list li { padding:8px 9px; border-radius:6px; background:var(--background-color-default,#0d1117); font:11px/16px var(--font-mono,Consolas,monospace); overflow-wrap:anywhere; }
.empty { color:var(--text-color-muted,#8b949e); font-size:12px; }
.links { display:flex; gap:8px; flex-wrap:wrap; }
.links a { color:#58a6ff; text-decoration:none; }
.stack { display:grid; gap:20px; }
.toast { position:fixed; right:20px; bottom:20px; padding:9px 12px; border-radius:7px; background:#238636; color:#fff; opacity:0; transform:translateY(8px); transition:.2s; pointer-events:none; }
.toast.show { opacity:1; transform:none; }
@media (max-width:850px) { .grid { grid-template-columns:1fr; } .header-row { align-items:start; } .progress-wrap { min-width:130px; } }
@media (max-width:560px) { header, main { padding-left:18px; padding-right:18px; } .form-grid { grid-template-columns:1fr; } label.wide { grid-column:auto; } .command,.actions { margin-left:0; } }
</style>
</head>
<body>
<div class="shell">
<header>
<div class="header-row">
<div>
<div class="eyebrow"><span class="mark"></span> GitHub Copilot modernization</div>
<h1>Modernization Workflow</h1>
<p class="subtitle">Move from evidence to an approved plan and validated code changes with the Modernize CLI.</p>
</div>
<div class="progress-wrap"><div class="progress-value" id="progressValue">0%</div><div class="progress-track"><div class="progress-bar" id="progressBar"></div></div></div>
</div>
</header>
<main>
<div class="notice" id="notice"></div>
<div class="grid">
<section class="panel">
<div class="panel-head"><h2>Assess → Plan → Execute</h2><button class="small-btn" id="refresh">Refresh artifacts</button></div>
<div class="panel-body">
<div class="workflow" id="workflow"></div>
<div class="step" style="margin-top:12px">
<div class="step-top"><div class="number">↗</div><div><h3>Upgrade fast path</h3><p>Run the documented end-to-end plan-and-execute workflow when a separate plan review is not required.</p></div></div>
<div class="command"><code id="upgradeCommand"></code><button class="copy" data-command="upgrade">Copy</button></div>
</div>
</div>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head"><h2>Workflow setup</h2></div>
<div class="panel-body">
<form id="config">
<div class="form-grid">
<label>Technology<select name="language"><option value="auto">Auto-detect</option><option value="dotnet">.NET</option><option value="java">Java</option><option value="cpp">C++</option></select></label>
<label>Execution<select name="delegate"><option value="local">Local</option><option value="cloud">Cloud agent</option></select></label>
<label class="wide">Source<input name="source" required></label>
<label class="wide">Modernization goal<textarea name="goal" required></textarea></label>
<label class="wide">Upgrade target (optional)<input name="upgradeTarget" placeholder="Java 21, Spring Boot 3.2, or .NET 10"></label>
<label class="wide">Plan name<input name="planName" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" required></label>
</div>
<div class="save-row"><button class="small-btn primary" type="submit">Save configuration</button></div>
</form>
</div>
</section>
<section class="panel">
<div class="panel-head"><h2>Repository signal</h2></div>
<div class="panel-body"><dl class="meta" id="meta"></dl></div>
</section>
<section class="panel">
<div class="panel-head"><h2>Detected artifacts</h2></div>
<div class="panel-body"><ul class="artifact-list" id="artifacts"></ul></div>
</section>
<section class="panel">
<div class="panel-head"><h2>Reference</h2></div>
<div class="panel-body links">
<a href="https://github.com/microsoft/modernize-cli" target="_blank" rel="noreferrer">Modernize CLI</a>
<a href="https://learn.microsoft.com/azure/developer/github-copilot-app-modernization/modernization-agent/overview" target="_blank" rel="noreferrer">Overview</a>
<a href="https://learn.microsoft.com/azure/developer/github-copilot-app-modernization/modernization-agent/cli-commands" target="_blank" rel="noreferrer">CLI commands</a>
</div>
</section>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
const TOKEN=${serialize(token)};
let state=${serialize(initialState)};
const definitions={
assess:{title:"Assess",description:"Analyze source, dependencies, risks, and cloud readiness.",number:1},
plan:{title:"Plan",description:"Create and review an ordered plan with tasks and success criteria.",number:2},
execute:{title:"Execute",description:"Apply transformations, validate builds, scan CVEs, and summarize.",number:3}
};
function esc(value){return String(value??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));}
function toast(message){const el=document.querySelector("#toast");el.textContent=message;el.classList.add("show");setTimeout(()=>el.classList.remove("show"),1500);}
async function post(path,body={}){const response=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","x-canvas-token":TOKEN},body:JSON.stringify(body)});const value=await response.json();if(!response.ok)throw new Error(value.error||"Request failed");state=value;render();}
function render(){
const complete=Object.values(state.steps).filter(s=>s.status==="complete").length;
const percent=Math.round(complete/3*100);
document.querySelector("#progressValue").textContent=percent+"%";
document.querySelector("#progressBar").style.width=percent+"%";
const notice=document.querySelector("#notice");
notice.style.display=state.compatibilityNotice?"block":"none";notice.textContent=state.compatibilityNotice||"";
document.querySelector("#workflow").innerHTML=Object.entries(definitions).map(([key,d])=>{
const status=state.steps[key].status;
return '<article class="step '+esc(status)+'"><div class="step-top"><div class="number">'+d.number+'</div><div><h3>'+d.title+'</h3><p>'+d.description+'</p></div><span class="status '+esc(status)+'">'+esc(status)+'</span></div><div class="command"><code>'+esc(state.commands[key])+'</code><button class="copy" data-command="'+key+'">Copy</button></div><div class="actions"><button class="small-btn" data-step="'+key+'" data-status="active">Set active</button><button class="small-btn primary" data-step="'+key+'" data-status="complete">Mark complete</button><button class="small-btn" data-step="'+key+'" data-status="blocked">Blocked</button></div></article>';
}).join("");
document.querySelector("#upgradeCommand").textContent=state.commands.upgrade;
const form=document.querySelector("#config");
for(const name of ["language","delegate","source","goal","upgradeTarget","planName"]) form.elements[name].value=state[name];
document.querySelector("#meta").innerHTML=[
["Workspace",state.workspace],["Detected",state.detectedLanguage],["Using",state.effectiveLanguage],["State",state.stateId]
].map(([a,b])=>"<div><dt>"+esc(a)+"</dt><dd>"+esc(b)+"</dd></div>").join("");
const artifacts=[...state.artifacts.assessment,...state.artifacts.plan,...state.artifacts.execution];
document.querySelector("#artifacts").innerHTML=artifacts.length?artifacts.slice(0,12).map(x=>"<li>"+esc(x)+"</li>").join(""):'<li class="empty">No Modernize CLI output detected yet.</li>';
}
document.addEventListener("click",async event=>{
const copy=event.target.closest("[data-command]");
if(copy){await navigator.clipboard.writeText(state.commands[copy.dataset.command]);toast("Command copied");}
const step=event.target.closest("[data-step]");
if(step){try{await post("/api/step",{step:step.dataset.step,status:step.dataset.status});toast("Progress updated");}catch(error){toast(error.message);}}
});
document.querySelector("#refresh").addEventListener("click",async()=>{try{await post("/api/refresh");toast("Artifacts refreshed");}catch(error){toast(error.message);}});
document.querySelector("#config").addEventListener("submit",async event=>{event.preventDefault();try{await post("/api/configure",Object.fromEntries(new FormData(event.target)));toast("Configuration saved");}catch(error){toast(error.message);}});
const events=new EventSource("/events");events.addEventListener("state",event=>{state=JSON.parse(event.data);render();});
render();
</script>
</body>
</html>`;
}
@@ -0,0 +1,104 @@
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { getWorkflowState } from "./state.mjs";
import { renderHtml } from "./renderer.mjs";
function sendJson(res, status, value) {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(value));
}
async function readJson(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 64 * 1024) throw new Error("Request body is too large.");
chunks.push(chunk);
}
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
}
export async function pushState(rec, state) {
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
for (const client of rec.clients) client.write(payload);
}
export async function createInstanceServer(options) {
const token = randomBytes(24).toString("hex");
const clients = new Set();
let rec;
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
try {
if (req.method === "GET" && url.pathname === "/") {
const state = await getWorkflowState(options.workspace, options.stateId);
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
});
res.end(renderHtml(state, token));
return;
}
if (req.method === "GET" && url.pathname === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
clients.add(res);
req.on("close", () => clients.delete(res));
return;
}
if (req.method !== "POST" || req.headers["x-canvas-token"] !== token) {
sendJson(res, 403, { error: "Request rejected." });
return;
}
const input = await readJson(req);
let state;
if (url.pathname === "/api/configure") {
state = await options.onConfigure(input);
} else if (url.pathname === "/api/step") {
state = await options.onSetStep(input.step, input.status);
} else if (url.pathname === "/api/refresh") {
state = await options.onRefresh();
} else {
sendJson(res, 404, { error: "Not found." });
return;
}
await pushState(rec, state);
sendJson(res, 200, state);
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
rec = {
...options,
server,
clients,
url: `http://127.0.0.1:${address.port}/`,
};
if (Object.keys(options.initialConfig).some((key) =>
["language", "source", "goal", "planName"].includes(key))) {
await options.onConfigure(options.initialConfig);
}
return rec;
}
@@ -0,0 +1,227 @@
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const DEFAULT_STATE = {
version: 1,
language: "auto",
source: ".",
goal: "modernize this application for Azure",
upgradeTarget: "",
planName: "modernization-plan",
delegate: "local",
steps: {
assess: { status: "pending", updatedAt: null },
plan: { status: "pending", updatedAt: null },
execute: { status: "pending", updatedAt: null },
},
};
function statePath(workspace, stateId) {
return join(workspace, ".github", "modernize", `canvas-${stateId}.json`);
}
function cleanStateId(value) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
throw new Error("Invalid workflow state identifier.");
}
return value;
}
export function resolveWorkspace(input, fallback) {
const candidate = input || fallback || process.cwd();
return resolve(candidate);
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function loadState(workspace, stateId) {
const path = statePath(workspace, cleanStateId(stateId));
try {
const saved = JSON.parse(await readFile(path, "utf8"));
return {
...DEFAULT_STATE,
...saved,
steps: {
...DEFAULT_STATE.steps,
...(saved.steps ?? {}),
},
};
} catch (error) {
if (error.code === "ENOENT") return structuredClone(DEFAULT_STATE);
throw error;
}
}
async function saveState(workspace, stateId, state) {
const path = statePath(workspace, cleanStateId(stateId));
await mkdir(join(workspace, ".github", "modernize"), { recursive: true });
const next = { ...state, updatedAt: new Date().toISOString() };
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
return next;
}
async function walkNames(root, maxDepth = 3, depth = 0) {
if (depth > maxDepth || !(await exists(root))) return [];
const output = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
if ([".git", "node_modules", "bin", "obj", "target", "build"].includes(entry.name)) continue;
const path = join(root, entry.name);
output.push(path);
if (entry.isDirectory()) output.push(...await walkNames(path, maxDepth, depth + 1));
}
return output;
}
function detectLanguage(paths) {
const names = paths.map((path) => path.toLowerCase());
if (names.some((path) => /\.(sln|csproj|fsproj|vbproj)$/.test(path))) return "dotnet";
if (names.some((path) => /\/(pom\.xml|build\.gradle|build\.gradle\.kts)$/.test(path.replaceAll("\\", "/")))) return "java";
if (names.some((path) => /\/(cmakelists\.txt|makefile)$/.test(path.replaceAll("\\", "/")) || /\.(cpp|cc|cxx|vcxproj)$/.test(path))) return "cpp";
return "unknown";
}
async function scanArtifacts(workspace, planName) {
const modernizeRoot = join(workspace, ".github", "modernize");
const assessmentRoot = join(modernizeRoot, "assessment");
const planRoot = join(modernizeRoot, planName);
const assessmentFiles = (await walkNames(assessmentRoot, 3))
.filter((path) => /\.(json|md|html|ya?ml)$/i.test(path));
const planFiles = (await walkNames(planRoot, 2))
.filter((path) => /\.(json|md|html)$/i.test(path));
const assessmentOutputs = assessmentFiles.filter((path) =>
!/config|canvas-/i.test(path) && /\.(json|md|html)$/i.test(path));
const summaryFiles = planFiles.filter((path) => /summary|progress|result/i.test(path));
return {
assessment: assessmentFiles.map((path) => path.slice(workspace.length + 1)),
plan: planFiles.map((path) => path.slice(workspace.length + 1)),
execution: summaryFiles.map((path) => path.slice(workspace.length + 1)),
hasAssessment: assessmentOutputs.length > 0,
hasPlan: planFiles.some((path) => /plan\.md$/i.test(path))
&& planFiles.some((path) => /tasks\.json$/i.test(path)),
hasExecution: summaryFiles.length > 0,
};
}
function quote(value) {
const text = String(value).trim();
if (/^[A-Za-z0-9_./:\\-]+$/.test(text)) return text;
return `"${text.replaceAll('"', '\\"')}"`;
}
function languageFlag(language) {
if (language === "java" || language === "dotnet") return ` --language ${language}`;
return "";
}
function delegateFlag(delegate) {
return delegate === "cloud" ? " --delegate cloud" : "";
}
function commands(state, artifacts) {
const source = quote(state.source);
const name = quote(state.planName);
const goal = quote(state.goal);
const upgradeTarget = state.upgradeTarget ? ` ${quote(state.upgradeTarget)}` : "";
const language = state.language === "auto" ? state.detectedLanguage : state.language;
const assessment = artifacts.assessment.find((path) => /assessment.*\.json$/i.test(path))
?? artifacts.assessment.find((path) => /\.json$/i.test(path));
const assessmentFlag = assessment ? ` --assess-file-path ${quote(assessment)}` : "";
return {
assess: `modernize assess --source ${source}${delegateFlag(state.delegate)}${state.delegate === "cloud" ? " --wait" : ""}`,
plan: `modernize plan create ${goal} --source ${source} --plan-name ${name}${languageFlag(language)}${assessmentFlag}`,
execute: `modernize plan execute --source ${source} --plan-name ${name}${languageFlag(language)}${delegateFlag(state.delegate)}`,
upgrade: `modernize upgrade${upgradeTarget} --source ${source}${delegateFlag(state.delegate)}`,
};
}
function deriveSteps(state, artifacts) {
const steps = structuredClone(state.steps);
if (artifacts.hasAssessment) steps.assess.status = "complete";
if (artifacts.hasPlan) {
steps.assess.status = "complete";
steps.plan.status = "complete";
}
if (artifacts.hasExecution) {
steps.assess.status = "complete";
steps.plan.status = "complete";
steps.execute.status = "complete";
}
return steps;
}
export async function getWorkflowState(workspace, stateId = "default") {
const saved = await loadState(workspace, stateId);
const paths = await walkNames(workspace, 3);
const detectedLanguage = detectLanguage(paths);
const artifacts = await scanArtifacts(workspace, saved.planName);
const state = {
...saved,
stateId,
workspace,
detectedLanguage,
effectiveLanguage: saved.language === "auto" ? detectedLanguage : saved.language,
artifacts,
steps: deriveSteps(saved, artifacts),
};
const notices = [];
if (state.effectiveLanguage === "cpp") {
notices.push("The current CLI reference documents explicit --language values for Java and .NET only. C++ commands use CLI auto-detection; verify support with your installed Modernize CLI version.");
}
if (state.delegate === "cloud" && !/^https:\/\/github\.com\//i.test(state.source)) {
notices.push("Cloud delegation requires a github.com repository URL as the source. Local paths and other Git providers are not supported.");
}
return {
...state,
commands: commands(state, artifacts),
compatibilityNotice: notices.length ? notices.join(" ") : null,
};
}
export async function configureWorkflow(workspace, stateId, input) {
const current = await loadState(workspace, stateId);
const allowed = ["language", "source", "goal", "upgradeTarget", "planName", "delegate"];
const next = { ...current };
for (const key of allowed) {
if (input[key] !== undefined) next[key] = String(input[key]).trim();
}
if (!["auto", "dotnet", "java", "cpp"].includes(next.language)) {
throw new Error("Language must be auto, dotnet, java, or cpp.");
}
if (!["local", "cloud"].includes(next.delegate)) {
throw new Error("Execution must be local or cloud.");
}
if (!next.source || !next.goal) {
throw new Error("Source and modernization goal are required.");
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(next.planName)) {
throw new Error("Plan name must contain only letters, numbers, dots, underscores, and hyphens.");
}
await saveState(workspace, stateId, next);
return getWorkflowState(workspace, stateId);
}
export async function setStepStatus(workspace, stateId, step, status) {
if (!["assess", "plan", "execute"].includes(step)) throw new Error("Unknown workflow step.");
if (!["pending", "active", "complete", "blocked"].includes(status)) throw new Error("Unknown step status.");
const current = await loadState(workspace, stateId);
current.steps[step] = { status, updatedAt: new Date().toISOString() };
await saveState(workspace, stateId, current);
return getWorkflowState(workspace, stateId);
}
export async function refreshWorkflow(workspace, stateId = "default") {
const current = await loadState(workspace, stateId);
const refreshed = await getWorkflowState(workspace, stateId);
current.steps = refreshed.steps;
await saveState(workspace, stateId, current);
return getWorkflowState(workspace, stateId);
}
+20
View File
@@ -0,0 +1,20 @@
# Modernization Workflow Plugin
Interactive canvas for visualizing and guiding the GitHub Copilot Modernize CLI
Assess, Plan, and Execute workflow for .NET, Java, and C++ application
repositories.
## Installation
```bash
copilot plugin install modernization-workflow@awesome-copilot
```
## Source
This plugin is part of
[Awesome Copilot](https://github.com/github/awesome-copilot).
## License
MIT
@@ -0,0 +1,33 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "modernization-workflow",
"description": "Visualize and guide the GitHub Copilot Modernize CLI Assess, Plan, and Execute workflow for .NET, Java, and C++ application repositories.",
"version": "1.0.0",
"author": {
"name": "EMEA App GBB",
"url": "https://github.com/EmeaAppGbb"
},
"homepage": "https://github.com/EmeaAppGbb/modernization-canvas",
"repository": "https://github.com/EmeaAppGbb/modernization-canvas",
"license": "MIT",
"keywords": [
"app-modernization",
"assessment-dashboard",
"azure-migration",
"cpp-modernization",
"dotnet-modernization",
"java-modernization",
"modernization-canvas",
"modernize-cli"
],
"extensions": {
"com.github.copilot": {
"logo": "assets/preview.png"
},
"com.github.awesome-copilot": {
"extensions": [
"./extensions/modernization-workflow"
]
}
}
}