mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
Keep usage logins visible and explain unavailable quota (#5876)
* fix(usage): keep existing logins visible and explain unavailable quota Credential discovery must preserve accounts whose login exists even when it has expired. Keep vendor failures on the discovered account and isolate discovery failures without inventing provider cards. * test(usage): align credential route tests with account discovery
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { fetchUsage, identify } from "./server/usage.js";
|
||||
import { fetchUsage, discover } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Cursor",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
identify,
|
||||
discover: () => discover(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { identify, fetchUsage } from "./usage.js";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
|
||||
@@ -97,14 +97,14 @@ describe("cursor usage source", () => {
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
const report = await fetchFirst((url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "cursor",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
error: report.status === "error" ? report.error : null,
|
||||
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -250,26 +250,68 @@ describe("cursor usage source", () => {
|
||||
expect(cursor.status).toBe("available");
|
||||
});
|
||||
|
||||
it("stays unavailable when state.vscdb is unreadable", async () => {
|
||||
const path = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
||||
mkdirSync(path, { recursive: true });
|
||||
writeFileSync(join(path, "state.vscdb"), "not a database");
|
||||
const cursor = findProvider(await service().listUsage(), "cursor");
|
||||
expect(cursor.status).toBe("unavailable");
|
||||
it("omits unreadable state.vscdb", async () => {
|
||||
const directory = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(join(directory, "state.vscdb"), "invalid database");
|
||||
expect(await discover()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("identify returns a key when fetch finds cursor credentials", async () => {
|
||||
it("discovery returns a locator when fetch finds cursor credentials", async () => {
|
||||
const previous = process.env["CURSOR_ACCESS_TOKEN"];
|
||||
try {
|
||||
process.env["CURSOR_ACCESS_TOKEN"] = "fixture-token";
|
||||
let requested = false;
|
||||
await fetchUsage({}, async () => {
|
||||
await fetchFirst(async () => {
|
||||
requested = true;
|
||||
return new Response(null, { status: 401 });
|
||||
});
|
||||
expect(requested).toBe(true);
|
||||
expect(await identify()).toEqual({ key: "default" });
|
||||
expect(await discover()).toEqual([
|
||||
{ key: "default", input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" } },
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
|
||||
else process.env["CURSOR_ACCESS_TOKEN"] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
describe("account discovery", () => {
|
||||
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
|
||||
const original = { ...process.env };
|
||||
try {
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
process.env.HOME = directory;
|
||||
process.env.USERPROFILE = directory;
|
||||
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
|
||||
expect(await discover()).toEqual([]);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
Object.assign(process.env, original);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchFirst(fetchApi: typeof fetch) {
|
||||
const accounts = await discover();
|
||||
const account = accounts[0];
|
||||
if (!account) throw new Error("No configured account");
|
||||
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
|
||||
}
|
||||
|
||||
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
|
||||
const previous = process.env["CURSOR_ACCESS_TOKEN"];
|
||||
try {
|
||||
process.env["CURSOR_ACCESS_TOKEN"] = "fixture-rejected-login";
|
||||
const report = await fetchUsage(
|
||||
{ store: "env", locator: "CURSOR_ACCESS_TOKEN" },
|
||||
async () => new Response(null, { status }),
|
||||
);
|
||||
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
|
||||
else process.env["CURSOR_ACCESS_TOKEN"] = previous;
|
||||
|
||||
@@ -7,7 +7,8 @@ import { z } from "zod";
|
||||
import {
|
||||
toneFromUsedPct,
|
||||
usedPctOf,
|
||||
unavailableUsage,
|
||||
unavailable,
|
||||
type UsageAccount,
|
||||
type UsageReport,
|
||||
type UsageBalance,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
@@ -114,24 +115,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readCursorTokenFromSqlite(homeDir: string): Promise<string | null> {
|
||||
const dbPaths: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
}
|
||||
dbPaths.push(
|
||||
join(
|
||||
homeDir,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Cursor",
|
||||
"User",
|
||||
"globalStorage",
|
||||
"state.vscdb",
|
||||
),
|
||||
);
|
||||
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
|
||||
async function readCursorTokenFromSqlite(path: string): Promise<string | null> {
|
||||
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
|
||||
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
|
||||
const sqliteSpecifier: string = "node:sqlite";
|
||||
@@ -142,24 +126,19 @@ async function readCursorTokenFromSqlite(homeDir: string): Promise<string | null
|
||||
return null; // runtime without node:sqlite
|
||||
}
|
||||
|
||||
for (const path of dbPaths) {
|
||||
if (!existsSync(path)) continue;
|
||||
let db: CursorStateDatabase | undefined;
|
||||
try {
|
||||
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
||||
const token = cursorTokenFromDb(db);
|
||||
if (token) return token;
|
||||
} catch {
|
||||
// Locked, permission, corrupt, or schema failures try the next candidate.
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
if (!existsSync(path)) return null;
|
||||
let db: CursorStateDatabase | undefined;
|
||||
try {
|
||||
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
||||
return cursorTokenFromDb(db);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readCursorTokenFromAuthJson(homeDir: string): Promise<string | null> {
|
||||
const path = join(homeDir, ".config", "cursor", "auth.json");
|
||||
async function readCursorTokenFromAuthJson(path: string): Promise<string | null> {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
@@ -173,16 +152,8 @@ export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
const homeDir = homedir();
|
||||
|
||||
const token =
|
||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||
process.env["CURSOR_TOKEN"] ||
|
||||
(await readCursorTokenFromSqlite(homeDir)) ||
|
||||
(await readCursorTokenFromAuthJson(homeDir));
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
const token = await readToken(input);
|
||||
if (!token) throw new Error("Cursor login store no longer exists");
|
||||
|
||||
const res = await fetchApi(
|
||||
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
@@ -198,9 +169,9 @@ export async function fetchUsage(
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
if (res.status === 401 || res.status === 403)
|
||||
return unavailable({ kind: "rejected", status: res.status });
|
||||
if (!res.ok) throw new Error(`Cursor usage API returned ${res.status}`);
|
||||
|
||||
const resp = CursorUsageResponseSchema.parse(await res.json());
|
||||
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
||||
@@ -230,12 +201,40 @@ export async function fetchUsage(
|
||||
};
|
||||
}
|
||||
|
||||
export async function identify() {
|
||||
const home = homedir();
|
||||
async function readToken(input: UsageInput): Promise<string | undefined> {
|
||||
if (input.store === "env") return process.env[input.locator];
|
||||
const token =
|
||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||
process.env["CURSOR_TOKEN"] ||
|
||||
(await readCursorTokenFromSqlite(home)) ||
|
||||
(await readCursorTokenFromAuthJson(home));
|
||||
return token ? { key: "default" } : null;
|
||||
input.store === "sqlite"
|
||||
? await readCursorTokenFromSqlite(input.locator)
|
||||
: await readCursorTokenFromAuthJson(input.locator);
|
||||
return token ?? undefined;
|
||||
}
|
||||
export async function discover(): Promise<UsageAccount[]> {
|
||||
const home = homedir();
|
||||
const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({
|
||||
store: "env",
|
||||
locator,
|
||||
}));
|
||||
if (process.env.APPDATA)
|
||||
candidates.push({
|
||||
store: "sqlite",
|
||||
locator: join(process.env.APPDATA, "Cursor", "User", "globalStorage", "state.vscdb"),
|
||||
});
|
||||
candidates.push(
|
||||
...[
|
||||
join(
|
||||
home,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Cursor",
|
||||
"User",
|
||||
"globalStorage",
|
||||
"state.vscdb",
|
||||
),
|
||||
join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
|
||||
].map((locator) => ({ store: "sqlite" as const, locator })),
|
||||
{ store: "file", locator: join(home, ".config", "cursor", "auth.json") },
|
||||
);
|
||||
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export const inputSchema = z
|
||||
.object({
|
||||
store: z.enum(["env", "file", "sqlite"]),
|
||||
locator: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user