394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import { execFileSync, execSync } from "child_process";
|
|
import { readFileSync, writeFileSync } from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
|
const REPO = "bipprojectbali/desa-plus";
|
|
const STACK_ENV = "stg";
|
|
const BASE_URL = process.env.BASE_URL ?? "";
|
|
const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? "";
|
|
|
|
const GH = (args: string[]) =>
|
|
execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
|
|
|
|
const GIT = (args: string[]) =>
|
|
execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
|
|
|
|
// --- version helpers ---
|
|
|
|
function bumpVersion(version: string, type: "patch" | "minor" | "major"): string {
|
|
const [maj, min, pat] = version.split(".").map(Number);
|
|
if (type === "major") return `${maj + 1}.0.0`;
|
|
if (type === "minor") return `${maj}.${min + 1}.0`;
|
|
return `${maj}.${min}.${pat + 1}`;
|
|
}
|
|
|
|
function readPkgVersion(): string {
|
|
const pkg = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf-8"));
|
|
return pkg.version as string;
|
|
}
|
|
|
|
function applyVersionBump(newVersion: string): void {
|
|
const pkgPath = path.join(PROJECT_ROOT, "package.json");
|
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
pkg.version = newVersion;
|
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
}
|
|
|
|
// --- deployed version check ---
|
|
|
|
async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise<string> {
|
|
if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg.";
|
|
|
|
const url = `${BASE_URL}/api/version-app`;
|
|
const interval = 15_000;
|
|
const maxAttempts = Math.ceil(timeoutMs / interval);
|
|
let last = "";
|
|
|
|
for (let i = 1; i <= maxAttempts; i++) {
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
try {
|
|
const res = await fetch(url);
|
|
const data = (await res.json()) as { version?: string };
|
|
last = data.version ?? "?";
|
|
if (last === expected) {
|
|
return `Versi terverifikasi di stg: ${last}`;
|
|
}
|
|
} catch {
|
|
last = "error fetch";
|
|
}
|
|
}
|
|
return `Timeout: versi stg masih ${last}, expected ${expected}`;
|
|
}
|
|
|
|
// --- MCP server ---
|
|
|
|
const server = new Server(
|
|
{ name: "deploy-stg", version: "1.0.0" },
|
|
{ capabilities: { tools: {} } }
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: "deploy",
|
|
description:
|
|
"Full deploy ke stg: bump version, commit, push ke build remote, publish Docker image, tunggu selesai, repull Portainer, verifikasi versi.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
stack_name: {
|
|
type: "string",
|
|
description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.",
|
|
},
|
|
bump: {
|
|
type: "string",
|
|
enum: ["patch", "minor", "major"],
|
|
description: "Jenis bump versi (default: patch)",
|
|
default: "patch",
|
|
},
|
|
},
|
|
required: [],
|
|
},
|
|
},
|
|
{
|
|
name: "publish",
|
|
description:
|
|
"Trigger workflow publish.yml: build & push Docker image ke GHCR (selalu stg, tag dari package.json). Kembalikan URL run.",
|
|
inputSchema: { type: "object", properties: {}, required: [] },
|
|
},
|
|
{
|
|
name: "repull",
|
|
description:
|
|
"Trigger workflow re-pull.yml: redeploy stack di Portainer stg dengan pull image terbaru. Kembalikan URL run.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
stack_name: {
|
|
type: "string",
|
|
description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.",
|
|
},
|
|
},
|
|
required: [],
|
|
},
|
|
},
|
|
{
|
|
name: "run_status",
|
|
description:
|
|
"Cek status GitHub Actions run terbaru untuk workflow tertentu, atau semua workflow.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
workflow: {
|
|
type: "string",
|
|
enum: ["publish.yml", "re-pull.yml", "all"],
|
|
description: "Nama workflow file atau 'all' untuk semua (default: all)",
|
|
default: "all",
|
|
},
|
|
limit: {
|
|
type: "number",
|
|
description: "Jumlah run yang ditampilkan (default 5)",
|
|
default: 5,
|
|
},
|
|
},
|
|
required: [],
|
|
},
|
|
},
|
|
{
|
|
name: "check_version",
|
|
description:
|
|
"Bandingkan versi lokal (package.json) dengan versi yang berjalan di stg (/api/version-app).",
|
|
inputSchema: { type: "object", properties: {}, required: [] },
|
|
},
|
|
],
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const { name, arguments: args } = request.params;
|
|
|
|
try {
|
|
// ── deploy ─────────────────────────────────────────────────────────────
|
|
if (name === "deploy") {
|
|
const { stack_name: _sn, bump = "patch" } = (args ?? {}) as {
|
|
stack_name?: string;
|
|
bump?: "patch" | "minor" | "major";
|
|
};
|
|
const stack_name = _sn || DEFAULT_STACK_NAME;
|
|
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
|
|
|
|
// 0. Cek migrasi — buat otomatis jika schema ada perubahan
|
|
let migrationCreated = false;
|
|
try {
|
|
execFileSync(
|
|
"./node_modules/.bin/prisma",
|
|
["migrate", "diff", "--from-migrations", "prisma/migrations", "--to-schema-datamodel", "prisma/schema.prisma", "--exit-code"],
|
|
{ encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" }
|
|
);
|
|
} catch {
|
|
// Ada schema diff — buat migration otomatis
|
|
execFileSync(
|
|
"./node_modules/.bin/prisma",
|
|
["migrate", "dev", "--create-only", "--name", "auto"],
|
|
{ encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" }
|
|
);
|
|
migrationCreated = true;
|
|
}
|
|
|
|
const oldVersion = readPkgVersion();
|
|
const newVersion = bumpVersion(oldVersion, bump);
|
|
|
|
// 1. Bump version in package.json
|
|
applyVersionBump(newVersion);
|
|
|
|
// 2. Commit (version bump + migration jika ada)
|
|
GIT(["add", "package.json", "prisma/migrations"]);
|
|
GIT(["commit", "-m", migrationCreated
|
|
? `bump: version ${newVersion} + migration`
|
|
: `bump: version ${newVersion}`
|
|
]);
|
|
|
|
// 3. Push to build remote (GitHub)
|
|
const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
GIT(["push", "build", `${currentBranch}:stg`, "--force"]);
|
|
|
|
// 4. Trigger publish
|
|
GH([
|
|
"workflow", "run", "publish.yml",
|
|
"--repo", REPO,
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
"--field", `tag=${newVersion}`,
|
|
]);
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
|
|
const publishRunId = GH([
|
|
"run", "list", "--repo", REPO,
|
|
"--workflow", "publish.yml",
|
|
"--limit", "1",
|
|
"--json", "databaseId",
|
|
"--jq", ".[0].databaseId",
|
|
]);
|
|
const publishUrl = GH([
|
|
"run", "list", "--repo", REPO,
|
|
"--workflow", "publish.yml",
|
|
"--limit", "1",
|
|
"--json", "url",
|
|
"--jq", ".[0].url",
|
|
]);
|
|
|
|
// 5. Wait for publish to finish
|
|
execSync(`gh run watch ${publishRunId} --repo ${REPO} --exit-status`, {
|
|
encoding: "utf-8",
|
|
cwd: PROJECT_ROOT,
|
|
timeout: 30 * 60 * 1000,
|
|
stdio: "pipe",
|
|
});
|
|
|
|
// 6. Trigger repull
|
|
GH([
|
|
"workflow", "run", "re-pull.yml",
|
|
"--repo", REPO,
|
|
"--field", `stack_name=${stack_name}`,
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
]);
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
|
|
const repullUrl = GH([
|
|
"run", "list", "--repo", REPO,
|
|
"--workflow", "re-pull.yml",
|
|
"--limit", "1",
|
|
"--json", "url",
|
|
"--jq", ".[0].url",
|
|
]);
|
|
|
|
// 7. Wait for repull, then verify version
|
|
await new Promise((r) => setTimeout(r, 30_000));
|
|
const versionCheck = await waitForDeployedVersion(newVersion);
|
|
|
|
const localVer = readPkgVersion();
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: [
|
|
`Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`,
|
|
`Publish run : ${publishUrl}`,
|
|
`Repull run : ${repullUrl}`,
|
|
``,
|
|
`Versi lokal : ${localVer}`,
|
|
versionCheck,
|
|
].join("\n"),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ── publish ────────────────────────────────────────────────────────────
|
|
if (name === "publish") {
|
|
const tag = readPkgVersion();
|
|
|
|
GH([
|
|
"workflow", "run", "publish.yml",
|
|
"--repo", REPO,
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
"--field", `tag=${tag}`,
|
|
]);
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
|
|
const runUrl = GH([
|
|
"run", "list", "--repo", REPO,
|
|
"--workflow", "publish.yml",
|
|
"--limit", "1",
|
|
"--json", "url",
|
|
"--jq", ".[0].url",
|
|
]);
|
|
|
|
return {
|
|
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }],
|
|
};
|
|
}
|
|
|
|
// ── repull ─────────────────────────────────────────────────────────────
|
|
if (name === "repull") {
|
|
const { stack_name: _sn } = (args ?? {}) as { stack_name?: string };
|
|
const stack_name = _sn || DEFAULT_STACK_NAME;
|
|
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
|
|
|
|
GH([
|
|
"workflow", "run", "re-pull.yml",
|
|
"--repo", REPO,
|
|
"--field", `stack_name=${stack_name}`,
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
]);
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
|
|
const runUrl = GH([
|
|
"run", "list", "--repo", REPO,
|
|
"--workflow", "re-pull.yml",
|
|
"--limit", "1",
|
|
"--json", "url",
|
|
"--jq", ".[0].url",
|
|
]);
|
|
|
|
return {
|
|
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }],
|
|
};
|
|
}
|
|
|
|
// ── run_status ─────────────────────────────────────────────────────────
|
|
if (name === "run_status") {
|
|
const { workflow = "all", limit = 5 } = (args ?? {}) as {
|
|
workflow?: string;
|
|
limit?: number;
|
|
};
|
|
const workflowArgs = workflow === "all" ? [] : ["--workflow", workflow];
|
|
|
|
const output = GH([
|
|
"run", "list",
|
|
"--repo", REPO,
|
|
...workflowArgs,
|
|
"--limit", String(limit),
|
|
"--json", "workflowName,status,conclusion,startedAt,url,databaseId",
|
|
"--jq",
|
|
'.[] | "[\(.status)/\(.conclusion // "-")] \(.workflowName) — \(.startedAt)\n \(.url)"',
|
|
]);
|
|
|
|
return {
|
|
content: [{ type: "text", text: output || "Tidak ada run ditemukan." }],
|
|
};
|
|
}
|
|
|
|
// ── check_version ──────────────────────────────────────────────────────
|
|
if (name === "check_version") {
|
|
const localVersion = readPkgVersion();
|
|
let stgVersion = "tidak dapat dijangkau";
|
|
|
|
if (BASE_URL) {
|
|
try {
|
|
const res = await fetch(`${BASE_URL}/api/version-app`);
|
|
const data = (await res.json()) as { version?: string };
|
|
stgVersion = data.version ?? "?";
|
|
} catch (e) {
|
|
stgVersion = `error: ${(e as Error).message}`;
|
|
}
|
|
} else {
|
|
stgVersion = "BASE_URL tidak di-set";
|
|
}
|
|
|
|
const match = localVersion === stgVersion ? "✓ sama" : "✗ beda";
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: [
|
|
`Lokal (package.json) : ${localVersion}`,
|
|
`Stg (/api/version-app): ${stgVersion}`,
|
|
`Status : ${match}`,
|
|
].join("\n"),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
isError: true,
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
});
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|