Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ For changes under `src/`, `tests/` or `.github/`:
table in the same change.
- Changing an example under `examples/`? Doc snippets that start with
`# examples/<path>` must match the file exactly (`npm test` checks).
- Every request to the Vapi API sends `"User-Agent": userAgentGet()` from
`src/user-agent.ts`; `npm test` fails on a `fetch` without it.
- Commit messages follow Conventional Commits (`fix(pull): …`, `docs: …`).
- When you hit engine friction ("this should be better"), add or update an
entry in `improvements.md` in the same change. Upstream's log collects
Expand Down
9 changes: 9 additions & 0 deletions docs/guides/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ Tracks resource ID ↔ Vapi UUID mappings per org:

Every resource type has a section. Keys are sorted, so diffs stay readable.

## What Vapi sees

Every API request uses the org's private key and identifies the tool with a
User-Agent: `vapi-gitops-<command>/<version>`, plus ` (ci)` when the `CI` or
`GITHUB_ACTIONS` variable is set. For example, `npm run apply` in a GitHub
workflow sends `vapi-gitops-apply/1.0.0 (ci)`. Vapi uses it to count how
the tool is used. Nothing else is sent beyond the requests themselves; there
is no separate telemetry.

## Where things live

| Path | What it is |
Expand Down
4 changes: 4 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import type { VapiResponse } from "./types.ts";
import { userAgentGet } from "./user-agent.ts";
import {
INITIAL_DELAY_MS,
MAX_RETRIES,
Expand Down Expand Up @@ -97,6 +98,7 @@ export async function vapiRequest<T = VapiResponse>(
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
body: JSON.stringify(body),
});
Expand Down Expand Up @@ -140,6 +142,7 @@ export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
method: "GET",
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});

Expand Down Expand Up @@ -188,6 +191,7 @@ export async function vapiDelete(endpoint: string): Promise<void> {
method: "DELETE",
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});

Expand Down
6 changes: 5 additions & 1 deletion src/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { dirname, join, resolve } from "path";
import * as readline from "readline";
import { fileURLToPath } from "url";
import type { Environment, StateFile } from "./types.ts";
import { userAgentGet } from "./user-agent.ts";

const require = createRequire(import.meta.url);

Expand Down Expand Up @@ -362,6 +363,7 @@ async function createCall(
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.token}`,
"User-Agent": userAgentGet(),
},
body: JSON.stringify(body),
});
Expand Down Expand Up @@ -1046,7 +1048,9 @@ function createMicrophoneStream(onData: (data: Buffer) => void): {
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("Cannot find module")) {
console.warn("⚠️ 'mic' module not installed. Microphone input disabled.");
console.warn(
"⚠️ 'mic' module not installed. Microphone input disabled.",
);
console.warn(" Install with: npm install mic");
} else if (msg.includes("sox") || msg.includes("rec")) {
console.warn("⚠️ sox/rec not found. Required for microphone input.");
Expand Down
11 changes: 9 additions & 2 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FOLDER_MAP } from "./resource-parse.ts";
import { slugify } from "./slug-utils.ts";
import { loadState } from "./state.ts";
import type { ResourceType } from "./types.ts";
import { userAgentGet } from "./user-agent.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Dangerous Sync - Delete everything NOT in state file
Expand All @@ -29,7 +30,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
async function vapiGet<T>(endpoint: string, debug = false): Promise<T> {
await sleep(REQUEST_DELAY_MS);
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});
if (!response.ok) {
throw new Error(`GET ${endpoint} failed: ${response.status}`);
Expand Down Expand Up @@ -67,7 +71,10 @@ async function vapiDelete(endpoint: string): Promise<void> {
await sleep(REQUEST_DELAY_MS);
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});
if (!response.ok && response.status !== 404) {
throw new Error(`DELETE ${endpoint} failed: ${response.status}`);
Expand Down
6 changes: 5 additions & 1 deletion src/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js";
// the launcher, which runs before any org/token is selected.
import { isBackupCopyFile } from "./slug-utils.ts";
import type { StateFile } from "./types.ts";
import { userAgentGet } from "./user-agent.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Constants
Expand Down Expand Up @@ -223,7 +224,10 @@ async function apiGet(
): Promise<unknown> {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": userAgentGet(),
},
});
if (!response.ok) {
const text = await response.text();
Expand Down
2 changes: 2 additions & 0 deletions src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { reconcileStateKeyForResource } from "./reconcile-state-key.ts";
import { writeSnapshot } from "./snapshot.ts";
import { mergeScoped } from "./state-merge.ts";
import { userAgentGet } from "./user-agent.ts";
import {
summarizeFindings,
validateNoIgnoredReferences,
Expand Down Expand Up @@ -303,6 +304,7 @@ async function upsertResourceWithStateRecovery(options: {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
},
);
Expand Down
2 changes: 2 additions & 0 deletions src/rollback-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { listSnapshotTimestamps, loadSnapshot } from "./snapshot.ts";
import { userAgentGet } from "./user-agent.ts";

const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE_DIR = join(__dirname, "..");
Expand Down Expand Up @@ -186,6 +187,7 @@ async function main(): Promise<void> {
headers: {
Authorization: `Bearer ${cfg.token}`,
"Content-Type": "application/json",
"User-Agent": userAgentGet(),
},
body: JSON.stringify(entry.payload.platform),
});
Expand Down
14 changes: 11 additions & 3 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
SETUP_USAGE,
} from "./setup-args.ts";
import { slugify } from "./slug-utils.ts";
import { userAgentGet } from "./user-agent.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Constants
Expand Down Expand Up @@ -94,7 +95,10 @@ const c = {
async function apiGet(token: string, endpoint: string): Promise<unknown> {
const response = await fetch(`${vapiBaseUrl}${endpoint}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": userAgentGet(),
},
});

if (!response.ok) {
Expand Down Expand Up @@ -367,7 +371,9 @@ async function runDirectSetup(options: DirectSetupOptions): Promise<void> {
const resourceDir = join(BASE_DIR, "resources", slug);
const stateFile = join(BASE_DIR, `.vapi-state.${slug}.json`);

console.log(c.bold(`\n Vapi GitOps — non-interactive setup for "${slug}"\n`));
console.log(
c.bold(`\n Vapi GitOps — non-interactive setup for "${slug}"\n`),
);

// Never clobber an org that already has local state. Re-running setup is
// a destructive operation in the wizard (it deletes and re-pulls), and an
Expand Down Expand Up @@ -517,7 +523,9 @@ async function main(): Promise<void> {
// so explain the non-interactive path instead.
if (!process.stdin.isTTY) {
console.error(
c.red("\n ✗ The setup wizard needs an interactive terminal (stdin is not a TTY).\n"),
c.red(
"\n ✗ The setup wizard needs an interactive terminal (stdin is not a TTY).\n",
),
);
console.error(SETUP_USAGE);
process.exit(1);
Expand Down
59 changes: 54 additions & 5 deletions src/user-agent.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
// User-Agent for the API requests this tool makes, so simulation runs started
// from gitops can be told apart in the platform's analytics.
// User-Agent for every API request this tool makes, so gitops traffic can be
// told apart in the platform's request logs and analytics:
//
// vapi-gitops-<command>/<package version>[ (ci)]
//
// `<command>` is the npm script that started the process (`npm run apply`
// labels the pull and push it runs as `apply`), or the entry script's name
// when it was run directly, as the PR check workflow does. The `sim` and
// `check` labels are fixed by their callers, because analytics already counts
// simulation runs by those prefixes; keep them stable.
//
// Config-free on purpose (like api-key.ts): importing config.ts would parse
// argv and exit, which breaks importing this from sim.ts and tests.

import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const PACKAGE_JSON_PATH = join(
Expand All @@ -14,6 +22,12 @@ const PACKAGE_JSON_PATH = join(
"package.json",
);

export interface UserAgentContext {
env: NodeJS.ProcessEnv;
// The entry script, process.argv[1].
scriptPath?: string;
}

function packageVersionRead(): string {
try {
const parsed: unknown = JSON.parse(
Expand All @@ -34,6 +48,41 @@ function packageVersionRead(): string {
return "unknown";
}

export function userAgentGet(product: "sim" | "check"): string {
return `vapi-gitops-${product}/${packageVersionRead()}`;
const PACKAGE_VERSION = packageVersionRead();

// A User-Agent product token allows few characters; keep to a safe subset.
function tokenClean(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
}

function commandNameGet(context: UserAgentContext): string {
const npmScript = context.env.npm_lifecycle_event;
if (npmScript && tokenClean(npmScript)) return tokenClean(npmScript);
const script = context.scriptPath
? basename(context.scriptPath).replace(/\.[cm]?[jt]s$/, "")
: "";
return tokenClean(script.replace(/-cmd$/, "")) || "cli";
}

function ciRun(env: NodeJS.ProcessEnv): boolean {
const ci = env.CI?.toLowerCase();
return (
env.GITHUB_ACTIONS === "true" ||
(ci !== undefined && ci !== "" && ci !== "false" && ci !== "0")
);
}

export function userAgentGet(
product?: "sim" | "check",
context: UserAgentContext = {
env: process.env,
scriptPath: process.argv[1],
},
): string {
const command = product ?? commandNameGet(context);
const ci = ciRun(context.env) ? " (ci)" : "";
return `vapi-gitops-${command}/${PACKAGE_VERSION}${ci}`;
}
7 changes: 6 additions & 1 deletion tests/cleanup-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ function runCleanup(
["--import", "tsx", "src/cleanup.ts", "test-cleanup-org", ...args],
{
cwd,
env: { ...process.env, VAPI_TOKEN: "fake-token-not-used" },
env: {
...process.env,
VAPI_TOKEN: "fake-token-not-used",
// Nothing listens here: tests must never reach the real API.
VAPI_BASE_URL: "http://127.0.0.1:9",
},
encoding: "utf-8",
timeout: 20_000,
},
Expand Down
12 changes: 8 additions & 4 deletions tests/new-file-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ import { fileURLToPath } from "node:url";
process.argv = ["node", "test", "test-fixture-org"];
process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used";

const { detectOrphanYamls, formatGateMessage } = await import(
"../src/new-file-gate.ts"
);
const { detectOrphanYamls, formatGateMessage } =
await import("../src/new-file-gate.ts");

import type { OrphanReport } from "../src/new-file-gate.ts";
import type { ResourceState, ResourceType, StateFile } from "../src/types.ts";
Expand Down Expand Up @@ -459,7 +458,12 @@ function runPush(
["--import", "tsx", "src/push.ts", fx.env, ...extraArgs],
{
cwd: fx.dir,
env: { ...process.env, VAPI_TOKEN: "fake-token-not-used" },
env: {
...process.env,
VAPI_TOKEN: "fake-token-not-used",
// Nothing listens here: tests must never reach the real API.
VAPI_BASE_URL: "http://127.0.0.1:9",
},
encoding: "utf-8",
timeout: 30_000,
},
Expand Down
60 changes: 60 additions & 0 deletions tests/user-agent-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

// Every request gitops makes to the Vapi API must carry the gitops
// User-Agent, or that traffic is indistinguishable from any other Node
// script ("node"). api.ts carries push, pull, apply and promote, so it's
// checked against a real server; the other call sites are checked by
// reading them.

const SRC = fileURLToPath(new URL("../src", import.meta.url));

// GitHub's API, not Vapi's: the commit status client sets its own.
const NOT_VAPI = new Set(["check-status.ts"]);

test("every fetch to the Vapi API in src/ sets the User-Agent", () => {
const missing: string[] = [];
for (const file of readdirSync(SRC).filter((f) => f.endsWith(".ts"))) {
if (NOT_VAPI.has(file)) continue;
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
lines.forEach((line, index) => {
if (!/\bfetch\(/.test(line)) return;
// The options object follows within a few lines.
const call = lines.slice(index, index + 12).join("\n");
if (!call.includes('"User-Agent"')) missing.push(`${file}:${index + 1}`);
});
}
assert.deepEqual(missing, []);
});

test("api.ts requests carry the command's User-Agent", async () => {
const seen: Array<string | undefined> = [];
const server = createServer((req, res) => {
seen.push(req.headers["user-agent"]);
res.writeHead(200, { "Content-Type": "application/json" });
res.end("[]");
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
// config.ts reads these at import.
process.argv = ["node", "src/push.ts", "ua-test-org"];
process.env.VAPI_TOKEN = "test-token-not-used";
process.env.VAPI_BASE_URL = `http://127.0.0.1:${port}`;
process.env.npm_lifecycle_event = "apply";
delete process.env.CI;
delete process.env.GITHUB_ACTIONS;
try {
const { vapiGet } = await import("../src/api.ts");
const { userAgentGet } = await import("../src/user-agent.ts");
await vapiGet("/assistant");
assert.deepEqual(seen, [userAgentGet()]);
assert.match(seen[0] ?? "", /^vapi-gitops-apply\/[^ ]+$/);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
Loading
Loading