Skip to content

Commit ea91d7d

Browse files
feat(cli): device-code flow is now the default for ctx7 login (#2720)
The localhost-callback path is gone. Every install — laptop, SSH, Codespace, Docker, CI — goes through the same boxed prompt and verification page. Three reasons to make this the default: - The localhost flow was broken anywhere the browser couldn't reach 127.0.0.1:52417 (SSH, Docker, Codespaces). Auto-detection via SSH_CONNECTION / $DISPLAY was a half-fix that depended on env vars users don't always set. - Device flow works everywhere, has no random port-binding behavior, and still ends in the same long-lived ctx7sk- API key. - One UX path is simpler to support than two. Drops the --device flag (it was the opt-in for what's now the default). Older CLI versions (<= 0.5.0) continue to work against the unchanged auth endpoints, so pinned installs are unaffected. The legacy localhost machinery in utils/auth.ts is left in place for now — nothing imports it from commands/auth.ts anymore, and a follow-up can delete it once we're confident no rollback is needed.
1 parent 22a6089 commit ea91d7d

5 files changed

Lines changed: 24 additions & 684 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ctx7": patch
3+
---
4+
5+
`ctx7 login` now always uses the device-code flow. The localhost-callback path is removed — every install (laptop, SSH, Codespace, Docker, CI) goes through the same boxed prompt and verification page. Drops the `--device` flag (it was the opt-in for what's now the default). Older CLI versions (≤ 0.5.0) continue to work against the unchanged auth endpoints, so pinned installs are unaffected.

packages/cli/src/__tests__/auth-commands.test.ts

Lines changed: 11 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,13 @@ import { Command } from "commander";
44
const mockGetValidAccessToken = vi.fn();
55
const mockClearTokens = vi.fn();
66
const mockSaveTokens = vi.fn();
7-
const mockGeneratePKCE = vi.fn();
8-
const mockGenerateState = vi.fn();
9-
const mockCreateCallbackServer = vi.fn();
10-
const mockExchangeCodeForTokens = vi.fn();
11-
const mockBuildAuthorizationUrl = vi.fn();
12-
const mockShouldUseDeviceFlow = vi.fn((..._args: unknown[]) => false);
137
const mockStartDeviceAuthorization = vi.fn();
148
const mockPollDeviceToken = vi.fn();
159

1610
vi.mock("../utils/auth.js", () => ({
1711
getValidAccessToken: (...args: unknown[]) => mockGetValidAccessToken(...args),
1812
clearTokens: (...args: unknown[]) => mockClearTokens(...args),
1913
saveTokens: (...args: unknown[]) => mockSaveTokens(...args),
20-
generatePKCE: (...args: unknown[]) => mockGeneratePKCE(...args),
21-
generateState: (...args: unknown[]) => mockGenerateState(...args),
22-
createCallbackServer: (...args: unknown[]) => mockCreateCallbackServer(...args),
23-
exchangeCodeForTokens: (...args: unknown[]) => mockExchangeCodeForTokens(...args),
24-
buildAuthorizationUrl: (...args: unknown[]) => mockBuildAuthorizationUrl(...args),
25-
shouldUseDeviceFlow: (...args: unknown[]) => mockShouldUseDeviceFlow(...args),
2614
startDeviceAuthorization: (...args: unknown[]) => mockStartDeviceAuthorization(...args),
2715
pollDeviceToken: (...args: unknown[]) => mockPollDeviceToken(...args),
2816
DEFAULT_DEVICE_POLL_INTERVAL_SECONDS: 5,
@@ -47,7 +35,7 @@ vi.mock("open", () => ({ default: (...args: unknown[]) => mockOpen(...args) }));
4735
vi.mock("../constants.js", () => ({ CLI_CLIENT_ID: "test-client-id" }));
4836
vi.mock("../utils/api.js", () => ({ getBaseUrl: () => "https://test.context7.com" }));
4937

50-
import { registerAuthCommands, performLogin, performDeviceLogin } from "../commands/auth.js";
38+
import { registerAuthCommands, performLogin } from "../commands/auth.js";
5139
import { trackEvent } from "../utils/tracking.js";
5240

5341
let logOutput: string[];
@@ -104,15 +92,7 @@ describe("login command", () => {
10492
test("calls process.exit(1) when login fails", async () => {
10593
mockGetValidAccessToken.mockResolvedValue(null);
10694
mockClearTokens.mockReturnValue(false);
107-
// Mock performLogin to fail by making createCallbackServer reject
108-
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
109-
mockGenerateState.mockReturnValue("state");
110-
mockCreateCallbackServer.mockReturnValue({
111-
port: Promise.resolve(52417),
112-
result: Promise.reject(new Error("timeout")),
113-
close: vi.fn(),
114-
});
115-
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
95+
mockStartDeviceAuthorization.mockRejectedValue(new Error("network down"));
11696

11797
await runCommand("login").catch(() => {});
11898
expect(process.exit).toHaveBeenCalledWith(1);
@@ -190,128 +170,6 @@ describe("whoami command", () => {
190170
});
191171

192172
describe("performLogin", () => {
193-
test("returns access_token on success", async () => {
194-
const mockClose = vi.fn();
195-
mockGeneratePKCE.mockReturnValue({ codeVerifier: "verifier", codeChallenge: "challenge" });
196-
mockGenerateState.mockReturnValue("state");
197-
mockCreateCallbackServer.mockReturnValue({
198-
port: Promise.resolve(52417),
199-
result: Promise.resolve({ code: "auth-code", state: "state" }),
200-
close: mockClose,
201-
});
202-
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
203-
mockExchangeCodeForTokens.mockResolvedValue({
204-
access_token: "new-token",
205-
token_type: "bearer",
206-
});
207-
208-
const result = await performLogin();
209-
expect(result).toBe("new-token");
210-
expect(mockSaveTokens).toHaveBeenCalled();
211-
expect(mockClose).toHaveBeenCalled();
212-
});
213-
214-
test("opens browser by default", async () => {
215-
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
216-
mockGenerateState.mockReturnValue("s");
217-
mockCreateCallbackServer.mockReturnValue({
218-
port: Promise.resolve(52417),
219-
result: Promise.resolve({ code: "code", state: "s" }),
220-
close: vi.fn(),
221-
});
222-
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
223-
mockExchangeCodeForTokens.mockResolvedValue({
224-
access_token: "tok",
225-
token_type: "bearer",
226-
});
227-
228-
await performLogin(true);
229-
expect(mockOpen).toHaveBeenCalledWith("https://example.com/auth");
230-
});
231-
232-
test("skips browser open when openBrowser=false", async () => {
233-
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
234-
mockGenerateState.mockReturnValue("s");
235-
mockCreateCallbackServer.mockReturnValue({
236-
port: Promise.resolve(52417),
237-
result: Promise.resolve({ code: "code", state: "s" }),
238-
close: vi.fn(),
239-
});
240-
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
241-
mockExchangeCodeForTokens.mockResolvedValue({
242-
access_token: "tok",
243-
token_type: "bearer",
244-
});
245-
246-
await performLogin(false);
247-
expect(mockOpen).not.toHaveBeenCalled();
248-
});
249-
250-
test("returns null on callback failure", async () => {
251-
const mockClose = vi.fn();
252-
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
253-
mockGenerateState.mockReturnValue("s");
254-
mockCreateCallbackServer.mockReturnValue({
255-
port: Promise.resolve(52417),
256-
result: Promise.reject(new Error("User cancelled")),
257-
close: mockClose,
258-
});
259-
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
260-
261-
const result = await performLogin();
262-
expect(result).toBeNull();
263-
expect(mockClose).toHaveBeenCalled();
264-
});
265-
266-
test("uses device flow when forceDevice=true", async () => {
267-
mockStartDeviceAuthorization.mockResolvedValue({
268-
device_code: "dc",
269-
user_code: "ABCD-EFGH",
270-
verification_uri: "https://t/oauth/device",
271-
verification_uri_complete: "https://t/oauth/device?user_code=ABCD-EFGH",
272-
expires_in: 600,
273-
interval: 0,
274-
});
275-
mockPollDeviceToken.mockResolvedValue({
276-
status: "approved",
277-
tokens: { access_token: "ctx7sk-x", token_type: "bearer" },
278-
});
279-
vi.stubGlobal(
280-
"fetch",
281-
vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({}) })
282-
);
283-
284-
const result = await performLogin(false, true);
285-
expect(result).toBe("ctx7sk-x");
286-
expect(mockCreateCallbackServer).not.toHaveBeenCalled();
287-
});
288-
289-
test("uses device flow when shouldUseDeviceFlow returns true", async () => {
290-
mockShouldUseDeviceFlow.mockReturnValueOnce(true);
291-
mockStartDeviceAuthorization.mockResolvedValue({
292-
device_code: "dc",
293-
user_code: "X",
294-
verification_uri: "https://t",
295-
verification_uri_complete: undefined,
296-
expires_in: 600,
297-
interval: 0,
298-
});
299-
mockPollDeviceToken.mockResolvedValue({
300-
status: "approved",
301-
tokens: { access_token: "tok", token_type: "bearer" },
302-
});
303-
vi.stubGlobal(
304-
"fetch",
305-
vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({}) })
306-
);
307-
308-
const result = await performLogin(false);
309-
expect(result).toBe("tok");
310-
expect(mockStartDeviceAuthorization).toHaveBeenCalled();
311-
});
312-
});
313-
314-
describe("performDeviceLogin", () => {
315173
const authorization = {
316174
device_code: "dc",
317175
user_code: "ABCD-EFGH",
@@ -336,7 +194,7 @@ describe("performDeviceLogin", () => {
336194
tokens: { access_token: "ctx7sk-x", token_type: "bearer" },
337195
});
338196

339-
const result = await performDeviceLogin(false);
197+
const result = await performLogin(false);
340198
expect(result).toBe("ctx7sk-x");
341199
expect(mockSaveTokens).toHaveBeenCalledWith({
342200
access_token: "ctx7sk-x",
@@ -348,15 +206,15 @@ describe("performDeviceLogin", () => {
348206
mockStartDeviceAuthorization.mockResolvedValue(authorization);
349207
mockPollDeviceToken.mockResolvedValue({ status: "denied" });
350208

351-
expect(await performDeviceLogin(false)).toBeNull();
209+
expect(await performLogin(false)).toBeNull();
352210
expect(mockSaveTokens).not.toHaveBeenCalled();
353211
});
354212

355213
test("returns null on expired", async () => {
356214
mockStartDeviceAuthorization.mockResolvedValue(authorization);
357215
mockPollDeviceToken.mockResolvedValue({ status: "expired" });
358216

359-
expect(await performDeviceLogin(false)).toBeNull();
217+
expect(await performLogin(false)).toBeNull();
360218
expect(mockSaveTokens).not.toHaveBeenCalled();
361219
});
362220

@@ -372,7 +230,7 @@ describe("performDeviceLogin", () => {
372230
tokens: { access_token: "t", token_type: "bearer" },
373231
});
374232

375-
const pending = performDeviceLogin(false);
233+
const pending = performLogin(false);
376234
// transient bumps the interval by 5s (mirroring slow_down), so the
377235
// 2nd and 3rd polls each need a 5s wait. Advance enough to cover both.
378236
await vi.advanceTimersByTimeAsync(11_000);
@@ -393,7 +251,7 @@ describe("performDeviceLogin", () => {
393251
tokens: { access_token: "t", token_type: "bearer" },
394252
});
395253

396-
const pending = performDeviceLogin(false);
254+
const pending = performLogin(false);
397255
// First poll fires after the initial 0ms interval; slow_down then
398256
// bumps the interval by 5000ms before the second poll.
399257
await vi.advanceTimersByTimeAsync(5500);
@@ -408,7 +266,7 @@ describe("performDeviceLogin", () => {
408266
test("returns null when start request throws", async () => {
409267
mockStartDeviceAuthorization.mockRejectedValue(new Error("network down"));
410268

411-
expect(await performDeviceLogin(false)).toBeNull();
269+
expect(await performLogin(false)).toBeNull();
412270
expect(mockPollDeviceToken).not.toHaveBeenCalled();
413271
});
414272

@@ -422,7 +280,7 @@ describe("performDeviceLogin", () => {
422280
tokens: { access_token: "t", token_type: "bearer" },
423281
});
424282

425-
await performDeviceLogin(true);
283+
await performLogin(true);
426284
expect(mockOpen).toHaveBeenCalledWith(authorization.verification_uri_complete);
427285
} finally {
428286
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true });
@@ -436,7 +294,7 @@ describe("performDeviceLogin", () => {
436294
tokens: { access_token: "t", token_type: "bearer" },
437295
});
438296

439-
await performDeviceLogin(false);
297+
await performLogin(false);
440298
expect(mockOpen).not.toHaveBeenCalled();
441299
});
442300

@@ -449,7 +307,7 @@ describe("performDeviceLogin", () => {
449307
tokens: { access_token: "t", token_type: "bearer" },
450308
});
451309

452-
const pending = performDeviceLogin(false);
310+
const pending = performLogin(false);
453311
// Two 5s polls.
454312
await vi.advanceTimersByTimeAsync(11_000);
455313
const result = await pending;

0 commit comments

Comments
 (0)