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
3 changes: 3 additions & 0 deletions apps/cli/src/bundle.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 () => {
Expand Down
17 changes: 12 additions & 5 deletions apps/cli/src/commands/codebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodebaseDocument, "createdAt" | "updatedAt" | "deletedAt"> & {
Expand Down Expand Up @@ -56,16 +57,22 @@ async function readError(response: Response): Promise<string> {
return error.error ?? JSON.stringify(error);
}

async function fetchWithUserAgent(url: string, init?: RequestInit): Promise<Response> {
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<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
const response = await fetchWithUserAgent(url, init);
if (!response.ok) {
throw new Error(await readError(response));
}
return await response.json() as T;
}

async function resolveCodebase(baseUrl: string, idOrSlug: string): Promise<CodebaseApiDocument> {
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;
}
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions apps/cli/src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
8 changes: 5 additions & 3 deletions apps/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/src/utils/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiError,
CLI_USER_AGENT,
apiFetch,
DEFAULT_API_BASE_PATH,
getApiBasePath,
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/utils/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/utils/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\//) },
});
});

Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/utils/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -107,6 +108,7 @@ export async function fetchLatestVersion(timeoutMs = 5000): Promise<string | und
try {
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3+json",
"User-Agent": CLI_USER_AGENT,
};
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (token) {
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/cli-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` 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 |
Expand Down