diff --git a/.changeset/cli-version-header.md b/.changeset/cli-version-header.md new file mode 100644 index 00000000..9a7c569c --- /dev/null +++ b/.changeset/cli-version-header.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": minor +--- + +Send an `x-taskless-cli-version` header on every request to the Taskless service (rule generation, reconcile, `whoami`, and the device-auth flow) declaring the CLI's version. The service uses this to gate capability-dependent responses — notably runtime rules — on the CLI being new enough to handle them; a request without the header is treated as a pre-runtime CLI. The version is also emitted with the CLI's telemetry so usage is recorded client-side rather than inferred server-side. diff --git a/packages/cli/src/api/client.ts b/packages/cli/src/api/client.ts index 345702a2..ef3a200f 100644 --- a/packages/cli/src/api/client.ts +++ b/packages/cli/src/api/client.ts @@ -2,6 +2,7 @@ import createClient from "openapi-fetch"; import type { paths } from "../generated/api"; import { getApiBaseUrl } from "./config"; +import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; /** Create a typed API client for the Taskless CLI API */ export function createApiClient(token: string) { @@ -9,6 +10,9 @@ export function createApiClient(token: string) { const baseUrl = getApiBaseUrl().replace(/\/cli\/?$/, ""); return createClient({ baseUrl, - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + [CLI_VERSION_HEADER]: CLI_VERSION, + }, }); } diff --git a/packages/cli/src/api/reconcile.ts b/packages/cli/src/api/reconcile.ts index b3ff6124..eee9c873 100644 --- a/packages/cli/src/api/reconcile.ts +++ b/packages/cli/src/api/reconcile.ts @@ -1,4 +1,5 @@ import { getApiBaseUrl } from "./config"; +import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; /** * Server-owned rule reconciliation (TSKL-270). The CLI reports the rule files @@ -87,6 +88,7 @@ export async function reconcile( headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + [CLI_VERSION_HEADER]: CLI_VERSION, }, body: JSON.stringify(request), }); diff --git a/packages/cli/src/auth/device-flow.ts b/packages/cli/src/auth/device-flow.ts index 0dabe9ec..1bc0cf6d 100644 --- a/packages/cli/src/auth/device-flow.ts +++ b/packages/cli/src/auth/device-flow.ts @@ -1,4 +1,5 @@ import { getApiBaseUrl } from "../api/config"; +import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; const CLIENT_ID = "taskless-cli"; @@ -43,7 +44,10 @@ class HttpDeviceFlowProvider implements DeviceFlowProvider { } const response = await fetch(`${baseUrl}/auth/device`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + [CLI_VERSION_HEADER]: CLI_VERSION, + }, body: JSON.stringify(body), }); @@ -61,7 +65,10 @@ class HttpDeviceFlowProvider implements DeviceFlowProvider { const baseUrl = getApiBaseUrl(); const response = await fetch(`${baseUrl}/auth/token`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + [CLI_VERSION_HEADER]: CLI_VERSION, + }, body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index 4a46355f..d2e03fb1 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -7,10 +7,7 @@ import { decodeJwt } from "jose"; import { decodeOrgId } from "./auth/jwt"; import { getConfigDirectory, getToken } from "./auth/token"; - -declare const __VERSION__: string; -const CLI_VERSION: string = - typeof __VERSION__ === "string" ? __VERSION__ : "unknown"; +import { CLI_VERSION } from "./version"; const POSTHOG_PROJECT_TOKEN = "phc_stymptTiUskp4zM3m9StNSGheHwjskaYagpxV7rDjZyc"; diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 00000000..5253aed8 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,16 @@ +declare const __VERSION__: string; + +/** + * The CLI's own version, injected at build time from `package.json` via the Vite + * `__VERSION__` define. Falls back to `"unknown"` outside a build (e.g. tests). + */ +export const CLI_VERSION: string = + typeof __VERSION__ === "string" ? __VERSION__ : "unknown"; + +/** + * Header the CLI sends on every request to the Taskless service, declaring its + * version so the service can gate capability-dependent responses (e.g. runtime + * rules) on the CLI being new enough. A request without it is treated by the + * service as a pre-runtime CLI. + */ +export const CLI_VERSION_HEADER = "x-taskless-cli-version"; diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index 81ef4b02..dfe2aa4e 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -24,12 +24,14 @@ type Responder = (request: ReconcileRequestBody) => { interface MockServer { apiUrl: string; requests: ReconcileRequestBody[]; + headers: Record[]; close: () => Promise; } /** Start a mock reconcile endpoint on a random port. */ function startMockServer(responder: Responder): Promise { const requests: ReconcileRequestBody[] = []; + const headers: Record[] = []; const server: Server = createServer((request, response) => { if (request.method !== "POST" || request.url !== "/cli/api/reconcile") { response.writeHead(404).end("{}"); @@ -40,6 +42,7 @@ function startMockServer(responder: Responder): Promise { request.on("end", () => { const parsed = JSON.parse(raw) as ReconcileRequestBody; requests.push(parsed); + headers.push(request.headers); const { statusCode, body } = responder(parsed); response.writeHead(statusCode, { "content-type": "application/json" }); response.end(JSON.stringify(body ?? {})); @@ -52,6 +55,7 @@ function startMockServer(responder: Responder): Promise { resolvePromise({ apiUrl: `http://127.0.0.1:${String(port)}/cli`, requests, + headers, close: () => new Promise((done) => server.close(() => done())), }); }); @@ -326,4 +330,24 @@ describe("check: static vs runtime dispatch", () => { await server.close(); } }); + + it("declares the CLI version via the x-taskless-cli-version header", async () => { + const server = await startMockServer(() => ({ + statusCode: 200, + body: { run: [], unsafe: [], unknown: [], missing: [] }, + })); + try { + await runCli(["check", "-d", directory, "--json"], { + TASKLESS_TOKEN: "fake.token", + TASKLESS_API_URL: server.apiUrl, + }); + expect(server.requests).toHaveLength(1); + const version = server.headers[0]?.["x-taskless-cli-version"]; + expect(typeof version).toBe("string"); + expect(version).not.toBe(""); + expect(version).not.toBe("unknown"); + } finally { + await server.close(); + } + }); });