Skip to content

Commit 277c9f5

Browse files
committed
fix: fall back to cursor.com/install when lab latest-version returns 403
1 parent 532d8fd commit 277c9f5

6 files changed

Lines changed: 151 additions & 13 deletions

File tree

.changeset/fix-cursor-cli-download-urls.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"cursor-action": patch
33
---
44

5-
Fix Cursor CLI download URLs: resolve `latest` via the lab endpoint, use `windows` in artifact paths on Win32, and allow pinning lab build ids in `cursor-version` input validation.
5+
Fix Cursor CLI download URLs: resolve `latest` via the lab endpoint (validate HTTP 200 and lab id shape), fall back to parsing `https://cursor.com/install` when the lab `latest-version` URL returns 403, use `windows` in artifact paths on Win32, and allow pinning lab build ids in `cursor-version` input validation.

__tests__/cursor-version.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
extractLabVersionFromInstallScript,
5+
parseLabVersionString,
6+
} from "../src/cursor-version";
7+
8+
describe("parseLabVersionString", () => {
9+
it("accepts lab build id with optional v prefix and trailing newline", () => {
10+
expect(parseLabVersionString("v2026.03.20-44cb435\n")).toBe(
11+
"2026.03.20-44cb435"
12+
);
13+
});
14+
15+
it("rejects S3 AccessDenied XML bodies", () => {
16+
expect(
17+
parseLabVersionString(
18+
'<?xml version="1.0"?><Error><Code>AccessDenied</Code></Error>'
19+
)
20+
).toBeNull();
21+
});
22+
});
23+
24+
describe("extractLabVersionFromInstallScript", () => {
25+
it("parses DOWNLOAD_URL from bash installer", () => {
26+
const script = `DOWNLOAD_URL="https://downloads.cursor.com/lab/2026.03.20-44cb435/\${OS}/\${ARCH}/agent-cli-package.tar.gz"`;
27+
expect(extractLabVersionFromInstallScript(script)).toBe(
28+
"2026.03.20-44cb435"
29+
);
30+
});
31+
32+
it("parses lab URL from PowerShell installer", () => {
33+
const script = `$downloadUrl = 'https://downloads.cursor.com/lab/2026.03.20-44cb435/'`;
34+
expect(extractLabVersionFromInstallScript(script)).toBe(
35+
"2026.03.20-44cb435"
36+
);
37+
});
38+
});

__tests__/installer.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,45 @@ describe("resolveVersion", () => {
2222

2323
it("resolves latest from Cursor endpoint and normalizes v prefix", () => {
2424
mockHttpGet.mockResolvedValue({
25+
message: { statusCode: 200 },
2526
readBody: mock(() => Promise.resolve("v2026.03.20-44cb435\n")),
2627
});
2728

2829
expect(resolveVersion("latest")).resolves.toBe("2026.03.20-44cb435");
30+
expect(mockHttpGet).toHaveBeenCalledTimes(1);
2931
expect(mockHttpGet).toHaveBeenCalledWith(
3032
"https://downloads.cursor.com/lab/latest-version"
3133
);
3234
});
3335

36+
it("falls back to install script when latest-version is non-200", () => {
37+
const accessDeniedXml = `<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code></Error>`;
38+
const bashSnippet = `DOWNLOAD_URL="https://downloads.cursor.com/lab/2026.03.20-44cb435/\${OS}/\${ARCH}/agent-cli-package.tar.gz"`;
39+
40+
mockHttpGet
41+
.mockResolvedValueOnce({
42+
message: { statusCode: 403 },
43+
readBody: mock(() => Promise.resolve(accessDeniedXml)),
44+
})
45+
.mockResolvedValueOnce({
46+
message: { statusCode: 200 },
47+
readBody: mock(() =>
48+
Promise.resolve(`#!/usr/bin/env bash\n${bashSnippet}\n`)
49+
),
50+
});
51+
52+
expect(resolveVersion("latest")).resolves.toBe("2026.03.20-44cb435");
53+
expect(mockHttpGet).toHaveBeenCalledTimes(2);
54+
expect(mockHttpGet).toHaveBeenNthCalledWith(
55+
1,
56+
"https://downloads.cursor.com/lab/latest-version"
57+
);
58+
expect(mockHttpGet).toHaveBeenNthCalledWith(
59+
2,
60+
"https://cursor.com/install"
61+
);
62+
});
63+
3464
it("returns pinned version without calling network", () => {
3565
expect(resolveVersion("v1.2.3")).resolves.toBe("1.2.3");
3666
expect(mockHttpGet).not.toHaveBeenCalled();

src/cursor-version.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/** Cursor CLI lab artifacts use build ids like `2026.03.20-44cb435`. */
2+
export const CURSOR_LAB_VERSION_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9]+)?$/;
3+
4+
/**
5+
* Parses a plain-text latest-version response body (single line, optional `v` prefix).
6+
*/
7+
export const parseLabVersionString = (body: string): string | null => {
8+
const firstLine =
9+
body.trim().replace(/^v/, "").split(/\r?\n/)[0]?.trim() ?? "";
10+
return CURSOR_LAB_VERSION_RE.test(firstLine) ? firstLine : null;
11+
};
12+
13+
/**
14+
* Extracts the lab build id from the official bash or PowerShell install script body.
15+
*/
16+
export const extractLabVersionFromInstallScript = (
17+
body: string
18+
): string | null => {
19+
const m = body.match(/https:\/\/downloads\.cursor\.com\/lab\/([^/'"\s]+)\//);
20+
const candidate = m?.[1]?.trim();
21+
if (!candidate || !CURSOR_LAB_VERSION_RE.test(candidate)) {
22+
return null;
23+
}
24+
return candidate;
25+
};

src/input.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { getInput, warning } from "@actions/core";
22

3+
import { CURSOR_LAB_VERSION_RE } from "./cursor-version";
34
import type { ActionInputs, Permission } from "./types";
45

56
const VALID_PERMISSIONS: Permission[] = ["read-only", "read-write", "full"];
67

7-
/** Cursor CLI artifacts use paths like `2026.03.20-44cb435`, not npm-style semver only. */
8-
const PINNED_CURSOR_VERSION_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9]+)?$/;
9-
108
/**
119
* Reads, validates, and returns all action inputs.
1210
* @returns The validated action inputs.
@@ -50,7 +48,7 @@ export const getInputs = (): ActionInputs => {
5048
const normalizedCursorVersion = cursorVersion.replace(/^v/, "");
5149
if (
5250
cursorVersion !== "latest" &&
53-
!PINNED_CURSOR_VERSION_RE.test(normalizedCursorVersion)
51+
!CURSOR_LAB_VERSION_RE.test(normalizedCursorVersion)
5452
) {
5553
throw new Error(
5654
`Invalid 'cursor-version' value: '${cursorVersion}'. ` +

src/installer.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ import {
1313
find,
1414
} from "@actions/tool-cache";
1515

16+
import {
17+
extractLabVersionFromInstallScript,
18+
parseLabVersionString,
19+
} from "./cursor-version";
1620
import type { Arch, Platform } from "./types";
1721

1822
const CURSOR_DOWNLOAD_BASE = "https://downloads.cursor.com/lab";
1923
const CURSOR_VERSION_URL = "https://downloads.cursor.com/lab/latest-version";
24+
const CURSOR_INSTALL_SCRIPT_URL = "https://cursor.com/install";
2025

2126
const getPlatform = (): Platform => {
2227
const p = process.platform;
@@ -44,9 +49,38 @@ const getArch = (): Arch => {
4449
throw new Error(`Unsupported architecture: ${a}`);
4550
};
4651

52+
const tryResolveLatestFromVersionUrl = async (
53+
client: HttpClient
54+
): Promise<string | null> => {
55+
const response = await client.get(CURSOR_VERSION_URL);
56+
const statusCode = response.message.statusCode ?? 0;
57+
const body = await response.readBody();
58+
if (statusCode !== 200) {
59+
debug(
60+
`Cursor lab latest-version returned HTTP ${statusCode}; trying install script fallback.`
61+
);
62+
return null;
63+
}
64+
return parseLabVersionString(body);
65+
};
66+
67+
const tryResolveLatestFromInstallScript = async (
68+
client: HttpClient
69+
): Promise<string | null> => {
70+
const response = await client.get(CURSOR_INSTALL_SCRIPT_URL);
71+
const statusCode = response.message.statusCode ?? 0;
72+
const body = await response.readBody();
73+
if (statusCode !== 200) {
74+
throw new Error(
75+
`Cursor install script returned HTTP ${statusCode} (expected 200).`
76+
);
77+
}
78+
return extractLabVersionFromInstallScript(body);
79+
};
80+
4781
/**
48-
* Resolves "latest" to a concrete semver version string by querying
49-
* Cursor's version endpoint.
82+
* Resolves "latest" to a concrete lab build id via the version endpoint, with
83+
* fallback to the official install script (the lab endpoint often returns 403).
5084
*/
5185
export const resolveVersion = async (version: string): Promise<string> => {
5286
if (version !== "latest") {
@@ -55,14 +89,27 @@ export const resolveVersion = async (version: string): Promise<string> => {
5589
}
5690

5791
debug("Resolving latest Cursor CLI version...");
58-
const client = new HttpClient("cursor-action");
92+
// Match `curl https://cursor.com/install` so cursor.com returns the shell script (not HTML).
93+
const client = new HttpClient("curl/8.5.0 (compatible; cursor-action)");
5994

6095
try {
61-
const response = await client.get(CURSOR_VERSION_URL);
62-
const body = await response.readBody();
63-
const resolved = body.trim().replace(/^v/, "");
64-
debug(`Resolved latest version: ${resolved}`);
65-
return resolved;
96+
const fromEndpoint = await tryResolveLatestFromVersionUrl(client);
97+
if (fromEndpoint) {
98+
debug(`Resolved latest version (lab endpoint): ${fromEndpoint}`);
99+
return fromEndpoint;
100+
}
101+
102+
const fromScript = await tryResolveLatestFromInstallScript(client);
103+
if (fromScript) {
104+
debug(`Resolved latest version (install script): ${fromScript}`);
105+
return fromScript;
106+
}
107+
108+
throw new Error(
109+
"Could not resolve latest Cursor CLI version: lab endpoint did not return a valid version " +
110+
"and the official install script did not contain a download URL. " +
111+
"Pin `cursor-version` to a known lab build id, or try again later."
112+
);
66113
} catch (error) {
67114
throw new Error(`Failed to resolve latest Cursor CLI version: ${error}`, {
68115
cause: error,

0 commit comments

Comments
 (0)