diff --git a/src/api.ts b/src/api.ts index a639eab..5fe790c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 @@ -40,32 +47,11 @@ function formatBodyPreview(body: Record): 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; @@ -83,13 +69,6 @@ async function throttle(): Promise { 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( method: "POST" | "PATCH", endpoint: string, diff --git a/src/config.ts b/src/config.ts index 96220ca..bcd45c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"; // ───────────────────────────────────────────────────────────────────────────── @@ -326,76 +330,20 @@ export function removeExcludedKeys( // ───────────────────────────────────────────────────────────────────────────── // Ignore Patterns (.vapi-ignore) // -// Resources matching any pattern in resources//.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 `/` (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 `/` matches the ignore list. // Returns the matched pattern (truthy) or null. export function matchesIgnore( @@ -403,12 +351,7 @@ export function matchesIgnore( 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. diff --git a/src/org-connection.ts b/src/org-connection.ts new file mode 100644 index 0000000..4bf523f --- /dev/null +++ b/src/org-connection.ts @@ -0,0 +1,112 @@ +// Config-free org connection helpers: resolve an org's API key and base URL +// from a token map or `.env.`, 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 `{ "": "" }` 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 { + 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(); + 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; + // Named in the "missing token" error so it points at the right variable. + tokensEnvName: string; + // A configured base URL wins over `.env.`'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