From 8de0fedffabdbdcb6e36c3c2e1cef25a33568745 Mon Sep 17 00:00:00 2001 From: Scott Lowe Date: Thu, 1 Oct 2026 15:48:54 -0700 Subject: [PATCH] refactor: move resource parsing and org connection helpers into config-free modules The PR check has to read an org's resource files and resolve org keys without importing config.ts, which parses argv and exits at import time. Move the code rather than copy it, so push and the check read files the same way: - src/resource-parse.ts: FOLDER_MAP, VALID_EXTENSIONS, frontmatter and file parsing, the directory scan and loader, .vapi-ignore reading and matching, and a new orgResourcesRead(rootDir, org). The two copies of the .md body -> system message injection become one function. - src/org-connection.ts: envValue, tokensParse(envName), connectionLoad and childRun from promote-cmd.ts, parameterised on the root dir, token variable and base URL. - api.ts takes VapiApiError, parseApiMessage, shouldRetry and the backoff constants from vapi-client.ts and re-exports VapiApiError. - promotion.ts imports FOLDER_MAP/VALID_EXTENSIONS and exports SLUG_RE and the bindings parse/resolve/apply helpers. resources.ts, config.ts and promote-cmd.ts delegate, and keep their exports, logs and error messages unchanged. Refs TEST-141 Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 43 +--- src/config.ts | 77 +------ src/org-connection.ts | 112 ++++++++++ src/promote-cmd.ts | 105 ++------- src/promotion.ts | 29 +-- src/resource-parse.ts | 402 +++++++++++++++++++++++++++++++++++ src/resources.ts | 315 ++------------------------- src/vapi-client.ts | 6 +- tests/org-connection.test.ts | 200 +++++++++++++++++ tests/resource-parse.test.ts | 236 ++++++++++++++++++++ 10 files changed, 1023 insertions(+), 502 deletions(-) create mode 100644 src/org-connection.ts create mode 100644 src/resource-parse.ts create mode 100644 tests/org-connection.test.ts create mode 100644 tests/resource-parse.test.ts 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