Skip to content

Commit b3c11fe

Browse files
committed
feat: implement credential management and enhance error handling
- Added credential management functionality to automatically resolve and map environment-specific credential UUIDs to human-readable names. - Introduced a new `credentials.ts` module for credential resolution, including functions for building forward and reverse maps. - Enhanced error handling in API requests by introducing a custom `VapiApiError` class for better error reporting. - Updated the `pull` and `push` processes to incorporate credential resolution, ensuring seamless integration across environments. - Improved logging for unresolved credentials and API errors to provide clearer feedback during operations. - Revised the README to document the new credential management features and their usage.
1 parent 7469fca commit b3c11fe

9 files changed

Lines changed: 318 additions & 41 deletions

File tree

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ vapi-gitops/
178178
│ ├── state.ts # State file management
179179
│ ├── resources.ts # Resource loading (YAML, MD, TS)
180180
│ ├── resolver.ts # Reference resolution
181+
│ ├── credentials.ts # Credential resolution (name ↔ UUID)
181182
│ └── delete.ts # Deletion & orphan checks
182183
├── resources/
183184
│ ├── assistants/ # Assistant files (.md or .yml)
@@ -527,12 +528,53 @@ toolIds:
527528
- "uuid-1234-5678-abcd"
528529
```
529530

531+
### Credential Management
532+
533+
Credentials (API keys, JWT secrets, etc.) are environment-specific and managed automatically through the state file. No secrets are stored in resource files or git.
534+
535+
**How it works:**
536+
537+
1. **Pull** fetches all credentials from `GET /credential` and stores `name-slug → UUID` in the state file
538+
2. **Pull** replaces credential UUIDs with human-readable names in resource files
539+
3. **Push** reverses the mapping — resolves credential names back to UUIDs before sending to the API
540+
541+
```yaml
542+
# Resource file stores credential NAME (environment-agnostic)
543+
server:
544+
url: https://my-api.com/endpoint
545+
credentialId: my-server-credential # ← human-readable name
546+
```
547+
548+
```json
549+
// State file stores credential UUID (environment-specific)
550+
{
551+
"credentials": {
552+
"my-server-credential": "2f6db611-ad08-4099-8bd8-74db37b0a07e"
553+
}
554+
}
555+
```
556+
557+
**Cross-environment workflow:**
558+
559+
Each environment has its own state file with its own credential UUIDs. The same resource file works across all environments — only the state file differs:
560+
561+
```
562+
.vapi-state.dev.json → "my-cred": "uuid-for-dev"
563+
.vapi-state.stg.json → "my-cred": "uuid-for-stg"
564+
.vapi-state.prod.json → "my-cred": "uuid-for-prod"
565+
```
566+
567+
> **Note:** Credentials are auto-discovered from the Vapi API by name. Create credentials with the same name in each environment's Vapi org, and pull will populate the mappings automatically.
568+
530569
### State File
531570

532571
Tracks mapping between resource IDs and Vapi UUIDs:
533572

534573
```json
535574
{
575+
"credentials": {
576+
"my-server-credential": "uuid-0000"
577+
},
536578
"tools": {
537579
"my-tool": "uuid-1234"
538580
},
@@ -593,6 +635,17 @@ Check the state file has correct UUID:
593635
2. Find the resource entry
594636
3. If incorrect, delete entry and re-run push
595637

638+
### "Credential with ID not found" errors
639+
640+
The credential UUID doesn't exist in the target environment. Fix:
641+
1. Run `npm run pull:{env}` to fetch credentials into the state file
642+
2. If the credential doesn't exist in the target org, create it in the Vapi dashboard with the same name
643+
3. Pull again — the mapping will be auto-populated
644+
645+
### "Unresolved credential" warnings
646+
647+
A resource file has a `credentialId` that couldn't be resolved to a UUID. This means the credential name isn't in the state file. Run `pull` to populate credential mappings.
648+
596649
### "property X should not exist" API errors
597650

598651
Some properties can't be updated after creation. Add them to `UPDATE_EXCLUDED_KEYS` in `src/config.ts`.

src/api.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ import type { VapiResponse } from "./types.ts";
55
// HTTP Client for Vapi API
66
// ─────────────────────────────────────────────────────────────────────────────
77

8+
export class VapiApiError extends Error {
9+
constructor(
10+
public readonly method: string,
11+
public readonly endpoint: string,
12+
public readonly statusCode: number,
13+
public readonly apiMessage: string,
14+
public readonly rawBody: string,
15+
) {
16+
super(`API ${method} ${endpoint} failed (${statusCode}): ${apiMessage}`);
17+
this.name = "VapiApiError";
18+
}
19+
}
20+
21+
function parseApiMessage(body: string): string {
22+
try {
23+
const parsed = JSON.parse(body);
24+
if (typeof parsed.message === "string") return parsed.message;
25+
if (Array.isArray(parsed.message)) return parsed.message.join("; ");
26+
} catch { /* not JSON, use raw body */ }
27+
return body;
28+
}
29+
830
const MAX_RETRIES = 5;
931
const INITIAL_DELAY_MS = 2000;
1032
const REQUEST_DELAY_MS = 700; // Delay between requests to avoid rate limits
@@ -55,12 +77,10 @@ export async function vapiRequest<T = VapiResponse>(
5577
}
5678

5779
const errorText = await response.text();
58-
throw new Error(
59-
`API ${method} ${endpoint} failed (${response.status}): ${errorText}`
60-
);
80+
throw new VapiApiError(method, endpoint, response.status, parseApiMessage(errorText), errorText);
6181
}
6282

63-
throw new Error(`API ${method} ${endpoint} failed: max retries exceeded`);
83+
throw new VapiApiError(method, endpoint, 429, "max retries exceeded", "");
6484
}
6585

6686
export async function vapiDelete(endpoint: string): Promise<void> {
@@ -88,11 +108,9 @@ export async function vapiDelete(endpoint: string): Promise<void> {
88108
}
89109

90110
const errorText = await response.text();
91-
throw new Error(
92-
`API DELETE ${endpoint} failed (${response.status}): ${errorText}`
93-
);
111+
throw new VapiApiError("DELETE", endpoint, response.status, parseApiMessage(errorText), errorText);
94112
}
95113

96-
throw new Error(`API DELETE ${endpoint} failed: max retries exceeded`);
114+
throw new VapiApiError("DELETE", endpoint, 429, "max retries exceeded", "");
97115
}
98116

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function parseFlags(): { forceDelete: boolean; applyFilter: ApplyFilter } {
127127

128128
function loadEnvFile(env: string, baseDir: string): void {
129129
const envFiles = [
130-
join(baseDir, `.env.${env}`), // .env.dev, .env.stg, .env.prod
130+
join(baseDir, `.env.${env}`), // .env.dev, .env.staging, .env.prod
131131
join(baseDir, `.env.${env}.local`), // .env.dev.local (for local overrides)
132132
join(baseDir, ".env.local"), // .env.local (always loaded last)
133133
];

src/credentials.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { StateFile } from "./types.ts";
2+
3+
// ─────────────────────────────────────────────────────────────────────────────
4+
// Credential Resolution — resolve org-specific credential UUIDs across environments
5+
//
6+
// Credentials are pulled from the API and stored in state (name-slug → UUID).
7+
// Resource files store credential NAMES (e.g., "roofr-server-credential").
8+
// Push resolves names → UUIDs. Pull resolves UUIDs → names.
9+
// ─────────────────────────────────────────────────────────────────────────────
10+
11+
// Build UUID → name reverse map from state.credentials
12+
export function credentialReverseMap(state: StateFile): Map<string, string> {
13+
const map = new Map<string, string>();
14+
for (const [name, uuid] of Object.entries(state.credentials)) {
15+
map.set(uuid, name);
16+
}
17+
return map;
18+
}
19+
20+
// Build name → UUID forward map from state.credentials
21+
export function credentialForwardMap(state: StateFile): Map<string, string> {
22+
const map = new Map<string, string>();
23+
for (const [name, uuid] of Object.entries(state.credentials)) {
24+
map.set(name, uuid);
25+
}
26+
return map;
27+
}
28+
29+
// ─────────────────────────────────────────────────────────────────────────────
30+
// Deep walk: replace string values matching the map keys
31+
// Works at any depth in any object/array structure
32+
// ─────────────────────────────────────────────────────────────────────────────
33+
34+
export function deepReplaceValues<T>(obj: T, replacements: Map<string, string>): T {
35+
if (replacements.size === 0) return obj;
36+
return walk(obj, replacements) as T;
37+
}
38+
39+
function walk(value: unknown, replacements: Map<string, string>): unknown {
40+
if (typeof value === "string") {
41+
return replacements.get(value) ?? value;
42+
}
43+
44+
if (Array.isArray(value)) {
45+
return value.map((item) => walk(item, replacements));
46+
}
47+
48+
if (value !== null && typeof value === "object") {
49+
const result: Record<string, unknown> = {};
50+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
51+
result[key] = walk(val, replacements);
52+
}
53+
return result;
54+
}
55+
56+
return value;
57+
}

src/delete.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { vapiDelete } from "./api.ts";
1+
import { vapiDelete, VapiApiError } from "./api.ts";
22
import { FORCE_DELETE } from "./config.ts";
33
import { extractReferencedIds } from "./resolver.ts";
44
import type {
@@ -229,7 +229,8 @@ export async function deleteOrphanedResources(
229229
delete state[stateKey][resourceId];
230230
deleted++;
231231
} catch (error) {
232-
console.error(` ❌ Failed to delete ${type} ${resourceId}:`, error);
232+
const msg = error instanceof VapiApiError ? error.apiMessage : (error instanceof Error ? error.message : String(error));
233+
console.error(` ❌ Failed to delete ${type} ${resourceId}: ${msg}`);
233234
throw error;
234235
}
235236
}

src/pull.ts

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { execSync } from "child_process";
2+
import { existsSync, readdirSync } from "fs";
23
import { mkdir, writeFile } from "fs/promises";
34
import { join, dirname, relative } from "path";
45
import { stringify } from "yaml";
56
import { VAPI_ENV, VAPI_BASE_URL, VAPI_TOKEN, RESOURCES_DIR, BASE_DIR, APPLY_FILTER } from "./config.ts";
67
import { loadState, saveState } from "./state.ts";
8+
import { credentialReverseMap, deepReplaceValues } from "./credentials.ts";
79
import type { StateFile, ResourceType } from "./types.ts";
810

911
// ─────────────────────────────────────────────────────────────────────────────
@@ -108,7 +110,9 @@ export async function fetchAllResources(resourceType: ResourceType): Promise<Vap
108110

109111
if (!response.ok) {
110112
const errorText = await response.text();
111-
throw new Error(`API GET ${endpoint} failed (${response.status}): ${errorText}`);
113+
let apiMessage = errorText;
114+
try { const parsed = JSON.parse(errorText); if (typeof parsed.message === "string") apiMessage = parsed.message; } catch {}
115+
throw new Error(`API GET ${endpoint} failed (${response.status}): ${apiMessage}`);
112116
}
113117

114118
const data = await response.json();
@@ -121,6 +125,64 @@ export async function fetchAllResources(resourceType: ResourceType): Promise<Vap
121125
return data as VapiResource[];
122126
}
123127

128+
// ─────────────────────────────────────────────────────────────────────────────
129+
// Credential Fetching
130+
// ─────────────────────────────────────────────────────────────────────────────
131+
132+
interface VapiCredential {
133+
id: string;
134+
name?: string;
135+
provider: string;
136+
[key: string]: unknown;
137+
}
138+
139+
async function fetchCredentials(): Promise<VapiCredential[]> {
140+
const url = `${VAPI_BASE_URL}/credential`;
141+
const response = await fetch(url, {
142+
method: "GET",
143+
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
144+
});
145+
146+
if (!response.ok) {
147+
const errorText = await response.text();
148+
let apiMessage = errorText;
149+
try { const parsed = JSON.parse(errorText); if (typeof parsed.message === "string") apiMessage = parsed.message; } catch {}
150+
throw new Error(`API GET /credential failed (${response.status}): ${apiMessage}`);
151+
}
152+
153+
return (await response.json()) as VapiCredential[];
154+
}
155+
156+
function credentialSlug(cred: VapiCredential): string {
157+
const base = cred.name || cred.provider || "credential";
158+
return slugify(base);
159+
}
160+
161+
async function pullCredentials(state: StateFile): Promise<void> {
162+
console.log("\n🔑 Pulling credentials...");
163+
const credentials = await fetchCredentials();
164+
console.log(` Found ${credentials.length} credentials in Vapi`);
165+
166+
const newSection: Record<string, string> = {};
167+
// Build reverse map from existing state to preserve slug stability
168+
const existingReverse = new Map<string, string>();
169+
for (const [slug, uuid] of Object.entries(state.credentials)) {
170+
existingReverse.set(uuid, slug);
171+
}
172+
173+
for (const cred of credentials) {
174+
// Reuse existing slug if available, otherwise generate a new one
175+
let slug = existingReverse.get(cred.id);
176+
if (!slug) {
177+
slug = credentialSlug(cred);
178+
}
179+
newSection[slug] = cred.id;
180+
console.log(` 🔑 ${slug} -> ${cred.id}`);
181+
}
182+
183+
state.credentials = newSection;
184+
}
185+
124186
// ─────────────────────────────────────────────────────────────────────────────
125187
// Naming & Slug Generation
126188
// ─────────────────────────────────────────────────────────────────────────────
@@ -147,6 +209,28 @@ function generateResourceId(resource: VapiResource): string {
147209
return name ? `${slugify(name)}-${shortId}` : `resource-${shortId}`;
148210
}
149211

212+
// When pulling a new environment, a resource may already exist on disk under a
213+
// different UUID suffix (e.g., `end-call-tool-8102e715` from dev). Match by
214+
// name-slug so we reuse the existing file instead of creating a duplicate.
215+
function findExistingResourceId(
216+
resourceType: ResourceType,
217+
resource: VapiResource,
218+
): string | undefined {
219+
const name = extractName(resource);
220+
if (!name) return undefined;
221+
222+
const nameSlug = slugify(name);
223+
const dir = join(RESOURCES_DIR, FOLDER_MAP[resourceType]);
224+
if (!existsSync(dir)) return undefined;
225+
226+
const matches = readdirSync(dir)
227+
.filter((f) => /\.(yml|yaml|md)$/.test(f))
228+
.map((f) => f.replace(/\.(yml|yaml|md)$/, ""))
229+
.filter((id) => id === nameSlug || id.startsWith(nameSlug + "-"));
230+
231+
return matches.length === 1 ? matches[0] : undefined;
232+
}
233+
150234
// ─────────────────────────────────────────────────────────────────────────────
151235
// Resource Processing
152236
// ─────────────────────────────────────────────────────────────────────────────
@@ -395,6 +479,7 @@ export async function pullResourceType(
395479
console.log(` Found ${resources.length} ${resourceType} in Vapi`);
396480

397481
const reverseMap = buildReverseMap(state, resourceType);
482+
const credReverse = credentialReverseMap(state);
398483
const newStateSection: Record<string, string> = {};
399484

400485
let created = 0;
@@ -407,7 +492,9 @@ export async function pullResourceType(
407492
const isNew = !resourceId;
408493

409494
if (!resourceId) {
410-
resourceId = generateResourceId(resource);
495+
// Reuse an existing file's resourceId if the name matches (cross-env pull)
496+
resourceId = findExistingResourceId(resourceType, resource)
497+
?? generateResourceId(resource);
411498
}
412499

413500
// Skip files that have been locally modified or deleted (default mode)
@@ -426,17 +513,18 @@ export async function pullResourceType(
426513
// Detect platform defaults (orgId is null/missing — read-only, immutable)
427514
const isPlatformDefault = resource.orgId === null || resource.orgId === undefined;
428515

429-
// Clean and resolve references
516+
// Clean, resolve resource references, and replace credential UUIDs with names
430517
const cleaned = cleanResource(resource);
431518
const resolved = resolveReferencesToResourceIds(cleaned, state);
519+
const withCredNames = deepReplaceValues(resolved, credReverse);
432520

433521
// Mark platform defaults so apply skips them
434522
if (isPlatformDefault) {
435-
resolved._platformDefault = true;
523+
withCredNames._platformDefault = true;
436524
}
437525

438526
// Write to file
439-
const filePath = await writeResourceFile(resourceType, resourceId, resolved);
527+
const filePath = await writeResourceFile(resourceType, resourceId, withCredNames);
440528
const icon = isPlatformDefault ? "🔒" : isNew ? "✨" : "📝";
441529
const relPath = relative(BASE_DIR, filePath);
442530
console.log(` ${icon} ${resourceId} -> ${relPath}${isPlatformDefault ? " (platform default, read-only)" : ""}`);
@@ -492,6 +580,9 @@ async function main(): Promise<void> {
492580

493581
const state = loadState();
494582

583+
// Credentials are always pulled first — they're needed to reverse-resolve UUIDs in resource files
584+
await pullCredentials(state);
585+
495586
const zero: PullStats = { created: 0, updated: 0, skipped: 0 };
496587
const stats: Record<string, PullStats> = {
497588
tools: { ...zero },
@@ -541,6 +632,6 @@ async function main(): Promise<void> {
541632

542633
// Run the pull engine
543634
main().catch((error) => {
544-
console.error("\n❌ Pull failed:", error);
635+
console.error("\n❌ Pull failed:", error instanceof Error ? error.message : error);
545636
process.exit(1);
546637
});

0 commit comments

Comments
 (0)