318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join, basename } from "node:path";
|
|
import { Type } from "@sinclair/typebox";
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
async function dockerExec(args: string[], cwd: string): Promise<{ ok: boolean; output: string }> {
|
|
try {
|
|
const result = await pi.exec("docker", args, { cwd });
|
|
return {
|
|
ok: result.code === 0,
|
|
output: (result.stdout || "").trim() || (result.stderr || "").trim() || "(no output)",
|
|
};
|
|
} catch (err: any) {
|
|
return { ok: false, output: `Docker error: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
async function getContainerStatus(cwd: string): Promise<string> {
|
|
const result = await dockerExec(
|
|
["ps", "--format", "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"],
|
|
cwd,
|
|
);
|
|
if (!result.ok) return `\u274C Docker not available: ${result.output}`;
|
|
if (!result.output.trim() || result.output.split("\n").length <= 1) {
|
|
return "\u{1F4AD} No running containers.";
|
|
}
|
|
return `\u{1F433} Running Containers:\n\n${result.output}`;
|
|
}
|
|
|
|
async function getContainerLogs(container: string, cwd: string, tail: number = 50): Promise<string> {
|
|
const result = await dockerExec(["logs", "--tail", String(tail), container], cwd);
|
|
if (!result.ok) return `\u274C Failed to get logs for '${container}': ${result.output}`;
|
|
return `\u{1F4DC} Logs for ${container} (last ${tail} lines):\n\n${result.output}`;
|
|
}
|
|
|
|
async function buildImage(cwd: string, tag?: string, dockerfile?: string): Promise<string> {
|
|
const imageTag = tag || basename(cwd).toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
|
|
const args = ["build", "-t", imageTag];
|
|
if (dockerfile) args.push("-f", dockerfile);
|
|
args.push(".");
|
|
|
|
const result = await dockerExec(args, cwd);
|
|
if (!result.ok) return `\u274C Build failed:\n${result.output.substring(0, 1000)}`;
|
|
|
|
// Get image info
|
|
const info = await dockerExec(["images", imageTag, "--format", "{{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedAt}}"], cwd);
|
|
return `\u2705 Image built successfully: ${imageTag}\n${info.output}\n\nRun: docker run -d -p 3000:3000 ${imageTag}`;
|
|
}
|
|
|
|
async function analyzeProjectForDockerfile(cwd: string): Promise<string> {
|
|
const analysis: string[] = [];
|
|
|
|
// Detect project type
|
|
if (existsSync(join(cwd, "package.json"))) {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
if (deps["next"]) {
|
|
analysis.push("Detected: Next.js application");
|
|
analysis.push("Recommended: Multi-stage build with Node.js Alpine");
|
|
analysis.push(generateNodeDockerfile("next", pkg));
|
|
} else if (deps["vite"] || deps["react-scripts"]) {
|
|
analysis.push("Detected: SPA (React/Vite)");
|
|
analysis.push("Recommended: Multi-stage build with nginx");
|
|
analysis.push(generateSPADockerfile(pkg));
|
|
} else {
|
|
analysis.push("Detected: Node.js application");
|
|
analysis.push(generateNodeDockerfile("node", pkg));
|
|
}
|
|
} catch {
|
|
analysis.push("Could not parse package.json");
|
|
}
|
|
} else if (existsSync(join(cwd, "requirements.txt")) || existsSync(join(cwd, "pyproject.toml"))) {
|
|
analysis.push("Detected: Python application");
|
|
analysis.push(generatePythonDockerfile(cwd));
|
|
} else if (existsSync(join(cwd, "go.mod"))) {
|
|
analysis.push("Detected: Go application");
|
|
analysis.push(generateGoDockerfile());
|
|
} else if (existsSync(join(cwd, "Cargo.toml"))) {
|
|
analysis.push("Detected: Rust application");
|
|
analysis.push("Recommended: Multi-stage build with rust:alpine");
|
|
} else {
|
|
analysis.push("Could not detect project type. Provide a Dockerfile manually.");
|
|
}
|
|
|
|
return analysis.join("\n\n");
|
|
}
|
|
|
|
function generateNodeDockerfile(type: string, pkg: any): string {
|
|
const startCmd = pkg.scripts?.start ? "npm start" : "node index.js";
|
|
if (type === "next") {
|
|
return `# Suggested Dockerfile for Next.js
|
|
FROM node:20-alpine AS deps
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
|
|
FROM node:20-alpine AS builder
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM node:20-alpine AS runner
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
COPY --from=builder /app/.next/standalone ./
|
|
COPY --from=builder /app/.next/static ./.next/static
|
|
COPY --from=builder /app/public ./public
|
|
EXPOSE 3000
|
|
CMD ["node", "server.js"]`;
|
|
}
|
|
return `# Suggested Dockerfile for Node.js
|
|
FROM node:20-alpine
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci --omit=dev
|
|
COPY . .
|
|
EXPOSE 3000
|
|
CMD ["${startCmd.split(" ")[0]}", ${startCmd.split(" ").slice(1).map((s: string) => `"${s}"`).join(", ")}]`;
|
|
}
|
|
|
|
function generateSPADockerfile(pkg: any): string {
|
|
const buildDir = pkg.scripts?.build?.includes("vite") ? "dist" : "build";
|
|
return `# Suggested Dockerfile for SPA
|
|
FROM node:20-alpine AS builder
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM nginx:alpine
|
|
COPY --from=builder /app/${buildDir} /usr/share/nginx/html
|
|
COPY <<EOF /etc/nginx/conf.d/default.conf
|
|
server {
|
|
listen 80;
|
|
root /usr/share/nginx/html;
|
|
location / {
|
|
try_files \$uri \$uri/ /index.html;
|
|
}
|
|
}
|
|
EOF
|
|
EXPOSE 80
|
|
CMD ["nginx", "-g", "daemon off;"]`;
|
|
}
|
|
|
|
function generatePythonDockerfile(cwd: string): string {
|
|
const hasRequirements = existsSync(join(cwd, "requirements.txt"));
|
|
const hasPyproject = existsSync(join(cwd, "pyproject.toml"));
|
|
const installCmd = hasRequirements
|
|
? "COPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt"
|
|
: hasPyproject
|
|
? "COPY pyproject.toml .\nRUN pip install --no-cache-dir ."
|
|
: "# Add your dependency installation here";
|
|
return `# Suggested Dockerfile for Python
|
|
FROM python:3.12-slim
|
|
WORKDIR /app
|
|
${installCmd}
|
|
COPY . .
|
|
EXPOSE 8000
|
|
CMD ["python", "main.py"]`;
|
|
}
|
|
|
|
function generateGoDockerfile(): string {
|
|
return `# Suggested Dockerfile for Go
|
|
FROM golang:1.22-alpine AS builder
|
|
WORKDIR /app
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN CGO_ENABLED=0 go build -o /app/server .
|
|
|
|
FROM alpine:latest
|
|
WORKDIR /app
|
|
COPY --from=builder /app/server .
|
|
EXPOSE 8080
|
|
CMD ["./server"]`;
|
|
}
|
|
|
|
pi.registerTool({
|
|
name: "docker_manage",
|
|
label: "Docker Manager",
|
|
description: "Manage Docker containers: list running containers, build images, view logs, or generate Dockerfiles from project analysis.",
|
|
parameters: Type.Object({
|
|
action: Type.String({ description: "Action: 'status', 'build', 'logs', 'generate', 'run', 'stop'" }),
|
|
container: Type.Optional(Type.String({ description: "Container name or ID (for logs/stop)" })),
|
|
tag: Type.Optional(Type.String({ description: "Image tag for build" })),
|
|
port: Type.Optional(Type.String({ description: "Port mapping for run (e.g., '3000:3000')" })),
|
|
}),
|
|
execute: async (args, ctx) => {
|
|
const action = args.action.toLowerCase();
|
|
|
|
switch (action) {
|
|
case "status":
|
|
case "ps":
|
|
case "list": {
|
|
const status = await getContainerStatus(ctx.cwd);
|
|
return { output: status };
|
|
}
|
|
case "build": {
|
|
if (!existsSync(join(ctx.cwd, "Dockerfile"))) {
|
|
return { error: "No Dockerfile found. Use action 'generate' to create one." };
|
|
}
|
|
const result = await buildImage(ctx.cwd, args.tag);
|
|
return { output: result };
|
|
}
|
|
case "logs": {
|
|
if (!args.container) return { error: "Specify a container name or ID." };
|
|
const logs = await getContainerLogs(args.container, ctx.cwd);
|
|
return { output: logs };
|
|
}
|
|
case "generate": {
|
|
const analysis = await analyzeProjectForDockerfile(ctx.cwd);
|
|
return { output: `\u{1F433} Dockerfile Generation:\n\n${analysis}` };
|
|
}
|
|
case "run": {
|
|
const tag = args.tag || basename(ctx.cwd).toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
|
|
const runArgs = ["run", "-d"];
|
|
if (args.port) runArgs.push("-p", args.port);
|
|
runArgs.push(tag);
|
|
const result = await dockerExec(runArgs, ctx.cwd);
|
|
return { output: result.ok ? `\u2705 Container started: ${result.output.substring(0, 12)}` : `\u274C ${result.output}` };
|
|
}
|
|
case "stop": {
|
|
if (!args.container) return { error: "Specify a container name or ID." };
|
|
const result = await dockerExec(["stop", args.container], ctx.cwd);
|
|
return { output: result.ok ? `\u2705 Stopped: ${args.container}` : `\u274C ${result.output}` };
|
|
}
|
|
default:
|
|
return { error: `Unknown action '${action}'. Use: status, build, logs, generate, run, stop` };
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("docker", {
|
|
description: "Docker manager: /docker [build|logs <container>|generate|status]",
|
|
handler: async (args, ctx) => {
|
|
const parts = args.trim().split(/\s+/);
|
|
const subcommand = (parts[0] || "status").toLowerCase();
|
|
|
|
switch (subcommand) {
|
|
case "status":
|
|
case "ps":
|
|
case "": {
|
|
const status = await getContainerStatus(ctx.cwd);
|
|
ctx.ui.notify(status, "info");
|
|
break;
|
|
}
|
|
case "build": {
|
|
if (!existsSync(join(ctx.cwd, "Dockerfile"))) {
|
|
ctx.ui.notify("\u274C No Dockerfile found. Use /docker generate first.", "warning");
|
|
return;
|
|
}
|
|
ctx.ui.notify("\u{1F528} Building Docker image...", "info");
|
|
const tag = parts[1] || undefined;
|
|
const result = await buildImage(ctx.cwd, tag);
|
|
ctx.ui.notify(result, "info");
|
|
break;
|
|
}
|
|
case "logs": {
|
|
const container = parts[1];
|
|
if (!container) {
|
|
ctx.ui.notify("Usage: /docker logs <container-name-or-id>", "warning");
|
|
return;
|
|
}
|
|
const tail = parseInt(parts[2] || "50", 10);
|
|
const logs = await getContainerLogs(container, ctx.cwd, tail);
|
|
ctx.ui.notify(logs, "info");
|
|
break;
|
|
}
|
|
case "generate": {
|
|
ctx.ui.notify("\u{1F50D} Analyzing project for Dockerfile generation...", "info");
|
|
const analysis = await analyzeProjectForDockerfile(ctx.cwd);
|
|
ctx.ui.notify(`\u{1F433} Dockerfile Suggestion:\n\n${analysis}\n\n\u{1F4A1} Tip: Ask JAE to create the Dockerfile based on this analysis.`, "info");
|
|
break;
|
|
}
|
|
case "stop": {
|
|
const container = parts[1];
|
|
if (!container) {
|
|
ctx.ui.notify("Usage: /docker stop <container-name-or-id>", "warning");
|
|
return;
|
|
}
|
|
const result = await dockerExec(["stop", container], ctx.cwd);
|
|
ctx.ui.notify(result.ok ? `\u2705 Stopped: ${container}` : `\u274C ${result.output}`, "info");
|
|
break;
|
|
}
|
|
case "run": {
|
|
const tag = parts[1];
|
|
if (!tag) {
|
|
ctx.ui.notify("Usage: /docker run <image> [port-mapping]", "warning");
|
|
return;
|
|
}
|
|
const port = parts[2] || undefined;
|
|
const runArgs = ["run", "-d"];
|
|
if (port) runArgs.push("-p", port);
|
|
runArgs.push(tag);
|
|
const result = await dockerExec(runArgs, ctx.cwd);
|
|
ctx.ui.notify(result.ok ? `\u2705 Container started: ${result.output.substring(0, 12)}` : `\u274C ${result.output}`, "info");
|
|
break;
|
|
}
|
|
default:
|
|
ctx.ui.notify(
|
|
`\u{1F433} Docker Manager\n${'\u2500'.repeat(30)}\n\n` +
|
|
"Commands:\n" +
|
|
" /docker - Show running containers\n" +
|
|
" /docker build - Build image from Dockerfile\n" +
|
|
" /docker logs <id> - Show container logs\n" +
|
|
" /docker generate - Generate Dockerfile for project\n" +
|
|
" /docker run <img> - Run a container\n" +
|
|
" /docker stop <id> - Stop a container",
|
|
"info",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
}
|