Skip to content

Commit 353273a

Browse files
committed
feat: add unprepare-db command to remove monitoring setup
Implements the inverse of prepare-db, allowing users to cleanly remove all postgres_ai monitoring infrastructure from a database. Features: - Drops helper functions (explain_generic, table_describe) - Drops postgres_ai.pg_statistic view and postgres_ai schema - Revokes permissions (pg_monitor, CONNECT, SELECT on pg_index) - Optionally drops the monitoring role (--keep-role to preserve) - Supports Supabase provider (skips role operations) - Confirmation prompt with --force to skip - --print-sql for offline SQL plan review - JSON output mode for automation
1 parent 39ddbd5 commit 353273a

File tree

5 files changed

+440
-1
lines changed

5 files changed

+440
-1
lines changed

cli/bin/postgres-ai.ts

Lines changed: 297 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Client } from "pg";
1212
import { startMcpServer } from "../lib/mcp-server";
1313
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
1414
import { resolveBaseUrls } from "../lib/util";
15-
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
15+
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
1616
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
1717
import * as pkce from "../lib/pkce";
1818
import * as authServer from "../lib/auth-server";
@@ -1335,6 +1335,302 @@ program
13351335
}
13361336
});
13371337

1338+
program
1339+
.command("unprepare-db [conn]")
1340+
.description("remove monitoring setup: drop monitoring user, views, schema, and revoke permissions")
1341+
.option("--db-url <url>", "PostgreSQL connection URL (admin) (deprecated; pass it as positional arg)")
1342+
.option("-h, --host <host>", "PostgreSQL host (psql-like)")
1343+
.option("-p, --port <port>", "PostgreSQL port (psql-like)")
1344+
.option("-U, --username <username>", "PostgreSQL user (psql-like)")
1345+
.option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
1346+
.option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
1347+
.option("--monitoring-user <name>", "Monitoring role name to remove", DEFAULT_MONITORING_USER)
1348+
.option("--keep-role", "Keep the monitoring role (only revoke permissions and drop objects)", false)
1349+
.option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
1350+
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
1351+
.option("--force", "Skip confirmation prompt", false)
1352+
.option("--json", "Output result as JSON (machine-readable)", false)
1353+
.addHelpText(
1354+
"after",
1355+
[
1356+
"",
1357+
"Examples:",
1358+
" postgresai unprepare-db postgresql://admin@host:5432/dbname",
1359+
" postgresai unprepare-db \"dbname=dbname host=host user=admin\"",
1360+
" postgresai unprepare-db -h host -p 5432 -U admin -d dbname",
1361+
"",
1362+
"Admin password:",
1363+
" --admin-password <password> or PGPASSWORD=... (libpq standard)",
1364+
"",
1365+
"Keep role but remove objects/permissions:",
1366+
" postgresai unprepare-db <conn> --keep-role",
1367+
"",
1368+
"Inspect SQL without applying changes:",
1369+
" postgresai unprepare-db <conn> --print-sql",
1370+
"",
1371+
"Offline SQL plan (no DB connection):",
1372+
" postgresai unprepare-db --print-sql",
1373+
"",
1374+
"Skip confirmation prompt:",
1375+
" postgresai unprepare-db <conn> --force",
1376+
].join("\n")
1377+
)
1378+
.action(async (conn: string | undefined, opts: {
1379+
dbUrl?: string;
1380+
host?: string;
1381+
port?: string;
1382+
username?: string;
1383+
dbname?: string;
1384+
adminPassword?: string;
1385+
monitoringUser: string;
1386+
keepRole?: boolean;
1387+
provider?: string;
1388+
printSql?: boolean;
1389+
force?: boolean;
1390+
json?: boolean;
1391+
}, cmd: Command) => {
1392+
// JSON output helper
1393+
const jsonOutput = opts.json;
1394+
const outputJson = (data: Record<string, unknown>) => {
1395+
console.log(JSON.stringify(data, null, 2));
1396+
};
1397+
const outputError = (error: {
1398+
message: string;
1399+
step?: string;
1400+
code?: string;
1401+
detail?: string;
1402+
hint?: string;
1403+
}) => {
1404+
if (jsonOutput) {
1405+
outputJson({
1406+
success: false,
1407+
error,
1408+
});
1409+
} else {
1410+
console.error(`Error: unprepare-db: ${error.message}`);
1411+
if (error.step) console.error(` Step: ${error.step}`);
1412+
if (error.code) console.error(` Code: ${error.code}`);
1413+
if (error.detail) console.error(` Detail: ${error.detail}`);
1414+
if (error.hint) console.error(` Hint: ${error.hint}`);
1415+
}
1416+
process.exitCode = 1;
1417+
};
1418+
1419+
const shouldPrintSql = !!opts.printSql;
1420+
const dropRole = !opts.keepRole;
1421+
1422+
// Validate provider and warn if unknown
1423+
const providerWarning = validateProvider(opts.provider);
1424+
if (providerWarning) {
1425+
console.warn(`⚠ ${providerWarning}`);
1426+
}
1427+
1428+
// Offline mode: allow printing SQL without providing/using an admin connection.
1429+
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
1430+
if (shouldPrintSql) {
1431+
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
1432+
1433+
const plan = await buildUninitPlan({
1434+
database,
1435+
monitoringUser: opts.monitoringUser,
1436+
dropRole,
1437+
provider: opts.provider,
1438+
});
1439+
1440+
console.log("\n--- SQL plan (offline; not connected) ---");
1441+
console.log(`-- database: ${database}`);
1442+
console.log(`-- monitoring user: ${opts.monitoringUser}`);
1443+
console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
1444+
console.log(`-- drop role: ${dropRole}`);
1445+
for (const step of plan.steps) {
1446+
console.log(`\n-- ${step.name}`);
1447+
console.log(step.sql);
1448+
}
1449+
console.log("\n--- end SQL plan ---\n");
1450+
return;
1451+
}
1452+
}
1453+
1454+
let adminConn;
1455+
try {
1456+
adminConn = resolveAdminConnection({
1457+
conn,
1458+
dbUrlFlag: opts.dbUrl,
1459+
host: opts.host ?? process.env.PGHOST,
1460+
port: opts.port ?? process.env.PGPORT,
1461+
username: opts.username ?? process.env.PGUSER,
1462+
dbname: opts.dbname ?? process.env.PGDATABASE,
1463+
adminPassword: opts.adminPassword,
1464+
envPassword: process.env.PGPASSWORD,
1465+
});
1466+
} catch (e) {
1467+
const msg = e instanceof Error ? e.message : String(e);
1468+
if (jsonOutput) {
1469+
outputError({ message: msg });
1470+
} else {
1471+
console.error(`Error: unprepare-db: ${msg}`);
1472+
if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
1473+
console.error("");
1474+
cmd.outputHelp({ error: true });
1475+
}
1476+
process.exitCode = 1;
1477+
}
1478+
return;
1479+
}
1480+
1481+
if (!jsonOutput) {
1482+
console.log(`Connecting to: ${adminConn.display}`);
1483+
console.log(`Monitoring user: ${opts.monitoringUser}`);
1484+
console.log(`Drop role: ${dropRole}`);
1485+
}
1486+
1487+
// Confirmation prompt (unless --force or --json)
1488+
if (!opts.force && !jsonOutput && !shouldPrintSql) {
1489+
const answer = await new Promise<string>((resolve) => {
1490+
const readline = getReadline();
1491+
readline.question(
1492+
`This will remove the monitoring setup for user "${opts.monitoringUser}"${dropRole ? " and drop the role" : ""}. Continue? [y/N] `,
1493+
(ans) => resolve(ans.trim().toLowerCase())
1494+
);
1495+
});
1496+
if (answer !== "y" && answer !== "yes") {
1497+
console.log("Aborted.");
1498+
return;
1499+
}
1500+
}
1501+
1502+
let client: Client | undefined;
1503+
try {
1504+
const connResult = await connectWithSslFallback(Client, adminConn);
1505+
client = connResult.client;
1506+
1507+
const dbRes = await client.query("select current_database() as db");
1508+
const database = dbRes.rows?.[0]?.db;
1509+
if (typeof database !== "string" || !database) {
1510+
throw new Error("Failed to resolve current database name");
1511+
}
1512+
1513+
const plan = await buildUninitPlan({
1514+
database,
1515+
monitoringUser: opts.monitoringUser,
1516+
dropRole,
1517+
provider: opts.provider,
1518+
});
1519+
1520+
if (shouldPrintSql) {
1521+
console.log("\n--- SQL plan ---");
1522+
for (const step of plan.steps) {
1523+
console.log(`\n-- ${step.name}`);
1524+
console.log(step.sql);
1525+
}
1526+
console.log("\n--- end SQL plan ---\n");
1527+
return;
1528+
}
1529+
1530+
const { applied, errors } = await applyUninitPlan({ client, plan });
1531+
1532+
if (jsonOutput) {
1533+
outputJson({
1534+
success: errors.length === 0,
1535+
action: "unprepare",
1536+
database,
1537+
monitoringUser: opts.monitoringUser,
1538+
dropRole,
1539+
applied,
1540+
errors,
1541+
});
1542+
if (errors.length > 0) {
1543+
process.exitCode = 1;
1544+
}
1545+
} else {
1546+
if (errors.length === 0) {
1547+
console.log("✓ unprepare-db completed");
1548+
console.log(`Applied ${applied.length} steps`);
1549+
} else {
1550+
console.log("⚠ unprepare-db completed with errors");
1551+
console.log(`Applied ${applied.length} steps`);
1552+
console.log("Errors:");
1553+
for (const err of errors) {
1554+
console.log(` - ${err}`);
1555+
}
1556+
process.exitCode = 1;
1557+
}
1558+
}
1559+
} catch (error) {
1560+
const errAny = error as any;
1561+
let message = "";
1562+
if (error instanceof Error && error.message) {
1563+
message = error.message;
1564+
} else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
1565+
message = errAny.message;
1566+
} else {
1567+
message = String(error);
1568+
}
1569+
if (!message || message === "[object Object]") {
1570+
message = "Unknown error";
1571+
}
1572+
1573+
const errorObj: {
1574+
message: string;
1575+
code?: string;
1576+
detail?: string;
1577+
hint?: string;
1578+
} = { message };
1579+
1580+
if (errAny && typeof errAny === "object") {
1581+
if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
1582+
if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
1583+
if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
1584+
}
1585+
1586+
if (jsonOutput) {
1587+
outputJson({
1588+
success: false,
1589+
error: errorObj,
1590+
});
1591+
process.exitCode = 1;
1592+
} else {
1593+
console.error(`Error: unprepare-db: ${message}`);
1594+
if (errAny && typeof errAny === "object") {
1595+
if (typeof errAny.code === "string" && errAny.code) {
1596+
console.error(` Code: ${errAny.code}`);
1597+
}
1598+
if (typeof errAny.detail === "string" && errAny.detail) {
1599+
console.error(` Detail: ${errAny.detail}`);
1600+
}
1601+
if (typeof errAny.hint === "string" && errAny.hint) {
1602+
console.error(` Hint: ${errAny.hint}`);
1603+
}
1604+
}
1605+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
1606+
if (errAny.code === "42501") {
1607+
console.error(" Context: dropping roles/objects requires sufficient privileges");
1608+
console.error(" Fix: connect as a superuser (or a role with appropriate DROP privileges)");
1609+
}
1610+
if (errAny.code === "ECONNREFUSED") {
1611+
console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
1612+
}
1613+
if (errAny.code === "ENOTFOUND") {
1614+
console.error(" Hint: DNS resolution failed; double-check the host name");
1615+
}
1616+
if (errAny.code === "ETIMEDOUT") {
1617+
console.error(" Hint: connection timed out; check network/firewall rules");
1618+
}
1619+
}
1620+
process.exitCode = 1;
1621+
}
1622+
} finally {
1623+
if (client) {
1624+
try {
1625+
await client.end();
1626+
} catch {
1627+
// ignore
1628+
}
1629+
}
1630+
closeReadline();
1631+
}
1632+
});
1633+
13381634
program
13391635
.command("checkup [conn]")
13401636
.description("generate health check reports directly from PostgreSQL (express mode)")

0 commit comments

Comments
 (0)