commit 8af5a27b4ff28db32108a5eacdf3c79ec4564eab Author: Ondrej Trochta Date: Sat Mar 7 23:35:45 2026 +0100 Initial commit: DocMaster - Docker project manager Web UI + CLI for managing dockerized projects (start/stop/deploy). Features: live deploy workflow with SSE streaming, health checks, port map. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e9eee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.js.map diff --git a/assets/docmaster.svg b/assets/docmaster.svg new file mode 100644 index 0000000..bedc06a --- /dev/null +++ b/assets/docmaster.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + DocMaster + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4337b3f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "pm-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pm-cli", + "version": "1.0.0", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.0.0" + }, + "bin": { + "pm": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d21e59 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "docmaster", + "version": "1.0.0", + "description": "DocMaster - Web UI for managing Docker-based projects", + "type": "module", + "bin": { + "pm": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "start": "node dist/cli.js", + "web": "node dist/server.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0" + }, + "dependencies": { + "commander": "^12.0.0", + "chalk": "^5.3.0" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..5623b90 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,94 @@ +import { getAllProjects, PROJECTS } from "./config.js"; +import { getRunningContainers, composeUp, composeDown, composeBuild } from "./docker.js"; +import { getGitStatus, gitPull } from "./git.js"; +import { checkHealth } from "./health.js"; + +export interface ProjectStatusDTO { + name: string; + key: string; + branch: string; + behindRemote: number; + devAheadOfMain: number; + lastCommit: string; + lastCommitDev: string; + services: ServiceStatusDTO[]; +} + +export interface ServiceStatusDTO { + label: string; + container: string; + port?: number; + healthUrl?: string; + running: boolean; + status: string; + isApp: boolean; +} + +const PROJECT_KEYS: Record = {}; +for (const [key, cfg] of Object.entries(PROJECTS)) { + PROJECT_KEYS[cfg.name] = key; +} + +export function getStatusAll(): ProjectStatusDTO[] { + const containers = getRunningContainers(); + return getAllProjects().map((p) => { + const git = getGitStatus(p); + return { + name: p.name, + key: PROJECT_KEYS[p.name] ?? p.name.toLowerCase(), + branch: git.currentBranch, + behindRemote: git.behindRemote, + devAheadOfMain: git.devAheadOfMain, + lastCommit: git.lastCommit, + lastCommitDev: git.lastCommitDev, + services: p.services.map((s) => { + const c = containers.find((ct) => ct.name === s.container); + return { + label: s.label, + container: s.container, + port: s.port, + healthUrl: s.healthUrl, + running: !!c?.status.startsWith("Up"), + status: c?.status ?? "Stopped", + isApp: p.appContainers.includes(s.container), + }; + }), + }; + }); +} + +export function getHealthAll() { + return getAllProjects().flatMap((p) => + checkHealth(p).map((h) => ({ ...h, project: p.name })) + ); +} + +export async function actionProject(key: string, action: string): Promise { + const project = PROJECTS[key]; + if (!project) return `Unknown project: ${key}`; + + switch (action) { + case "up": + await composeUp(project); + return `${project.name} started.`; + case "down": + await composeDown(project, true); + return `${project.name} stopped.`; + case "restart": + await composeDown(project, true); + await composeBuild(project); + await composeUp(project); + return `${project.name} restarted.`; + case "update": + await composeDown(project, true); + await gitPull(project); + await composeBuild(project); + await composeUp(project); + return `${project.name} updated.`; + case "build": + await composeBuild(project); + return `${project.name} built.`; + default: + return `Unknown action: ${action}`; + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..f4a36fe --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import chalk from "chalk"; +import { resolveProject, getAllProjects } from "./config.js"; +import { getRunningContainers, composeUp, composeDown, composeBuild, composeLogs, composePs } from "./docker.js"; +import { getGitStatus, gitPull } from "./git.js"; +import { checkHealth } from "./health.js"; +import { printStatusTable, printPortMap, printHealth } from "./display.js"; + +const program = new Command(); + +program.name("pm").description("Project Manager CLI").version("1.0.0"); + +// --- pm status --- +program + .command("status") + .alias("s") + .description("Show status of all projects, containers, and git branches") + .action(() => { + const projects = getAllProjects(); + const containers = getRunningContainers(); + const gitStatuses = new Map(projects.map((p) => [p.name, getGitStatus(p)])); + printStatusTable(projects, containers, gitStatuses); + }); + +// --- pm up --- +program + .command("up [project]") + .description("Start project containers (default: all)") + .action(async (name?: string) => { + const projects = name ? [requireProject(name)] : getAllProjects(); + for (const p of projects) { + await composeUp(p); + } + console.log(chalk.green("\nDone.")); + }); + +// --- pm down --- +program + .command("down [project]") + .description("Stop project containers (default: all)") + .option("-a, --all-containers", "Stop all containers including DBs") + .action(async (name: string | undefined, opts: { allContainers?: boolean }) => { + const projects = name ? [requireProject(name)] : getAllProjects(); + for (const p of projects) { + await composeDown(p, !opts.allContainers); + } + console.log(chalk.green("\nDone.")); + }); + +// --- pm restart --- +program + .command("restart ") + .alias("r") + .description("Restart project (stop app containers, rebuild, start)") + .action(async (name: string) => { + const project = requireProject(name); + await composeDown(project, true); + await composeBuild(project); + await composeUp(project); + console.log(chalk.green(`\n${project.name} restarted.`)); + }); + +// --- pm update --- +program + .command("update [project]") + .alias("u") + .description("Pull latest code, rebuild, and restart (default: all)") + .option("--no-cache", "Build without Docker cache") + .action(async (name?: string) => { + const projects = name ? [requireProject(name)] : getAllProjects(); + for (const project of projects) { + console.log(chalk.bold(`\n=== Updating ${project.name} ===`)); + + // 1. Stop app containers + await composeDown(project, true); + + // 2. Git pull + const pullResult = await gitPull(project); + if (pullResult !== 0) { + console.log(chalk.red(`Git pull failed for ${project.name}, skipping.`)); + continue; + } + + // 3. Build + const buildResult = await composeBuild(project); + if (buildResult !== 0) { + console.log(chalk.red(`Build failed for ${project.name}.`)); + continue; + } + + // 4. Start + await composeUp(project); + + // 5. Health check + const results = checkHealth(project); + if (results.length > 0) { + printHealth(results, project.name); + } + + console.log(chalk.green(`${project.name} updated successfully.`)); + } + }); + +// --- pm logs --- +program + .command("logs ") + .alias("l") + .description("Show Docker logs for a project") + .option("-f, --follow", "Follow log output", false) + .option("-n, --tail ", "Number of lines to show", "50") + .action(async (name: string, opts: { follow: boolean; tail: string }) => { + const project = requireProject(name); + await composeLogs(project, opts.follow, parseInt(opts.tail)); + }); + +// --- pm ports --- +program + .command("ports") + .alias("p") + .description("Show port map for all services") + .action(() => { + const projects = getAllProjects(); + const containers = getRunningContainers(); + printPortMap(projects, containers); + }); + +// --- pm health [project] --- +program + .command("health [project]") + .alias("h") + .description("Check health endpoints (default: all)") + .action((name?: string) => { + const projects = name ? [requireProject(name)] : getAllProjects(); + for (const project of projects) { + const results = checkHealth(project); + if (results.length > 0) { + printHealth(results, project.name); + } + } + }); + +// --- pm ps [project] --- +program + .command("ps [project]") + .description("Show Docker compose ps for a project") + .action(async (name?: string) => { + const projects = name ? [requireProject(name)] : getAllProjects(); + for (const p of projects) { + console.log(chalk.bold(`\n${p.name}:`)); + await composePs(p); + } + }); + +// --- pm build --- +program + .command("build ") + .alias("b") + .description("Build Docker images for a project (without restart)") + .action(async (name: string) => { + const project = requireProject(name); + await composeBuild(project); + console.log(chalk.green(`\n${project.name} built.`)); + }); + +function requireProject(name: string) { + const project = resolveProject(name); + if (!project) { + console.error(chalk.red(`Unknown project: "${name}"`)); + console.error(`Available: csai, centralstore (cs), crypto (robot)`); + process.exit(1); + } + return project; +} + +program.parse(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..1e93b1d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,77 @@ +export interface ServiceInfo { + container: string; + port?: number; + healthUrl?: string; + label: string; +} + +export interface ProjectConfig { + name: string; + path: string; + composePath: string; + branch: string; + services: ServiceInfo[]; + /** Container names to stop/build (excludes DBs) */ + appContainers: string[]; +} + +export const PROJECTS: Record = { + csai: { + name: "CSAI", + path: "/home/zuf/dev/CSAI", + composePath: "/home/zuf/dev/CSAI/docker/docker-compose.yml", + branch: "main", + services: [ + { container: "csai-api", port: 5208, healthUrl: "http://localhost:5208/health", label: "API" }, + { container: "csai-web", port: 5274, healthUrl: "http://localhost:5274", label: "Web (Blazor)" }, + { container: "csai-postgres", port: 5435, label: "PostgreSQL" }, + ], + appContainers: ["csai-api", "csai-web"], + }, + + centralstore: { + name: "CentralStore", + path: "/home/zuf/dev/CentralStore", + composePath: "/home/zuf/dev/CentralStore/docker/docker-compose.yml", + branch: "main", + services: [ + { container: "centralstore-postgres", port: 5434, label: "PostgreSQL" }, + { container: "centralstore-rabbitmq", port: 5672, healthUrl: "http://localhost:15672", label: "RabbitMQ" }, + { container: "centralstore-pgadmin", port: 5050, healthUrl: "http://localhost:5050", label: "pgAdmin" }, + ], + appContainers: [], + }, + + crypto: { + name: "OtCryptorobot", + path: "/home/zuf/dev/OtCryptorobot", + composePath: "/home/zuf/dev/OtCryptorobot/_Docker/docker-compose.yml", + branch: "main", + services: [ + { container: "crypto-robotcbbe", port: 5010, healthUrl: "http://localhost:5010/health", label: "Backend API" }, + { container: "crypto-robotfe", port: 5001, healthUrl: "http://localhost:5001", label: "Frontend (Blazor)" }, + { container: "crypto-marketdataservice", port: 5020, healthUrl: "http://localhost:5020/health", label: "MarketDataService" }, + { container: "crypto-postgres-prod", port: 5432, label: "PostgreSQL (prod)" }, + { container: "crypto-postgres-dev", port: 5433, label: "PostgreSQL (dev)" }, + ], + appContainers: ["crypto-robotcbbe", "crypto-robotfe", "crypto-marketdataservice"], + }, +}; + +export const PROJECT_ALIASES: Record = { + csai: "csai", + centralstore: "centralstore", + cs: "centralstore", + crypto: "crypto", + otcryptorobot: "crypto", + robot: "crypto", +}; + +export function resolveProject(name: string): ProjectConfig | undefined { + const key = PROJECT_ALIASES[name.toLowerCase()]; + return key ? PROJECTS[key] : undefined; +} + +export function getAllProjects(): ProjectConfig[] { + return Object.values(PROJECTS); +} diff --git a/src/deploy.ts b/src/deploy.ts new file mode 100644 index 0000000..513156c --- /dev/null +++ b/src/deploy.ts @@ -0,0 +1,102 @@ +import http from "node:http"; +import { spawn } from "node:child_process"; +import { PROJECTS, type ProjectConfig } from "./config.js"; +import { exec } from "./shell.js"; + +type StepStatus = "pending" | "running" | "done" | "error"; + +interface Step { + name: string; + cmd: string; + cwd: string; +} + +function getDeploySteps(project: ProjectConfig): Step[] { + const composePath = project.composePath; + const appContainers = project.appContainers.join(" "); + const steps: Step[] = [ + { name: "git fetch", cmd: "git fetch --all", cwd: project.path }, + { name: "git checkout main", cmd: "git checkout main", cwd: project.path }, + { name: "git merge dev", cmd: "git merge dev --no-edit", cwd: project.path }, + ]; + + if (appContainers) { + steps.push( + { name: "docker build", cmd: `docker compose -f ${composePath} build ${appContainers}`, cwd: project.path }, + { name: "docker stop", cmd: `docker compose -f ${composePath} stop ${appContainers}`, cwd: project.path }, + { name: "docker start", cmd: `docker compose -f ${composePath} up -d`, cwd: project.path }, + ); + } else { + steps.push( + { name: "docker restart", cmd: `docker compose -f ${composePath} up -d`, cwd: project.path }, + ); + } + + steps.push( + { name: "git push", cmd: "git push origin main", cwd: project.path }, + ); + + return steps; +} + +function sendSSE(res: http.ServerResponse, event: string, data: unknown) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function runStep(step: Step): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve) => { + let output = ""; + const child = spawn("bash", ["-c", step.cmd], { + cwd: step.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout.on("data", (d) => (output += d.toString())); + child.stderr.on("data", (d) => (output += d.toString())); + child.on("close", (code) => resolve({ exitCode: code ?? 1, output })); + }); +} + +export async function handleDeploy(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) { + const project = PROJECTS[projectKey]; + if (!project) { + res.writeHead(404); + res.end("Unknown project"); + return; + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const steps = getDeploySteps(project); + + sendSSE(res, "init", { + project: project.name, + steps: steps.map((s) => s.name), + }); + + let failed = false; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + sendSSE(res, "step", { index: i, name: step.name, status: "running" }); + + const result = await runStep(step); + + if (result.exitCode !== 0) { + sendSSE(res, "step", { index: i, name: step.name, status: "error", output: result.output }); + sendSSE(res, "done", { success: false, error: `Step "${step.name}" failed` }); + failed = true; + break; + } + + sendSSE(res, "step", { index: i, name: step.name, status: "done", output: result.output }); + } + + if (!failed) { + sendSSE(res, "done", { success: true }); + } + + res.end(); +} diff --git a/src/display.ts b/src/display.ts new file mode 100644 index 0000000..8e3ab93 --- /dev/null +++ b/src/display.ts @@ -0,0 +1,89 @@ +import chalk from "chalk"; +import type { ProjectConfig } from "./config.js"; +import type { ContainerStatus } from "./docker.js"; +import type { GitStatus } from "./git.js"; +import type { HealthResult } from "./health.js"; + +function pad(str: string, len: number): string { + return str.length >= len ? str.substring(0, len) : str + " ".repeat(len - str.length); +} + +export function printStatusTable( + projects: ProjectConfig[], + containers: ContainerStatus[], + gitStatuses: Map +) { + console.log(); + console.log(chalk.bold("PROJECT STATUS")); + console.log(chalk.gray("─".repeat(90))); + console.log( + chalk.gray( + `${pad("Project", 18)} ${pad("Branch", 12)} ${pad("Behind", 8)} ${pad("Container", 28)} ${pad("Status", 14)} Port` + ) + ); + console.log(chalk.gray("─".repeat(90))); + + for (const project of projects) { + const git = gitStatuses.get(project.name); + let firstRow = true; + + for (const service of project.services) { + const container = containers.find((c) => c.name === service.container); + const isUp = container?.status.startsWith("Up"); + const statusText = container ? container.status.split(" (")[0] : "Stopped"; + const statusColor = isUp ? chalk.green : chalk.red; + + const projectCol = firstRow ? chalk.bold.white(pad(project.name, 18)) : pad("", 18); + const branchCol = firstRow && git ? chalk.cyan(pad(git.currentBranch, 12)) : pad("", 12); + const behindCol = + firstRow && git + ? git.behindRemote > 0 + ? chalk.yellow(pad(`${git.behindRemote}`, 8)) + : chalk.green(pad("0", 8)) + : pad("", 8); + + console.log( + `${projectCol} ${branchCol} ${behindCol} ${pad(service.label, 28)} ${statusColor(pad(statusText, 14))} ${service.port ?? ""}` + ); + firstRow = false; + } + console.log(chalk.gray("─".repeat(90))); + } + console.log(); +} + +export function printPortMap(projects: ProjectConfig[], containers: ContainerStatus[]) { + console.log(); + console.log(chalk.bold("PORT MAP")); + console.log(chalk.gray("─".repeat(60))); + console.log(chalk.gray(`${pad("Port", 8)} ${pad("Service", 30)} ${pad("Project", 18)} Status`)); + console.log(chalk.gray("─".repeat(60))); + + const allServices = projects.flatMap((p) => + p.services + .filter((s) => s.port) + .map((s) => ({ ...s, projectName: p.name })) + ); + allServices.sort((a, b) => (a.port ?? 0) - (b.port ?? 0)); + + for (const svc of allServices) { + const container = containers.find((c) => c.name === svc.container); + const isUp = container?.status.startsWith("Up"); + const status = isUp ? chalk.green("UP") : chalk.red("DOWN"); + console.log(`${pad(String(svc.port), 8)} ${pad(svc.label, 30)} ${pad(svc.projectName, 18)} ${status}`); + } + console.log(); +} + +export function printHealth(results: HealthResult[], projectName: string) { + console.log(); + console.log(chalk.bold(`HEALTH CHECK: ${projectName}`)); + console.log(chalk.gray("─".repeat(70))); + + for (const r of results) { + const status = r.ok ? chalk.green(`${r.statusCode} OK`) : chalk.red(`${r.statusCode} FAIL`); + const time = chalk.gray(`${r.responseTime}ms`); + console.log(` ${pad(r.service, 25)} ${status} ${time} ${chalk.gray(r.url)}`); + } + console.log(); +} diff --git a/src/docker.ts b/src/docker.ts new file mode 100644 index 0000000..3f640d7 --- /dev/null +++ b/src/docker.ts @@ -0,0 +1,55 @@ +import { exec, execLive } from "./shell.js"; +import type { ProjectConfig } from "./config.js"; + +export interface ContainerStatus { + name: string; + status: string; + ports: string; + image: string; +} + +export function getRunningContainers(): ContainerStatus[] { + const result = exec('docker ps --format "{{.Names}}||{{.Status}}||{{.Ports}}||{{.Image}}"'); + if (!result.stdout) return []; + return result.stdout.split("\n").map((line) => { + const [name, status, ports, image] = line.split("||"); + return { name, status, ports, image }; + }); +} + +function composeCmd(project: ProjectConfig, action: string): string { + return `docker compose -f ${project.composePath} ${action}`; +} + +export async function composeUp(project: ProjectConfig): Promise { + console.log(`Starting ${project.name}...`); + return execLive(composeCmd(project, "up -d")); +} + +export async function composeDown(project: ProjectConfig, appOnly = true): Promise { + if (appOnly && project.appContainers.length > 0) { + console.log(`Stopping ${project.name} app containers: ${project.appContainers.join(", ")}...`); + return execLive(composeCmd(project, `stop ${project.appContainers.join(" ")}`)); + } + console.log(`Stopping all ${project.name} containers...`); + return execLive(composeCmd(project, "stop")); +} + +export async function composeBuild(project: ProjectConfig): Promise { + if (project.appContainers.length === 0) { + console.log(`${project.name} has no app containers to build (infra only).`); + return 0; + } + console.log(`Building ${project.name}: ${project.appContainers.join(", ")}...`); + return execLive(composeCmd(project, `build ${project.appContainers.join(" ")}`)); +} + +export async function composeLogs(project: ProjectConfig, follow: boolean, tail: number): Promise { + const containers = project.appContainers.length > 0 ? project.appContainers.join(" ") : ""; + const flags = follow ? `-f --tail=${tail}` : `--tail=${tail}`; + return execLive(composeCmd(project, `logs ${flags} ${containers}`)); +} + +export async function composePs(project: ProjectConfig): Promise { + return execLive(composeCmd(project, "ps")); +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..9d29010 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,36 @@ +import { exec, execLive } from "./shell.js"; +import type { ProjectConfig } from "./config.js"; + +export interface GitStatus { + currentBranch: string; + behindRemote: number; + devAheadOfMain: number; + lastCommit: string; + lastCommitDev: string; +} + +export function getGitStatus(project: ProjectConfig): GitStatus { + const branch = exec("git branch --show-current", project.path).stdout || "unknown"; + exec("git fetch --all --quiet", project.path); + const behind = exec(`git rev-list HEAD..origin/${branch} --count`, project.path); + const behindCount = parseInt(behind.stdout) || 0; + const lastCommit = exec("git log -1 --oneline", project.path).stdout || ""; + + const devAhead = exec("git rev-list main..dev --count 2>/dev/null", project.path); + const devAheadCount = parseInt(devAhead.stdout) || 0; + + const lastCommitDev = exec("git log dev -1 --oneline 2>/dev/null", project.path).stdout || ""; + + return { + currentBranch: branch, + behindRemote: behindCount, + devAheadOfMain: devAheadCount, + lastCommit, + lastCommitDev, + }; +} + +export async function gitPull(project: ProjectConfig): Promise { + console.log(`Pulling latest changes for ${project.name}...`); + return execLive("git pull", project.path); +} diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..2ad4aec --- /dev/null +++ b/src/health.ts @@ -0,0 +1,28 @@ +import { exec } from "./shell.js"; +import type { ProjectConfig } from "./config.js"; + +export interface HealthResult { + service: string; + url: string; + ok: boolean; + statusCode: number; + responseTime: number; +} + +export function checkHealth(project: ProjectConfig): HealthResult[] { + return project.services + .filter((s) => s.healthUrl) + .map((service) => { + const start = Date.now(); + const result = exec(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 5 ${service.healthUrl}`); + const elapsed = Date.now() - start; + const code = parseInt(result.stdout) || 0; + return { + service: service.label, + url: service.healthUrl!, + ok: code >= 200 && code < 400, + statusCode: code, + responseTime: elapsed, + }; + }); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..ee3b91f --- /dev/null +++ b/src/server.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import http from "node:http"; +import { getStatusAll, getHealthAll, actionProject } from "./api.js"; +import { handleDeploy } from "./deploy.js"; +import { getIndexHtml } from "./web-ui.js"; + +const PORT = 5080; + +function json(res: http.ServerResponse, data: unknown, status = 200) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function html(res: http.ServerResponse, content: string) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(content); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${PORT}`); + const path = url.pathname; + + try { + if (path === "/" && req.method === "GET") { + html(res, getIndexHtml()); + } else if (path === "/api/status" && req.method === "GET") { + json(res, getStatusAll()); + } else if (path === "/api/health" && req.method === "GET") { + json(res, getHealthAll()); + } else if (path === "/api/action" && req.method === "POST") { + const body = await readBody(req); + const { project, action } = JSON.parse(body); + const result = await actionProject(project, action); + json(res, { message: result }); + } else if (path.startsWith("/api/deploy/") && req.method === "GET") { + const projectKey = path.replace("/api/deploy/", ""); + await handleDeploy(req, res, projectKey); + } else { + res.writeHead(404); + res.end("Not found"); + } + } catch (err: any) { + json(res, { error: err.message }, 500); + } +}); + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve) => { + let data = ""; + req.on("data", (chunk) => (data += chunk)); + req.on("end", () => resolve(data)); + }); +} + +server.listen(PORT, () => { + console.log(`DocMaster running at http://localhost:${PORT}`); +}); diff --git a/src/shell.ts b/src/shell.ts new file mode 100644 index 0000000..be081ff --- /dev/null +++ b/src/shell.ts @@ -0,0 +1,48 @@ +import { execSync, spawn } from "node:child_process"; + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export function exec(cmd: string, cwd?: string): ExecResult { + try { + const stdout = execSync(cmd, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 300_000, + }); + return { stdout: stdout.trim(), stderr: "", exitCode: 0 }; + } catch (err: any) { + return { + stdout: (err.stdout ?? "").toString().trim(), + stderr: (err.stderr ?? "").toString().trim(), + exitCode: err.status ?? 1, + }; + } +} + +export function execLive(cmd: string, cwd?: string): Promise { + return new Promise((resolve) => { + const child = spawn("bash", ["-c", cmd], { + cwd, + stdio: "inherit", + }); + child.on("close", (code) => resolve(code ?? 0)); + }); +} + +export function execCapture(cmd: string, cwd?: string): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve) => { + let output = ""; + const child = spawn("bash", ["-c", cmd], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout.on("data", (d) => (output += d.toString())); + child.stderr.on("data", (d) => (output += d.toString())); + child.on("close", (code) => resolve({ exitCode: code ?? 1, output })); + }); +} diff --git a/src/web-ui.ts b/src/web-ui.ts new file mode 100644 index 0000000..b25a018 --- /dev/null +++ b/src/web-ui.ts @@ -0,0 +1,271 @@ +export function getIndexHtml(): string { + return ` + + + + +DocMaster + + + + +
+

DocMaster

+
+ + +
+
+ +
+
Loading...
+
+ + + +`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8d4e7cc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +}