From 6f0c59f44f4d0cc4416dc3e0f8bef8c51bd4c4a7 Mon Sep 17 00:00:00 2001 From: Scott Lowe Date: Sat, 3 Oct 2026 00:57:10 -0700 Subject: [PATCH] feat: identify every gitops API request with a User-Agent Only `npm run sim` and `npm run check` identified themselves. Setup, pull, push, apply, promote, cleanup, rollback, call and audit sent Node's default `node` User-Agent, about a sixth of all api.vapi.ai traffic, so gitops usage beyond simulations couldn't be counted. - src/user-agent.ts: `vapi-gitops-/`, plus ` (ci)` when GITHUB_ACTIONS=true or CI is set (not false or 0). The command comes from a fixed list of this repo's commands, so a fork's own npm script names are never sent and npx is not a label; otherwise the entry script names it, else `cli`. The first process pins its label in VAPI_GITOPS_COMMAND, which spawned processes inherit, so the PR check's bindings pull is labelled check and `npm run apply`'s pull and push are labelled apply. sim and check keep their labels; promotion gate runs are labelled promote, apart from PR check runs. - Every fetch sends it, the GitHub status call included. tests/user-agent-coverage.test.ts checks each call on its own, scans src/ recursively, and checks api.ts against a local server. - cleanup-safety and new-file-gate tests sent about a dozen requests to the real api.vapi.ai per `npm test` (fake key, 401s), which the new User-Agent made visible in the request logs. tests/no-vapi-api.ts now loads before every test file: an unroutable base URL and no inherited real key, for the tests and every CLI they spawn. - how-it-works.md says exactly what the API sees; AGENTS.md says every request sends the header. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + docs/guides/how-it-works.md | 11 ++++ package.json | 2 +- src/api.ts | 4 ++ src/apply.ts | 2 + src/call.ts | 2 + src/check-status.ts | 4 ++ src/cleanup.ts | 11 +++- src/interactive.ts | 6 +- src/promotion-gate.ts | 3 +- src/push.ts | 2 + src/rollback-cmd.ts | 2 + src/setup.ts | 6 +- src/user-agent.ts | 104 ++++++++++++++++++++++++++++-- tests/cleanup-safety.test.ts | 7 +- tests/new-file-gate.test.ts | 7 +- tests/no-vapi-api.ts | 6 ++ tests/user-agent-coverage.test.ts | 63 ++++++++++++++++++ tests/user-agent.test.ts | 79 +++++++++++++++++++++-- 19 files changed, 303 insertions(+), 20 deletions(-) create mode 100644 tests/no-vapi-api.ts create mode 100644 tests/user-agent-coverage.test.ts diff --git a/AGENTS.md b/AGENTS.md index ee7cf2f..80c9176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,6 +321,8 @@ For changes under `src/`, `tests/` or `.github/`: table in the same change. - Changing an example under `examples/`? Doc snippets that start with `# examples/` 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 diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 6646e5c..0d08adf 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -145,6 +145,17 @@ 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-/`, where `` is the gitops +command you ran (`apply`, `push`, `pull`, …; `cli` if it can't be told). It +adds ` (ci)` when `GITHUB_ACTIONS=true` or `CI` is set to anything other than +`false` or `0`. For example, `npm run apply` in a GitHub workflow sends +`vapi-gitops-apply/1.0.0 (ci)`. Your own npm script names are never sent. +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 | diff --git a/package.json b/package.json index 9d083a9..50ad256 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "rollback": "tsx src/rollback-cmd.ts", "promote": "tsx src/promote-cmd.ts", "build": "tsc --noEmit", - "test": "node --import tsx --test tests/*.test.ts" + "test": "node --import tsx --import ./tests/no-vapi-api.ts --test tests/*.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/src/api.ts b/src/api.ts index 5fe790c..c7f0e88 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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, @@ -97,6 +98,7 @@ export async function vapiRequest( headers: { "Content-Type": "application/json", Authorization: `Bearer ${VAPI_TOKEN}`, + "User-Agent": userAgentGet(), }, body: JSON.stringify(body), }); @@ -140,6 +142,7 @@ export async function vapiGet(endpoint: string): Promise { method: "GET", headers: { Authorization: `Bearer ${VAPI_TOKEN}`, + "User-Agent": userAgentGet(), }, }); @@ -188,6 +191,7 @@ export async function vapiDelete(endpoint: string): Promise { method: "DELETE", headers: { Authorization: `Bearer ${VAPI_TOKEN}`, + "User-Agent": userAgentGet(), }, }); diff --git a/src/apply.ts b/src/apply.ts index 5d3211d..5650408 100644 --- a/src/apply.ts +++ b/src/apply.ts @@ -2,6 +2,8 @@ import { execSync } from "child_process"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { assertStateMigrated } from "./migrate-hash-store.ts"; +// Pins this command's User-Agent label for the pull and push it spawns. +import "./user-agent.ts"; // ───────────────────────────────────────────────────────────────────────────── // Apply: Pull → Merge → Push (safe bidirectional sync) diff --git a/src/call.ts b/src/call.ts index 9e569c1..fa1c599 100644 --- a/src/call.ts +++ b/src/call.ts @@ -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); @@ -362,6 +363,7 @@ async function createCall( headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}`, + "User-Agent": userAgentGet(), }, body: JSON.stringify(body), }); diff --git a/src/check-status.ts b/src/check-status.ts index 87b6f75..7e4ab34 100644 --- a/src/check-status.ts +++ b/src/check-status.ts @@ -5,6 +5,8 @@ // `Vapi Evals` (a stable, documented name) is what branch protection // requires: per-target statuses only exist on PRs that touch a check. +import { userAgentGet } from "./user-agent.ts"; + export const AGGREGATE_CONTEXT = "Vapi Evals"; export type CommitState = "pending" | "success" | "failure" | "error"; @@ -84,6 +86,8 @@ export async function commitStatusPost( Accept: "application/vnd.github+json", "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", + // GitHub asks API clients to name themselves. + "User-Agent": userAgentGet("check"), }, body: JSON.stringify({ context: status.context, diff --git a/src/cleanup.ts b/src/cleanup.ts index 28d9441..1be99cd 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -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 @@ -29,7 +30,10 @@ function isRecord(value: unknown): value is Record { async function vapiGet(endpoint: string, debug = false): Promise { 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}`); @@ -67,7 +71,10 @@ async function vapiDelete(endpoint: string): Promise { 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}`); diff --git a/src/interactive.ts b/src/interactive.ts index 9e9534a..6348df1 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -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 @@ -223,7 +224,10 @@ async function apiGet( ): Promise { 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(); diff --git a/src/promotion-gate.ts b/src/promotion-gate.ts index 4bd2612..09a1c8f 100644 --- a/src/promotion-gate.ts +++ b/src/promotion-gate.ts @@ -153,7 +153,8 @@ export async function promotionGateRun( connectionFor: () => ({ token: connection.token, baseUrl: connection.baseUrl ?? DEFAULT_BASE_URL, - userAgent: userAgentGet("check"), + // Gate runs are counted apart from PR check runs. + userAgent: userAgentGet("promote"), }), deadline: gateDeadline(check, Date.now()), signal: controller.signal, diff --git a/src/push.ts b/src/push.ts index 5bac449..ea80668 100644 --- a/src/push.ts +++ b/src/push.ts @@ -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, @@ -303,6 +304,7 @@ async function upsertResourceWithStateRecovery(options: { method: "GET", headers: { Authorization: `Bearer ${process.env.VAPI_TOKEN}`, + "User-Agent": userAgentGet(), }, }, ); diff --git a/src/rollback-cmd.ts b/src/rollback-cmd.ts index ee3e0f2..6908bfb 100644 --- a/src/rollback-cmd.ts +++ b/src/rollback-cmd.ts @@ -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, ".."); @@ -186,6 +187,7 @@ async function main(): Promise { headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", + "User-Agent": userAgentGet(), }, body: JSON.stringify(entry.payload.platform), }); diff --git a/src/setup.ts b/src/setup.ts index e87d66a..a6fdb61 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -20,6 +20,7 @@ import { SETUP_USAGE, } from "./setup-args.ts"; import { slugify } from "./slug-utils.ts"; +import { userAgentGet } from "./user-agent.ts"; // ───────────────────────────────────────────────────────────────────────────── // Constants @@ -94,7 +95,10 @@ const c = { async function apiGet(token: string, endpoint: string): Promise { const response = await fetch(`${vapiBaseUrl}${endpoint}`, { method: "GET", - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": userAgentGet(), + }, }); if (!response.ok) { diff --git a/src/user-agent.ts b/src/user-agent.ts index 39a383b..35c19b8 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -1,11 +1,25 @@ -// 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-/[ (ci)] +// +// `` is the gitops command that started the run, from a fixed list +// (`cli` when it can't be told), so the label set stays bounded and never +// carries a name a user chose, such as a fork's own npm script. The first +// gitops process pins it in VAPI_GITOPS_COMMAND, which every process it +// spawns inherits: `npm run apply` labels the pull and push it runs as +// `apply`, and the PR check's bindings pull is labelled `check`. +// +// The `sim`, `check` and `promote` labels are fixed by their callers. Analytics +// counts simulation runs by the `vapi-gitops-sim/` and `vapi-gitops-check/` +// prefixes, so keep those prefixes stable; the version and the ` (ci)` suffix +// may vary. // // 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( @@ -14,6 +28,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( @@ -34,6 +54,80 @@ 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, ""); +} + +// The commands a label can name: this repo's npm scripts. Hard-coded, not +// read from package.json, because forks add their own scripts there. +const COMMANDS = new Set([ + "setup", + "apply", + "push", + "pull", + "migrate", + "call", + "cleanup", + "validate", + "audit", + "sim", + "check", + "rollback", + "promote", +]); + +export const COMMAND_ENV = "VAPI_GITOPS_COMMAND"; + +function commandKnown(value: string | undefined): string | undefined { + const token = tokenClean(value ?? ""); + return COMMANDS.has(token) ? token : undefined; +} + +// The pinned label, else the npm script, else the entry script, else `cli`. +// `npx tsx src/push-cmd.ts` sets npm_lifecycle_event=npx, which isn't a +// command, so it falls through to the entry script: `push`. +function commandNameGet(context: UserAgentContext): string { + const script = context.scriptPath + ? basename(context.scriptPath) + .replace(/\.[cm]?[jt]s$/, "") + .replace(/-cmd$/, "") + : undefined; + return ( + commandKnown(context.env[COMMAND_ENV]) ?? + commandKnown(context.env.npm_lifecycle_event) ?? + commandKnown(script) ?? + "cli" + ); +} + +// Pin this process's label for every process it spawns. +process.env[COMMAND_ENV] ??= commandNameGet({ + env: process.env, + scriptPath: process.argv[1], +}); + +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" | "promote", + 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}`; } diff --git a/tests/cleanup-safety.test.ts b/tests/cleanup-safety.test.ts index 0575aba..08749c9 100644 --- a/tests/cleanup-safety.test.ts +++ b/tests/cleanup-safety.test.ts @@ -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, }, diff --git a/tests/new-file-gate.test.ts b/tests/new-file-gate.test.ts index 4ffb6d1..bca14c2 100644 --- a/tests/new-file-gate.test.ts +++ b/tests/new-file-gate.test.ts @@ -459,7 +459,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, }, diff --git a/tests/no-vapi-api.ts b/tests/no-vapi-api.ts new file mode 100644 index 0000000..2681176 --- /dev/null +++ b/tests/no-vapi-api.ts @@ -0,0 +1,6 @@ +// Loaded before every test file (package.json's test script), and inherited +// by every CLI a test spawns: tests must never reach the real Vapi API or use +// a developer's real key. A test that needs a key sets a fake one itself. +process.env.VAPI_BASE_URL = "http://127.0.0.1:9"; +delete process.env.VAPI_PRIVATE_API_KEY; +delete process.env.VAPI_TOKEN; diff --git a/tests/user-agent-coverage.test.ts b/tests/user-agent-coverage.test.ts new file mode 100644 index 0000000..c448b31 --- /dev/null +++ b/tests/user-agent-coverage.test.ts @@ -0,0 +1,63 @@ +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)); + +// Each call is checked on its own, from `fetch(` to its closing `);`, so one +// call's header can't vouch for a neighbour's, and subfolders are scanned too. +// Every fetch in src/ is covered, the GitHub status call included: GitHub also +// asks clients to send a User-Agent. +test("every fetch in src/ sets the User-Agent", () => { + const missing: string[] = []; + const files = readdirSync(SRC, { recursive: true, encoding: "utf8" }); + for (const file of files.filter((f) => f.endsWith(".ts"))) { + readFileSync(join(SRC, file), "utf8") + .split(/\bfetch\(/) + .slice(1) + .forEach((rest, i) => { + const call = rest.slice(0, rest.indexOf(");") + 2); + if (!call.includes('"User-Agent"')) + missing.push(`${file} (fetch #${i + 1})`); + }); + } + assert.deepEqual(missing, []); +}); + +test("api.ts requests carry the command's User-Agent", async () => { + const seen: Array = []; + const server = createServer((req, res) => { + seen.push(req.headers["user-agent"]); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end("[]"); + }); + await new Promise((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.VAPI_GITOPS_COMMAND; + 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((resolve) => server.close(() => resolve())); + } +}); diff --git a/tests/user-agent.test.ts b/tests/user-agent.test.ts index 5ba18f8..a1e43ae 100644 --- a/tests/user-agent.test.ts +++ b/tests/user-agent.test.ts @@ -6,18 +6,83 @@ import test from "node:test"; import { runSimulation } from "../src/sim.ts"; import { userAgentGet } from "../src/user-agent.ts"; -// The User-Agent is how gitops-started simulation runs are counted in the -// platform's analytics (`user_agent` on the run-started event), so its -// format is a contract worth pinning. +// The User-Agent is how gitops traffic is counted in the platform's request +// logs and analytics (simulation runs by `user_agent` on the run-started +// event), so its format is a contract worth pinning. const packageJsonPath = new URL("../package.json", import.meta.url); const packageVersion = ( JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { version: string } ).version; -test("userAgentGet: names the product and the package version", () => { - assert.equal(userAgentGet("sim"), `vapi-gitops-sim/${packageVersion}`); - assert.equal(userAgentGet("check"), `vapi-gitops-check/${packageVersion}`); +const at = ( + env: NodeJS.ProcessEnv, + scriptPath?: string, + product?: "sim" | "check", +) => userAgentGet(product, { env, scriptPath }); + +test("userAgentGet: sim and check keep their fixed labels", () => { + assert.deepEqual( + [ + at({}, "/repo/src/sim-cmd.ts", "sim"), + at( + { npm_lifecycle_event: "promote" }, + "/repo/src/promote-cmd.ts", + "check", + ), + ], + [ + `vapi-gitops-sim/${packageVersion}`, + `vapi-gitops-check/${packageVersion}`, + ], + ); +}); + +test("userAgentGet: names a known command only: pinned, npm script, entry script, else cli", () => { + assert.deepEqual( + [ + // `npm run apply` runs pull.ts and push.ts as children: still apply. + at({ npm_lifecycle_event: "apply" }, "/repo/src/push.ts"), + // The PR check runs check-cmd.ts directly; its bindings pull inherits + // the pinned label. + at({ VAPI_GITOPS_COMMAND: "check" }, "/repo/src/pull.ts"), + at({}, "/repo/src/check-cmd.ts"), + at({}, "/repo/src/pull.ts"), + // npx isn't a command, so the entry script names it. + at({ npm_lifecycle_event: "npx" }, "/repo/src/push-cmd.ts"), + // A fork's own script name is never sent. + at({ npm_lifecycle_event: "deploy:acme-prod" }, "/repo/src/apply-cmd.ts"), + at({ npm_lifecycle_event: "check:All" }), + at({}), + ], + [ + `vapi-gitops-apply/${packageVersion}`, + `vapi-gitops-check/${packageVersion}`, + `vapi-gitops-check/${packageVersion}`, + `vapi-gitops-pull/${packageVersion}`, + `vapi-gitops-push/${packageVersion}`, + `vapi-gitops-apply/${packageVersion}`, + `vapi-gitops-cli/${packageVersion}`, + `vapi-gitops-cli/${packageVersion}`, + ], + ); +}); + +test("userAgentGet: marks runs in CI", () => { + const marked = (env: NodeJS.ProcessEnv) => + at({ npm_lifecycle_event: "push", ...env }).endsWith(" (ci)"); + assert.deepEqual( + [ + { GITHUB_ACTIONS: "true" }, + { CI: "true" }, + { CI: "1" }, + { CI: "false" }, + { CI: "0" }, + { CI: "" }, + {}, + ].map(marked), + [true, true, true, false, false, false, false], + ); }); test("runSimulation: sends the sim User-Agent on run create", async () => { @@ -53,5 +118,5 @@ test("runSimulation: sends the sim User-Agent on run create", async () => { } assert.equal(seen.method, "POST"); assert.equal(seen.url, "/eval/simulation/run"); - assert.equal(seen.userAgent, `vapi-gitops-sim/${packageVersion}`); + assert.equal(seen.userAgent, userAgentGet("sim")); });