Skip to content

Commit eb29042

Browse files
committed
feat(gitops): add .vapi-ignore for explicit resource opt-out
Pull previously had only two terminal states for a remote resource missing locally: "new resource, pull it" or "locally deleted, skip forever (state-tracked intent)". There was no way to say "I don't want this resource managed by my repo at all" without either letting it sit broken locally or relying on the deletion-intent state forever. Add a `.vapi-ignore` file (per-environment, gitignore-style) that lets users explicitly opt resources out of pull. Ignored resources are never written to disk and never tracked in state, distinct from "locally deleted" which keeps a UUID mapping. Skip messages now distinguish three reasons: 🚫 = matched .vapi-ignore (not tracked) ✏️ = locally modified (preserved) 🗑️ = locally deleted (intent in state) Pattern syntax is gitignore-flavored with `*`, `**`, and `?` globs matching against `<folderPath>/<resourceId>`. See `resources/<env>/.vapi-ignore.example` for documentation.
1 parent a1e3228 commit eb29042

6 files changed

Lines changed: 271 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ This project manages **Vapi voice agent configurations** as code. All resources
1010

1111
**Template-safe first run:** In a fresh clone, prefer `npm run pull:dev:bootstrap` (or the matching env) to refresh `.vapi-state.<env>.json` and credential mappings without materializing the target org's resources into `resources/<env>/`. `push:<env>` will auto-run the same bootstrap sync when it detects empty or stale state for the resources being applied.
1212

13+
**Excluding resources from sync (`.vapi-ignore`):** To prevent specific resources from being pulled at all (e.g. assistants owned by another team or legacy resources you don't want to manage), create `resources/<env>/.vapi-ignore` with gitignore-style patterns. See `resources/<env>/.vapi-ignore.example` for syntax and examples. Ignored resources are silently skipped on every pull and never tracked in state — distinct from "locally deleted" which keeps an entry in state.
14+
1315
**Learnings & recipes:** Before configuring resources or debugging issues, read the relevant file in **`docs/learnings/`**. Load only what you need:
1416

1517
| Working on | Read |

resources/dev/.vapi-ignore.example

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# .vapi-ignore — explicit opt-out for resources this repo does NOT manage.
2+
#
3+
# Resources matching any pattern below are skipped during `pull:<env>` —
4+
# never written to disk, never tracked in state. Use this for:
5+
# - Resources owned by another team or repo
6+
# - Legacy/broken resources you don't want to touch
7+
# - Resource types you don't care about (e.g. an entire `simulations/**`)
8+
#
9+
# To activate this file, copy it to `.vapi-ignore` (no `.example` suffix).
10+
#
11+
# ─────────────────────────────────────────────────────────────────────
12+
# Pattern syntax
13+
# ─────────────────────────────────────────────────────────────────────
14+
# Patterns match against `<folderPath>/<resourceId>` (no extension):
15+
# - assistants/...
16+
# - tools/...
17+
# - squads/...
18+
# - structuredOutputs/...
19+
# - simulations/personalities/...
20+
# - simulations/scenarios/...
21+
# - simulations/tests/...
22+
# - simulations/suites/...
23+
#
24+
# Wildcards:
25+
# - `*` matches any characters within a single path segment
26+
# - `**` matches across path segments
27+
# - `?` matches a single character
28+
#
29+
# Lines starting with `#` are comments. Blank lines are ignored.
30+
#
31+
# ─────────────────────────────────────────────────────────────────────
32+
# Examples
33+
# ─────────────────────────────────────────────────────────────────────
34+
35+
# Ignore a specific assistant by exact resource ID
36+
# assistants/ab-assistant-56b80091
37+
38+
# Ignore all assistants whose name starts with `legacy-`
39+
# assistants/legacy-*
40+
41+
# Ignore an entire resource type
42+
# simulations/**
43+
44+
# Ignore tools owned by another team
45+
# tools/team-x-*
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# .vapi-ignore — explicit opt-out for resources this repo does NOT manage.
2+
#
3+
# Resources matching any pattern below are skipped during `pull:<env>` —
4+
# never written to disk, never tracked in state. Use this for:
5+
# - Resources owned by another team or repo
6+
# - Legacy/broken resources you don't want to touch
7+
# - Resource types you don't care about (e.g. an entire `simulations/**`)
8+
#
9+
# To activate this file, copy it to `.vapi-ignore` (no `.example` suffix).
10+
#
11+
# ─────────────────────────────────────────────────────────────────────
12+
# Pattern syntax
13+
# ─────────────────────────────────────────────────────────────────────
14+
# Patterns match against `<folderPath>/<resourceId>` (no extension):
15+
# - assistants/...
16+
# - tools/...
17+
# - squads/...
18+
# - structuredOutputs/...
19+
# - simulations/personalities/...
20+
# - simulations/scenarios/...
21+
# - simulations/tests/...
22+
# - simulations/suites/...
23+
#
24+
# Wildcards:
25+
# - `*` matches any characters within a single path segment
26+
# - `**` matches across path segments
27+
# - `?` matches a single character
28+
#
29+
# Lines starting with `#` are comments. Blank lines are ignored.
30+
#
31+
# ─────────────────────────────────────────────────────────────────────
32+
# Examples
33+
# ─────────────────────────────────────────────────────────────────────
34+
35+
# Ignore a specific assistant by exact resource ID
36+
# assistants/ab-assistant-56b80091
37+
38+
# Ignore all assistants whose name starts with `legacy-`
39+
# assistants/legacy-*
40+
41+
# Ignore an entire resource type
42+
# simulations/**
43+
44+
# Ignore tools owned by another team
45+
# tools/team-x-*

resources/stg/.vapi-ignore.example

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# .vapi-ignore — explicit opt-out for resources this repo does NOT manage.
2+
#
3+
# Resources matching any pattern below are skipped during `pull:<env>` —
4+
# never written to disk, never tracked in state. Use this for:
5+
# - Resources owned by another team or repo
6+
# - Legacy/broken resources you don't want to touch
7+
# - Resource types you don't care about (e.g. an entire `simulations/**`)
8+
#
9+
# To activate this file, copy it to `.vapi-ignore` (no `.example` suffix).
10+
#
11+
# ─────────────────────────────────────────────────────────────────────
12+
# Pattern syntax
13+
# ─────────────────────────────────────────────────────────────────────
14+
# Patterns match against `<folderPath>/<resourceId>` (no extension):
15+
# - assistants/...
16+
# - tools/...
17+
# - squads/...
18+
# - structuredOutputs/...
19+
# - simulations/personalities/...
20+
# - simulations/scenarios/...
21+
# - simulations/tests/...
22+
# - simulations/suites/...
23+
#
24+
# Wildcards:
25+
# - `*` matches any characters within a single path segment
26+
# - `**` matches across path segments
27+
# - `?` matches a single character
28+
#
29+
# Lines starting with `#` are comments. Blank lines are ignored.
30+
#
31+
# ─────────────────────────────────────────────────────────────────────
32+
# Examples
33+
# ─────────────────────────────────────────────────────────────────────
34+
35+
# Ignore a specific assistant by exact resource ID
36+
# assistants/ab-assistant-56b80091
37+
38+
# Ignore all assistants whose name starts with `legacy-`
39+
# assistants/legacy-*
40+
41+
# Ignore an entire resource type
42+
# simulations/**
43+
44+
# Ignore tools owned by another team
45+
# tools/team-x-*

src/config.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,93 @@ export function removeExcludedKeys(
261261
}
262262
return filtered;
263263
}
264+
265+
// ─────────────────────────────────────────────────────────────────────────────
266+
// Ignore Patterns (.vapi-ignore)
267+
//
268+
// Resources matching any pattern in resources/<env>/.vapi-ignore are skipped
269+
// during pull (never written, never tracked). This is the explicit opt-out
270+
// mechanism for resources that exist on the dashboard but should not be
271+
// managed by this repo.
272+
//
273+
// Pattern syntax (gitignore-flavored, simplified):
274+
// - Matches against `<folderPath>/<resourceId>` (no extension)
275+
// e.g. `assistants/ab-assistant-56b80091`
276+
// - `*` matches any run of characters within a single path segment
277+
// - `**` matches across path segments (zero or more)
278+
// - Lines starting with `#` are comments
279+
// - Blank lines are ignored
280+
// - Leading `!` is reserved for future negation; treated as a comment today
281+
// ─────────────────────────────────────────────────────────────────────────────
282+
283+
let cachedIgnorePatterns: string[] | null = null;
284+
285+
function getIgnoreFilePath(): string {
286+
return join(BASE_DIR, "resources", VAPI_ENV, ".vapi-ignore");
287+
}
288+
289+
export function loadIgnorePatterns(): string[] {
290+
if (cachedIgnorePatterns !== null) return cachedIgnorePatterns;
291+
292+
const path = getIgnoreFilePath();
293+
if (!existsSync(path)) {
294+
cachedIgnorePatterns = [];
295+
return cachedIgnorePatterns;
296+
}
297+
298+
const raw = readFileSync(path, "utf-8");
299+
cachedIgnorePatterns = raw
300+
.split("\n")
301+
.map((line) => line.trim())
302+
.filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!"));
303+
304+
return cachedIgnorePatterns;
305+
}
306+
307+
// Convert a gitignore-flavored glob to a RegExp. We keep the implementation
308+
// intentionally small (no node_modules) since pull.ts is the only consumer.
309+
function compilePattern(pattern: string): RegExp {
310+
// Escape regex metacharacters except the glob ones we handle explicitly.
311+
// `*` and `?` are translated below; everything else is literal.
312+
let regex = "";
313+
for (let i = 0; i < pattern.length; i++) {
314+
const c = pattern[i];
315+
if (c === "*") {
316+
// `**` → match any characters including path separators
317+
// `*` → match any characters within a single segment (no `/`)
318+
if (pattern[i + 1] === "*") {
319+
regex += ".*";
320+
i++; // consume the second `*`
321+
} else {
322+
regex += "[^/]*";
323+
}
324+
} else if (c === "?") {
325+
regex += "[^/]";
326+
} else if ("\\^$.|+(){}[]".includes(c as string)) {
327+
regex += `\\${c}`;
328+
} else {
329+
regex += c;
330+
}
331+
}
332+
return new RegExp(`^${regex}$`);
333+
}
334+
335+
// Check whether a resource at `<folderPath>/<resourceId>` matches the ignore list.
336+
// Returns the matched pattern (truthy) or null.
337+
export function matchesIgnore(
338+
folderPath: string,
339+
resourceId: string,
340+
patterns: string[] = loadIgnorePatterns(),
341+
): string | null {
342+
if (patterns.length === 0) return null;
343+
const target = `${folderPath}/${resourceId}`;
344+
for (const pattern of patterns) {
345+
if (compilePattern(pattern).test(target)) return pattern;
346+
}
347+
return null;
348+
}
349+
350+
// Test-only: clear the cache. Production code does not need to call this.
351+
export function _resetIgnoreCache(): void {
352+
cachedIgnorePatterns = null;
353+
}

src/pull.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
BASE_DIR,
1313
APPLY_FILTER,
1414
BOOTSTRAP_SYNC,
15+
loadIgnorePatterns,
16+
matchesIgnore,
1517
} from "./config.ts";
1618
import { loadState, saveState } from "./state.ts";
1719
import { credentialReverseMap, replaceCredentialRefs } from "./credentials.ts";
@@ -669,9 +671,25 @@ export async function pullResourceType(
669671

670672
removeUuidMappings(newStateSection, resource.id, resourceId);
671673

674+
const folderPath = FOLDER_MAP[resourceType];
675+
676+
// Skip resources matched by .vapi-ignore.
677+
// These are explicit opt-outs — the resource exists on the dashboard but
678+
// this repo does not manage it. Do NOT track in state (so a future
679+
// un-ignore pulls cleanly and so state doesn't accumulate stale entries).
680+
if (!bootstrap && !force) {
681+
const matched = matchesIgnore(folderPath, resourceId);
682+
if (matched) {
683+
console.log(
684+
` 🚫 ${resourceId} (matched .vapi-ignore: ${matched})`,
685+
);
686+
skipped++;
687+
continue;
688+
}
689+
}
690+
672691
// Skip files that have been locally modified (git detection)
673692
if (!bootstrap && changedFiles) {
674-
const folderPath = FOLDER_MAP[resourceType];
675693
const mdPath = join(
676694
"resources",
677695
VAPI_ENV,
@@ -685,24 +703,27 @@ export async function pullResourceType(
685703
`${resourceId}.yml`,
686704
);
687705
if (changedFiles.has(mdPath) || changedFiles.has(ymlPath)) {
688-
console.log(` ${resourceId} (locally changed, skipping)`);
706+
console.log(` ${resourceId} (locally modified, preserving)`);
689707
newStateSection[resourceId] = resource.id;
690708
skipped++;
691709
continue;
692710
}
693711
}
694712

695-
// Skip resources whose local file was deleted (works without git)
696-
// A resource that was previously tracked (in state) but has no local file = intentional deletion
713+
// Skip resources whose local file was deleted (works without git).
714+
// A resource that was previously tracked in state but now has no local
715+
// file is treated as an intentional deletion. To stop tracking it
716+
// entirely (so it never re-appears on pull), add it to .vapi-ignore.
697717
if (!bootstrap && !force && !isNew) {
698-
const folderPath = FOLDER_MAP[resourceType];
699718
const dir = join(RESOURCES_DIR, folderPath);
700719
const fileExists =
701720
existsSync(join(dir, `${resourceId}.md`)) ||
702721
existsSync(join(dir, `${resourceId}.yml`)) ||
703722
existsSync(join(dir, `${resourceId}.yaml`));
704723
if (!fileExists) {
705-
console.log(` ⏭️ ${resourceId} (locally deleted, skipping)`);
724+
console.log(
725+
` 🗑️ ${resourceId} (deleted locally, intent in state — add to .vapi-ignore to stop tracking)`,
726+
);
706727
newStateSection[resourceId] = resource.id;
707728
skipped++;
708729
continue;
@@ -808,13 +829,22 @@ export async function runPull(options: PullOptions = {}): Promise<PullResult> {
808829
}
809830
if (changedFiles.size > 0) {
810831
console.log(
811-
`\n📦 ${changedFiles.size} locally changed file(s) will be preserved`,
832+
`\n📦 ${changedFiles.size} locally modified file(s) will be preserved`,
812833
);
813834
console.log(
814835
" Use --force to overwrite all local files with platform state",
815836
);
816837
}
817-
} else if (force) {
838+
}
839+
840+
const ignorePatterns = !bootstrap && !force ? loadIgnorePatterns() : [];
841+
if (ignorePatterns.length > 0) {
842+
console.log(
843+
`\n🚫 ${ignorePatterns.length} pattern(s) loaded from .vapi-ignore — matching resources will be skipped`,
844+
);
845+
}
846+
847+
if (force) {
818848
console.log(
819849
"\n⚡ Force mode: overwriting all local files with platform state",
820850
);
@@ -926,7 +956,12 @@ export async function runPull(options: PullOptions = {}): Promise<PullResult> {
926956
}
927957

928958
if (totalSkipped > 0) {
929-
console.log(`\n ℹ️ ${totalSkipped} file(s) preserved (locally changed)`);
959+
console.log(
960+
`\n ℹ️ ${totalSkipped} resource(s) skipped — see lines above for reasons:`,
961+
);
962+
console.log(" 🚫 = matched .vapi-ignore (not tracked)");
963+
console.log(" ✏️ = locally modified (preserved)");
964+
console.log(" 🗑️ = locally deleted (intent in state)");
930965
console.log(" Run with --force to overwrite: npm run pull:dev:force");
931966
}
932967

0 commit comments

Comments
 (0)