Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cli-version-header.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/cli/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ 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) {
// Schema paths include the /cli/ prefix, so the base URL is the origin
const baseUrl = getApiBaseUrl().replace(/\/cli\/?$/, "");
return createClient<paths>({
baseUrl,
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
[CLI_VERSION_HEADER]: CLI_VERSION,
},
});
}
2 changes: 2 additions & 0 deletions packages/cli/src/api/reconcile.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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),
});
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/auth/device-flow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getApiBaseUrl } from "../api/config";
import { CLI_VERSION, CLI_VERSION_HEADER } from "../version";

const CLIENT_ID = "taskless-cli";

Expand Down Expand Up @@ -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),
});

Expand All @@ -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,
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/version.ts
Original file line number Diff line number Diff line change
@@ -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";
24 changes: 24 additions & 0 deletions packages/cli/test/runtime-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ type Responder = (request: ReconcileRequestBody) => {
interface MockServer {
apiUrl: string;
requests: ReconcileRequestBody[];
headers: Record<string, string | string[] | undefined>[];
close: () => Promise<void>;
}

/** Start a mock reconcile endpoint on a random port. */
function startMockServer(responder: Responder): Promise<MockServer> {
const requests: ReconcileRequestBody[] = [];
const headers: Record<string, string | string[] | undefined>[] = [];
const server: Server = createServer((request, response) => {
if (request.method !== "POST" || request.url !== "/cli/api/reconcile") {
response.writeHead(404).end("{}");
Expand All @@ -40,6 +42,7 @@ function startMockServer(responder: Responder): Promise<MockServer> {
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 ?? {}));
Expand All @@ -52,6 +55,7 @@ function startMockServer(responder: Responder): Promise<MockServer> {
resolvePromise({
apiUrl: `http://127.0.0.1:${String(port)}/cli`,
requests,
headers,
close: () => new Promise((done) => server.close(() => done())),
});
});
Expand Down Expand Up @@ -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();
}
});
});
Loading