Skip to content
Open
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
43 changes: 11 additions & 32 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import type { VapiResponse } from "./types.ts";
import {
INITIAL_DELAY_MS,
MAX_RETRIES,
parseApiMessage,
shouldRetry,
VapiApiError,
} from "./vapi-client.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Dry-run accounting
Expand Down Expand Up @@ -40,32 +47,11 @@ function formatBodyPreview(body: Record<string, unknown>): string {
// HTTP Client for Vapi API
// ─────────────────────────────────────────────────────────────────────────────

export class VapiApiError extends Error {
constructor(
public readonly method: string,
public readonly endpoint: string,
public readonly statusCode: number,
public readonly apiMessage: string,
public readonly rawBody: string,
) {
super(`API ${method} ${endpoint} failed (${statusCode}): ${apiMessage}`);
this.name = "VapiApiError";
}
}
// The error class, message parsing and retry policy are shared with the
// config-free client in vapi-client.ts. VapiApiError is re-exported so
// existing importers and `instanceof` checks see one class.
export { VapiApiError };

function parseApiMessage(body: string): string {
try {
const parsed = JSON.parse(body);
if (typeof parsed.message === "string") return parsed.message;
if (Array.isArray(parsed.message)) return parsed.message.join("; ");
} catch {
/* not JSON, use raw body */
}
return body;
}

const MAX_RETRIES = 5;
const INITIAL_DELAY_MS = 2000;
const REQUEST_DELAY_MS = 700; // Delay between requests to avoid rate limits

let lastRequestTime = 0;
Expand All @@ -83,13 +69,6 @@ async function throttle(): Promise<void> {
lastRequestTime = Date.now();
}

// 429 = rate limit. 5xx = transient server error (gateway timeout, upstream
// hiccup, deploy in progress). Both are worth retrying with backoff; surfacing
// a 502 as a hard failure forces the operator to re-run the entire push.
function shouldRetry(status: number): boolean {
return status === 429 || (status >= 500 && status < 600);
}

export async function vapiRequest<T = VapiResponse>(
method: "POST" | "PATCH",
endpoint: string,
Expand Down
77 changes: 10 additions & 67 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { existsSync, readFileSync } from "fs";
import { basename, dirname, join, relative, resolve } from "path";
import { fileURLToPath } from "url";
import type { Environment, ResourceType } from "./types.ts";
import {
ignorePatternsRead,
matchesIgnore as matchesIgnorePatterns,
} from "./resource-parse.ts";
import { VALID_RESOURCE_TYPES } from "./types.ts";

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -326,89 +330,28 @@ export function removeExcludedKeys(
// ─────────────────────────────────────────────────────────────────────────────
// Ignore Patterns (.vapi-ignore)
//
// Resources matching any pattern in resources/<env>/.vapi-ignore are skipped
// during pull (never written, never tracked). This is the explicit opt-out
// mechanism for resources that exist on the dashboard but should not be
// managed by this repo.
//
// Pattern syntax (gitignore-flavored, simplified):
// - Matches against `<folderPath>/<resourceId>` (no extension)
// e.g. `assistants/ab-assistant-56b80091`
// - `*` matches any run of characters within a single path segment
// - `**` matches across path segments (zero or more)
// - Lines starting with `#` are comments
// - Blank lines are ignored
// - Leading `!` is reserved for future negation; treated as a comment today
// Syntax and matching live in resource-parse.ts (config-free); this binds
// them to the configured org's resources directory and caches the read.
// ─────────────────────────────────────────────────────────────────────────────

let cachedIgnorePatterns: string[] | null = null;

function getIgnoreFilePath(): string {
return join(BASE_DIR, "resources", VAPI_ENV, ".vapi-ignore");
}

export function loadIgnorePatterns(): string[] {
if (cachedIgnorePatterns !== null) return cachedIgnorePatterns;

const path = getIgnoreFilePath();
if (!existsSync(path)) {
cachedIgnorePatterns = [];
return cachedIgnorePatterns;
}

const raw = readFileSync(path, "utf-8");
cachedIgnorePatterns = raw
.split("\n")
.map((line) => line.trim())
.filter(
(line) =>
line.length > 0 && !line.startsWith("#") && !line.startsWith("!"),
);

cachedIgnorePatterns = ignorePatternsRead(
join(BASE_DIR, "resources", VAPI_ENV),
);
return cachedIgnorePatterns;
}

// Convert a gitignore-flavored glob to a RegExp. We keep the implementation
// intentionally small (no node_modules) since pull.ts is the only consumer.
function compilePattern(pattern: string): RegExp {
// Escape regex metacharacters except the glob ones we handle explicitly.
// `*` and `?` are translated below; everything else is literal.
let regex = "";
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i];
if (c === "*") {
// `**` → match any characters including path separators
// `*` → match any characters within a single segment (no `/`)
if (pattern[i + 1] === "*") {
regex += ".*";
i++; // consume the second `*`
} else {
regex += "[^/]*";
}
} else if (c === "?") {
regex += "[^/]";
} else if ("\\^$.|+(){}[]".includes(c as string)) {
regex += `\\${c}`;
} else {
regex += c;
}
}
return new RegExp(`^${regex}$`);
}

// Check whether a resource at `<folderPath>/<resourceId>` matches the ignore list.
// Returns the matched pattern (truthy) or null.
export function matchesIgnore(
folderPath: string,
resourceId: string,
patterns: string[] = loadIgnorePatterns(),
): string | null {
if (patterns.length === 0) return null;
const target = `${folderPath}/${resourceId}`;
for (const pattern of patterns) {
if (compilePattern(pattern).test(target)) return pattern;
}
return null;
return matchesIgnorePatterns(folderPath, resourceId, patterns);
}

// Test-only: clear the cache. Production code does not need to call this.
Expand Down
112 changes: 112 additions & 0 deletions src/org-connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Config-free org connection helpers: resolve an org's API key and base URL
// from a token map or `.env.<org>`, and run an engine script against that org
// in a child process. Shared by promotion and the PR check, which both work
// with several orgs in one process and so can't use config.ts.

import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { resolveApiKey } from "./api-key.ts";

export interface OrgConnection {
token: string;
baseUrl?: string;
}

export function envValue(content: string, key: string): string | undefined {
const line = content
.split("\n")
.find((candidate) => candidate.trimStart().startsWith(`${key}=`));
if (!line) return undefined;
const value = line.slice(line.indexOf("=") + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
)
return value.slice(1, -1);
return value || undefined;
}

// Parse a JSON `{ "<org>": "<token>" }` map from an env var. Promotion and the
// PR check use separate variables so each gets only the keys it needs.
export function tokensParse(envName: string): Map<string, string> {
const configured = process.env[envName];
if (!configured) return new Map();
let raw: unknown;
try {
raw = JSON.parse(configured);
} catch {
throw new Error(`${envName} must be valid JSON`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw))
throw new Error(`${envName} must map org slugs to tokens`);
const tokens = new Map<string, string>();
for (const [org, token] of Object.entries(raw)) {
if (typeof token !== "string" || token.length === 0)
throw new Error(
`${envName} entry for ${org} must be a non-empty token string`,
);
tokens.set(org, token);
}
return tokens;
}

export interface ConnectionLoadArgs {
rootDir: string;
org: string;
tokens: Map<string, string>;
// Named in the "missing token" error so it points at the right variable.
tokensEnvName: string;
// A configured base URL wins over `.env.<org>`'s VAPI_BASE_URL.
baseUrl?: string;
}

export function connectionLoad(args: ConnectionLoadArgs): OrgConnection {
const { rootDir, org, tokens, tokensEnvName } = args;
const envPath = resolve(rootDir, `.env.${org}`);
const envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
const envToken = resolveApiKey({
VAPI_PRIVATE_API_KEY: envValue(envContent, "VAPI_PRIVATE_API_KEY"),
VAPI_TOKEN: envValue(envContent, "VAPI_TOKEN"),
});
const token = tokens.get(org) ?? envToken;
if (!token)
throw new Error(
`Missing token for org ${org}; set ${tokensEnvName} or .env.${org}`,
);
return {
token,
baseUrl: args.baseUrl ?? envValue(envContent, "VAPI_BASE_URL"),
};
}

export interface ChildRunArgs {
rootDir: string;
script: string;
org: string;
connection: OrgConnection;
args: string[];
}

// Run `node --import tsx <script> <org> ...args` with the org's key in the
// environment, so the child's config.ts binds that org.
export function childRun(run: ChildRunArgs): void {
const { rootDir, script, org, connection, args } = run;
const environment: NodeJS.ProcessEnv = {
...process.env,
VAPI_PRIVATE_API_KEY: connection.token,
VAPI_TOKEN: connection.token,
};
if (connection.baseUrl) environment.VAPI_BASE_URL = connection.baseUrl;
if (!connection.baseUrl) delete environment.VAPI_BASE_URL;
const result = spawnSync(
process.execPath,
["--import", "tsx", script, org, ...args],
{ cwd: rootDir, env: environment, stdio: "inherit" },
);
if (result.error)
throw new Error(
`Failed to run ${script} for ${org}: ${result.error.message}`,
);
if (result.status !== 0) throw new Error(`${script} failed for ${org}`);
}
Loading
Loading