diff --git a/.env.example b/.env.example index 14d8142..0d6ac91 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ # Required: Vapi private API key for the organization you are syncing to. # Get one at https://dashboard.vapi.ai/org/api-keys → "Private API Keys" (not a public key). # (Older setups used VAPI_TOKEN — still accepted, VAPI_PRIVATE_API_KEY wins if both are set.) +# To keep keys in your shell instead, export one per org under the org's own +# name, e.g. VAPI_PRIVATE_API_KEY_MY_ORG for my-org. Don't export the generic +# VAPI_PRIVATE_API_KEY in a shell profile: it overrides every org's file. VAPI_PRIVATE_API_KEY=your-vapi-private-key-here # Optional: API base URL — defaults to US (https://api.vapi.ai). diff --git a/AGENTS.md b/AGENTS.md index 80c9176..41666dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ plain `npm run pull` (it never overwrites local edits), `npm run push -- **2. Never handle API keys.** Don't ask the human to paste a key into chat, don't print or read out `.env.*` files, never pass a key as a command-line argument, and never commit `.env.*`. Keys live in `.env.` (gitignored) -or the environment. +or the environment, as `VAPI_PRIVATE_API_KEY_`. **3. Reference resources by ID, never by UUID.** A resource's ID is its path under the type folder without the extension (`tools/lookup-patient.yml` is @@ -98,9 +98,12 @@ wizard needs a terminal. Instead: 1. `nvm use` (or check `node --version` satisfies `engines` in `package.json`), then `npm ci`. 2. **Get the API key without handling it.** Ask the human to create `.env.` from `.env.example` and put a Vapi **private API key** in - `VAPI_PRIVATE_API_KEY`, or confirm it's already exported. Point them to - https://dashboard.vapi.ai/org/api-keys → **Private API Keys** (a public key - won't work). + `VAPI_PRIVATE_API_KEY`, or confirm it's already exported as + `VAPI_PRIVATE_API_KEY_` (`acme-dev` → `VAPI_PRIVATE_API_KEY_ACME_DEV`). + Point them to https://dashboard.vapi.ai/org/api-keys → **Private API Keys** + (a public key won't work). If setup or a command reports a different key in + `.env.`, stop and tell the human: the exported key may be another + org's. Don't unset variables or edit `.env` files to silence it. 3. Ask whether to download the org's existing resources: - Managing an existing org → `npm run setup -- ` (`--resources all`, the default). - Authoring from scratch → `npm run setup -- --resources none` (state only, no files). diff --git a/README.md b/README.md index 6565d7b..f601d6a 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,20 @@ run commands without one, so pass the org name to skip every prompt: cp .env.example .env.my-org # then paste the private API key into VAPI_PRIVATE_API_KEY npm run setup -- my-org -# Option B — the key is already in the environment (CI secret, shell export) -VAPI_PRIVATE_API_KEY=... npm run setup -- my-org +# Option B — the key is already exported under the org's own name (for example in your shell profile) +export VAPI_PRIVATE_API_KEY_MY_ORG=... +npm run setup -- my-org ``` +**Several orgs, one shell.** Every command looks for the org's key in this +order: `VAPI_PRIVATE_API_KEY_` (the org name upper-cased, hyphens as +underscores: `acme-dev` → `VAPI_PRIVATE_API_KEY_ACME_DEV`), then +`VAPI_PRIVATE_API_KEY`, then `.env.`. Export the per-org name, not +`VAPI_PRIVATE_API_KEY`: the generic name applies to every org, so one org's +key would be used for all of them. When it overrides a different key in +`.env.`, commands warn and setup refuses. A CI job that works in one +org can keep using `VAPI_PRIVATE_API_KEY`. + | Option | Default | Meaning | | --- | --- | --- | | `--region us\|eu` | `VAPI_BASE_URL` if set, else auto-detect (US, then EU) | Which Vapi API to use | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index a707d4d..d3edcd1 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -15,6 +15,22 @@ A variable already set in your shell or CI environment takes precedence over the file. `.env..local` and `.env.local` are also read, after `.env.`, and only fill in variables that are still unset. +### Keys in your shell + +Commands look for an org's key in this order: + +1. `VAPI_PRIVATE_API_KEY_`: the org name upper-cased, with hyphens as + underscores (`acme-dev` → `VAPI_PRIVATE_API_KEY_ACME_DEV`). +2. `VAPI_PRIVATE_API_KEY` (or `VAPI_TOKEN`) in the environment. +3. `VAPI_PRIVATE_API_KEY` (or `VAPI_TOKEN`) in `.env.`. + +To keep keys in your shell profile, export one `VAPI_PRIVATE_API_KEY_` +per org. Don't export `VAPI_PRIVATE_API_KEY` there: it applies to every org, +so one org's key would be used for all of them. When the generic variable +overrides a different key in `.env.`, commands print a warning and +`npm run setup` refuses to continue. Setup also refuses a key that another +org's `.env` file already holds. + ## CI and workflow settings Set these in GitHub under **Settings → Secrets and variables → Actions**. diff --git a/src/api-key.ts b/src/api-key.ts index e0293dc..a523bb5 100644 --- a/src/api-key.ts +++ b/src/api-key.ts @@ -33,12 +33,77 @@ export function resolveApiKey(...sources: Vars[]): string | undefined { return undefined; } +/** Placeholder values copied from .env.example should not count as a token. */ +export function isPlaceholderToken(token: string): boolean { + return /^your-.*-here$/i.test(token) || token === ""; +} + +/** + * The org's own key variable: `acme-dev` → VAPI_PRIVATE_API_KEY_ACME_DEV. + * Org slugs are lowercase letters, digits and hyphens, so the mapping can't + * collide. Unlike the generic name, one of these per org can be exported from + * a shell profile without one org's key leaking into another org's commands. + */ +export function orgApiKeyVar(org: string): string { + return `${API_KEY_VAR}_${org.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`; +} + +export interface OrgApiKey { + token: string; + /** Where the key came from, for logs: a variable name or `.env.`. */ + source: string; + /** Set when an exported generic key overrides a different key in `.env.`. */ + warning?: string; +} + +function usable(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && !isPlaceholderToken(trimmed) ? trimmed : undefined; +} + +/** + * Resolve one org's key. In order: VAPI_PRIVATE_API_KEY_ in the + * environment, then the generic VAPI_PRIVATE_API_KEY / VAPI_TOKEN in the + * environment (how CI passes a secret), then the org's .env file values. + * + * The generic exported name applies to every org, so when it overrides a + * different key in `.env.` that is almost always a key for another org. + * The result carries a warning naming both sources (never the values). + */ +export function resolveOrgApiKey( + org: string, + env: Vars, + file: Vars, +): OrgApiKey | undefined { + const orgVar = orgApiKeyVar(org); + const fromOrgVar = usable(env[orgVar]); + if (fromOrgVar) return { token: fromOrgVar, source: `the ${orgVar} environment variable` }; + + const fromFile = usable(file[API_KEY_VAR]) ?? usable(file[LEGACY_API_KEY_VAR]); + for (const name of [API_KEY_VAR, LEGACY_API_KEY_VAR]) { + const fromEnv = usable(env[name]); + if (!fromEnv) continue; + const resolved: OrgApiKey = { token: fromEnv, source: `the ${name} environment variable` }; + if (fromFile && fromFile !== fromEnv) { + resolved.warning = + `Using the key in the ${name} environment variable, not the different key in .env.${org}.\n` + + ` An exported ${name} applies to every org, so it may belong to another org.\n` + + ` Unset it, or export ${orgVar} to choose this org's key explicitly.`; + } + return resolved; + } + + if (fromFile) return { token: fromFile, source: `.env.${org}` }; + return undefined; +} + /** Human-readable fix-it text for a missing key. */ export function missingApiKeyMessage(org: string): string { return [ `No Vapi private API key found for org "${org}".`, ` Copy a private key from ${API_KEYS_URL} (Private API Keys section),`, ` then add it to .env.${org} as: ${API_KEY_VAR}=`, + ` or export ${orgApiKeyVar(org)}= in your shell.`, ` (${LEGACY_API_KEY_VAR} is still accepted for existing setups.)`, ].join("\n"); } diff --git a/src/call.ts b/src/call.ts index fa1c599..d3cfcda 100644 --- a/src/call.ts +++ b/src/call.ts @@ -1,4 +1,4 @@ -import { missingApiKeyMessage, resolveApiKey } from "./api-key.ts"; +import { missingApiKeyMessage, resolveOrgApiKey } from "./api-key.ts"; import { execSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { createRequire } from "module"; @@ -149,7 +149,9 @@ function loadEnvFile(env: string): { token: string; baseUrl: string } { } } - const token = resolveApiKey(process.env, envVars); + const resolved = resolveOrgApiKey(env, process.env, envVars); + if (resolved?.warning) console.warn(`⚠️ ${resolved.warning}`); + const token = resolved?.token; const baseUrl = process.env.VAPI_BASE_URL || envVars.VAPI_BASE_URL || "https://api.vapi.ai"; diff --git a/src/config.ts b/src/config.ts index bcd45c2..cc20738 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { missingApiKeyMessage, resolveApiKey } from "./api-key.ts"; +import { missingApiKeyMessage, resolveOrgApiKey } from "./api-key.ts"; import { existsSync, readFileSync } from "fs"; import { basename, dirname, join, relative, resolve } from "path"; import { fileURLToPath } from "url"; @@ -215,7 +215,10 @@ function parseFlags(): { // Environment File Loading // ───────────────────────────────────────────────────────────────────────────── -function loadEnvFile(env: string, baseDir: string): void { +// Returns the files' own values (first file wins), so key resolution can tell +// a key from .env. apart from one that was exported in the shell. +function loadEnvFile(env: string, baseDir: string): Record { + const fileVars: Record = {}; const envFiles = [ join(baseDir, `.env.${env}`), // e.g. .env.my-org join(baseDir, `.env.${env}.local`), // e.g. .env.my-org.local (local overrides) @@ -243,6 +246,7 @@ function loadEnvFile(env: string, baseDir: string): void { value = value.slice(1, -1); } + if (fileVars[key] === undefined) fileVars[key] = value; // Only set if not already defined (env vars take precedence) if (process.env[key] === undefined) { process.env[key] = value; @@ -251,6 +255,7 @@ function loadEnvFile(env: string, baseDir: string): void { console.log(`📁 Loaded env file: ${basename(envFile)}`); } } + return fileVars; } // ───────────────────────────────────────────────────────────────────────────── @@ -273,23 +278,29 @@ export const { applyFilter: APPLY_FILTER, } = parseFlags(); -// The private API key: VAPI_PRIVATE_API_KEY (canonical) or VAPI_TOKEN (legacy). -// Capture the real environment before .env files are merged in, so an -// exported key of either name still beats a key in the .env file. -const ENV_API_KEY = resolveApiKey(process.env); +// The private API key. Capture the real environment before .env files are +// merged in, so an exported key still beats a key in the .env file: +// VAPI_PRIVATE_API_KEY_, then VAPI_PRIVATE_API_KEY / VAPI_TOKEN, then +// .env.. See resolveOrgApiKey. +const SHELL_ENV = { ...process.env }; -loadEnvFile(VAPI_ENV, BASE_DIR); +const ENV_FILE_VARS = loadEnvFile(VAPI_ENV, BASE_DIR); +const API_KEY = resolveOrgApiKey(VAPI_ENV, SHELL_ENV, ENV_FILE_VARS); // API configuration -export const VAPI_TOKEN = ENV_API_KEY ?? resolveApiKey(process.env); +export const VAPI_TOKEN = API_KEY?.token; export const VAPI_BASE_URL = process.env.VAPI_BASE_URL || "https://api.vapi.ai"; if (!VAPI_TOKEN) { console.error(`❌ ${missingApiKeyMessage(VAPI_ENV)}`); process.exit(1); } -// Some call sites read process.env.VAPI_TOKEN directly; keep them working -// when only VAPI_PRIVATE_API_KEY was provided. +if (API_KEY?.warning) console.warn(`⚠️ ${API_KEY.warning}`); +else if (API_KEY && API_KEY.source !== `.env.${VAPI_ENV}`) + console.log(`🔑 Using the API key from ${API_KEY.source}`); +// Some call sites read the variables directly; keep them on the key chosen +// above, so code reading the generic name can't see another org's key. +process.env.VAPI_PRIVATE_API_KEY = VAPI_TOKEN; process.env.VAPI_TOKEN = VAPI_TOKEN; // Paths diff --git a/src/interactive.ts b/src/interactive.ts index 6348df1..c5e09fa 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -1,4 +1,4 @@ -import { missingApiKeyMessage, resolveApiKey } from "./api-key.ts"; +import { missingApiKeyMessage, resolveOrgApiKey } from "./api-key.ts"; import { confirm, select } from "@inquirer/prompts"; import { execSync } from "child_process"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; @@ -166,7 +166,9 @@ function loadOrgEnv(slug: string): { token: string; baseUrl: string } { vars[trimmed.slice(0, eq).trim()] = val; } - const token = resolveApiKey(vars); + const resolved = resolveOrgApiKey(slug, process.env, vars); + if (resolved?.warning) console.warn(`⚠️ ${resolved.warning}`); + const token = resolved?.token; if (!token) { throw new Error(missingApiKeyMessage(slug)); } diff --git a/src/org-connection.ts b/src/org-connection.ts index 4bf523f..3ce203c 100644 --- a/src/org-connection.ts +++ b/src/org-connection.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { resolveApiKey } from "./api-key.ts"; +import { orgApiKeyVar, resolveApiKey } from "./api-key.ts"; export interface OrgConnection { token: string; @@ -69,10 +69,11 @@ export function connectionLoad(args: ConnectionLoadArgs): OrgConnection { VAPI_PRIVATE_API_KEY: envValue(envContent, "VAPI_PRIVATE_API_KEY"), VAPI_TOKEN: envValue(envContent, "VAPI_TOKEN"), }); - const token = tokens.get(org) ?? envToken; + const token = + tokens.get(org) ?? (process.env[orgApiKeyVar(org)]?.trim() || envToken); if (!token) throw new Error( - `Missing token for org ${org}; set ${tokensEnvName} or .env.${org}`, + `Missing token for org ${org}; set ${tokensEnvName}, ${orgApiKeyVar(org)} or .env.${org}`, ); return { token, @@ -96,6 +97,8 @@ export function childRun(run: ChildRunArgs): void { ...process.env, VAPI_PRIVATE_API_KEY: connection.token, VAPI_TOKEN: connection.token, + // The child resolves the per-org variable first, so pin it too. + [orgApiKeyVar(org)]: connection.token, }; if (connection.baseUrl) environment.VAPI_BASE_URL = connection.baseUrl; if (!connection.baseUrl) delete environment.VAPI_BASE_URL; diff --git a/src/rollback-cmd.ts b/src/rollback-cmd.ts index 6908bfb..544c3e7 100644 --- a/src/rollback-cmd.ts +++ b/src/rollback-cmd.ts @@ -8,7 +8,7 @@ // Self-contained (does not import config.ts) so it can run in isolation // without triggering the global CLI parser. -import { missingApiKeyMessage, resolveApiKey } from "./api-key.ts"; +import { missingApiKeyMessage, resolveOrgApiKey } from "./api-key.ts"; import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -49,7 +49,9 @@ function loadEnvFile(env: string): RollbackEnv { if (envVars[key] === undefined) envVars[key] = value; } } - const token = resolveApiKey(process.env, envVars); + const resolved = resolveOrgApiKey(env, process.env, envVars); + if (resolved?.warning) console.warn(`⚠️ ${resolved.warning}`); + const token = resolved?.token; const baseUrl = process.env.VAPI_BASE_URL || envVars.VAPI_BASE_URL || "https://api.vapi.ai"; if (!token) { diff --git a/src/setup-args.ts b/src/setup-args.ts index cbaf0ad..e700aaf 100644 --- a/src/setup-args.ts +++ b/src/setup-args.ts @@ -7,8 +7,8 @@ // // The API key is deliberately NOT accepted as an argument: argv ends up in // shell history, process listings, and agent transcripts. It comes from the -// VAPI_PRIVATE_API_KEY (or legacy VAPI_TOKEN) environment variable or an existing -// `.env.` file instead. +// VAPI_PRIVATE_API_KEY_ or VAPI_PRIVATE_API_KEY (or legacy VAPI_TOKEN) +// environment variable or an existing `.env.` file instead. export type SetupRegion = "us" | "eu"; export type SetupResources = "all" | "none"; @@ -41,12 +41,13 @@ Options (non-interactive): -h, --help Show this help Needs a Vapi PRIVATE API key, from https://dashboard.vapi.ai/org/api-keys -(Private API Keys section). It is read from VAPI_PRIVATE_API_KEY in the environment -or in an existing .env. file (legacy VAPI_TOKEN also works), and is -never accepted as a command-line argument. +(Private API Keys section). It is read from VAPI_PRIVATE_API_KEY_ in the +environment (my-org → VAPI_PRIVATE_API_KEY_MY_ORG), then VAPI_PRIVATE_API_KEY in +the environment, then an existing .env. file (legacy VAPI_TOKEN also works). +It is never accepted as a command-line argument. Examples: - VAPI_PRIVATE_API_KEY=... npm run setup -- my-org + VAPI_PRIVATE_API_KEY_MY_ORG=... npm run setup -- my-org npm run setup -- my-org --resources none (with .env.my-org already created)`; /** Parse the arguments that follow `npm run setup --`. */ @@ -66,7 +67,7 @@ export function parseSetupArgs(argv: string[]): SetupArgs { mode: "error", message: "Refusing to read an API key from the command line (it would leak into shell history and logs). " + - "Set VAPI_PRIVATE_API_KEY in the environment or put it in .env. instead.", + "Set VAPI_PRIVATE_API_KEY_ in the environment or put it in .env. instead.", }; } @@ -145,7 +146,5 @@ export function readEnvValue(content: string, key: string): string | undefined { return undefined; } -/** Placeholder values copied from .env.example should not count as a token. */ -export function isPlaceholderToken(token: string): boolean { - return /^your-.*-here$/i.test(token) || token === ""; -} +// Lives in api-key.ts so the engine's key resolution skips placeholders too. +export { isPlaceholderToken } from "./api-key.ts"; diff --git a/src/setup.ts b/src/setup.ts index a6fdb61..4c7a0e2 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,6 +1,6 @@ import { confirm, input, password, select } from "@inquirer/prompts"; import { execSync } from "child_process"; -import { existsSync, readdirSync } from "fs"; +import { existsSync, readdirSync, readFileSync } from "fs"; import { mkdir, readFile, rm, writeFile } from "fs/promises"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -8,13 +8,13 @@ import { API_KEY_VAR, API_KEYS_URL, LEGACY_API_KEY_VAR, - resolveApiKey, + orgApiKeyVar, + resolveOrgApiKey, } from "./api-key.ts"; import { updateEnvConnection } from "./bindings.ts"; import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js"; import { type DirectSetupOptions, - isPlaceholderToken, parseSetupArgs, readEnvValue, SETUP_USAGE, @@ -302,7 +302,12 @@ async function deleteExistingOrg(slug: string): Promise { // Pull integration // ───────────────────────────────────────────────────────────────────────────── -function invokePull(slug: string, selectedIds: Set): void { +function invokePull( + slug: string, + selectedIds: Set, + token: string, + baseUrl: string, +): void { // Group selected resources by type so we can pass --id per invocation const byType = new Map(); for (const id of selectedIds) { @@ -315,9 +320,16 @@ function invokePull(slug: string, selectedIds: Set): void { const binDir = join(BASE_DIR, "node_modules", ".bin"); const pathSep = process.platform === "win32" ? ";" : ":"; + // Pin the key that was just pasted and validated. The child pulls resolve + // exported variables before .env., so an exported key for another + // org would otherwise download that org into this one's folder. const env = { ...process.env, PATH: `${binDir}${pathSep}${process.env.PATH ?? ""}`, + [orgApiKeyVar(slug)]: token, + VAPI_PRIVATE_API_KEY: token, + VAPI_TOKEN: token, + VAPI_BASE_URL: baseUrl, }; execSync(`tsx src/pull.ts ${slug} --bootstrap --bindings-only`, { @@ -360,6 +372,21 @@ function runPullScript(args: string[]): void { }); } +// The org whose .env. already holds this key, if any. Each org has its +// own key, so a match means the key was meant for that org. +function orgWithKey(slug: string, token: string): string | undefined { + for (const name of readdirSync(BASE_DIR)) { + const org = name.match(/^\.env\.([a-z0-9-]+)$/)?.[1]; + if (!org || org === slug || org === "example" || org === "local") continue; + const content = readFileSync(join(BASE_DIR, name), "utf-8"); + const key = + readEnvValue(content, API_KEY_VAR) ?? + readEnvValue(content, LEGACY_API_KEY_VAR); + if (key === token) return org; + } + return undefined; +} + function fail(message: string): never { console.error(c.red(`\n ✗ ${message}\n`)); process.exit(1); @@ -383,37 +410,39 @@ async function runDirectSetup(options: DirectSetupOptions): Promise { ` To start over, a human should run the interactive wizard: npm run setup`, ); } - - // ── Token: env var first, then an existing .env. ──────────────── + // ── Token: VAPI_PRIVATE_API_KEY_, the generic variable, then .env. const envFileContent = existsSync(envPath) ? await readFile(envPath, "utf-8") : ""; - const fromEnv = resolveApiKey(process.env); - const fromFile = resolveApiKey({ - VAPI_PRIVATE_API_KEY: readEnvValue(envFileContent, "VAPI_PRIVATE_API_KEY"), - VAPI_TOKEN: readEnvValue(envFileContent, "VAPI_TOKEN"), + const resolved = resolveOrgApiKey(slug, process.env, { + [API_KEY_VAR]: readEnvValue(envFileContent, API_KEY_VAR), + [LEGACY_API_KEY_VAR]: readEnvValue(envFileContent, LEGACY_API_KEY_VAR), }); - const token = - fromEnv && !isPlaceholderToken(fromEnv) - ? fromEnv - : fromFile && !isPlaceholderToken(fromFile) - ? fromFile - : undefined; - - if (!token) { + + if (!resolved) { fail( `No Vapi private API key found.\n` + ` Copy one from ${API_KEYS_URL} (Private API Keys section), then either:\n` + ` • add it to .env.${slug} as ${API_KEY_VAR}= (see .env.example), or\n` + - ` • export ${API_KEY_VAR}= in the environment\n` + + ` • export ${orgApiKeyVar(slug)}= in the environment\n` + ` Then re-run: npm run setup -- ${slug}`, ); } - console.log( - c.dim( - ` Using API key from ${fromEnv && token === fromEnv ? `${process.env[API_KEY_VAR]?.trim() ? API_KEY_VAR : LEGACY_API_KEY_VAR} environment variable` : `.env.${slug}`}`, - ), - ); + // Setup writes the key into .env. and downloads that org, so a key + // that may belong to another org is a hard stop here, not a warning. + if (resolved.warning) { + fail(`${resolved.warning}\n Setup stopped before writing .env.${slug} or downloading anything.`); + } + const otherOrg = orgWithKey(slug, resolved.token); + if (otherOrg) { + fail( + `The key from ${resolved.source} is already the key in .env.${otherOrg}.\n` + + ` Each org has its own key, so this is probably ${otherOrg}'s key, not ${slug}'s.\n` + + ` Use ${slug}'s own key, or delete .env.${otherOrg} if it is out of date.`, + ); + } + const token = resolved.token; + console.log(c.dim(` Using API key from ${resolved.source}`)); // ── Region: flag > VAPI_BASE_URL > auto-detect ──────────────────────── const configuredBase = @@ -449,6 +478,7 @@ async function runDirectSetup(options: DirectSetupOptions): Promise { // Child pulls give process env precedence over .env files, so pin the // validated connection explicitly rather than inheriting a stale value. + process.env[orgApiKeyVar(slug)] = token; process.env.VAPI_PRIVATE_API_KEY = token; process.env.VAPI_TOKEN = token; process.env.VAPI_BASE_URL = vapiBaseUrl; @@ -661,7 +691,7 @@ async function main(): Promise { console.log(" Writing environment file only.\n"); await writeEnvFile(slug, trimmedKey, vapiBaseUrl); await mkdir(resourceDir, { recursive: true }); - invokePull(slug, new Set()); + invokePull(slug, new Set(), trimmedKey, vapiBaseUrl); printSummary(slug); return; } @@ -765,7 +795,7 @@ async function main(): Promise { console.log(c.bold(" Downloading...\n")); - invokePull(slug, selectedIds); + invokePull(slug, selectedIds, trimmedKey, vapiBaseUrl); // ── Done ────────────────────────────────────────────────────────────── diff --git a/src/sim.ts b/src/sim.ts index 4befa3e..12989b8 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -4,7 +4,7 @@ // triggering the CLI argument parser in `config.ts`. Env-loading is inlined // here (rather than importing from `config.ts`) for the same reason. -import { missingApiKeyMessage, resolveApiKey } from "./api-key.ts"; +import { missingApiKeyMessage, resolveOrgApiKey } from "./api-key.ts"; import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -101,7 +101,9 @@ export function loadEnvFile(env: string): SimEnv { if (envVars[key] === undefined) envVars[key] = value; } } - const token = resolveApiKey(process.env, envVars); + const resolved = resolveOrgApiKey(env, process.env, envVars); + if (resolved?.warning) console.warn(`⚠️ ${resolved.warning}`); + const token = resolved?.token; const baseUrl = process.env.VAPI_BASE_URL || envVars.VAPI_BASE_URL || "https://api.vapi.ai"; if (!token) { diff --git a/tests/api-key.test.ts b/tests/api-key.test.ts index 30fb24d..465d316 100644 --- a/tests/api-key.test.ts +++ b/tests/api-key.test.ts @@ -3,7 +3,9 @@ import test from "node:test"; import { API_KEYS_URL, missingApiKeyMessage, + orgApiKeyVar, resolveApiKey, + resolveOrgApiKey, } from "../src/api-key.ts"; // VAPI_PRIVATE_API_KEY is the canonical name (it matches the dashboard's @@ -48,4 +50,94 @@ test("missingApiKeyMessage points at the dashboard and the canonical name", () = assert.match(msg, /Private API Keys/); assert.match(msg, /VAPI_PRIVATE_API_KEY=/); assert.match(msg, /\.env\.my-org/); + assert.match(msg, /VAPI_PRIVATE_API_KEY_MY_ORG=/); +}); + +// ── Per-org keys ───────────────────────────────────────────────────────── +// An exported VAPI_PRIVATE_API_KEY applies to every org, so it silently +// replaced each org's .env. key. VAPI_PRIVATE_API_KEY_ lets every +// org's key be exported at once, and a generic key that overrides a +// different .env. key is reported. + +test("orgApiKeyVar upper-cases the slug and turns hyphens into underscores", () => { + assert.deepEqual( + ["acme", "acme-dev", "team2-prod"].map(orgApiKeyVar), + [ + "VAPI_PRIVATE_API_KEY_ACME", + "VAPI_PRIVATE_API_KEY_ACME_DEV", + "VAPI_PRIVATE_API_KEY_TEAM2_PROD", + ], + ); +}); + +test("resolveOrgApiKey: the org's own variable beats the generic one and the file", () => { + assert.deepEqual( + resolveOrgApiKey( + "acme-dev", + { VAPI_PRIVATE_API_KEY_ACME_DEV: "org-key", VAPI_PRIVATE_API_KEY: "generic-key" }, + { VAPI_PRIVATE_API_KEY: "file-key" }, + ), + { token: "org-key", source: "the VAPI_PRIVATE_API_KEY_ACME_DEV environment variable" }, + ); +}); + +test("resolveOrgApiKey: another org's variable is ignored", () => { + assert.deepEqual( + resolveOrgApiKey( + "acme-dev", + { VAPI_PRIVATE_API_KEY_ACME_PROD: "prod-key" }, + { VAPI_PRIVATE_API_KEY: "file-key" }, + ), + { token: "file-key", source: ".env.acme-dev" }, + ); +}); + +test("resolveOrgApiKey: a generic key matching the file has no warning", () => { + assert.deepEqual( + resolveOrgApiKey("acme-dev", { VAPI_PRIVATE_API_KEY: "same" }, { VAPI_PRIVATE_API_KEY: "same" }), + { token: "same", source: "the VAPI_PRIVATE_API_KEY environment variable" }, + ); +}); + +test("resolveOrgApiKey: a generic key overriding a different file key warns", () => { + const resolved = resolveOrgApiKey( + "acme-prod", + { VAPI_PRIVATE_API_KEY: "dev-key" }, + { VAPI_PRIVATE_API_KEY: "prod-key" }, + ); + assert.equal(resolved?.token, "dev-key"); + assert.match(resolved?.warning ?? "", /not the different key in \.env\.acme-prod/); + assert.match(resolved?.warning ?? "", /export VAPI_PRIVATE_API_KEY_ACME_PROD/); +}); + +test("resolveOrgApiKey: the warning never contains either key", () => { + const warning = resolveOrgApiKey( + "acme-prod", + { VAPI_TOKEN: "secret-one" }, + { VAPI_PRIVATE_API_KEY: "secret-two" }, + )?.warning; + assert.match(warning ?? "", /VAPI_TOKEN environment variable/); + assert.doesNotMatch(warning ?? "", /secret-one|secret-two/); +}); + +test("resolveOrgApiKey: .env.example placeholders count as no key", () => { + assert.deepEqual( + resolveOrgApiKey( + "acme", + { VAPI_PRIVATE_API_KEY_ACME: "your-vapi-private-key-here" }, + { VAPI_PRIVATE_API_KEY: "your-vapi-private-key-here" }, + ), + undefined, + ); +}); + +test("resolveOrgApiKey: a placeholder in the file doesn't trigger the warning", () => { + assert.deepEqual( + resolveOrgApiKey( + "acme", + { VAPI_PRIVATE_API_KEY: "real-key" }, + { VAPI_PRIVATE_API_KEY: "your-vapi-private-key-here" }, + ), + { token: "real-key", source: "the VAPI_PRIVATE_API_KEY environment variable" }, + ); }); diff --git a/tests/config-api-key.test.ts b/tests/config-api-key.test.ts new file mode 100644 index 0000000..05d5368 --- /dev/null +++ b/tests/config-api-key.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// Every org command binds its key in config.ts. An exported generic +// VAPI_PRIVATE_API_KEY used to replace .env.'s key with only a +// "Loaded env file" line on screen, so a second org's commands silently ran +// with the first org's key. These run `validate` (offline) to see what a +// command reports about its key. + +const REPO = fileURLToPath(new URL("..", import.meta.url)); + +function validateRun( + envFile: string, + env: Record, +): { code: number | null; stdout: string; stderr: string } { + const dir = mkdtempSync(join(tmpdir(), "vapi-config-key-")); + try { + cpSync(join(REPO, "src"), join(dir, "src"), { recursive: true }); + cpSync(join(REPO, "package.json"), join(dir, "package.json")); + symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"), "dir"); + mkdirSync(join(dir, "resources", "acme-prod"), { recursive: true }); + writeFileSync(join(dir, ".env.acme-prod"), envFile); + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/validate-cmd.ts", "acme-prod"], + { cwd: dir, encoding: "utf8", timeout: 30_000, env: { ...process.env, ...env } }, + ); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("a generic exported key overriding a different .env. key warns on stderr", () => { + const run = validateRun("VAPI_PRIVATE_API_KEY=prod-key\n", { + VAPI_PRIVATE_API_KEY: "dev-key", + }); + assert.match(run.stderr, /not the different key in \.env\.acme-prod/); + assert.match(run.stderr, /VAPI_PRIVATE_API_KEY_ACME_PROD/); + assert.doesNotMatch(`${run.stdout}${run.stderr}`, /prod-key|dev-key/); +}); + +test("the org's own variable is used and named, with no warning", () => { + const run = validateRun("VAPI_PRIVATE_API_KEY=stale-key\n", { + VAPI_PRIVATE_API_KEY: "dev-key", + VAPI_PRIVATE_API_KEY_ACME_PROD: "prod-key", + }); + assert.match( + run.stdout, + /Using the API key from the VAPI_PRIVATE_API_KEY_ACME_PROD environment variable/, + ); + assert.doesNotMatch(run.stderr, /different key/); +}); + +test("a key only in .env. is used quietly", () => { + const run = validateRun("VAPI_PRIVATE_API_KEY=prod-key\n", {}); + assert.doesNotMatch(`${run.stdout}${run.stderr}`, /Using the API key|different key/); + assert.equal(run.code, 0); +}); diff --git a/tests/no-vapi-api.ts b/tests/no-vapi-api.ts index 2681176..4a74f5d 100644 --- a/tests/no-vapi-api.ts +++ b/tests/no-vapi-api.ts @@ -4,3 +4,6 @@ process.env.VAPI_BASE_URL = "http://127.0.0.1:9"; delete process.env.VAPI_PRIVATE_API_KEY; delete process.env.VAPI_TOKEN; +// Per-org keys (VAPI_PRIVATE_API_KEY_) a developer exports in their shell. +for (const name of Object.keys(process.env)) + if (name.startsWith("VAPI_PRIVATE_API_KEY_")) delete process.env[name]; diff --git a/tests/org-connection.test.ts b/tests/org-connection.test.ts index 5bb4899..747d9e9 100644 --- a/tests/org-connection.test.ts +++ b/tests/org-connection.test.ts @@ -136,7 +136,7 @@ test("connectionLoad names the token variable when no key is found", () => { }), { message: - "Missing token for org acme; set VAPI_CHECK_TOKENS or .env.acme", + "Missing token for org acme; set VAPI_CHECK_TOKENS, VAPI_PRIVATE_API_KEY_ACME or .env.acme", }, ); } finally { @@ -198,3 +198,23 @@ test("childRun throws when the script fails", () => { rmSync(root, { recursive: true, force: true }); } }); + +test("connectionLoad uses VAPI_PRIVATE_API_KEY_ before .env.", () => { + const root = mkdtempSync(join(tmpdir(), "org-connection-")); + try { + writeFileSync(join(root, ".env.acme-dev"), "VAPI_PRIVATE_API_KEY=file-key\n"); + const base = { rootDir: root, org: "acme-dev", tokensEnvName: "TEST_TOKENS" }; + assert.deepEqual( + withEnv("VAPI_PRIVATE_API_KEY_ACME_DEV", "org-var-key", () => [ + connectionLoad({ ...base, tokens: new Map([["acme-dev", "map-key"]]) }), + connectionLoad({ ...base, tokens: new Map() }), + ]), + [ + { token: "map-key", baseUrl: undefined }, + { token: "org-var-key", baseUrl: undefined }, + ], + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/setup-noninteractive.test.ts b/tests/setup-noninteractive.test.ts index 5928eec..3f8c88c 100644 --- a/tests/setup-noninteractive.test.ts +++ b/tests/setup-noninteractive.test.ts @@ -118,6 +118,8 @@ function runSetup(cwd: string, args: string[], env: Record = {}) delete baseEnv.VAPI_TOKEN; delete baseEnv.VAPI_PRIVATE_API_KEY; delete baseEnv.VAPI_BASE_URL; + for (const name of Object.keys(baseEnv)) + if (name.startsWith("VAPI_PRIVATE_API_KEY_")) delete baseEnv[name]; const result = spawnSync("node", ["--import", "tsx", "src/setup.ts", ...args], { cwd, env: { ...baseEnv, ...env }, @@ -188,3 +190,55 @@ test("direct setup refuses to touch an org that already exists locally", () => { fx.cleanup(); } }); + +// ── Keys that may belong to another org ────────────────────────────────── +// Setup writes the key into .env. and downloads that org, so it refuses +// a key that may be another org's instead of filling the folder with it. + +test("direct setup refuses a generic exported key that differs from .env.", () => { + const fx = sandbox(); + try { + writeFileSync(join(fx.dir, ".env.my-org"), "VAPI_PRIVATE_API_KEY=my-org-key\n"); + const res = runSetup(fx.dir, ["my-org"], { VAPI_PRIVATE_API_KEY: "other-org-key" }); + assert.equal(res.code, 1); + assert.match(res.output, /not the different key in \.env\.my-org/); + assert.match(res.output, /VAPI_PRIVATE_API_KEY_MY_ORG/); + assert.doesNotMatch(res.output, /Validating against/); + assert.doesNotMatch(res.output, /my-org-key|other-org-key/); + } finally { + fx.cleanup(); + } +}); + +test("direct setup refuses a key another org's .env file already holds", () => { + const fx = sandbox(); + try { + writeFileSync(join(fx.dir, ".env.acme-dev"), "VAPI_PRIVATE_API_KEY=dev-key\n"); + const res = runSetup(fx.dir, ["acme-prod"], { VAPI_PRIVATE_API_KEY: "dev-key" }); + assert.equal(res.code, 1); + assert.match(res.output, /already the key in \.env\.acme-dev/); + assert.doesNotMatch(res.output, /Validating against/); + } finally { + fx.cleanup(); + } +}); + +test("direct setup uses the org's own variable over a generic key and the file", () => { + const fx = sandbox(); + try { + writeFileSync(join(fx.dir, ".env.my-org"), "VAPI_PRIVATE_API_KEY=stale-key\n"); + const res = runSetup(fx.dir, ["my-org"], { + VAPI_PRIVATE_API_KEY: "other-org-key", + VAPI_PRIVATE_API_KEY_MY_ORG: "my-org-key", + }); + // Gets past the key checks; the test API base is unreachable, so it then + // fails at validation. + assert.match( + res.output, + /Using API key from the VAPI_PRIVATE_API_KEY_MY_ORG environment variable/, + ); + assert.match(res.output, /Validating against/); + } finally { + fx.cleanup(); + } +});