diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 435a20edd..fe6c87495 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -813,7 +813,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ npx hyperframes telemetry status ``` - Telemetry collects command names, render performance, example choices, and system info. It does **not** collect file paths, project names, video content, or personally identifiable information. Disable with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. + Telemetry collects command names, render performance, example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry does **not** collect file paths, project names, video content, environment variable values, or personally identifiable information. Disable with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. ### `skills` diff --git a/packages/cli/src/telemetry/agent_runtime.test.ts b/packages/cli/src/telemetry/agent_runtime.test.ts new file mode 100644 index 000000000..b87cbea9a --- /dev/null +++ b/packages/cli/src/telemetry/agent_runtime.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; + +// agent_runtime.ts reads node:os via release/platform and node:fs for the +// /proc files. detectAgentRuntime is exercised by mutating process.env; +// detectSandboxRuntime is exercised through a small set of node:os mocks. + +const VENDOR_ENV_KEYS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CODEX_HOME", + "CODEX_SANDBOX", + "CODEX_SANDBOX_NETWORK_DISABLED", + "CURSOR_TRACE_ID", + "CURSOR_AGENT", + "TERM_PROGRAM", + "GITHUB_ACTIONS", + "COPILOT_AGENT_ID", + "RUNNER_NAME", + "JULES_TASK_ID", + "JULES_SESSION", + "REPL_ID", + "REPLIT_USER", + "DEVIN_SESSION_ID", + "AIDER_RUN_ID", + "GEMINI_CLI", + "HERMES_QUIET", + "_HERMES_GATEWAY", + "HERMES_INFERENCE_PROVIDER", + "OPENCLAW_CLI", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", +] as const; + +function stripVendorEnv(): void { + for (const key of VENDOR_ENV_KEYS) delete process.env[key]; +} + +describe("detectAgentRuntime — base behavior", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("returns null on a plain shell with no agent markers", async () => { + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBeNull(); + }); + + it("first matching vendor wins (rule order)", async () => { + // Claude Code marker set alongside a Codex marker — Claude Code is the + // first rule, so it wins. + process.env["CLAUDECODE"] = "1"; + process.env["CODEX_HOME"] = "/home/codex"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("claude_code"); + }); + + it("never reads env-var values — even API-key-shaped values stay unread", async () => { + process.env["CODEX_HOME"] = "/home/codex"; + process.env["CODEX_API_KEY"] = "sk-supersecret-DO-NOT-LEAK"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + const result = detectAgentRuntime(); + expect(result).toBe("codex"); + expect(typeof result).toBe("string"); + expect((result ?? "").includes("supersecret")).toBe(false); + }); +}); + +describe("detectAgentRuntime — Claude Code", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects via CLAUDECODE=1", async () => { + process.env["CLAUDECODE"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("claude_code"); + }); + + it("detects via CLAUDE_CODE_ENTRYPOINT", async () => { + process.env["CLAUDE_CODE_ENTRYPOINT"] = "cli"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("claude_code"); + }); +}); + +describe("detectAgentRuntime — OpenAI Codex", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects via CODEX_HOME", async () => { + process.env["CODEX_HOME"] = "/home/codex"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("codex"); + }); + + it("detects via CODEX_SANDBOX_NETWORK_DISABLED", async () => { + process.env["CODEX_SANDBOX_NETWORK_DISABLED"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("codex"); + }); +}); + +describe("detectAgentRuntime — Cursor / Copilot / cohort", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects Cursor via TERM_PROGRAM=cursor", async () => { + process.env["TERM_PROGRAM"] = "cursor"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("cursor"); + }); + + it("detects Copilot Coding Agent via GITHUB_ACTIONS + COPILOT_AGENT_ID", async () => { + process.env["GITHUB_ACTIONS"] = "true"; + process.env["COPILOT_AGENT_ID"] = "abc123"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("copilot_agent"); + }); + + it("does NOT flag generic GitHub Actions as copilot_agent", async () => { + process.env["GITHUB_ACTIONS"] = "true"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBeNull(); + }); +}); + +describe("detectAgentRuntime — Jules / Replit / Devin / Hermes / openclaw", () => { + const savedEnv = { ...process.env }; + beforeEach(stripVendorEnv); + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("detects Jules via JULES_TASK_ID", async () => { + process.env["JULES_TASK_ID"] = "task-1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("jules"); + }); + + it("detects Replit via REPL_ID", async () => { + process.env["REPL_ID"] = "repl-1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("replit"); + }); + + it("detects Devin via DEVIN_SESSION_ID", async () => { + process.env["DEVIN_SESSION_ID"] = "sess-1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("devin"); + }); + + it("detects Hermes via HERMES_QUIET=1 (set unconditionally by cli.py)", async () => { + process.env["HERMES_QUIET"] = "1"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("hermes"); + }); + + it("detects openclaw via inherited OPENCLAW_STATE_DIR", async () => { + process.env["OPENCLAW_STATE_DIR"] = "/tmp/openclaw"; + const { detectAgentRuntime } = await import("./agent_runtime.js"); + expect(detectAgentRuntime()).toBe("openclaw"); + }); +}); + +describe("detectSandboxRuntime — kernel-string path", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("reports gvisor for a 4.19.0-gvisor kernel string", async () => { + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "4.19.0-gvisor", platform: () => "linux" }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).toBe("gvisor"); + }); + + it("reports gvisor for the legacy 4.4.0 Sentry kernel string", async () => { + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, release: () => "4.4.0", platform: () => "linux" }; + }); + const { detectSandboxRuntime } = await import("./agent_runtime.js"); + expect(detectSandboxRuntime()).toBe("gvisor"); + }); +}); diff --git a/packages/cli/src/telemetry/agent_runtime.ts b/packages/cli/src/telemetry/agent_runtime.ts new file mode 100644 index 000000000..23c4a77c4 --- /dev/null +++ b/packages/cli/src/telemetry/agent_runtime.ts @@ -0,0 +1,214 @@ +import { existsSync, readFileSync } from "node:fs"; +import { platform, release } from "node:os"; +import { detectWSL } from "./platform.js"; + +// --------------------------------------------------------------------------- +// Sandbox runtime + agent vendor fingerprinting. +// +// Goal: distinguish "real developer laptop" from "ephemeral managed sandbox +// driving the CLI on someone's behalf" (Codex Cloud, Claude Code Web, Cursor +// Background Agents, etc.) without collecting any PII. +// +// We only read: +// - well-known kernel strings (release(), /proc/version) +// - sandbox marker files (/.dockerenv etc.) +// - the *existence* of vendor environment variables — never the value +// (some are API keys). +// +// Output is two opaque strings: sandbox_runtime ('gvisor' | 'docker' | ...) +// and agent_runtime ('claude_code' | 'codex' | ...). Both null when unknown. +// --------------------------------------------------------------------------- + +export type SandboxRuntime = "gvisor" | "firecracker" | "docker" | "kvm" | "wsl" | null; + +export type AgentRuntime = + | "claude_code" + | "codex" + | "cursor" + | "copilot_agent" + | "jules" + | "replit" + | "devin" + | "aider" + | "gemini_cli" + | "hermes" + | "openclaw" + | null; + +interface VendorRule { + name: Exclude; + /** Check returns true when the named agent is driving the CLI. */ + check: (env: NodeJS.ProcessEnv) => boolean; +} + +// Ordering matters: the FIRST rule that matches wins. Put more specific rules +// before more generic ones (e.g. copilot_agent before a hypothetical generic +// 'github_actions' rule). +const VENDOR_RULES: VendorRule[] = [ + // Anthropic Claude Code — local Claude Code spawns subprocesses with + // CLAUDECODE=1 and an entrypoint marker. Same env vars appear in the + // Claude Code Web sandbox. + { + name: "claude_code", + check: (env) => env["CLAUDECODE"] === "1" || typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string", + }, + // OpenAI Codex — Codex CLI and Codex Cloud both set CODEX_HOME, and the + // managed sandbox additionally sets CODEX_SANDBOX_NETWORK_DISABLED. + { + name: "codex", + check: (env) => + typeof env["CODEX_HOME"] === "string" || + typeof env["CODEX_SANDBOX"] === "string" || + typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string", + }, + // Cursor IDE + Cursor Background Agents. + { + name: "cursor", + check: (env) => + typeof env["CURSOR_TRACE_ID"] === "string" || + typeof env["CURSOR_AGENT"] === "string" || + env["TERM_PROGRAM"] === "cursor", + }, + // GitHub Copilot Coding Agent runs inside GitHub Actions, but Copilot's + // workflow injects an extra marker that distinguishes it from generic CI. + { + name: "copilot_agent", + check: (env) => + env["GITHUB_ACTIONS"] === "true" && + (typeof env["COPILOT_AGENT_ID"] === "string" || env["RUNNER_NAME"] === "Copilot"), + }, + // Google Jules. + { + name: "jules", + check: (env) => + typeof env["JULES_TASK_ID"] === "string" || typeof env["JULES_SESSION"] === "string", + }, + // Replit / Replit Agent. + { + name: "replit", + check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "string", + }, + // Devin (Cognition). + { + name: "devin", + check: (env) => typeof env["DEVIN_SESSION_ID"] === "string", + }, + // Aider. + { + name: "aider", + check: (env) => typeof env["AIDER_RUN_ID"] === "string", + }, + // Gemini CLI — sets a known env var when invoking shell tools. + { + name: "gemini_cli", + check: (env) => typeof env["GEMINI_CLI"] === "string", + }, + // Nous Research Hermes Agent — cli.py:50 unconditionally executes + // os.environ["HERMES_QUIET"] = "1" + // at module load, so the marker propagates via os.environ to every + // subprocess spawned by Hermes. + // Source: https://github.com/NousResearch/hermes-agent (cli.py:50) + { + name: "hermes", + check: (env) => env["HERMES_QUIET"] === "1", + }, + // openclaw — multi-channel AI gateway. When openclaw spawns a CLI + // subprocess it builds the child env with OPENCLAW_STATE_DIR / + // OPENCLAW_CONFIG_PATH / OPENCLAW_DISABLE_AUTO_UPDATE set explicitly + // (extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts:344-351). + // We key on OPENCLAW_STATE_DIR since it's a path scope-bound to openclaw. + // Source: https://github.com/openclaw/openclaw + { + name: "openclaw", + check: (env) => + typeof env["OPENCLAW_STATE_DIR"] === "string" || + typeof env["OPENCLAW_CONFIG_PATH"] === "string", + }, +]; + +/** + * Identify the managed sandbox runtime hosting this CLI invocation. + * Returns null on a normal developer machine. Dispatches to runtime-specific + * detectors that each return a boolean; the priority order encoded here is + * deliberate (WSL > gVisor > Docker > Firecracker > KVM). + */ +export function detectSandboxRuntime(): SandboxRuntime { + if (platform() === "win32") return null; + if (detectWSL()) return "wsl"; + if (isGVisor()) return "gvisor"; + if (isDocker()) return "docker"; + if (isFirecracker()) return "firecracker"; + if (isKVM()) return "kvm"; + return null; +} + +/** + * Identify the coding-agent vendor that spawned this process, if any. + * Returns null on a regular interactive shell. Only checks for the + * EXISTENCE of well-known env vars — never reads their values. + */ +export function detectAgentRuntime(): AgentRuntime { + for (const rule of VENDOR_RULES) { + if (rule.check(process.env)) return rule.name; + } + return null; +} + +// --------------------------------------------------------------------------- +// Sandbox runtime detectors — one per runtime, kept small and side-effect-free. +// --------------------------------------------------------------------------- + +/** + * gVisor reports kernel string `4.19.0-gvisor` (current) or `4.4.0` (legacy + * Sentry kernel). Both are unambiguous: no production Linux box reports + * either today. /proc/version is the backup signal. + */ +function isGVisor(): boolean { + const kernel = release(); + if (kernel === "4.4.0" || kernel.includes("gvisor")) return true; + if (platform() !== "linux") return false; + try { + return readFileSync("/proc/version", "utf-8").includes("gVisor"); + } catch { + return false; + } +} + +function isDocker(): boolean { + if (existsSync("/.dockerenv")) return true; + if (platform() !== "linux") return false; + try { + const cgroup = readFileSync("/proc/1/cgroup", "utf-8"); + return cgroup.includes("docker") || cgroup.includes("containerd"); + } catch { + return false; + } +} + +/** + * AWS Firecracker microVMs expose /dev/vsock and report sys_vendor='Amazon EC2' + * with product_name containing 'Firecracker'. Full EC2 reports a real instance + * type like 't3.large', so the product_name check distinguishes them. + */ +function isFirecracker(): boolean { + if (platform() !== "linux") return false; + if (!existsSync("/dev/vsock")) return false; + try { + const sysVendor = readFileSync("/sys/class/dmi/id/sys_vendor", "utf-8").trim(); + if (sysVendor !== "Amazon EC2") return false; + const productName = readFileSync("/sys/class/dmi/id/product_name", "utf-8").trim(); + return productName.toLowerCase().includes("firecracker"); + } catch { + return false; + } +} + +function isKVM(): boolean { + if (platform() !== "linux") return false; + try { + const sysVendor = readFileSync("/sys/class/dmi/id/sys_vendor", "utf-8").trim(); + return sysVendor === "QEMU" || sysVendor.includes("KVM"); + } catch { + return false; + } +} diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 039c0d450..feb55989d 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -17,7 +17,7 @@ const FLUSH_TIMEOUT_MS = 5_000; // --------------------------------------------------------------------------- interface EventProperties { - [key: string]: string | number | boolean | undefined; + [key: string]: string | number | boolean | null | undefined; } let eventQueue: Array<{ @@ -81,30 +81,41 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi ci_name: sys.ci_name ?? undefined, is_wsl: sys.is_wsl, is_tty: sys.is_tty, + sandbox_runtime: sys.sandbox_runtime ?? undefined, + agent_runtime: sys.agent_runtime ?? undefined, }, timestamp: new Date().toISOString(), }); } +/** + * Drain the in-memory queue into a PostHog `/batch/` payload string. + * Returns null when there's nothing to send. Resets the queue as a side effect + * so callers can fire-and-forget the resulting payload. + * + * $ip:null tells PostHog not to record the request IP for any of these events. + * Server-side "Discard client IP data" is also enabled in project settings. + */ +function drainQueueToPayload(): string | null { + if (eventQueue.length === 0) return null; + const config = readConfig(); + const batch = eventQueue.map((e) => ({ + event: e.event, + properties: { ...e.properties, $ip: null }, + distinct_id: config.anonymousId, + timestamp: e.timestamp, + })); + eventQueue = []; + return JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); +} + /** * Flush all queued events to PostHog via async HTTP POST. * Called before normal process exit via `beforeExit`. */ export async function flush(): Promise { - if (eventQueue.length === 0) { - return; - } - - const config = readConfig(); - const batch = eventQueue.map((e) => ({ - event: e.event, - // $ip: null tells PostHog to not record the request IP for this event. - // Server-side "Discard client IP data" is also enabled in project settings. - properties: { ...e.properties, $ip: null }, - distinct_id: config.anonymousId, - timestamp: e.timestamp, - })); - eventQueue = []; + const payload = drainQueueToPayload(); + if (payload == null) return; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); @@ -113,7 +124,7 @@ export async function flush(): Promise { await fetch(`${POSTHOG_HOST}/batch/`, { method: "POST", headers: { "Content-Type": "application/json", Connection: "close" }, - body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }), + body: payload, signal: controller.signal, }); } catch { @@ -129,20 +140,8 @@ export async function flush(): Promise { * so the parent process exits immediately without waiting. */ export function flushSync(): void { - if (eventQueue.length === 0) { - return; - } - - const config = readConfig(); - const batch = eventQueue.map((e) => ({ - event: e.event, - properties: { ...e.properties, $ip: null }, - distinct_id: config.anonymousId, - timestamp: e.timestamp, - })); - eventQueue = []; - - const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); + const payload = drainQueueToPayload(); + if (payload == null) return; try { const { spawn } = require("node:child_process") as typeof import("node:child_process"); diff --git a/packages/cli/src/telemetry/platform.ts b/packages/cli/src/telemetry/platform.ts new file mode 100644 index 000000000..ac8b89fbc --- /dev/null +++ b/packages/cli/src/telemetry/platform.ts @@ -0,0 +1,18 @@ +import { platform, release } from "node:os"; +import { readFileSync } from "node:fs"; + +// Shared host-platform detectors used by both system.ts (overall metadata) +// and agent_runtime.ts (sandbox fingerprinting). Lives in its own module +// to avoid an import cycle between those two files. + +export function detectWSL(): boolean { + if (platform() !== "linux") return false; + try { + const osRelease = release().toLowerCase(); + if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; + const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase(); + return procVersion.includes("microsoft") || procVersion.includes("wsl"); + } catch { + return false; + } +} diff --git a/packages/cli/src/telemetry/system.ts b/packages/cli/src/telemetry/system.ts index 3719533e1..dc79883f7 100644 --- a/packages/cli/src/telemetry/system.ts +++ b/packages/cli/src/telemetry/system.ts @@ -1,5 +1,12 @@ import { cpus, totalmem, platform, release } from "node:os"; import { existsSync, readFileSync, statfsSync } from "node:fs"; +import { + detectAgentRuntime, + detectSandboxRuntime, + type AgentRuntime, + type SandboxRuntime, +} from "./agent_runtime.js"; +import { detectWSL } from "./platform.js"; // --------------------------------------------------------------------------- // System metadata collected once per CLI session and attached to all events. @@ -23,6 +30,20 @@ export interface SystemMeta { ci_name: string | null; is_wsl: boolean; is_tty: boolean; + /** + * Managed sandbox runtime hosting this invocation, when one is detectable + * (gvisor / firecracker / docker / kvm / wsl). null on a normal dev + * machine. Lets us distinguish "real laptop" from "ephemeral cloud + * sandbox driving the CLI" without geo guesswork. + */ + sandbox_runtime: SandboxRuntime; + /** + * Coding-agent vendor that spawned this process, if any (claude_code, + * codex, cursor, copilot_agent, jules, replit, devin, aider, gemini_cli). + * Detected by env-var existence only — values are never read. null when + * no agent is detected (i.e. a human invoked the CLI directly). + */ + agent_runtime: AgentRuntime; } let cached: SystemMeta | null = null; @@ -48,6 +69,8 @@ export function getSystemMeta(): SystemMeta { ci_name: getCIName(), is_wsl: detectWSL(), is_tty: Boolean(process.stdout?.isTTY), + sandbox_runtime: detectSandboxRuntime(), + agent_runtime: detectAgentRuntime(), }; return cached; } @@ -70,42 +93,36 @@ function detectDocker(): boolean { return false; } +// Each entry: env var name, optional named CI provider, predicate. +// Named providers come first so getCIName() picks the most specific match. +// `truthy` accepts 'true' or '1' to cover both common conventions. +const CI_PROVIDERS: Array<{ name: string | null; envVar: string; truthy?: true; presence?: true }> = + [ + { name: "github_actions", envVar: "GITHUB_ACTIONS", truthy: true }, + { name: "gitlab_ci", envVar: "GITLAB_CI", truthy: true }, + { name: "circleci", envVar: "CIRCLECI", truthy: true }, + { name: "jenkins", envVar: "JENKINS_URL", presence: true }, + { name: "buildkite", envVar: "BUILDKITE", truthy: true }, + { name: "travis", envVar: "TRAVIS", truthy: true }, + { name: null, envVar: "CONTINUOUS_INTEGRATION", truthy: true }, + { name: null, envVar: "CI", truthy: true }, + ]; + +function matchesProvider(p: (typeof CI_PROVIDERS)[number]): boolean { + const v = process.env[p.envVar]; + if (p.presence) return v != null; + return v === "true" || v === "1"; +} + function detectCI(): boolean { - return ( - process.env["CI"] === "true" || - process.env["CI"] === "1" || - process.env["CONTINUOUS_INTEGRATION"] === "true" || - process.env["GITHUB_ACTIONS"] === "true" || - process.env["GITLAB_CI"] === "true" || - process.env["CIRCLECI"] === "true" || - process.env["JENKINS_URL"] != null || - process.env["BUILDKITE"] === "true" || - process.env["TRAVIS"] === "true" || - false - ); + return CI_PROVIDERS.some(matchesProvider); } function getCIName(): string | null { - if (process.env["GITHUB_ACTIONS"] === "true") return "github_actions"; - if (process.env["GITLAB_CI"] === "true") return "gitlab_ci"; - if (process.env["CIRCLECI"] === "true") return "circleci"; - if (process.env["JENKINS_URL"] != null) return "jenkins"; - if (process.env["BUILDKITE"] === "true") return "buildkite"; - if (process.env["TRAVIS"] === "true") return "travis"; - if (detectCI()) return "unknown"; - return null; -} - -function detectWSL(): boolean { - if (platform() !== "linux") return false; - try { - const osRelease = release().toLowerCase(); - if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; - const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase(); - return procVersion.includes("microsoft") || procVersion.includes("wsl"); - } catch { - return false; + for (const provider of CI_PROVIDERS) { + if (provider.name && matchesProvider(provider)) return provider.name; } + return detectCI() ? "unknown" : null; } // ---------------------------------------------------------------------------