Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Every command works in two modes:
| --- | --- | --- | --- |
| `npm run setup` | ✅ | — | First-time org wizard — creates `.env.<org>` and `resources/<org>/`. |
| `npm run validate` | — | `npm run validate -- <org>` | Schema-check local YAML/MD with no network call. **Run before every `apply`.** |
| `npm run audit` | — | `npm run audit -- <org> [--type <t>]` | Read-only drift detector — orphan local YAML, state ghosts, UUID collisions, content-identical clusters, sibling base-slug clusters, dashboard orphans, assistants with inline `model.tools`. Exit 1 on any finding; safe to wire into CI. |
| `npm run apply` | ✅ | `npm run apply -- <org> [--force]` | **Default deploy verb.** Pull → merge → push in one safe pass; resilient against dashboard drift. |
| `npm run pull` | ✅ | `npm run pull -- <org> [flags]` | Fetch remote state into local files / state file. Local-first by default — won't clobber local edits. |
| `npm run push` | ✅ | `npm run push -- <org> [flags]` | Raw push without a pre-pull. **Skip unless you just ran `pull` and are certain state is fresh** — otherwise prefer `apply`. |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"call": "bash -c 'exec tsx src/call-cmd.ts \"$@\" 2> >(grep --line-buffered -v \"buffer underflow\" >&2)' --",
"cleanup": "tsx src/cleanup-cmd.ts",
"validate": "tsx src/validate-cmd.ts",
"audit": "tsx src/audit-cmd.ts",
"sim": "tsx src/sim-cmd.ts",
"rollback": "tsx src/rollback-cmd.ts",
"build": "tsc --noEmit",
Expand Down
110 changes: 110 additions & 0 deletions src/audit-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// CLI entry: `npm run audit -- <org>`
//
// Read-only audit for the state-vs-dashboard drift conditions that have been
// accumulating cruft in customer-fork repos. Mirrors `src/validate-cmd.ts` for
// argument parsing and env banner so the operator experience is consistent
// across the engine.
//
// Exit code: 0 if no findings, 1 if any (warn or error). No `--strict` flag in
// v1 — a single severity bar keeps the surface small while we observe what
// shows up in real customer state.

import { resolve } from "path";
import { fileURLToPath } from "url";
import {
type AuditFinding,
formatFinding,
runAudit,
summarizeFindings,
} from "./audit.ts";
import { APPLY_FILTER, VAPI_BASE_URL, VAPI_ENV } from "./config.ts";
import type { ResourceType } from "./types.ts";
import { VALID_RESOURCE_TYPES } from "./types.ts";

// Single source of truth for the exit-code contract. Exported so tests can pin
// behavior without duplicating the predicate.
export function exitCodeForFindings(findings: AuditFinding[]): 0 | 1 {
return findings.length === 0 ? 0 : 1;
}

function groupFindings(
findings: AuditFinding[],
): Map<ResourceType, AuditFinding[]> {
const grouped = new Map<ResourceType, AuditFinding[]>();
for (const f of findings) {
const arr = grouped.get(f.type) ?? [];
arr.push(f);
grouped.set(f.type, arr);
}
// Stable inner ordering: by rule, then by first resourceId.
for (const arr of grouped.values()) {
arr.sort((a, b) => {
if (a.rule !== b.rule) return a.rule.localeCompare(b.rule);
const aFirst = a.resourceIds[0] ?? "";
const bFirst = b.resourceIds[0] ?? "";
return aFirst.localeCompare(bFirst);
});
}
return grouped;
}

async function main(): Promise<void> {
console.log(
"═══════════════════════════════════════════════════════════════",
);
console.log(`🔎 Vapi GitOps Audit - Environment: ${VAPI_ENV}`);
console.log(` API: ${VAPI_BASE_URL}`);
console.log(
"═══════════════════════════════════════════════════════════════\n",
);

// Respect --type filter (parsed by config.ts into APPLY_FILTER). When the
// operator passes one or more --type flags we audit only those types; the
// default sweep covers every entry in VALID_RESOURCE_TYPES.
const types: ResourceType[] = APPLY_FILTER.resourceTypes?.length
? APPLY_FILTER.resourceTypes
: [...VALID_RESOURCE_TYPES];

if (APPLY_FILTER.resourceTypes?.length) {
console.log(`🔧 Type filter: ${types.join(", ")}\n`);
}

const findings = await runAudit({ types });

console.log(summarizeFindings(findings));

if (exitCodeForFindings(findings) === 0) {
process.exit(0);
}

// Group findings by resource type → rule for human-readable output.
const grouped = groupFindings(findings);

// Iterate types in the configured filter order so the operator can scan top-down.
for (const type of types) {
const arr = grouped.get(type);
if (!arr?.length) continue;
console.log(`\n${type} (${arr.length} finding(s)):`);
for (const f of arr) {
console.log(formatFinding(f));
}
}

// Any finding → exit 1. v1 has no --strict gate; a warning still indicates
// operator-actionable drift.
process.exit(exitCodeForFindings(findings));
}

const isMainModule =
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (isMainModule) {
main().catch((error) => {
console.error(
"\n❌ Audit failed:",
error instanceof Error ? error.message : error,
);
process.exit(1);
});
}
Loading