Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ plain `npm run pull` (it never overwrites local edits), `npm run push -- <org>
**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.<org>` (gitignored)
or the environment.
or the environment, as `VAPI_PRIVATE_API_KEY_<ORG>`.

**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
Expand Down Expand Up @@ -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.<org>` 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_<ORG>` (`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.<org>`, 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 -- <org>` (`--resources all`, the default).
- Authoring from scratch → `npm run setup -- <org> --resources none` (state only, no files).
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ORG>` (the org name upper-cased, hyphens as
underscores: `acme-dev` → `VAPI_PRIVATE_API_KEY_ACME_DEV`), then
`VAPI_PRIVATE_API_KEY`, then `.env.<org>`. 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.<org>`, 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 |
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ A variable already set in your shell or CI environment takes precedence over
the file. `.env.<org>.local` and `.env.local` are also read, after
`.env.<org>`, 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_<ORG>`: 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.<org>`.

To keep keys in your shell profile, export one `VAPI_PRIVATE_API_KEY_<ORG>`
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.<org>`, 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**.
Expand Down
65 changes: 65 additions & 0 deletions src/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === "<your-key>";
}

/**
* 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.<org>`. */
source: string;
/** Set when an exported generic key overrides a different key in `.env.<org>`. */
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_<ORG> 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.<org>` 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}=<your private API key>`,
` or export ${orgApiKeyVar(org)}=<your private API key> in your shell.`,
` (${LEGACY_API_KEY_VAR} is still accepted for existing setups.)`,
].join("\n");
}
6 changes: 4 additions & 2 deletions src/call.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";

Expand Down
31 changes: 21 additions & 10 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.<org> apart from one that was exported in the shell.
function loadEnvFile(env: string, baseDir: string): Record<string, string> {
const fileVars: Record<string, string> = {};
const envFiles = [
join(baseDir, `.env.${env}`), // e.g. .env.my-org
join(baseDir, `.env.${env}.local`), // e.g. .env.my-org.local (local overrides)
Expand Down Expand Up @@ -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;
Expand All @@ -251,6 +255,7 @@ function loadEnvFile(env: string, baseDir: string): void {
console.log(`📁 Loaded env file: ${basename(envFile)}`);
}
}
return fileVars;
}

// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -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_<ORG>, then VAPI_PRIVATE_API_KEY / VAPI_TOKEN, then
// .env.<org>. 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
Expand Down
6 changes: 4 additions & 2 deletions src/interactive.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
}
Expand Down
9 changes: 6 additions & 3 deletions src/org-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/rollback-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 10 additions & 11 deletions src/setup-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<org>` file instead.
// VAPI_PRIVATE_API_KEY_<ORG> or VAPI_PRIVATE_API_KEY (or legacy VAPI_TOKEN)
// environment variable or an existing `.env.<org>` file instead.

export type SetupRegion = "us" | "eu";
export type SetupResources = "all" | "none";
Expand Down Expand Up @@ -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.<org> 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_<ORG> in the
environment (my-org → VAPI_PRIVATE_API_KEY_MY_ORG), then VAPI_PRIVATE_API_KEY in
the environment, then an existing .env.<org> 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 --`. */
Expand All @@ -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.<org> instead.",
"Set VAPI_PRIVATE_API_KEY_<ORG> in the environment or put it in .env.<org> instead.",
};
}

Expand Down Expand Up @@ -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 === "<your-key>";
}
// Lives in api-key.ts so the engine's key resolution skips placeholders too.
export { isPlaceholderToken } from "./api-key.ts";
Loading
Loading