main
  1/**
  2 * Deployment Guard Extension - Protect production deployments
  3 *
  4 * Prevents accidental deployments to production hosts by requiring confirmation
  5 * for dangerous operations like `make switch`, `make host/<hostname>/switch`, etc.
  6 *
  7 * Features:
  8 * - Detects deployment commands (make switch, make boot, nixos-rebuild)
  9 * - Identifies production hosts from globals.nix
 10 * - Requires user confirmation before deployment
 11 * - Shows git status to ensure clean state
 12 * - Suggests dry-build first if not already run
 13 */
 14
 15import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 16import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
 17
 18// Production hosts that require extra confirmation
 19const PRODUCTION_HOSTS = [
 20	"rhea", // NixOS server
 21	"atlas", // VPS
 22];
 23
 24// Commands that trigger deployment
 25const DEPLOYMENT_PATTERNS = [
 26	/make\s+switch/,
 27	/make\s+boot/,
 28	/make\s+host\/[^/]+\/(switch|boot)/,
 29	/nixos-rebuild\s+(switch|boot)/,
 30];
 31
 32export default function (pi: ExtensionAPI) {
 33	pi.on("tool_call", async (event, ctx) => {
 34		if (!isToolCallEventType("bash", event)) return;
 35
 36		const command = event.input.command;
 37
 38		// Check if this is a deployment command
 39		const isDeployment = DEPLOYMENT_PATTERNS.some((pattern) => pattern.test(command));
 40		if (!isDeployment) return;
 41
 42		// Extract host from command if specified
 43		const hostMatch = command.match(/make\s+host\/([^/]+)\/(switch|boot)/);
 44		const targetHost = hostMatch ? hostMatch[1] : "current";
 45
 46		// Check if targeting production
 47		const isProduction = PRODUCTION_HOSTS.some((host) => 
 48			targetHost === host || (targetHost === "current" && command.includes(host))
 49		);
 50
 51		// Get git status
 52		const gitStatus = await pi.exec("git", ["status", "--porcelain"], { 
 53			cwd: ctx.cwd,
 54			timeout: 5 
 55		});
 56
 57		const isDirty = gitStatus.stdout.trim().length > 0;
 58		const hasUncommitted = isDirty;
 59
 60		// Build warning message
 61		let warningLines = [
 62			`⚠️  Deployment detected: ${command}`,
 63			`   Target: ${targetHost}`,
 64		];
 65
 66		if (isProduction) {
 67			warningLines.push(`   🔴 PRODUCTION HOST`);
 68		}
 69
 70		if (hasUncommitted) {
 71			warningLines.push(`   ⚠️  Uncommitted changes detected`);
 72		}
 73
 74		// Show git status if dirty
 75		if (isDirty) {
 76			warningLines.push("");
 77			warningLines.push("Git status:");
 78			gitStatus.stdout.split("\n").slice(0, 10).forEach((line) => {
 79				if (line.trim()) warningLines.push(`   ${line}`);
 80			});
 81		}
 82
 83		// Suggest dry-build if not already run
 84		if (!command.includes("dry-build") && !command.includes("build")) {
 85			warningLines.push("");
 86			warningLines.push("💡 Consider running dry-build first:");
 87			if (hostMatch) {
 88				warningLines.push(`   make host/${hostMatch[1]}/dry-build`);
 89			} else {
 90				warningLines.push(`   make dry-build`);
 91			}
 92		}
 93
 94		// Show warning
 95		ctx.ui.notify(warningLines.join("\n"), "warning");
 96
 97		// Require confirmation for production
 98		if (isProduction) {
 99			const confirmed = await ctx.ui.confirm(
100				"Deploy to Production?",
101				`This will deploy to ${targetHost}. Continue?`
102			);
103
104			if (!confirmed) {
105				return { block: true, reason: "Production deployment cancelled by user" };
106			}
107		} else {
108			// For non-production, just confirm if there are uncommitted changes
109			if (hasUncommitted) {
110				const confirmed = await ctx.ui.confirm(
111					"Deploy with uncommitted changes?",
112					"You have uncommitted changes. Deploy anyway?"
113				);
114
115				if (!confirmed) {
116					return { block: true, reason: "Deployment cancelled due to uncommitted changes" };
117				}
118			}
119		}
120
121		// Log the deployment
122		ctx.ui.setStatus("deployment", ctx.ui.theme.fg("warning", `🚀 Deploying to ${targetHost}...`));
123	});
124
125	pi.on("tool_result", async (event, ctx) => {
126		// Clear deployment status after tool completes
127		if (event.toolName === "bash" && 
128		    DEPLOYMENT_PATTERNS.some((p) => p.test(event.input.command))) {
129			ctx.ui.setStatus("deployment", undefined);
130		}
131	});
132}