Skip to content

Commit 5b69171

Browse files
committed
feat: add force options for apply commands and enhance pull logic
- Introduced new commands `apply:dev:force`, `apply:stg:force`, and `apply:prod:force` to enable deletions of locally removed resources during the apply process. - Updated the `apply.ts` script to handle `--force` argument, allowing users to push deletions to the platform. - Enhanced the pull logic to skip locally deleted resources unless the force option is specified, improving safety and user control. - Updated error messages and usage instructions to reflect the new functionality.
1 parent 0014aa0 commit 5b69171

5 files changed

Lines changed: 64 additions & 23 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"apply:dev": "tsx src/apply.ts dev",
1010
"apply:stg": "tsx src/apply.ts stg",
1111
"apply:prod": "tsx src/apply.ts prod",
12+
"apply:dev:force": "tsx src/apply.ts dev --force",
13+
"apply:stg:force": "tsx src/apply.ts stg --force",
14+
"apply:prod:force": "tsx src/apply.ts prod --force",
1215
"push:dev": "tsx src/push.ts dev",
1316
"push:stg": "tsx src/push.ts stg",
1417
"push:prod": "tsx src/push.ts prod",

src/apply.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,35 +26,45 @@ function runPassthrough(cmd: string): number {
2626

2727
async function main(): Promise<void> {
2828
const env = process.argv[2];
29-
const extraArgs = process.argv.slice(3).join(" ");
29+
const allArgs = process.argv.slice(3);
30+
const hasForce = allArgs.includes("--force");
31+
32+
// Pull never gets --force (apply's pull should always preserve local changes/deletions)
33+
const pullArgs = allArgs.filter(a => a !== "--force").join(" ");
34+
const pushArgs = allArgs.join(" ");
3035

3136
if (!env || !VALID_ENVIRONMENTS.includes(env as typeof VALID_ENVIRONMENTS[number])) {
32-
console.error("Usage: npm run apply:dev | apply:stg | apply:prod");
37+
console.error("Usage: npm run apply:dev [--force]");
3338
console.error("");
3439
console.error(" Pull → Merge → Push (safe bidirectional sync)");
3540
console.error("");
36-
console.error(" Pulls latest platform state, merges with your local");
37-
console.error(" changes, and pushes the result back to the platform.");
38-
console.error(" Stops on merge conflicts for manual resolution.");
41+
console.error(" Pulls latest platform state (preserving local changes),");
42+
console.error(" then pushes the result back to the platform.");
43+
console.error("");
44+
console.error(" --force Enable deletions: resources you deleted locally");
45+
console.error(" will also be deleted from the platform.");
3946
process.exit(1);
4047
}
4148

4249
console.log("═══════════════════════════════════════════════════════════════");
4350
console.log(`🔄 Vapi GitOps Apply - Environment: ${env}`);
4451
console.log(" Pull → Merge → Push");
52+
if (hasForce) {
53+
console.log(" ⚠️ Deletions enabled (--force)");
54+
}
4555
console.log("═══════════════════════════════════════════════════════════════\n");
4656

47-
// Step 1: Pull with merge (forward filters so only matching types are pulled)
48-
const pullCmd = `npx tsx src/pull.ts ${env} ${extraArgs}`.trim();
57+
// Step 1: Pull (never forced — always preserves local deletions/changes)
58+
const pullCmd = `npx tsx src/pull.ts ${env} ${pullArgs}`.trim();
4959
const pullExit = runPassthrough(pullCmd);
5060
if (pullExit !== 0) {
5161
console.error("\n❌ Pull had issues. Resolve conflicts before pushing.");
5262
process.exit(1);
5363
}
5464

55-
// Step 2: Push merged state
65+
// Step 2: Push merged state (--force forwarded here for deletions)
5666
console.log("\n🚀 Pushing merged state to platform...\n");
57-
const pushCmd = `npx tsx src/push.ts ${env} ${extraArgs}`.trim();
67+
const pushCmd = `npx tsx src/push.ts ${env} ${pushArgs}`.trim();
5868
const pushExit = runPassthrough(pushCmd);
5969
if (pushExit !== 0) {
6070
console.error("\n❌ Push failed!");

src/pull.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ export async function pullResourceType(
466466
resourceType: ResourceType,
467467
state: StateFile,
468468
changedFiles?: Set<string>,
469+
force?: boolean,
469470
): Promise<PullStats> {
470471
console.log(`\n📥 Pulling ${resourceType}...`);
471472

@@ -497,7 +498,7 @@ export async function pullResourceType(
497498
?? generateResourceId(resource);
498499
}
499500

500-
// Skip files that have been locally modified or deleted (default mode)
501+
// Skip files that have been locally modified (git detection)
501502
if (changedFiles) {
502503
const folderPath = FOLDER_MAP[resourceType];
503504
const mdPath = join("resources", folderPath, `${resourceId}.md`);
@@ -509,6 +510,22 @@ export async function pullResourceType(
509510
continue;
510511
}
511512
}
513+
514+
// Skip resources whose local file was deleted (works without git)
515+
// A resource that was previously tracked (in state) but has no local file = intentional deletion
516+
if (!force && !isNew) {
517+
const folderPath = FOLDER_MAP[resourceType];
518+
const dir = join(RESOURCES_DIR, folderPath);
519+
const fileExists = existsSync(join(dir, `${resourceId}.md`))
520+
|| existsSync(join(dir, `${resourceId}.yml`))
521+
|| existsSync(join(dir, `${resourceId}.yaml`));
522+
if (!fileExists) {
523+
console.log(` ⏭️ ${resourceId} (locally deleted, skipping)`);
524+
newStateSection[resourceId] = resource.id;
525+
skipped++;
526+
continue;
527+
}
528+
}
512529

513530
// Detect platform defaults (orgId is null/missing — read-only, immutable)
514531
const isPlatformDefault = resource.orgId === null || resource.orgId === undefined;
@@ -600,14 +617,14 @@ async function main(): Promise<void> {
600617
// e.g. structuredOutputs reference assistants, so assistants must be pulled first.
601618
const shouldPull = (type: ResourceType) => !typeFilter?.length || typeFilter.includes(type);
602619

603-
if (shouldPull("tools")) stats.tools = await pullResourceType("tools", state, changedFiles);
604-
if (shouldPull("assistants")) stats.assistants = await pullResourceType("assistants", state, changedFiles);
605-
if (shouldPull("structuredOutputs")) stats.structuredOutputs = await pullResourceType("structuredOutputs", state, changedFiles);
606-
if (shouldPull("squads")) stats.squads = await pullResourceType("squads", state, changedFiles);
607-
if (shouldPull("personalities")) stats.personalities = await pullResourceType("personalities", state, changedFiles);
608-
if (shouldPull("scenarios")) stats.scenarios = await pullResourceType("scenarios", state, changedFiles);
609-
if (shouldPull("simulations")) stats.simulations = await pullResourceType("simulations", state, changedFiles);
610-
if (shouldPull("simulationSuites")) stats.simulationSuites = await pullResourceType("simulationSuites", state, changedFiles);
620+
if (shouldPull("tools")) stats.tools = await pullResourceType("tools", state, changedFiles, force);
621+
if (shouldPull("assistants")) stats.assistants = await pullResourceType("assistants", state, changedFiles, force);
622+
if (shouldPull("structuredOutputs")) stats.structuredOutputs = await pullResourceType("structuredOutputs", state, changedFiles, force);
623+
if (shouldPull("squads")) stats.squads = await pullResourceType("squads", state, changedFiles, force);
624+
if (shouldPull("personalities")) stats.personalities = await pullResourceType("personalities", state, changedFiles, force);
625+
if (shouldPull("scenarios")) stats.scenarios = await pullResourceType("scenarios", state, changedFiles, force);
626+
if (shouldPull("simulations")) stats.simulations = await pullResourceType("simulations", state, changedFiles, force);
627+
if (shouldPull("simulationSuites")) stats.simulationSuites = await pullResourceType("simulationSuites", state, changedFiles, force);
611628

612629
await saveState(state);
613630

src/push.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export async function applyStructuredOutput(
122122
if (existingUuid) {
123123
const updatePayload = removeExcludedKeys(payload, "structuredOutputs");
124124
console.log(` 🔄 Updating structured output: ${resourceId} (${existingUuid})`);
125-
await vapiRequest("PATCH", `/structured-output/${existingUuid}`, updatePayload);
125+
await vapiRequest("PATCH", `/structured-output/${existingUuid}?schemaOverride=true`, updatePayload);
126126
return existingUuid;
127127
} else {
128128
console.log(` ✨ Creating structured output: ${resourceId}`);

src/resolver.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ function isUUID(value: string): boolean {
1111
return UUID_REGEX.test(value);
1212
}
1313

14+
// Check if a UUID is tracked in a state section (reverse lookup)
15+
function isKnownUUID(uuid: string, stateSection: Record<string, string>): boolean {
16+
return Object.values(stateSection).includes(uuid);
17+
}
18+
1419
export function resolveToolId(
1520
toolId: string,
1621
state: StateFile
1722
): string | null {
18-
// Remove comments from YAML (e.g., "transfer-call ## Reference...")
1923
const cleanId = toolId.split("##")[0]?.trim() ?? "";
2024

21-
// If already a UUID, return it directly
2225
if (isUUID(cleanId)) {
26+
if (!isKnownUUID(cleanId, state.tools)) {
27+
console.warn(` ⚠️ Untracked tool UUID (possibly deleted): ${cleanId}`);
28+
}
2329
return cleanId;
2430
}
2531

@@ -45,8 +51,10 @@ export function resolveStructuredOutputIds(
4551
.map((refId: string) => {
4652
const cleanId = refId.split("##")[0]?.trim() ?? "";
4753

48-
// If already a UUID, return it directly
4954
if (isUUID(cleanId)) {
55+
if (!isKnownUUID(cleanId, state.structuredOutputs)) {
56+
console.warn(` ⚠️ Untracked structured output UUID (possibly deleted): ${cleanId}`);
57+
}
5058
return cleanId;
5159
}
5260

@@ -66,8 +74,11 @@ export function resolveAssistantId(
6674
): string | null {
6775
const cleanId = assistantId.split("##")[0]?.trim() ?? "";
6876

69-
// If already a UUID, return it directly
7077
if (isUUID(cleanId)) {
78+
if (!isKnownUUID(cleanId, state.assistants)) {
79+
console.warn(` ⚠️ Untracked assistant UUID (possibly deleted): ${cleanId}`);
80+
return null;
81+
}
7182
return cleanId;
7283
}
7384

0 commit comments

Comments
 (0)