mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
0e208f59fc
* Add Tiny Tool Town submission canvas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add generated description choices Resolve picker opens from the active session working directory so linked worktrees work without an explicit repository path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address Tiny Tool review comments Detect root .csproj files as build signals and hide canvas access tokens from the visible URL after first load. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address latest Tiny Tool review comments Handle root-relative README images, preserve drafts during refresh, and suppress GitHub mentions before public issue creation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address canvas shutdown and CSP review Close all canvas servers during shutdown, use nonce-based CSP, make theme optional, and hide decorative preview dots from assistive tech. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
609 lines
21 KiB
JavaScript
609 lines
21 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk";
|
|
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
|
import {
|
|
buildIssueBody,
|
|
inspectRepository,
|
|
resolveRepoRoot,
|
|
validateSubmission,
|
|
} from "./lib/analyzer.mjs";
|
|
import { generateDescriptionOptions } from "./lib/copy-generator.mjs";
|
|
import { renderHtml } from "./lib/renderer.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const servers = new Map();
|
|
const states = new Map();
|
|
const ISSUE_REPOSITORY = "shanselman/TinyToolTown";
|
|
const MAX_REQUEST_BYTES = 1024 * 256;
|
|
let activeSession;
|
|
let copyClient;
|
|
let copyClientPromise;
|
|
let persisted = { repositories: {} };
|
|
let persistenceLoaded = false;
|
|
|
|
async function runGh(args, cwd) {
|
|
try {
|
|
const { stdout } = await execFileAsync("gh", args, {
|
|
cwd,
|
|
timeout: 30000,
|
|
maxBuffer: 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
return stdout.trim();
|
|
} catch (error) {
|
|
const detail = String(error?.stderr || error?.message || "GitHub CLI command failed.").trim();
|
|
throw new Error(detail);
|
|
}
|
|
}
|
|
|
|
function persistencePath() {
|
|
return activeSession?.workspacePath
|
|
? join(activeSession.workspacePath, "files", "tiny-tool-town-submitter.json")
|
|
: "";
|
|
}
|
|
|
|
async function loadPersistence() {
|
|
if (persistenceLoaded) return;
|
|
persistenceLoaded = true;
|
|
const path = persistencePath();
|
|
if (!path) return;
|
|
try {
|
|
persisted = JSON.parse(await readFile(path, "utf8"));
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") {
|
|
await activeSession?.log("Tiny Tool Town Submitter could not load its saved draft.", { level: "warning" });
|
|
}
|
|
}
|
|
if (!persisted || typeof persisted !== "object" || !persisted.repositories) {
|
|
persisted = { repositories: {} };
|
|
}
|
|
}
|
|
|
|
async function savePersistence() {
|
|
const path = persistencePath();
|
|
if (!path) return;
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function publicState(state) {
|
|
return {
|
|
repoPath: state.repoPath,
|
|
scannedAt: state.scannedAt,
|
|
metadata: state.metadata,
|
|
facts: state.facts,
|
|
recommendations: state.recommendations,
|
|
themes: state.themes,
|
|
submission: state.submission || null,
|
|
};
|
|
}
|
|
|
|
async function inspect(repoPath, { preserveDraft = true } = {}) {
|
|
await loadPersistence();
|
|
const fresh = await inspectRepository(repoPath);
|
|
const saved = persisted.repositories[fresh.repoPath];
|
|
if (preserveDraft && saved?.metadata) {
|
|
const savedValues = Object.fromEntries(
|
|
Object.entries(saved.metadata).filter(([field, value]) =>
|
|
field === "confirmations"
|
|
|| (typeof value === "string" && (value.trim() || !fresh.metadata[field])),
|
|
),
|
|
);
|
|
fresh.metadata = {
|
|
...fresh.metadata,
|
|
...savedValues,
|
|
confirmations: {
|
|
...fresh.metadata.confirmations,
|
|
...saved.metadata.confirmations,
|
|
},
|
|
};
|
|
}
|
|
if (saved?.submission) fresh.submission = saved.submission;
|
|
states.set(fresh.repoPath, fresh);
|
|
persisted.repositories[fresh.repoPath] = {
|
|
metadata: fresh.metadata,
|
|
submission: fresh.submission || null,
|
|
};
|
|
await savePersistence();
|
|
return fresh;
|
|
}
|
|
|
|
function repoPathFromContext(ctx) {
|
|
const repoPath = ctx.input?.repoPath || ctx.session?.workingDirectory;
|
|
if (!repoPath) {
|
|
throw new CanvasError(
|
|
"repository_unavailable",
|
|
"The active session did not provide a repository path. Open the canvas from a project session or pass repoPath explicitly.",
|
|
);
|
|
}
|
|
return repoPath;
|
|
}
|
|
|
|
async function stateFor(repoPath) {
|
|
const root = await resolveRepoRoot(repoPath);
|
|
return states.get(root) || inspect(root);
|
|
}
|
|
|
|
function applyMetadata(state, metadata) {
|
|
const allowed = [
|
|
"name",
|
|
"tagline",
|
|
"description",
|
|
"githubUrl",
|
|
"websiteUrl",
|
|
"thumbnailUrl",
|
|
"author",
|
|
"authorGitHub",
|
|
"tags",
|
|
"language",
|
|
"license",
|
|
"theme",
|
|
];
|
|
for (const field of allowed) {
|
|
if (typeof metadata?.[field] === "string") {
|
|
state.metadata[field] = metadata[field].trim();
|
|
}
|
|
}
|
|
if (metadata?.confirmations && typeof metadata.confirmations === "object") {
|
|
state.metadata.confirmations = {
|
|
freeOpenSource: metadata.confirmations.freeOpenSource === true,
|
|
notEnterpriseSaas: metadata.confirmations.notEnterpriseSaas === true,
|
|
publicAndWorks: metadata.confirmations.publicAndWorks === true,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function persistState(state) {
|
|
persisted.repositories[state.repoPath] = {
|
|
metadata: state.metadata,
|
|
submission: state.submission || null,
|
|
};
|
|
await savePersistence();
|
|
}
|
|
|
|
async function getCopyClient() {
|
|
if (!copyClientPromise) {
|
|
copyClient = new CopilotClient({
|
|
connection: RuntimeConnection.forStdio({
|
|
path: process.env.COPILOT_CLI_PATH || process.execPath,
|
|
}),
|
|
logLevel: "error",
|
|
});
|
|
copyClientPromise = copyClient.start()
|
|
.then(() => copyClient)
|
|
.catch((error) => {
|
|
copyClient = undefined;
|
|
copyClientPromise = undefined;
|
|
throw error;
|
|
});
|
|
}
|
|
return copyClientPromise;
|
|
}
|
|
|
|
async function stopCopyClient() {
|
|
const client = copyClient;
|
|
copyClient = undefined;
|
|
copyClientPromise = undefined;
|
|
if (client) {
|
|
await client.stop();
|
|
}
|
|
}
|
|
|
|
async function generateIsolatedDescriptionOptions(state) {
|
|
const client = await getCopyClient();
|
|
const session = await client.createSession({
|
|
workingDirectory: state.repoPath,
|
|
availableTools: [],
|
|
systemMessage: {
|
|
mode: "append",
|
|
content: "This is an isolated copywriting session. Never use tools or perform side effects. Return only the requested JSON.",
|
|
},
|
|
});
|
|
const sessionId = session.sessionId;
|
|
try {
|
|
return await generateDescriptionOptions(session, structuredClone(state.metadata));
|
|
} finally {
|
|
await session.disconnect();
|
|
try {
|
|
await client.deleteSession(sessionId);
|
|
} catch (error) {
|
|
await activeSession?.log(
|
|
`Tiny Tool Town Submitter could not delete copy session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ level: "warning" },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function titleFor(metadata) {
|
|
return `[Tool] ${metadata.name.trim()}`;
|
|
}
|
|
|
|
function suppressGitHubMentions(value) {
|
|
return String(value || "").replace(/@(?=[A-Za-z0-9_-])/g, "@\u200B");
|
|
}
|
|
|
|
function publicIssueMetadata(metadata) {
|
|
return Object.fromEntries(
|
|
Object.entries(metadata).map(([field, value]) => [
|
|
field,
|
|
typeof value === "string" ? suppressGitHubMentions(value) : value,
|
|
]),
|
|
);
|
|
}
|
|
|
|
async function submitIssue(state, metadata, confirmed) {
|
|
if (confirmed !== true) {
|
|
throw new CanvasError("confirmation_required", "Set confirm to true after reviewing the public issue contents.");
|
|
}
|
|
applyMetadata(state, metadata);
|
|
const submissionMetadata = structuredClone(state.metadata);
|
|
const errors = validateSubmission(submissionMetadata, state.facts);
|
|
if (errors.length) {
|
|
throw new CanvasError("submission_invalid", errors.join(" "));
|
|
}
|
|
|
|
const search = await runGh([
|
|
"issue",
|
|
"list",
|
|
"--repo",
|
|
ISSUE_REPOSITORY,
|
|
"--state",
|
|
"all",
|
|
"--search",
|
|
`${submissionMetadata.githubUrl} in:body`,
|
|
"--limit",
|
|
"10",
|
|
"--json",
|
|
"number,title,url,state",
|
|
], state.repoPath);
|
|
const existing = JSON.parse(search || "[]").find((issue) => issue.url);
|
|
if (existing) {
|
|
state.submission = {
|
|
url: existing.url,
|
|
number: existing.number,
|
|
existing: true,
|
|
submittedAt: new Date().toISOString(),
|
|
};
|
|
await persistState(state);
|
|
return { url: existing.url, existing: true, state: publicState(state) };
|
|
}
|
|
|
|
const issueMetadata = publicIssueMetadata(submissionMetadata);
|
|
const url = await runGh([
|
|
"issue",
|
|
"create",
|
|
"--repo",
|
|
ISSUE_REPOSITORY,
|
|
"--title",
|
|
titleFor(issueMetadata),
|
|
"--label",
|
|
"new-tool",
|
|
"--body",
|
|
buildIssueBody(issueMetadata),
|
|
], state.repoPath);
|
|
state.submission = {
|
|
url,
|
|
existing: false,
|
|
submittedAt: new Date().toISOString(),
|
|
};
|
|
await persistState(state);
|
|
return { url, existing: false, state: publicState(state) };
|
|
}
|
|
|
|
async function requestImprovementSession(state, recommendationIds) {
|
|
if (!activeSession) throw new Error("Copilot session is unavailable.");
|
|
const selected = state.recommendations.filter((item) => recommendationIds.includes(item.id));
|
|
if (!selected.length) {
|
|
throw new CanvasError("recommendations_required", "Select at least one current recommendation.");
|
|
}
|
|
const implementationPrompt = [
|
|
"Prepare this repository for a Tiny Tool Town submission.",
|
|
`Repository path: ${state.repoPath}`,
|
|
"",
|
|
"Implement these findings:",
|
|
...selected.map((item) => `- ${item.title}: ${item.prompt}`),
|
|
"",
|
|
"Preserve unrelated work. Follow repository conventions, add or update directly related documentation and assets, run the existing validation commands, and leave a PR-ready branch state.",
|
|
].join("\n");
|
|
const sessionRequest = [
|
|
"Create a new local project session in the current project for Tiny Tool Town readiness improvements.",
|
|
"Use the create_session tool with the current project_id, coordinate_with_creator: true, notify_on_idle: once, and name: Tiny Tool readiness.",
|
|
"Set kickoff mode to autopilot and use this exact kickoff prompt:",
|
|
JSON.stringify(implementationPrompt),
|
|
"",
|
|
"After the tool succeeds, reply with a one-line confirmation. Do not implement the findings in this current session.",
|
|
].join("\n");
|
|
await activeSession.send({
|
|
prompt: sessionRequest,
|
|
mode: "immediate",
|
|
displayPrompt: "Start Tiny Tool readiness session",
|
|
});
|
|
return {
|
|
status: "requested",
|
|
message: `Requested an implementation session for ${selected.length} recommendation${selected.length === 1 ? "" : "s"}.`,
|
|
recommendations: selected.map((item) => item.id),
|
|
};
|
|
}
|
|
|
|
async function readJson(req) {
|
|
const chunks = [];
|
|
let bytes = 0;
|
|
for await (const chunk of req) {
|
|
bytes += chunk.length;
|
|
if (bytes > MAX_REQUEST_BYTES) {
|
|
const error = new Error("Request body is too large.");
|
|
error.statusCode = 413;
|
|
throw error;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
if (!chunks.length) return {};
|
|
try {
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
} catch {
|
|
const error = new Error("Request body must be valid JSON.");
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function sendJson(res, statusCode, value) {
|
|
res.statusCode = statusCode;
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end(JSON.stringify(value));
|
|
}
|
|
|
|
async function closeServerEntry(entry) {
|
|
if (!entry?.server?.listening) return;
|
|
await new Promise((resolve, reject) => {
|
|
entry.server.close((error) => error ? reject(error) : resolve());
|
|
});
|
|
}
|
|
|
|
async function closeCanvasServer(instanceId) {
|
|
const entry = servers.get(instanceId);
|
|
if (entry) {
|
|
servers.delete(instanceId);
|
|
await closeServerEntry(entry);
|
|
}
|
|
}
|
|
|
|
async function closeAllCanvasServers() {
|
|
const entries = [...servers.values()];
|
|
servers.clear();
|
|
await Promise.all(entries.map((entry) => closeServerEntry(entry)));
|
|
}
|
|
|
|
async function handleRequest(entry, req, res) {
|
|
const url = new URL(req.url || "/", entry.url);
|
|
if (url.pathname === "/" && req.method === "GET") {
|
|
const nonce = randomUUID().replaceAll("-", "");
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.setHeader(
|
|
"Content-Security-Policy",
|
|
`default-src 'self'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; img-src https: data:; base-uri 'none'; form-action 'none'`,
|
|
);
|
|
res.end(renderHtml(nonce));
|
|
return;
|
|
}
|
|
if (url.searchParams.get("token") !== entry.token) {
|
|
sendJson(res, 403, { error: "Invalid canvas token." });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let state = await stateFor(entry.repoPath);
|
|
if (url.pathname === "/state" && req.method === "GET") {
|
|
sendJson(res, 200, publicState(state));
|
|
return;
|
|
}
|
|
if (url.pathname === "/save" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
applyMetadata(state, body.metadata);
|
|
await persistState(state);
|
|
sendJson(res, 200, publicState(state));
|
|
return;
|
|
}
|
|
if (url.pathname === "/refresh" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
applyMetadata(state, body.metadata);
|
|
await persistState(state);
|
|
state = await inspect(entry.repoPath);
|
|
sendJson(res, 200, publicState(state));
|
|
return;
|
|
}
|
|
if (url.pathname === "/generate-descriptions" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
applyMetadata(state, body.metadata);
|
|
await persistState(state);
|
|
const options = await generateIsolatedDescriptionOptions(state);
|
|
sendJson(res, 200, { options });
|
|
return;
|
|
}
|
|
if (url.pathname === "/submit" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
sendJson(res, 200, await submitIssue(state, body.metadata, body.confirm));
|
|
return;
|
|
}
|
|
if (url.pathname === "/implement" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
sendJson(res, 200, await requestImprovementSession(state, body.recommendationIds || []));
|
|
return;
|
|
}
|
|
sendJson(res, 404, { error: "Not found." });
|
|
} catch (error) {
|
|
sendJson(res, error?.statusCode || 400, {
|
|
error: error instanceof Error ? error.message : "Request failed.",
|
|
code: error?.code,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function startServer(instanceId, repoPath) {
|
|
const token = randomUUID();
|
|
const entry = { instanceId, repoPath, token, server: null, url: "" };
|
|
const server = createServer((req, res) => {
|
|
handleRequest(entry, req, res).catch((error) => {
|
|
sendJson(res, 500, { error: error instanceof Error ? error.message : "Unexpected server error." });
|
|
});
|
|
});
|
|
entry.server = server;
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
entry.url = `http://127.0.0.1:${port}/`;
|
|
return entry;
|
|
}
|
|
|
|
const metadataSchema = {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
tagline: { type: "string", maxLength: 100 },
|
|
description: { type: "string" },
|
|
githubUrl: { type: "string" },
|
|
websiteUrl: { type: "string" },
|
|
thumbnailUrl: { type: "string" },
|
|
author: { type: "string" },
|
|
authorGitHub: { type: "string" },
|
|
tags: { type: "string" },
|
|
language: { type: "string" },
|
|
license: { type: "string" },
|
|
theme: { type: "string" },
|
|
confirmations: {
|
|
type: "object",
|
|
properties: {
|
|
freeOpenSource: { type: "boolean" },
|
|
notEnterpriseSaas: { type: "boolean" },
|
|
publicAndWorks: { type: "boolean" },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
};
|
|
|
|
activeSession = await joinSession({
|
|
canvases: [
|
|
createCanvas({
|
|
id: "tiny-tool-town-submitter",
|
|
displayName: "Tiny Tool Town Submitter",
|
|
description: "Inspect a repository, improve its Tiny Tool Town readiness, submit the listing issue, and launch remediation work.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
repoPath: { type: "string", description: "Repository path to inspect; defaults to the current Git repository." },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
actions: [
|
|
{
|
|
name: "inspect_repository",
|
|
description: "Re-scan a repository and return Tiny Tool Town metadata and readiness recommendations.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { repoPath: { type: "string" } },
|
|
additionalProperties: false,
|
|
},
|
|
handler: async (ctx) => publicState(await inspect(repoPathFromContext(ctx), { preserveDraft: false })),
|
|
},
|
|
{
|
|
name: "update_submission",
|
|
description: "Update the saved Tiny Tool Town submission draft.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
repoPath: { type: "string" },
|
|
metadata: metadataSchema,
|
|
},
|
|
required: ["metadata"],
|
|
additionalProperties: false,
|
|
},
|
|
handler: async (ctx) => {
|
|
const state = await stateFor(repoPathFromContext(ctx));
|
|
applyMetadata(state, ctx.input.metadata);
|
|
await persistState(state);
|
|
return publicState(state);
|
|
},
|
|
},
|
|
{
|
|
name: "start_improvement_session",
|
|
description: "Request a dedicated project session to implement selected readiness recommendations.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
repoPath: { type: "string" },
|
|
recommendationIds: {
|
|
type: "array",
|
|
minItems: 1,
|
|
uniqueItems: true,
|
|
items: { type: "string" },
|
|
},
|
|
},
|
|
required: ["recommendationIds"],
|
|
additionalProperties: false,
|
|
},
|
|
handler: async (ctx) => {
|
|
const state = await stateFor(repoPathFromContext(ctx));
|
|
return requestImprovementSession(state, ctx.input.recommendationIds);
|
|
},
|
|
},
|
|
],
|
|
open: async (ctx) => {
|
|
const repoPath = await resolveRepoRoot(repoPathFromContext(ctx));
|
|
await stateFor(repoPath);
|
|
let entry = servers.get(ctx.instanceId);
|
|
if (entry && entry.repoPath !== repoPath) {
|
|
await closeCanvasServer(ctx.instanceId);
|
|
entry = null;
|
|
}
|
|
if (!entry) {
|
|
entry = await startServer(ctx.instanceId, repoPath);
|
|
servers.set(ctx.instanceId, entry);
|
|
}
|
|
return {
|
|
title: "Tiny Tool Town Submitter",
|
|
status: "Repository inspected",
|
|
url: `${entry.url}?token=${encodeURIComponent(entry.token)}`,
|
|
};
|
|
},
|
|
onClose: async (ctx) => {
|
|
await closeCanvasServer(ctx.instanceId);
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
async function shutdownExtension() {
|
|
await Promise.all([
|
|
stopCopyClient(),
|
|
closeAllCanvasServers(),
|
|
]);
|
|
}
|
|
|
|
activeSession.on("session.shutdown", () => {
|
|
void shutdownExtension().catch((error) => {
|
|
void activeSession?.log(
|
|
`Tiny Tool Town Submitter shutdown cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ level: "warning" },
|
|
);
|
|
});
|
|
});
|
|
|
|
process.once("SIGTERM", () => {
|
|
void shutdownExtension().finally(() => process.exit(0));
|
|
});
|