382 lines
17 KiB
TypeScript
382 lines
17 KiB
TypeScript
/**
|
||
* 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");
|
||
},
|
||
});
|
||
}
|