Skip to content

Commit 8de0fed

Browse files
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 <noreply@anthropic.com>
1 parent e66f1e7 commit 8de0fed

10 files changed

Lines changed: 1023 additions & 502 deletions

‎src/api.ts‎

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
22
import type { VapiResponse } from "./types.ts";
3+
import {
4+
INITIAL_DELAY_MS,
5+
MAX_RETRIES,
6+
parseApiMessage,
7+
shouldRetry,
8+
VapiApiError,
9+
} from "./vapi-client.ts";
310

411
// ─────────────────────────────────────────────────────────────────────────────
512
// Dry-run accounting
@@ -40,32 +47,11 @@ function formatBodyPreview(body: Record<string, unknown>): string {
4047
// HTTP Client for Vapi API
4148
// ─────────────────────────────────────────────────────────────────────────────
4249

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

56-
function parseApiMessage(body: string): string {
57-
try {
58-
const parsed = JSON.parse(body);
59-
if (typeof parsed.message === "string") return parsed.message;
60-
if (Array.isArray(parsed.message)) return parsed.message.join("; ");
61-
} catch {
62-
/* not JSON, use raw body */
63-
}
64-
return body;
65-
}
66-
67-
const MAX_RETRIES = 5;
68-
const INITIAL_DELAY_MS = 2000;
6955
const REQUEST_DELAY_MS = 700; // Delay between requests to avoid rate limits
7056

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

86-
// 429 = rate limit. 5xx = transient server error (gateway timeout, upstream
87-
// hiccup, deploy in progress). Both are worth retrying with backoff; surfacing
88-
// a 502 as a hard failure forces the operator to re-run the entire push.
89-
function shouldRetry(status: number): boolean {
90-
return status === 429 || (status >= 500 && status < 600);
91-
}
92-
9372
export async function vapiRequest<T = VapiResponse>(
9473
method: "POST" | "PATCH",
9574
endpoint: string,

‎src/config.ts‎

Lines changed: 10 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { existsSync, readFileSync } from "fs";
33
import { basename, dirname, join, relative, resolve } from "path";
44
import { fileURLToPath } from "url";
55
import type { Environment, ResourceType } from "./types.ts";
6+
import {
7+
ignorePatternsRead,
8+
matchesIgnore as matchesIgnorePatterns,
9+
} from "./resource-parse.ts";
610
import { VALID_RESOURCE_TYPES } from "./types.ts";
711

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

344337
let cachedIgnorePatterns: string[] | null = null;
345338

346-
function getIgnoreFilePath(): string {
347-
return join(BASE_DIR, "resources", VAPI_ENV, ".vapi-ignore");
348-
}
349-
350339
export function loadIgnorePatterns(): string[] {
351340
if (cachedIgnorePatterns !== null) return cachedIgnorePatterns;
352-
353-
const path = getIgnoreFilePath();
354-
if (!existsSync(path)) {
355-
cachedIgnorePatterns = [];
356-
return cachedIgnorePatterns;
357-
}
358-
359-
const raw = readFileSync(path, "utf-8");
360-
cachedIgnorePatterns = raw
361-
.split("\n")
362-
.map((line) => line.trim())
363-
.filter(
364-
(line) =>
365-
line.length > 0 && !line.startsWith("#") && !line.startsWith("!"),
366-
);
367-
341+
cachedIgnorePatterns = ignorePatternsRead(
342+
join(BASE_DIR, "resources", VAPI_ENV),
343+
);
368344
return cachedIgnorePatterns;
369345
}
370346

371-
// Convert a gitignore-flavored glob to a RegExp. We keep the implementation
372-
// intentionally small (no node_modules) since pull.ts is the only consumer.
373-
function compilePattern(pattern: string): RegExp {
374-
// Escape regex metacharacters except the glob ones we handle explicitly.
375-
// `*` and `?` are translated below; everything else is literal.
376-
let regex = "";
377-
for (let i = 0; i < pattern.length; i++) {
378-
const c = pattern[i];
379-
if (c === "*") {
380-
// `**` → match any characters including path separators
381-
// `*` → match any characters within a single segment (no `/`)
382-
if (pattern[i + 1] === "*") {
383-
regex += ".*";
384-
i++; // consume the second `*`
385-
} else {
386-
regex += "[^/]*";
387-
}
388-
} else if (c === "?") {
389-
regex += "[^/]";
390-
} else if ("\\^$.|+(){}[]".includes(c as string)) {
391-
regex += `\\${c}`;
392-
} else {
393-
regex += c;
394-
}
395-
}
396-
return new RegExp(`^${regex}$`);
397-
}
398-
399347
// Check whether a resource at `<folderPath>/<resourceId>` matches the ignore list.
400348
// Returns the matched pattern (truthy) or null.
401349
export function matchesIgnore(
402350
folderPath: string,
403351
resourceId: string,
404352
patterns: string[] = loadIgnorePatterns(),
405353
): string | null {
406-
if (patterns.length === 0) return null;
407-
const target = `${folderPath}/${resourceId}`;
408-
for (const pattern of patterns) {
409-
if (compilePattern(pattern).test(target)) return pattern;
410-
}
411-
return null;
354+
return matchesIgnorePatterns(folderPath, resourceId, patterns);
412355
}
413356

414357
// Test-only: clear the cache. Production code does not need to call this.

‎src/org-connection.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Config-free org connection helpers: resolve an org's API key and base URL
2+
// from a token map or `.env.<org>`, and run an engine script against that org
3+
// in a child process. Shared by promotion and the PR check, which both work
4+
// with several orgs in one process and so can't use config.ts.
5+
6+
import { spawnSync } from "node:child_process";
7+
import { existsSync, readFileSync } from "node:fs";
8+
import { resolve } from "node:path";
9+
import { resolveApiKey } from "./api-key.ts";
10+
11+
export interface OrgConnection {
12+
token: string;
13+
baseUrl?: string;
14+
}
15+
16+
export function envValue(content: string, key: string): string | undefined {
17+
const line = content
18+
.split("\n")
19+
.find((candidate) => candidate.trimStart().startsWith(`${key}=`));
20+
if (!line) return undefined;
21+
const value = line.slice(line.indexOf("=") + 1).trim();
22+
if (
23+
(value.startsWith('"') && value.endsWith('"')) ||
24+
(value.startsWith("'") && value.endsWith("'"))
25+
)
26+
return value.slice(1, -1);
27+
return value || undefined;
28+
}
29+
30+
// Parse a JSON `{ "<org>": "<token>" }` map from an env var. Promotion and the
31+
// PR check use separate variables so each gets only the keys it needs.
32+
export function tokensParse(envName: string): Map<string, string> {
33+
const configured = process.env[envName];
34+
if (!configured) return new Map();
35+
let raw: unknown;
36+
try {
37+
raw = JSON.parse(configured);
38+
} catch {
39+
throw new Error(`${envName} must be valid JSON`);
40+
}
41+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
42+
throw new Error(`${envName} must map org slugs to tokens`);
43+
const tokens = new Map<string, string>();
44+
for (const [org, token] of Object.entries(raw)) {
45+
if (typeof token !== "string" || token.length === 0)
46+
throw new Error(
47+
`${envName} entry for ${org} must be a non-empty token string`,
48+
);
49+
tokens.set(org, token);
50+
}
51+
return tokens;
52+
}
53+
54+
export interface ConnectionLoadArgs {
55+
rootDir: string;
56+
org: string;
57+
tokens: Map<string, string>;
58+
// Named in the "missing token" error so it points at the right variable.
59+
tokensEnvName: string;
60+
// A configured base URL wins over `.env.<org>`'s VAPI_BASE_URL.
61+
baseUrl?: string;
62+
}
63+
64+
export function connectionLoad(args: ConnectionLoadArgs): OrgConnection {
65+
const { rootDir, org, tokens, tokensEnvName } = args;
66+
const envPath = resolve(rootDir, `.env.${org}`);
67+
const envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
68+
const envToken = resolveApiKey({
69+
VAPI_PRIVATE_API_KEY: envValue(envContent, "VAPI_PRIVATE_API_KEY"),
70+
VAPI_TOKEN: envValue(envContent, "VAPI_TOKEN"),
71+
});
72+
const token = tokens.get(org) ?? envToken;
73+
if (!token)
74+
throw new Error(
75+
`Missing token for org ${org}; set ${tokensEnvName} or .env.${org}`,
76+
);
77+
return {
78+
token,
79+
baseUrl: args.baseUrl ?? envValue(envContent, "VAPI_BASE_URL"),
80+
};
81+
}
82+
83+
export interface ChildRunArgs {
84+
rootDir: string;
85+
script: string;
86+
org: string;
87+
connection: OrgConnection;
88+
args: string[];
89+
}
90+
91+
// Run `node --import tsx <script> <org> ...args` with the org's key in the
92+
// environment, so the child's config.ts binds that org.
93+
export function childRun(run: ChildRunArgs): void {
94+
const { rootDir, script, org, connection, args } = run;
95+
const environment: NodeJS.ProcessEnv = {
96+
...process.env,
97+
VAPI_PRIVATE_API_KEY: connection.token,
98+
VAPI_TOKEN: connection.token,
99+
};
100+
if (connection.baseUrl) environment.VAPI_BASE_URL = connection.baseUrl;
101+
if (!connection.baseUrl) delete environment.VAPI_BASE_URL;
102+
const result = spawnSync(
103+
process.execPath,
104+
["--import", "tsx", script, org, ...args],
105+
{ cwd: rootDir, env: environment, stdio: "inherit" },
106+
);
107+
if (result.error)
108+
throw new Error(
109+
`Failed to run ${script} for ${org}: ${result.error.message}`,
110+
);
111+
if (result.status !== 0) throw new Error(`${script} failed for ${org}`);
112+
}

0 commit comments

Comments
 (0)