Files
clinera-site/extensions/pi-devops.ts
T

382 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pi DevOps — Optimized extension for CharityRight/QuikCue infrastructure
*
* Features:
* 🏗️ Infra context — injects full topology into every AI prompt
* 🛡️ Safety guard — confirms destructive ops before execution
* 📊 Smart footer — model, context, cost, tool tally, server indicator
* 🎯 Focus widget — keeps current task visible at all times
* ⚡ /health — live container + service status check
* 📋 /logs [svc] — tail logs for a service
* 🗺️ /infra — print full infra topology
* 🎯 /focus [task] — set/change the current task
* 🛑 /stop — abort current AI action
* 🔧 /ssh [cmd] — run a raw SSH command on the server
*
* Usage: pi -e extensions/pi-devops.ts
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { basename } from "node:path";
import { execSync } from "node:child_process";
import { applyExtensionDefaults } from "./themeMap.ts";
// ═══════════════════════════════════════════════════════════════════════════
// Infra constants
// ═══════════════════════════════════════════════════════════════════════════
const SSH = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -p 22 root@159.195.60.33`;
const INCUS_CR = `${SSH} "incus exec cr-server-new --"`;
const INFRA_CONTEXT = `
<infra-topology>
You are Pi, a DevOps assistant managing live production infrastructure.
## Primary Server
- SSH: ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -p 22 root@159.195.60.33 "<cmd>"
- HAProxy routes: charityright domains → cr-server-new, quikcue domains → qc-server-new
## Incus Containers (on primary server)
- cr-server-new (10.213.16.224) — CharityRight. Docker runs INSIDE here.
- qc-server-new (10.213.16.234) — QuikCue
- qc-server — STOPPED legacy
## Docker (inside cr-server-new only!)
- Run: ssh ... "incus exec cr-server-new -- docker <cmd>"
- Postgres container: dokploy-migrated-cr-postgres-data
- DB: donation_warehouse
- Query: ssh ... "incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c \\"SELECT ...\\""
## Services
- /opt/ayn-antivirus — AYN antivirus scanner
- /opt/enthuse-db-sync-v2 — Enthuse donation sync
- /opt/launchgood-sync — LaunchGood sync
- /root/legacy-donation-system-laravel — CharityRight Laravel app
- /root/redis-v2 — Redis
## Critical Rules
- NEVER run docker commands directly on the primary host
- Always use incus exec cr-server-new -- for Docker ops
- Warn before any restart/stop of production services
- Always test with SELECT before UPDATE/DELETE
</infra-topology>`.trim();
// ═══════════════════════════════════════════════════════════════════════════
// Dangerous patterns
// ═══════════════════════════════════════════════════════════════════════════
const DANGEROUS: { pattern: RegExp; reason: string }[] = [
{ pattern: /rm\s+-[rRf]{1,3}\s+[\/~]/, reason: "Deleting from root or home!" },
{ pattern: /rm\s+-rf/, reason: "Recursive force delete" },
{ pattern: /DROP\s+(TABLE|DATABASE|SCHEMA)/i, reason: "SQL DROP detected" },
{ pattern: /TRUNCATE\s+TABLE/i, reason: "SQL TRUNCATE detected" },
{ pattern: /DELETE\s+FROM\s+\w+\s*(WHERE\s+1=1\s*)?(;|$)/i, reason: "Potentially unsafe DELETE" },
{ pattern: /UPDATE\s+\w+\s+SET\s+.*\s*;?\s*$/i, reason: "UPDATE without WHERE — may affect all rows" },
{ pattern: /docker\s+system\s+prune/, reason: "Docker prune — removes all unused data" },
{ pattern: /incus\s+(delete|stop)\s+\S+/, reason: "Incus container operation" },
{ pattern: /systemctl\s+(stop|disable|restart)\s+(haproxy|docker|nginx)/, reason: "Touching critical production service" },
{ pattern: /git\s+push\s+.*--force/, reason: "Force push — may overwrite remote history" },
{ pattern: /git\s+reset\s+--hard/, reason: "Hard reset — may lose uncommitted changes" },
{ pattern: /mkfs\./, reason: "Filesystem format command!" },
{ pattern: /dd\s+if=/, reason: "Raw disk copy via dd" },
{ pattern: /curl.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" },
{ pattern: /wget.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" },
{ pattern: /chmod\s+-R\s+777/, reason: "Recursive world-writable permissions" },
];
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
function runSSH(cmd: string, timeout = 15000): string {
try {
return execSync(`${SSH} "${cmd.replace(/"/g, '\\"')}"`, { timeout, encoding: "utf8" }).trim();
} catch (e: any) {
return e?.stdout?.trim() || e?.message || "SSH error";
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Extension
// ═══════════════════════════════════════════════════════════════════════════
export default function (pi: ExtensionAPI) {
let focus: string | undefined;
let activeCtx: ExtensionContext | undefined;
const toolCounts: Record<string, number> = {};
// ── Tool tracking ─────────────────────────────────────────────────────
pi.on("tool_execution_end", async (event) => {
toolCounts[event.toolName] = (toolCounts[event.toolName] || 0) + 1;
});
// ── Session start ─────────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
activeCtx = ctx;
// Ask for focus task (non-blocking)
void (async () => {
const answer = await ctx.ui.input(
"🎯 What are you working on today?",
"e.g. Debug launchgood sync, check cr-server disk, investigate slow queries"
);
if (answer?.trim()) {
focus = answer.trim();
setFocusWidget(ctx);
ctx.ui.notify(`🎯 Focus set: ${focus}`, "success");
} else {
ctx.ui.notify("💡 No focus set. Use /focus to set one anytime.", "info");
}
})();
// ── Footer ────────────────────────────────────────────────────────
ctx.ui.setFooter((tui, theme, footerData) => {
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
// Accumulate tokens + cost
let tokIn = 0, tokOut = 0, cost = 0;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "assistant") {
const m = entry.message as AssistantMessage;
tokIn += m.usage.input;
tokOut += m.usage.output;
cost += m.usage.cost.total;
}
}
const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
const model = ctx.model?.id || "no-model";
const branch = footerData.getGitBranch();
const dir = basename(ctx.cwd);
// Context bar
const usage = ctx.getContextUsage();
const pct = usage?.percent ?? 0;
const filled = Math.round(pct / 10);
const bar =
(pct < 70 ? theme.fg("success", "█".repeat(filled)) : pct < 90 ? theme.fg("warning", "█".repeat(filled)) : theme.fg("error", "█".repeat(filled))) +
theme.fg("dim", "░".repeat(10 - filled));
// Line 1: model | context | tokens | cost
const l1Left =
theme.fg("accent", " Pi") +
theme.fg("dim", ` ${model} `) +
bar +
theme.fg("dim", ` ${Math.round(pct)}%`);
const l1Right =
theme.fg("success", fmt(tokIn)) +
theme.fg("dim", "↓ ") +
theme.fg("accent", fmt(tokOut)) +
theme.fg("dim", "↑ ") +
theme.fg("warning", `$${cost.toFixed(4)}`) +
theme.fg("dim", " ");
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
// Line 2: cwd | branch | tools
const l2Left =
theme.fg("dim", ` ${dir}`) +
(branch
? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")")
: "") +
theme.fg("dim", " 159.195.60.33");
const entries = Object.entries(toolCounts);
const l2Right = entries.length === 0
? theme.fg("dim", "no tools yet ")
: entries.map(([n, c]) => theme.fg("accent", n) + theme.fg("dim", ":") + theme.fg("success", `${c}`)).join(theme.fg("dim", " ")) + " ";
const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right)));
const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, "");
return [line1, line2];
},
};
});
});
pi.on("session_switch", async (_event, ctx) => { activeCtx = ctx; });
// ── Safety guard ──────────────────────────────────────────────────────
pi.on("tool_call", async (event, ctx) => {
if (isToolCallEventType("bash", event)) {
const cmd = event.input.command;
for (const { pattern, reason } of DANGEROUS) {
if (pattern.test(cmd)) {
const confirmed = await ctx.ui.confirm(
"⚠️ Destructive Operation",
`${reason}\n\nCommand:\n${cmd}\n\nRun this on LIVE production?`,
{ timeout: 30000 }
);
if (!confirmed) {
ctx.ui.notify(`🛡️ Blocked: ${reason}`, "warning");
ctx.abort();
return {
block: true,
reason: `🛑 BLOCKED: ${reason}\n\nUser declined. Ask what to do instead.`,
};
}
break;
}
}
}
return { block: false };
});
// ── System prompt injection ───────────────────────────────────────────
pi.on("before_agent_start", async (event) => {
let extra = `\n\n${INFRA_CONTEXT}`;
if (focus) {
extra += `\n\n<current-focus>\n${focus}\nStay focused on this task. If asked something unrelated, note it but bring back to the focus.\n</current-focus>`;
}
return { systemPrompt: event.systemPrompt + extra };
});
// ── Focus widget ──────────────────────────────────────────────────────
function setFocusWidget(ctx: ExtensionContext) {
ctx.ui.setWidget("focus", () => ({
render(width: number): string[] {
const line = truncateToWidth(` 🎯 ${focus}`, width - 2, "…");
return [
" ".repeat(width),
line + " ".repeat(Math.max(0, width - visibleWidth(line))),
" ".repeat(width),
];
},
invalidate() {},
}));
}
// ═══════════════════════════════════════════════════════════════════════
// Commands
// ═══════════════════════════════════════════════════════════════════════
pi.registerCommand("stop", {
description: "Abort current AI action",
handler: async (args, ctx) => {
ctx.abort();
ctx.ui.notify(args?.trim() ? `🛑 Stopped: ${args.trim()}` : "🛑 Stopped.", "warning");
},
});
pi.registerCommand("focus", {
description: "Set or change current task focus",
handler: async (args, ctx) => {
if (args?.trim()) {
focus = args.trim();
setFocusWidget(ctx);
ctx.ui.notify(`🎯 Focus set: ${focus}`, "success");
} else {
const answer = await ctx.ui.input("🎯 What are you working on?", focus || "e.g. Debug sync, check logs...");
if (answer?.trim()) {
focus = answer.trim();
setFocusWidget(ctx);
ctx.ui.notify(`🎯 Focus set: ${focus}`, "success");
}
}
},
});
pi.registerCommand("infra", {
description: "Show infra topology",
handler: async (_args, ctx) => {
ctx.ui.notify(
"🗺️ Infra Topology\n\n" +
"Server: root@159.195.60.33\n\n" +
"Containers:\n" +
" cr-server-new (10.213.16.224) — CharityRight + Docker\n" +
" qc-server-new (10.213.16.234) — QuikCue\n" +
" qc-server — STOPPED\n\n" +
"HAProxy:\n" +
" charityright.* → cr-server-new:443\n" +
" quikcue.* → qc-server-new:443\n" +
" antivirus.quikcue.com → localhost:8877\n\n" +
"Services (on cr-server-new):\n" +
" /opt/ayn-antivirus\n" +
" /opt/enthuse-db-sync-v2\n" +
" /opt/launchgood-sync\n" +
" /root/legacy-donation-system-laravel\n" +
" /root/redis-v2",
"info"
);
},
});
pi.registerCommand("health", {
description: "Live check of containers and key services",
handler: async (_args, ctx) => {
ctx.ui.notify("⏳ Checking infra health...", "info");
try {
const containers = runSSH("incus list --format=csv -c ns", 10000);
const haproxy = runSSH("systemctl is-active haproxy", 5000);
const docker = runSSH("incus exec cr-server-new -- docker ps --format '{{.Names}}\\t{{.Status}}' 2>/dev/null | head -10", 10000);
const disk = runSSH("df -h / | tail -1 | awk '{print $5\" used of \"$2}'", 5000);
const mem = runSSH("free -m | awk 'NR==2{printf \"%sMB / %sMB (%.0f%%)\", $3,$2,$3*100/$2}'", 5000);
ctx.ui.notify(
"✅ Infrastructure Health\n\n" +
`HAProxy: ${haproxy === "active" ? "✅ active" : "❌ " + haproxy}\n\n` +
`Containers:\n${containers.split("\n").map(l => " " + l).join("\n")}\n\n` +
`Docker (cr-server-new):\n${docker.split("\n").map(l => " " + l).join("\n")}\n\n` +
`Disk: ${disk}\n` +
`RAM: ${mem}`,
"info"
);
} catch (e: any) {
ctx.ui.notify(`❌ Health check failed: ${e.message}`, "error");
}
},
});
pi.registerCommand("logs", {
description: "Tail logs for a service (usage: /logs launchgood)",
handler: async (args, ctx) => {
const services: Record<string, string> = {
launchgood: "incus exec cr-server-new -- journalctl -u launchgood-sync -n 50 --no-pager 2>/dev/null || incus exec cr-server-new -- tail -50 /opt/launchgood-sync/logs/app.log 2>/dev/null",
enthuse: "incus exec cr-server-new -- journalctl -u enthuse-sync -n 50 --no-pager 2>/dev/null || incus exec cr-server-new -- tail -50 /opt/enthuse-db-sync-v2/logs/app.log 2>/dev/null",
antivirus: "journalctl -u ayn-antivirus -n 50 --no-pager 2>/dev/null",
haproxy: "journalctl -u haproxy -n 50 --no-pager",
laravel: "incus exec cr-server-new -- tail -50 /root/legacy-donation-system-laravel/storage/logs/laravel.log 2>/dev/null",
};
const svc = args?.trim().toLowerCase();
if (!svc || !services[svc]) {
const opts = Object.keys(services);
const choice = await ctx.ui.select("Which service logs?", opts);
if (!choice) return;
const cmd = services[choice];
ctx.ui.notify("⏳ Fetching logs...", "info");
const out = runSSH(cmd, 15000);
ctx.ui.notify(`📋 ${choice} logs:\n\n${out.slice(-3000)}`, "info");
} else {
ctx.ui.notify("⏳ Fetching logs...", "info");
const out = runSSH(services[svc], 15000);
ctx.ui.notify(`📋 ${svc} logs:\n\n${out.slice(-3000)}`, "info");
}
},
});
pi.registerCommand("ssh", {
description: "Run a raw SSH command on the server",
handler: async (args, ctx) => {
if (!args?.trim()) {
ctx.ui.notify("Usage: /ssh <command>\nExample: /ssh incus list", "warning");
return;
}
ctx.ui.notify(`⏳ Running: ${args.trim()}`, "info");
const out = runSSH(args.trim(), 20000);
ctx.ui.notify(`$ ${args.trim()}\n\n${out}`, "info");
},
});
}