mirror of
https://github.com/upstash/context7.git
synced 2026-09-14 19:09:34 +08:00
Merge remote-tracking branch 'origin/master' into ctx7-1924-mcp-latest-version-guidance
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"ctx7": patch
|
||||
---
|
||||
|
||||
Fix `ctx7 library`, `ctx7 docs` and `ctx7 skills suggest` silently falling back to anonymous requests when the stored OAuth token expires, which surfaced misleading quota errors for authenticated users. `ctx7 generate` no longer forces a full interactive re-login when the token can be refreshed instead. All four commands now go through `getValidAccessToken()`, which refreshes expired credentials.
|
||||
|
||||
A successful refresh also keeps the stored `refresh_token` when the server omits one from the response, as permitted by RFC 6749 §6. Previously the response was written verbatim, so the refresh token was dropped and the user was silently logged out at the next expiry.
|
||||
@@ -43,5 +43,24 @@ export default defineConfig(
|
||||
// Prettier integration
|
||||
"prettier/prettier": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Commands must not hand-roll the "load tokens, check expiry" dance: it
|
||||
// skips the refresh and silently degrades to an anonymous request.
|
||||
files: ["src/commands/**/*.ts"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "../utils/auth.js",
|
||||
importNames: ["loadTokens", "isTokenExpired"],
|
||||
message: "Use getValidAccessToken() so an expired token refreshes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("login command", () => {
|
||||
});
|
||||
|
||||
test("calls process.exit(1) when login fails", async () => {
|
||||
mockGetValidAccessToken.mockResolvedValue(null);
|
||||
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||
mockClearTokens.mockReturnValue(false);
|
||||
mockStartDeviceAuthorization.mockRejectedValue(new Error("network down"));
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("logout command", () => {
|
||||
|
||||
describe("whoami command", () => {
|
||||
test("shows 'Not logged in' when no valid token", async () => {
|
||||
mockGetValidAccessToken.mockResolvedValue(null);
|
||||
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||
await runCommand("whoami");
|
||||
expect(logOutput.some((l) => l.includes("Not logged in"))).toBe(true);
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe("whoami command", () => {
|
||||
});
|
||||
|
||||
test("tracks whoami event", async () => {
|
||||
mockGetValidAccessToken.mockResolvedValue(null);
|
||||
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||
await runCommand("whoami");
|
||||
expect(trackEvent).toHaveBeenCalledWith("command", { name: "whoami" });
|
||||
});
|
||||
|
||||
@@ -221,9 +221,9 @@ describe("isTokenExpired", () => {
|
||||
});
|
||||
|
||||
describe("getValidAccessToken", () => {
|
||||
test("returns null when no tokens stored", async () => {
|
||||
test("returns undefined when no tokens stored", async () => {
|
||||
mfs.existsSync.mockReturnValue(false);
|
||||
expect(await getValidAccessToken()).toBeNull();
|
||||
expect(await getValidAccessToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns access_token when not expired", async () => {
|
||||
@@ -237,7 +237,7 @@ describe("getValidAccessToken", () => {
|
||||
expect(await getValidAccessToken()).toBe("valid-tok");
|
||||
});
|
||||
|
||||
test("returns null when expired and no refresh_token", async () => {
|
||||
test("returns undefined when expired and no refresh_token", async () => {
|
||||
const tokens: TokenData = {
|
||||
access_token: "expired-tok",
|
||||
token_type: "bearer",
|
||||
@@ -245,7 +245,7 @@ describe("getValidAccessToken", () => {
|
||||
};
|
||||
mfs.existsSync.mockReturnValue(true);
|
||||
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
|
||||
expect(await getValidAccessToken()).toBeNull();
|
||||
expect(await getValidAccessToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refreshes token when expired and refresh_token exists", async () => {
|
||||
@@ -285,7 +285,65 @@ describe("getValidAccessToken", () => {
|
||||
expect(mfs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when refresh fails", async () => {
|
||||
// RFC 6749 §6: dropping the stored refresh_token here would log the user out
|
||||
// at the next expiry, with no error to explain why.
|
||||
test("keeps the stored refresh_token when the refresh response omits one", async () => {
|
||||
const tokens: TokenData = {
|
||||
access_token: "expired-tok",
|
||||
token_type: "bearer",
|
||||
expires_at: Date.now() - 1000,
|
||||
refresh_token: "refresh-tok",
|
||||
};
|
||||
|
||||
mfs.existsSync.mockReturnValue(true);
|
||||
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ access_token: "new-tok", token_type: "bearer", expires_in: 3600 }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(await getValidAccessToken()).toBe("new-tok");
|
||||
|
||||
const written = JSON.parse(mfs.writeFileSync.mock.calls[0][1] as string);
|
||||
expect(written.refresh_token).toBe("refresh-tok");
|
||||
expect(written.access_token).toBe("new-tok");
|
||||
});
|
||||
|
||||
test("prefers a rotated refresh_token over the stored one", async () => {
|
||||
const tokens: TokenData = {
|
||||
access_token: "expired-tok",
|
||||
token_type: "bearer",
|
||||
expires_at: Date.now() - 1000,
|
||||
refresh_token: "refresh-tok",
|
||||
};
|
||||
|
||||
mfs.existsSync.mockReturnValue(true);
|
||||
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new-tok",
|
||||
token_type: "bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "rotated-tok",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(await getValidAccessToken()).toBe("new-tok");
|
||||
|
||||
const written = JSON.parse(mfs.writeFileSync.mock.calls[0][1] as string);
|
||||
expect(written.refresh_token).toBe("rotated-tok");
|
||||
});
|
||||
|
||||
test("returns undefined when refresh fails", async () => {
|
||||
const tokens: TokenData = {
|
||||
access_token: "expired-tok",
|
||||
token_type: "bearer",
|
||||
@@ -305,12 +363,12 @@ describe("getValidAccessToken", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(await getValidAccessToken()).toBeNull();
|
||||
expect(await getValidAccessToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
// An expired refresh token is indistinguishable from being logged out, so the
|
||||
// caller reports "not logged in" rather than surfacing a network error here.
|
||||
test("returns null when the refresh connection fails", async () => {
|
||||
test("returns undefined when the refresh connection fails", async () => {
|
||||
const tokens: TokenData = {
|
||||
access_token: "expired-tok",
|
||||
token_type: "bearer",
|
||||
@@ -329,7 +387,7 @@ describe("getValidAccessToken", () => {
|
||||
)
|
||||
);
|
||||
|
||||
expect(await getValidAccessToken()).toBeNull();
|
||||
expect(await getValidAccessToken()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
// Every command that talks to the API must obtain its token from
|
||||
// getValidAccessToken(), so an expired one refreshes instead of silently
|
||||
// degrading to an anonymous request. Unit tests on the helper cannot catch a
|
||||
// command that never calls it, which is how three commands drifted for months.
|
||||
const mockGetValidAccessToken = vi.fn();
|
||||
vi.mock("../utils/auth.js", () => ({
|
||||
getValidAccessToken: (...args: unknown[]) => mockGetValidAccessToken(...args),
|
||||
}));
|
||||
|
||||
const mockResolveLibrary = vi.fn();
|
||||
const mockGetLibraryContext = vi.fn();
|
||||
const mockSuggestSkills = vi.fn();
|
||||
vi.mock("../utils/api.js", () => ({
|
||||
resolveLibrary: (...args: unknown[]) => mockResolveLibrary(...args),
|
||||
getLibraryContext: (...args: unknown[]) => mockGetLibraryContext(...args),
|
||||
suggestSkills: (...args: unknown[]) => mockSuggestSkills(...args),
|
||||
getBaseUrl: () => "https://test.context7.com",
|
||||
listProjectSkills: vi.fn(),
|
||||
searchSkills: vi.fn(),
|
||||
downloadSkill: vi.fn(),
|
||||
getSkill: vi.fn(),
|
||||
getSkillQuota: vi.fn(),
|
||||
getSkillQuestions: vi.fn(),
|
||||
generateSkillStructured: vi.fn(),
|
||||
searchLibraries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/tracking.js", () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
vi.mock("../utils/deps.js", () => ({
|
||||
detectProjectDependencies: () => ["react"],
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
start: vi.fn().mockReturnThis(),
|
||||
stop: vi.fn().mockReturnThis(),
|
||||
succeed: vi.fn().mockReturnThis(),
|
||||
fail: vi.fn().mockReturnThis(),
|
||||
warn: vi.fn().mockReturnThis(),
|
||||
text: "",
|
||||
};
|
||||
vi.mock("ora", () => ({ default: () => mockSpinner }));
|
||||
|
||||
import { registerDocsCommands } from "../commands/docs.js";
|
||||
import { registerSkillCommands } from "../commands/skill.js";
|
||||
|
||||
const REFRESHED = "refreshed-token";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetValidAccessToken.mockResolvedValue(REFRESHED);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
async function run(register: (p: Command) => void, ...args: string[]): Promise<void> {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
register(program);
|
||||
await program.parseAsync(["node", "test", ...args]);
|
||||
}
|
||||
|
||||
describe("commands pass a refreshed token to the API", () => {
|
||||
test("ctx7 library", async () => {
|
||||
mockResolveLibrary.mockResolvedValue({ results: [{ id: "/a/b", title: "B" }] });
|
||||
|
||||
await run(registerDocsCommands, "library", "react");
|
||||
|
||||
expect(mockGetValidAccessToken).toHaveBeenCalled();
|
||||
expect(mockResolveLibrary).toHaveBeenCalledWith("react", undefined, REFRESHED);
|
||||
});
|
||||
|
||||
test("ctx7 docs", async () => {
|
||||
mockGetLibraryContext.mockResolvedValue("docs body");
|
||||
|
||||
await run(registerDocsCommands, "docs", "/a/b", "how does it work");
|
||||
|
||||
expect(mockGetValidAccessToken).toHaveBeenCalled();
|
||||
expect(mockGetLibraryContext).toHaveBeenCalledWith(
|
||||
"/a/b",
|
||||
"how does it work",
|
||||
{ type: "txt" },
|
||||
REFRESHED
|
||||
);
|
||||
});
|
||||
|
||||
test("ctx7 skills suggest", async () => {
|
||||
mockSuggestSkills.mockResolvedValue({ skills: [] });
|
||||
|
||||
await run(registerSkillCommands, "skills", "suggest");
|
||||
|
||||
expect(mockGetValidAccessToken).toHaveBeenCalled();
|
||||
expect(mockSuggestSkills).toHaveBeenCalledWith(["react"], REFRESHED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commands stay anonymous when no token is available", () => {
|
||||
beforeEach(() => {
|
||||
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("ctx7 library sends no token", async () => {
|
||||
mockResolveLibrary.mockResolvedValue({ results: [] });
|
||||
|
||||
await run(registerDocsCommands, "library", "react");
|
||||
|
||||
expect(mockResolveLibrary).toHaveBeenCalledWith("react", undefined, undefined);
|
||||
});
|
||||
|
||||
test("ctx7 docs sends no token", async () => {
|
||||
mockGetLibraryContext.mockResolvedValue("docs body");
|
||||
|
||||
await run(registerDocsCommands, "docs", "/a/b", "q");
|
||||
|
||||
expect(mockGetLibraryContext).toHaveBeenCalledWith("/a/b", "q", { type: "txt" }, undefined);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { resolveLibrary, getLibraryContext } from "../utils/api.js";
|
||||
import { recoverLibraryId } from "../utils/library-id.js";
|
||||
import { log } from "../utils/logger.js";
|
||||
import { trackEvent } from "../utils/tracking.js";
|
||||
import { loadTokens, isTokenExpired } from "../utils/auth.js";
|
||||
import { getValidAccessToken } from "../utils/auth.js";
|
||||
import type { LibrarySearchResult, ContextResponse } from "../types.js";
|
||||
|
||||
const isTTY = process.stdout.isTTY;
|
||||
@@ -18,12 +18,6 @@ function getReputationLabel(score: number | undefined): "High" | "Medium" | "Low
|
||||
return "Low";
|
||||
}
|
||||
|
||||
function getAccessToken(): string | undefined {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens || isTokenExpired(tokens)) return undefined;
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
function formatLibraryResult(lib: LibrarySearchResult, index: number): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${pc.dim(`${index + 1}.`)} ${pc.bold(`Title: ${lib.title}`)}`);
|
||||
@@ -57,7 +51,7 @@ async function resolveCommand(
|
||||
trackEvent("command", { name: "library" });
|
||||
|
||||
const spinner = isTTY ? ora(`Searching for "${library}"...`).start() : null;
|
||||
const accessToken = getAccessToken();
|
||||
const accessToken = await getValidAccessToken();
|
||||
|
||||
let data;
|
||||
try {
|
||||
@@ -137,9 +131,8 @@ async function queryCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = getAccessToken();
|
||||
|
||||
const spinner = isTTY ? ora(`Fetching docs for "${libraryId}"...`).start() : null;
|
||||
const accessToken = await getValidAccessToken();
|
||||
const outputType = options.json ? "json" : "txt";
|
||||
|
||||
let result;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
generateSkillStructured,
|
||||
getSkillQuota,
|
||||
} from "../utils/api.js";
|
||||
import { loadTokens, isTokenExpired } from "../utils/auth.js";
|
||||
import { getValidAccessToken } from "../utils/auth.js";
|
||||
import { performLogin } from "./auth.js";
|
||||
import { log } from "../utils/logger.js";
|
||||
import { promptForInstallTargets, getTargetDirs } from "../utils/ide.js";
|
||||
@@ -60,18 +60,16 @@ async function generateCommand(options: GenerateOptions): Promise<void> {
|
||||
trackEvent("command", { name: "generate" });
|
||||
log.blank();
|
||||
|
||||
let accessToken: string | null = null;
|
||||
const tokens = loadTokens();
|
||||
if (tokens && !isTokenExpired(tokens)) {
|
||||
accessToken = tokens.access_token;
|
||||
} else {
|
||||
let accessToken = await getValidAccessToken();
|
||||
if (!accessToken) {
|
||||
log.info("Authentication required. Logging in...");
|
||||
log.blank();
|
||||
accessToken = await performLogin();
|
||||
if (!accessToken) {
|
||||
const token = await performLogin();
|
||||
if (!token) {
|
||||
log.error("Login failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
accessToken = token;
|
||||
log.blank();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
} from "../types.js";
|
||||
import { homedir } from "os";
|
||||
import { detectProjectDependencies } from "../utils/deps.js";
|
||||
import { loadTokens, isTokenExpired } from "../utils/auth.js";
|
||||
import { getValidAccessToken } from "../utils/auth.js";
|
||||
|
||||
const SKILL_HUB_DEPRECATION_WARNING =
|
||||
"Warning: Skill commands are deprecated and will stop working in the next major release.";
|
||||
@@ -843,8 +843,7 @@ async function suggestCommand(options: SuggestOptions): Promise<void> {
|
||||
// Step 2: Single API call to backend
|
||||
const searchSpinner = ora("Finding matching skills...").start();
|
||||
|
||||
const tokens = loadTokens();
|
||||
const accessToken = tokens && !isTokenExpired(tokens) ? tokens.access_token : undefined;
|
||||
const accessToken = await getValidAccessToken();
|
||||
|
||||
let data;
|
||||
try {
|
||||
|
||||
@@ -97,29 +97,32 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenData> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a valid access token, refreshing if expired. Returns null if no
|
||||
* Returns a valid access token, refreshing if expired. Returns undefined if no
|
||||
* tokens are stored or refresh fails. Pre-0.5 installs may have OAuth tokens
|
||||
* with a `refresh_token`; new installs hold long-lived API keys that never
|
||||
* expire and skip the refresh path entirely.
|
||||
*/
|
||||
export async function getValidAccessToken(): Promise<string | null> {
|
||||
export async function getValidAccessToken(): Promise<string | undefined> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) return null;
|
||||
if (!tokens) return undefined;
|
||||
|
||||
if (!isTokenExpired(tokens)) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(tokens.refresh_token);
|
||||
saveTokens(newTokens);
|
||||
// RFC 6749 §6: the response MAY omit refresh_token, and the client then
|
||||
// keeps the one it already holds. Writing the response verbatim would
|
||||
// drop it and log the user out at the next expiry.
|
||||
saveTokens({ refresh_token: tokens.refresh_token, ...newTokens });
|
||||
return newTokens.access_token;
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user