diff --git a/apps/cli/src/bundle.integration.test.ts b/apps/cli/src/bundle.integration.test.ts index 11d8e0714..0be88fa5b 100644 --- a/apps/cli/src/bundle.integration.test.ts +++ b/apps/cli/src/bundle.integration.test.ts @@ -44,12 +44,14 @@ let server: Server; let port: number; let bundleDir: string; let isolatedBundle: string; +let lastUserAgent: string | undefined; beforeAll(async () => { bundleDir = mkdtempSync(join(tmpdir(), "scope-bundle-")); isolatedBundle = join(bundleDir, "scope.mjs"); copyFileSync(BUNDLE_PATH, isolatedBundle); server = createServer((req, res) => { + lastUserAgent = req.headers["user-agent"]; const url = new URL(req.url ?? "/", `http://localhost`); const response = MOCK_RESPONSES[url.pathname]; if (response) { @@ -112,6 +114,7 @@ describe("Bundle integration tests", () => { expect(stdout).toContain("req-test-001"); expect(stdout).toContain("coder-acp-copilot"); expect(stdout).toContain("done"); + expect(lastUserAgent).toMatch(/^scope-cli\/\d+\.\d+\.\d+/); }); it("criteria list fetches from mock API", async () => { diff --git a/apps/cli/src/commands/codebase.ts b/apps/cli/src/commands/codebase.ts index b703c89fa..736a53615 100644 --- a/apps/cli/src/commands/codebase.ts +++ b/apps/cli/src/commands/codebase.ts @@ -11,6 +11,7 @@ import { formatData, isMachineReadable } from "../utils/formatters.js"; import type { DisplayField, OutputFormat } from "../utils/types.js"; import { getDefaultApiUrl, normalizeUrl, withOutputOption, withProjectOption } from "../utils/shared.js"; import { requireProjectId } from "../utils/config.js"; +import { CLI_USER_AGENT } from "../utils/api-client.js"; type JsonDate = string | Date; type CodebaseApiDocument = Omit & { @@ -56,8 +57,14 @@ async function readError(response: Response): Promise { return error.error ?? JSON.stringify(error); } +async function fetchWithUserAgent(url: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + if (!headers.has("user-agent")) headers.set("user-agent", CLI_USER_AGENT); + return fetch(url, { ...init, headers }); +} + async function fetchJson(url: string, init?: RequestInit): Promise { - const response = await fetch(url, init); + const response = await fetchWithUserAgent(url, init); if (!response.ok) { throw new Error(await readError(response)); } @@ -65,7 +72,7 @@ async function fetchJson(url: string, init?: RequestInit): Promise { } async function resolveCodebase(baseUrl: string, idOrSlug: string): Promise { - const directResponse = await fetch(`${baseUrl}/api/v1/codebases/${encodeURIComponent(idOrSlug)}`); + const directResponse = await fetchWithUserAgent(`${baseUrl}/api/v1/codebases/${encodeURIComponent(idOrSlug)}`); if (directResponse.ok) { return await directResponse.json() as CodebaseApiDocument; } @@ -182,7 +189,7 @@ export function registerCodebaseCommands(program: Command): void { if (options.description) formData.append("description", options.description); const blob = new Blob([archiveBuffer], { type: "application/octet-stream" }); formData.append("archive", blob, basename(resolvedPath)); - const response = await fetch(`${baseUrl}/api/v1/codebases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: formData }); + const response = await fetchWithUserAgent(`${baseUrl}/api/v1/codebases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: formData }); if (!response.ok) { throw new Error(await readError(response)); } @@ -304,7 +311,7 @@ export function registerCodebaseCommands(program: Command): void { const blob = new Blob([archiveBuffer], { type: "application/octet-stream" }); formData.append("archive", blob, basename(resolvedPath)); - const response = await fetch(`${baseUrl}/api/v1/codebases/${encodeURIComponent(codebaseId(resolved))}/upload`, { + const response = await fetchWithUserAgent(`${baseUrl}/api/v1/codebases/${encodeURIComponent(codebaseId(resolved))}/upload`, { method: "POST", body: formData, }); @@ -340,7 +347,7 @@ export function registerCodebaseCommands(program: Command): void { const baseUrl = normalizeUrl(options.url); try { const resolved = await resolveCodebase(baseUrl, codebaseIdOrSlug); - const response = await fetch(`${baseUrl}/api/v1/codebases/${encodeURIComponent(codebaseId(resolved))}`, { + const response = await fetchWithUserAgent(`${baseUrl}/api/v1/codebases/${encodeURIComponent(codebaseId(resolved))}`, { method: "DELETE", }); if (!response.ok) { diff --git a/apps/cli/src/commands/report.ts b/apps/cli/src/commands/report.ts index 1dce6f3b6..a16231969 100644 --- a/apps/cli/src/commands/report.ts +++ b/apps/cli/src/commands/report.ts @@ -9,7 +9,7 @@ import { formatData, isMachineReadable } from "../utils/formatters.js"; import type { OutputFormat, DisplayField } from "../utils/types.js"; import { normalizeUrl, withOutputOption, withProjectOption, getCliName, getDefaultApiUrl } from "../utils/shared.js"; import { requireProjectId } from "../utils/config.js"; -import { apiFetch, getApiBasePath } from "../utils/api-client.js"; +import { apiFetch, CLI_USER_AGENT, getApiBasePath } from "../utils/api-client.js"; export function registerReportCommands(program: Command): void { // ─── Report management ────────────────────────────────────────────────────── @@ -52,7 +52,8 @@ report if (options.stream) { console.log(`\n${label('Streaming logs...')}\n`); const eventSource = new EventSource( - `${normalizeUrl(options.url)}${getApiBasePath()}/reports/${result.id}/logs?fromStart=true` + `${normalizeUrl(options.url)}${getApiBasePath()}/reports/${result.id}/logs?fromStart=true`, + { headers: { "User-Agent": CLI_USER_AGENT } }, ); eventSource.onmessage = (event: MessageEvent) => { @@ -264,7 +265,8 @@ report const fromStartParam = options.fromStart ? "&fromStart=true" : ""; const eventSource = new EventSource( - `${normalizeUrl(options.url)}${getApiBasePath()}/reports/${options.id}/logs?${fromStartParam}` + `${normalizeUrl(options.url)}${getApiBasePath()}/reports/${options.id}/logs?${fromStartParam}`, + { headers: { "User-Agent": CLI_USER_AGENT } }, ); eventSource.onmessage = (event: MessageEvent) => { diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 4c2727699..1e2af551b 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -17,7 +17,7 @@ import type { OutputFormat, DisplayField } from "../utils/types.js"; import { runGetAction } from "../run-get-action.js"; import { normalizeUrl, printFollowUpCommands, withOutputOption, withProjectOption, getDefaultApiUrl } from "../utils/shared.js"; import { requireProjectId } from "../utils/config.js"; -import { apiFetch, getApiBasePath } from "../utils/api-client.js"; +import { apiFetch, CLI_USER_AGENT, getApiBasePath } from "../utils/api-client.js"; import { parseGatesOption } from "../utils/gates.js"; import { buildResourceBindingSpecs, collectRepeatable } from "../utils/resources.js"; @@ -290,7 +290,9 @@ run // Stream logs console.log(`\n${banner('--- Streaming logs ---')}\n`); - const eventSource = new EventSource(`${normalizeUrl(url)}${getApiBasePath()}/requests/${result.id}/logs`); + const eventSource = new EventSource(`${normalizeUrl(url)}${getApiBasePath()}/requests/${result.id}/logs`, { + headers: { "User-Agent": CLI_USER_AGENT }, + }); eventSource.onmessage = (event) => { try { @@ -439,7 +441,7 @@ run ? `${normalizeUrl(options.url)}${getApiBasePath()}/requests/${id}/logs?fromStart=true` : `${normalizeUrl(options.url)}${getApiBasePath()}/requests/${id}/logs`; - const eventSource = new EventSource(url); + const eventSource = new EventSource(url, { headers: { "User-Agent": CLI_USER_AGENT } }); eventSource.onmessage = (event) => { try { diff --git a/apps/cli/src/utils/api-client.test.ts b/apps/cli/src/utils/api-client.test.ts index 7793f2922..038c333a2 100644 --- a/apps/cli/src/utils/api-client.test.ts +++ b/apps/cli/src/utils/api-client.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError, + CLI_USER_AGENT, apiFetch, DEFAULT_API_BASE_PATH, getApiBasePath, @@ -60,6 +61,18 @@ describe("apiFetch URL handling", () => { expect(mock).toHaveBeenCalledTimes(1); expect(reqOf(mock).url).toBe("http://localhost:3100/api/v1/criteria"); + expect(reqOf(mock).headers.get("user-agent")).toBe(CLI_USER_AGENT); + }); + + it("preserves a caller-supplied User-Agent", async () => { + const mock = vi.fn().mockResolvedValue(okJson()); + vi.stubGlobal("fetch", mock); + + await apiFetch("http://localhost:3100", "/criteria", { + headers: { "User-Agent": "scope-test-client/1" }, + }); + + expect(reqOf(mock).headers.get("user-agent")).toBe("scope-test-client/1"); }); it("preserves query strings and adds a leading slash when missing", async () => { diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 2ab2dba76..fe3dab734 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -31,6 +31,9 @@ import ky, { type KyInstance, type BeforeRequestHook } from "ky"; import { normalizeUrl } from "./shared.js"; +/** Identify CLI traffic separately from Portal and other API clients in request analytics. */ +export const CLI_USER_AGENT = `scope-cli/${process.env.SCOPE_CLI_VERSION ?? "0.1.0-dev"}`; + /** Init accepted by {@link apiFetch}. Adds a couple of client-only knobs to `RequestInit`. */ export interface ApiFetchInit extends RequestInit { /** Redact entire request/response log bodies when registering or validating credentials. */ @@ -478,6 +481,7 @@ export async function apiFetch(baseUrl: string | undefined, path: string, init?: const url = `${normalizeUrl(baseUrl)}${withProjectId(resolveApiPath(path), init?.projectId)}`; const headers = new Headers(init?.headers); + if (!headers.has("user-agent")) headers.set("user-agent", CLI_USER_AGENT); if (init?.skipAuth) headers.set(SKIP_AUTH_HEADER, "1"); // Strip our client-only fields before handing the init to ky. diff --git a/apps/cli/src/utils/update-check.test.ts b/apps/cli/src/utils/update-check.test.ts index 32bc5dccf..d1c6ba1a3 100644 --- a/apps/cli/src/utils/update-check.test.ts +++ b/apps/cli/src/utils/update-check.test.ts @@ -31,7 +31,7 @@ describe("public CLI release lookup", () => { expect(await fetchLatestVersion()).toBe("1.2.3"); expect(fetchMock).toHaveBeenCalledWith(RELEASES_URL, { signal: expect.any(AbortSignal), - headers: { Accept: "application/vnd.github.v3+json" }, + headers: { Accept: "application/vnd.github.v3+json", "User-Agent": expect.stringMatching(/^scope-cli\//) }, }); }); diff --git a/apps/cli/src/utils/update-check.ts b/apps/cli/src/utils/update-check.ts index b7f90e59b..40c0bbebc 100644 --- a/apps/cli/src/utils/update-check.ts +++ b/apps/cli/src/utils/update-check.ts @@ -13,6 +13,7 @@ import { execSync } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; import semver from "semver"; +import { CLI_USER_AGENT } from "./api-client.js"; const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour const CONFIG_DIR = join(homedir(), ".config", "scope"); @@ -107,6 +108,7 @@ export async function fetchLatestVersion(timeoutMs = 5000): Promise = { Accept: "application/vnd.github.v3+json", + "User-Agent": CLI_USER_AGENT, }; const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; if (token) { diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index b2906b3ed..cea2d110b 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -70,6 +70,11 @@ guidance. `SCOPE_DEFAULT_API_URL` is no longer injected or read, and must also configure `SCOPE_API_URL` explicitly. Help, version, and CLI updates remain available without API configuration. +CLI HTTP requests identify themselves with `User-Agent: scope-cli/` so +request analytics can distinguish them from Portal traffic. This includes Scope +API calls, log streams, and the GitHub release update check. The bundled CLI uses +its injected package version; source-mode development uses `0.1.0-dev`. + ### esbuild plugins | Plugin | Purpose |