diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ca292..8f98a5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] jobs: build: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d7eaac5..104a82a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] schedule: - cron: '0 0 * * 1' # Weekly on Monday diff --git a/README.md b/README.md index c4e9963..7eaaa79 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A modern, feature-rich LeetCode CLI built with TypeScript. - 📸 **Solution snapshots** - Save, restore, and compare solution versions - 👥 **Collaborative coding** - Solve problems with a partner - 📁 **Workspaces** - Isolate contexts (interview prep, study, contests) -- ⚙️ **Configurable** - Set language, editor, and working directory +- ⚙️ **Configurable** - Set language, editor, working directory, and LeetCode site - 🖥️ **Interactive TUI** - Launch full-screen terminal workflow with `leetcode` - 📂 **Smart file discovery** - Use problem ID, filename, or full path - 🔄 **Git Sync** - Auto-sync solutions to GitHub/GitLab @@ -54,6 +54,9 @@ leetcode # Login with your LeetCode cookies leetcode login +# Optional: switch to LeetCode China +leetcode config --site leetcode.cn + # Get today's daily challenge leetcode daily @@ -79,6 +82,19 @@ Run `leetcode` with no arguments to open the full-screen TUI. See [docs/tui.md](docs/tui.md) for full keybindings and behavior. +## Site Support + +- Default site: `leetcode.com` +- Optional site: `leetcode.cn` +- Set site with: + +```bash +leetcode config --site leetcode.com +leetcode config --site leetcode.cn +``` + +The CLI keeps command semantics the same and applies site-specific GraphQL queries/adapters internally. + ## Commands | Command | Description | diff --git a/docs/commands.md b/docs/commands.md index a523cab..cf1d6f6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -44,6 +44,7 @@ See [TUI Guide](tui.md) for complete behavior and screen-specific shortcuts. Login to LeetCode with browser cookies. Notes: +- `leetcode login` lets you choose site (`leetcode.com` or `leetcode.cn`) before cookie input. - Default credential backend is system keychain. - Set `LEETCODECLI_CREDENTIAL_BACKEND=file` with `LEETCODECLI_MASTER_KEY` for encrypted file mode. - If both `LEETCODE_SESSION` and `LEETCODE_CSRF_TOKEN` are set, login runs in read-only env mode. @@ -499,9 +500,10 @@ View or set configuration. **Options**: - `-l, --lang ` - Set default programming language +- `-s, --site ` - Set LeetCode site (`leetcode.com` or `leetcode.cn`) - `-e, --editor ` - Set editor command - `-w, --workdir ` - Set working directory for solutions -- `-r, --repo ` - Set Git repository URL +- `-r, --repo [url]` - Set or clear Git repository URL - `-i, --interactive` - Interactive configuration mode **Examples**: @@ -515,6 +517,10 @@ leetcode config -l python3 leetcode config --lang java leetcode config --lang sql +# Set site +leetcode config -s leetcode.com +leetcode config --site leetcode.cn + # Set editor leetcode config -e code leetcode config --editor vim @@ -525,6 +531,7 @@ leetcode config --workdir /Users/you/projects/leetcode # Set Git repository leetcode config -r https://github.com/user/repo.git +leetcode config --repo # Interactive configuration leetcode config -i diff --git a/docs/config.md b/docs/config.md index d0b5b2a..4688083 100644 --- a/docs/config.md +++ b/docs/config.md @@ -4,14 +4,15 @@ The CLI requires your LeetCode authentication cookies. -1. Login to [leetcode.com](https://leetcode.com). -2. Open Browser DevTools (F12) -> Application -> Cookies. -3. Find `LEETCODE_SESSION` and `csrftoken`. -4. Run: +1. Choose your site (`leetcode.com` or `leetcode.cn`). +2. Login to that site in your browser. +3. Open Browser DevTools (F12) -> Application -> Cookies. +4. Find `LEETCODE_SESSION` and `csrftoken`. +5. Run: ```bash leetcode login ``` -5. Paste the values. +6. Paste the values. ## Credential Storage @@ -69,6 +70,10 @@ leetcode config -l python3 # Set SQL as default language leetcode config -l sql +# Select LeetCode site +leetcode config -s leetcode.com +leetcode config -s leetcode.cn + # Set default work directory leetcode config -w ~/Development/my-leetcode @@ -86,6 +91,7 @@ Config is stored per-workspace in `~/.leetcode/workspaces//config.json`. | `editor` | Command to open files (code, vim, nano) | | `workDir` | Directory where solution files are saved | | `syncRepo` | Remote Git repository URL | +| `site` | LeetCode site (`leetcode.com` or `leetcode.cn`) | ## Workspace-Aware Storage diff --git a/docs/index.md b/docs/index.md index 109e12d..eaa096b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,6 +19,7 @@ A modern, feature-rich command-line interface for LeetCode, built with TypeScrip ## Features - 🔐 Cookie-based authentication +- 🌐 Site-aware API support (`leetcode.com` and `leetcode.cn`) - 📝 Auto-generation of solution files - 🧪 Local testing against sample cases - 📤 Direct submission to LeetCode diff --git a/docs/releases.md b/docs/releases.md index 445e3a2..8bfd2c2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,38 @@ # Release Notes +## v3.1.0 + +> **Release Date**: 2026-05-01 +> **Focus**: LeetCode China (leetcode.cn) Full Support + Credential UX + +### 🌏 LeetCode China Support + +- Full `leetcode.cn` integration across CLI and TUI. +- New `LeetCodeSite` type with site utility helpers (`normalizeLeetCodeSiteInput`, `getLeetCodeSiteLabel`). +- GraphQL query packs split into site-specific files (`queries.global.ts`, `queries.cn.ts`) — CN uses the correct native schema for problem list, daily, and problem detail. +- CN-specific Zod schemas for type-safe response parsing. +- CN response adapters that normalize China API responses into the shared CLI data model. +- `LeetCodeClient` is now fully site-aware: switches base URL, query pack, and cookie headers automatically. +- Site selection available in `login`, `config`, and `workspace` commands. +- Site preference persisted per workspace in config file. +- TUI Config screen: site switching with a mandatory confirmation modal that forces an immediate session logout and redirect to login. + +### 🔐 Credential & Auth UX + +- `leetcode login --help` now documents all three credential storage backends: + 1. **System Keychain** (default) — macOS/Windows/Linux OS-native secure storage via `keytar`. + 2. **Encrypted File** — AES-256-GCM via `LEETCODECLI_CREDENTIAL_BACKEND=file` + `LEETCODECLI_MASTER_KEY`. + 3. **Environment Variables** — read-only headless mode via `LEETCODE_SESSION` + `LEETCODE_CSRF_TOKEN`. +- TUI site switch now correctly wipes the in-memory session (not just persisted config), ensuring users are immediately signed out and redirected to the login screen. + +### 🧪 Test Stability + +- Mocked `versionStorage` in update/changelog tests to prevent phantom `999.0.0` update cache leakage into real environments. +- Mocked outbound `got` network requests in changelog tests to prevent rate-limit failures in CI. +- All 283 tests pass cleanly across Node 20/22/24 on Ubuntu and macOS. + +--- + ## v3.0.1 > **Release Date**: 2026-04-20 diff --git a/docs/testing.md b/docs/testing.md index 96897f8..f674ce6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -81,6 +81,7 @@ Run the actual compiled CLI binary to catch: Deterministic CLI command-flow checks run in the CI OS matrix (Linux/macOS/Windows): - Config set/read with SQL language and path values +- Config set/read with site selection (`leetcode.com` / `leetcode.cn`) - Workspace create/use/list/current command flows - Snapshot save/list/diff flow on real SQL files - Built CLI help output includes SQL language support diff --git a/package-lock.json b/package-lock.json index 963f4c8..531c759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@night-slayer18/leetcode-cli", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@night-slayer18/leetcode-cli", - "version": "3.0.1", + "version": "3.1.0", "license": "Apache-2.0", "dependencies": { "@supabase/supabase-js": "^2.90.1", @@ -4652,9 +4652,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index be8f6a8..a69628a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@night-slayer18/leetcode-cli", - "version": "3.0.1", + "version": "3.1.0", "description": "A modern LeetCode CLI built with TypeScript", "type": "module", "main": "dist/index.js", diff --git a/src/__tests__/api/client-cn.test.ts b/src/__tests__/api/client-cn.test.ts new file mode 100644 index 0000000..1beb63d --- /dev/null +++ b/src/__tests__/api/client-cn.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LeetCodeClient } from '../../api/client.js'; + +describe('LeetCodeClient cn getProblemById', () => { + it('uses list search to resolve cn problem ids by exact frontend id', async () => { + const client = new LeetCodeClient('leetcode.cn'); + + const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({ + total: 1, + problems: [ + { + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + }, + ], + }); + + const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + content: '

Given an array of integers...

', + codeSnippets: [], + sampleTestCase: '', + exampleTestcases: '', + hints: [], + companyTags: [], + stats: '{}', + }); + + const result = await client.getProblemById('1'); + + expect(getProblemsSpy).toHaveBeenCalledWith({ searchKeywords: '1', limit: 50, skip: 0 }); + expect(getProblemSpy).toHaveBeenCalledWith('two-sum'); + expect(result.titleSlug).toBe('two-sum'); + }); + + it('throws when cn list search does not contain the exact frontend id', async () => { + const client = new LeetCodeClient('leetcode.cn'); + + const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({ + total: 12, + problems: [ + { + questionId: '200', + questionFrontendId: '200', + title: '岛屿数量', + titleSlug: 'number-of-islands', + difficulty: 'Medium', + isPaidOnly: false, + acRate: 61.2, + topicTags: [], + status: null, + }, + ], + }); + + const getProblemSpy = vi.spyOn(client, 'getProblem'); + + await expect(client.getProblemById('2')).rejects.toThrow('Problem #2 not found'); + + expect(getProblemsSpy).toHaveBeenCalledWith({ searchKeywords: '2', limit: 50, skip: 0 }); + expect(getProblemSpy).not.toHaveBeenCalled(); + }); + + it('searches additional cn result pages until the exact frontend id is found', async () => { + const client = new LeetCodeClient('leetcode.cn'); + + const getProblemsSpy = vi + .spyOn(client, 'getProblems') + .mockResolvedValueOnce({ + total: 120, + problems: Array.from({ length: 50 }, (_, index) => ({ + questionId: String(index + 100), + questionFrontendId: String(index + 100), + title: `Problem ${index + 100}`, + titleSlug: `problem-${index + 100}`, + difficulty: 'Easy' as const, + isPaidOnly: false, + acRate: 50, + topicTags: [], + status: null, + })), + }) + .mockResolvedValueOnce({ + total: 120, + problems: [ + { + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + }, + ], + }); + + const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + content: '

Given an array of integers...

', + codeSnippets: [], + sampleTestCase: '', + exampleTestcases: '', + hints: [], + companyTags: [], + stats: '{}', + }); + + await client.getProblemById('1'); + + expect(getProblemsSpy).toHaveBeenNthCalledWith(1, { searchKeywords: '1', limit: 50, skip: 0 }); + expect(getProblemsSpy).toHaveBeenNthCalledWith(2, { searchKeywords: '1', limit: 50, skip: 50 }); + expect(getProblemSpy).toHaveBeenCalledWith('two-sum'); + }); +}); diff --git a/src/__tests__/api/cn-adapter.test.ts b/src/__tests__/api/cn-adapter.test.ts new file mode 100644 index 0000000..0f6f87f --- /dev/null +++ b/src/__tests__/api/cn-adapter.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeCnDailyChallenge, + normalizeCnProblemDetail, + normalizeCnProblemList, + normalizeCnSkillStats, + normalizeCnUserProfile, +} from '../../api/adapters/cn.js'; +import { CnProblemDetailSchema, CnProblemListSchema } from '../../schemas/api.js'; + +describe('cn adapters', () => { + it('normalizes daily challenge payload from todayRecord', () => { + const result = normalizeCnDailyChallenge({ + todayRecord: [ + { + date: '2026-04-20', + question: { + questionId: '1', + frontendQuestionId: '1', + difficulty: 'Easy', + title: 'Two Sum', + titleCn: '两数之和', + titleSlug: 'two-sum', + paidOnly: false, + acRate: 52.3, + status: 'ac', + topicTags: [{ name: 'Array', id: 'array' }], + }, + }, + ], + }); + + expect(result.question.title).toBe('两数之和'); + expect(result.question.titleSlug).toBe('two-sum'); + expect(result.question.questionFrontendId).toBe('1'); + expect(result.link).toBe('/problems/two-sum/'); + }); + + it('normalizes problem list payload from leetcode.cn', () => { + const result = normalizeCnProblemList({ + problemsetQuestionList: { + total: 3, + questions: [ + { + frontendQuestionId: '1', + title: 'Two Sum', + titleCn: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + paidOnly: false, + acRate: '52.3', + status: 'AC', + topicTags: [{ name: 'Array', nameTranslated: '数组', id: 'array', slug: 'array' }], + }, + { + frontendQuestionId: '2', + title: 'Add Two Numbers', + titleCn: '两数相加', + titleSlug: 'add-two-numbers', + difficulty: 'Medium', + paidOnly: false, + acRate: '42.1', + status: 'TRIED', + topicTags: [], + }, + { + frontendQuestionId: '3', + title: 'Longest Substring Without Repeating Characters', + titleCn: '无重复字符的最长子串', + titleSlug: 'longest-substring-without-repeating-characters', + difficulty: 'Medium', + paidOnly: false, + acRate: '38.4', + status: 'NOT_STARTED', + topicTags: [], + }, + ], + }, + }); + + expect(result.total).toBe(3); + expect(result.problems[0]).toMatchObject({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + status: 'ac', + }); + expect(result.problems[0]?.topicTags).toEqual([{ name: '数组', slug: 'array' }]); + expect(result.problems[1]?.status).toBe('notac'); + expect(result.problems[2]?.status).toBeNull(); + }); + + it('accepts leetcode.cn problem list status enums before normalization', () => { + const parsed = CnProblemListSchema.parse({ + problemsetQuestionList: { + total: 3, + questions: [ + { frontendQuestionId: '1', status: 'AC' }, + { frontendQuestionId: '2', status: 'TRIED' }, + { frontendQuestionId: '3', status: 'NOT_STARTED' }, + ], + }, + }); + + expect(parsed.problemsetQuestionList.questions).toHaveLength(3); + }); + + it('normalizes problem detail payload from leetcode.cn', () => { + const parsed = CnProblemDetailSchema.parse({ + question: { + questionId: '1', + questionFrontendId: '1', + title: 'Two Sum', + translatedTitle: '两数之和', + titleSlug: 'two-sum', + translatedContent: '

给定一个整数数组 nums 和一个整数目标值 target...

', + difficulty: 'Easy', + isPaidOnly: false, + acRate: '52.3', + status: 'AC', + topicTags: [{ name: 'Array', slug: 'array', translatedName: '数组' }], + codeSnippets: [{ lang: 'TypeScript', langSlug: 'typescript', code: 'function twoSum() {}' }], + sampleTestCase: '[2,7,11,15]\n9', + exampleTestcases: '[2,7,11,15]\n9', + hints: ['Use a hash map.'], + stats: '{}', + }, + }); + + const result = normalizeCnProblemDetail(parsed); + + expect(result).toMatchObject({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + status: 'ac', + sampleTestCase: '[2,7,11,15]\n9', + exampleTestcases: '[2,7,11,15]\n9', + }); + expect(result.topicTags).toEqual([{ name: '数组', slug: 'array' }]); + expect(result.companyTags).toBeNull(); + }); + + it('normalizes cn profile payload into shared user profile shape', () => { + const profile = normalizeCnUserProfile('night-slayer', { + userProfilePublicProfile: { + siteRanking: 123, + profile: { + userSlug: 'night-slayer', + realName: 'Night Slayer', + }, + }, + userProfileUserQuestionProgress: { + numAcceptedQuestions: [ + { difficulty: 'Easy', count: 10 }, + { difficulty: 'Medium', count: 7 }, + { difficulty: 'Hard', count: 2 }, + ], + }, + }); + + expect(profile.username).toBe('night-slayer'); + expect(profile.ranking).toBe(123); + expect(profile.acSubmissionNum.find((entry) => entry.difficulty === 'All')?.count).toBe(19); + expect(profile.streak).toBe(0); + expect(profile.submissionCalendar).toBe(''); + }); + + it('normalizes cn skill scores into three buckets', () => { + const stats = normalizeCnSkillStats({ + userProfilePublicProfile: { + profile: { + skillSet: { + topicAreaScores: [ + { score: 12, topicArea: { name: 'Array', slug: 'array' } }, + { score: 6, topicArea: { name: 'Graph', slug: 'graph' } }, + { score: 3, topicArea: { name: 'DP', slug: 'dynamic-programming' } }, + ], + }, + }, + }, + }); + + expect(stats.advanced.length).toBeGreaterThan(0); + expect(stats.intermediate.length).toBeGreaterThan(0); + expect(stats.fundamental.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/api/query-resolver.test.ts b/src/__tests__/api/query-resolver.test.ts new file mode 100644 index 0000000..72a678d --- /dev/null +++ b/src/__tests__/api/query-resolver.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getQueryPack } from '../../api/query-resolver.js'; +import { PROBLEM_LIST_QUERY as PROBLEM_LIST_QUERY_GLOBAL } from '../../api/queries.global.js'; +import { PROBLEM_DETAIL_QUERY as PROBLEM_DETAIL_QUERY_GLOBAL } from '../../api/queries.global.js'; +import { DAILY_CHALLENGE_QUERY as DAILY_CHALLENGE_QUERY_GLOBAL } from '../../api/queries.global.js'; +import { DAILY_CHALLENGE_QUERY_CN } from '../../api/queries.cn.js'; + +describe('query resolver', () => { + it('returns global query pack for leetcode.com', () => { + const pack = getQueryPack('leetcode.com'); + expect(pack.DAILY_CHALLENGE_QUERY).toContain('activeDailyCodingChallengeQuestion'); + expect(pack.DAILY_CHALLENGE_QUERY).toBe(DAILY_CHALLENGE_QUERY_GLOBAL); + }); + + it('returns cn query pack for leetcode.cn', () => { + const pack = getQueryPack('leetcode.cn'); + expect(pack.DAILY_CHALLENGE_QUERY).toContain('todayRecord'); + expect(pack.DAILY_CHALLENGE_QUERY).toBe(DAILY_CHALLENGE_QUERY_CN); + expect(pack.PROBLEM_LIST_QUERY).not.toBe(PROBLEM_LIST_QUERY_GLOBAL); + expect(pack.PROBLEM_LIST_QUERY).toContain('titleCn'); + expect(pack.PROBLEM_LIST_QUERY).toContain('frontendQuestionId'); + expect(pack.PROBLEM_DETAIL_QUERY).not.toBe(PROBLEM_DETAIL_QUERY_GLOBAL); + expect(pack.PROBLEM_DETAIL_QUERY).toContain('translatedTitle'); + expect(pack.PROBLEM_DETAIL_QUERY).toContain('translatedContent'); + }); +}); diff --git a/src/__tests__/commands/auth.test.ts b/src/__tests__/commands/auth.test.ts index 8b0d6d5..2001cd6 100644 --- a/src/__tests__/commands/auth.test.ts +++ b/src/__tests__/commands/auth.test.ts @@ -21,12 +21,25 @@ vi.mock('../../api/client.js', () => ({ leetcodeClient: createMockLeetCodeClient(), })); +vi.mock('../../storage/config.js', () => ({ + config: { + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + setSite: vi.fn(), + }, +})); + // Mock inquirer for login prompts vi.mock('inquirer', () => ({ default: { - prompt: vi.fn().mockResolvedValue({ - session: 'test-session', - csrfToken: 'test-csrf', + prompt: vi.fn().mockImplementation((questions: Array<{ name: string }>) => { + if (questions.some((question) => question.name === 'site')) { + return Promise.resolve({ site: 'leetcode.com' }); + } + return Promise.resolve({ + session: 'test-session', + csrfToken: 'test-csrf', + }); }), }, })); @@ -45,6 +58,7 @@ vi.mock('ora', () => ({ import { loginCommand, logoutCommand, whoamiCommand } from '../../commands/login.js'; import { credentials } from '../../storage/credentials.js'; import { leetcodeClient } from '../../api/client.js'; +import { config } from '../../storage/config.js'; describe('Authentication Commands', () => { beforeEach(() => { @@ -60,6 +74,7 @@ describe('Authentication Commands', () => { await loginCommand(); + expect(config.setSite).toHaveBeenCalledWith('leetcode.com'); expect(credentials.set).toHaveBeenCalled(); }); diff --git a/src/__tests__/commands/changelog.test.ts b/src/__tests__/commands/changelog.test.ts index 21581ac..a18d834 100644 --- a/src/__tests__/commands/changelog.test.ts +++ b/src/__tests__/commands/changelog.test.ts @@ -1,6 +1,20 @@ // Changelog command tests - integration tests that hit real GitHub API import { describe, it, expect, vi, beforeEach } from 'vitest'; import { outputContains } from '../setup.js'; + +vi.mock('got', () => ({ + default: () => ({ + text: async () => ` +## v2.0.0 +### ⚠️ Breaking Change +- **Config**: Something changed + +## v1.0.0 +- Initial release +`, + }), +})); + import { changelogCommand } from '../../commands/changelog.js'; describe('Changelog Command', () => { diff --git a/src/__tests__/commands/config.test.ts b/src/__tests__/commands/config.test.ts index e86aaf3..74a5be0 100644 --- a/src/__tests__/commands/config.test.ts +++ b/src/__tests__/commands/config.test.ts @@ -10,15 +10,18 @@ vi.mock('../../storage/config.js', () => ({ workDir: '/tmp/leetcode', editor: 'code', repo: undefined, + site: 'leetcode.com', })), getLanguage: vi.fn(() => 'typescript'), getWorkDir: vi.fn(() => '/tmp/leetcode'), getEditor: vi.fn(() => 'code'), getRepo: vi.fn(() => undefined), + getSite: vi.fn(() => 'leetcode.com'), setLanguage: vi.fn(), setWorkDir: vi.fn(), setEditor: vi.fn(), setRepo: vi.fn(), + setSite: vi.fn(), getPath: vi.fn(() => '/tmp/.leetcode/config.json'), getActiveWorkspace: vi.fn(() => 'default'), }, @@ -27,6 +30,7 @@ vi.mock('../../storage/config.js', () => ({ vi.mock('../../storage/credentials.js', () => ({ credentials: { get: vi.fn(() => ({ session: 'test', csrfToken: 'test' })), + clear: vi.fn(() => Promise.resolve({ ok: true })), }, })); @@ -36,6 +40,7 @@ vi.mock('inquirer', () => ({ language: 'java', editor: 'vim', workDir: '/home/user/leetcode', + proceed: true, }), }, })); @@ -89,5 +94,10 @@ describe('Config Command', () => { expect(config.setRepo).toHaveBeenCalledWith('https://github.com/user/repo.git'); }); + + it('should set site', async () => { + await configCommand({ site: 'leetcode.cn' }); + expect(config.setSite).toHaveBeenCalledWith('leetcode.cn'); + }); }); }); diff --git a/src/__tests__/commands/solve.test.ts b/src/__tests__/commands/solve.test.ts index 8862435..8bdd425 100644 --- a/src/__tests__/commands/solve.test.ts +++ b/src/__tests__/commands/solve.test.ts @@ -172,6 +172,16 @@ describe('Solve Commands', () => { // Should warn about existing file but not fail expect(leetcodeClient.getProblemById).toHaveBeenCalled(); }); + + it('should not create a file when problem id lookup fails', async () => { + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(leetcodeClient.getProblemById).mockRejectedValueOnce(new Error('Problem #1 not found')); + + const result = await pickCommand('1', { open: false }); + + expect(result).toBe(false); + expect(writeFile).not.toHaveBeenCalled(); + }); }); describe('file creation', () => { diff --git a/src/__tests__/commands/update.test.ts b/src/__tests__/commands/update.test.ts index 13bb637..8208359 100644 --- a/src/__tests__/commands/update.test.ts +++ b/src/__tests__/commands/update.test.ts @@ -1,6 +1,22 @@ // Update command tests - integration tests that hit real npm registry import { describe, it, expect, vi, beforeEach } from 'vitest'; import { outputContains } from '../setup.js'; + +vi.mock('got', () => ({ + default: () => ({ + json: async () => ({ version: '999.0.0' }), + }), +})); + +vi.mock('../../storage/version.js', () => ({ + versionStorage: { + shouldCheck: vi.fn(() => true), + getCached: vi.fn(() => null), + updateCache: vi.fn(), + clearCache: vi.fn(), + }, +})); + import { updateCommand } from '../../commands/update.js'; import { versionStorage } from '../../storage/version.js'; diff --git a/src/__tests__/integration/cli-cross-os-e2e.test.ts b/src/__tests__/integration/cli-cross-os-e2e.test.ts index 030fd4c..9ea7c56 100644 --- a/src/__tests__/integration/cli-cross-os-e2e.test.ts +++ b/src/__tests__/integration/cli-cross-os-e2e.test.ts @@ -64,6 +64,8 @@ describe('CLI Cross-OS E2E', () => { 'config', '--lang', 'sql', + '--site', + 'leetcode.cn', '--workdir', sharedWorkDir, '--editor', @@ -76,6 +78,8 @@ describe('CLI Cross-OS E2E', () => { expect(showConfig.exitCode).toBe(0); expect(showConfig.stdout.toLowerCase()).toContain('language:'); expect(showConfig.stdout.toLowerCase()).toContain('sql'); + expect(showConfig.stdout.toLowerCase()).toContain('site:'); + expect(showConfig.stdout.toLowerCase()).toContain('leetcode.cn'); expect(showConfig.stdout).toContain(sharedWorkDir); }); @@ -123,9 +127,10 @@ describe('CLI Cross-OS E2E', () => { expect(diff.stdout.toLowerCase()).toContain('diff'); }); - it('should show sql in config help for built CLI', () => { + it('should show sql and site options in config help for built CLI', () => { const help = runCLI(['config', '--help']); expect(help.exitCode).toBe(0); expect(help.stdout.toLowerCase()).toContain('sql'); + expect(help.stdout).toContain('--site'); }); }); diff --git a/src/__tests__/integration/cli.test.ts b/src/__tests__/integration/cli.test.ts index 61c1437..570f7e6 100644 --- a/src/__tests__/integration/cli.test.ts +++ b/src/__tests__/integration/cli.test.ts @@ -172,6 +172,7 @@ describe('CLI Integration Tests', () => { const { stdout } = runCLI(['config', '--help']); expect(stdout).toContain('config'); expect(stdout).toContain('--lang'); + expect(stdout).toContain('--site'); expect(stdout).toContain('--editor'); }); diff --git a/src/__tests__/mocks/leetcodeClient.ts b/src/__tests__/mocks/leetcodeClient.ts index bcead05..2aff2cb 100644 --- a/src/__tests__/mocks/leetcodeClient.ts +++ b/src/__tests__/mocks/leetcodeClient.ts @@ -98,6 +98,7 @@ export const mockUserStats = { // Create mock client export function createMockLeetCodeClient() { return { + setSite: vi.fn(), setCredentials: vi.fn(), checkAuth: vi.fn().mockResolvedValue({ isSignedIn: true, username: 'TestUser' }), getProblems: vi.fn().mockResolvedValue(mockProblems), diff --git a/src/__tests__/tui/auth-effects.test.ts b/src/__tests__/tui/auth-effects.test.ts index d669f39..9229b0c 100644 --- a/src/__tests__/tui/auth-effects.test.ts +++ b/src/__tests__/tui/auth-effects.test.ts @@ -41,11 +41,20 @@ const { mockCredentials, mockLeetCodeClient } = vi.hoisted(() => ({ getPath: vi.fn(() => '/tmp/.leetcode/credentials.v2.enc.json'), }, mockLeetCodeClient: { + setSite: vi.fn(), setCredentials: vi.fn(), checkAuth: vi.fn(), }, })); +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + setSite: vi.fn(), + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + vi.mock('../../storage/credentials.js', () => ({ credentials: mockCredentials, describeCredentialStatus: vi.fn((status: { reason: string | null }) => { @@ -60,6 +69,10 @@ vi.mock('../../api/client.js', () => ({ leetcodeClient: mockLeetCodeClient, })); +vi.mock('../../storage/config.js', () => ({ + config: mockConfig, +})); + import { executeCommand } from '../../tui/commands/effects.js'; async function flushAsync(): Promise { @@ -84,7 +97,7 @@ describe('TUI auth effects', () => { const dispatched: AppMsg[] = []; executeCommand( - { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' }, + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' }, (msg) => dispatched.push(msg) ); @@ -109,7 +122,7 @@ describe('TUI auth effects', () => { const dispatched: AppMsg[] = []; executeCommand( - { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' }, + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' }, (msg) => dispatched.push(msg) ); @@ -165,4 +178,32 @@ describe('TUI auth effects', () => { expect(dispatched).toContainEqual({ type: 'GLOBAL_ERROR', error: 'System keychain is unavailable.' }); expect(dispatched).toContainEqual({ type: 'AUTH_CHECK_COMPLETE', user: null }); }); + + it('should persist selected site before verifying login credentials', async () => { + mockCredentials.status.mockResolvedValueOnce({ + mode: 'keychain', + backend: 'keychain', + source: null, + hasCredentials: false, + readOnly: false, + reason: null, + path: null, + } as any); + mockLeetCodeClient.checkAuth.mockResolvedValueOnce({ + isSignedIn: true, + username: 'dong', + }); + + const dispatched: AppMsg[] = []; + executeCommand( + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.cn' }, + (msg) => dispatched.push(msg) + ); + + await flushAsync(); + + expect(mockConfig.setSite).toHaveBeenCalledWith('leetcode.cn'); + expect(mockLeetCodeClient.setSite).toHaveBeenCalledWith('leetcode.cn'); + expect(dispatched).toContainEqual({ type: 'LOGIN_SUCCESS', username: 'dong' }); + }); }); diff --git a/src/__tests__/tui/login-screen.test.ts b/src/__tests__/tui/login-screen.test.ts new file mode 100644 index 0000000..c6e7cb5 --- /dev/null +++ b/src/__tests__/tui/login-screen.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + +vi.mock('../../storage/config.js', () => ({ + config: mockConfig, +})); + +import { init, update } from '../../tui/screens/login/index.js'; + +describe('TUI login screen', () => { + it('should move from instructions to site selection before credential entry', () => { + const [initialModel] = init(); + + const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); + const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, siteModel); + + expect(siteModel.step).toBe('site'); + expect(inputModel.step).toBe('input'); + expect(inputModel.site).toBe('leetcode.com'); + }); + + it('should include selected site in the login command payload', () => { + const [initialModel] = init(); + const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); + const [cnModel] = update({ type: 'LOGIN_SWITCH_SITE' }, siteModel); + const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, cnModel); + const [sessionModel] = update({ type: 'LOGIN_SESSION_INPUT', value: 'session-token' }, inputModel); + const [readyModel] = update({ type: 'LOGIN_CSRF_INPUT', value: 'csrf-token' }, sessionModel); + const [, cmd] = update({ type: 'LOGIN_SUBMIT' }, readyModel); + + expect(cmd).toEqual({ + type: 'CMD_LOGIN', + session: 'session-token', + csrf: 'csrf-token', + site: 'leetcode.cn', + }); + }); +}); diff --git a/src/__tests__/utils/auth.test.ts b/src/__tests__/utils/auth.test.ts index bd88cda..561b101 100644 --- a/src/__tests__/utils/auth.test.ts +++ b/src/__tests__/utils/auth.test.ts @@ -33,6 +33,13 @@ vi.mock('../../storage/credentials.js', () => ({ }), })); +vi.mock('../../storage/config.js', () => ({ + config: { + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + vi.mock('../../api/client.js', () => ({ leetcodeClient: mockLeetCodeClient, })); diff --git a/src/api/adapters/cn.ts b/src/api/adapters/cn.ts new file mode 100644 index 0000000..8d05893 --- /dev/null +++ b/src/api/adapters/cn.ts @@ -0,0 +1,299 @@ +import type { DailyChallenge, Problem, ProblemDetail } from '../../types.js'; + +interface CnTopicTag { + name?: string; + nameTranslated?: string; + id?: string | number; +} + +interface CnQuestion { + questionId?: string | number; + frontendQuestionId?: string | number; + questionFrontendId?: string | number; + difficulty?: string; + title?: string; + titleCn?: string; + titleSlug?: string; + paidOnly?: boolean; + isPaidOnly?: boolean; + acRate?: number | string; + status?: string | null; + topicTags?: CnTopicTag[]; +} + +interface CnProblemListItem { + frontendQuestionId?: string | number; + title?: string; + titleCn?: string; + titleSlug?: string; + difficulty?: string; + paidOnly?: boolean; + acRate?: number | string; + status?: string | null; + topicTags?: Array; +} + +interface CnProblemDetailTag { + name?: string; + slug?: string; + translatedName?: string; +} + +interface CnProblemDetailShape { + question: { + questionId?: string | number; + questionFrontendId?: string | number; + title?: string; + translatedTitle?: string; + titleSlug?: string; + translatedContent?: string | null; + difficulty?: string; + isPaidOnly?: boolean; + acRate?: number | string; + status?: string | null; + topicTags?: CnProblemDetailTag[]; + codeSnippets?: Array<{ lang: string; langSlug: string; code: string }> | null; + sampleTestCase?: string; + exampleTestcases?: string; + hints?: string[]; + stats?: string; + }; +} + +interface CnDailyRecord { + date?: string; + link?: string; + question?: CnQuestion; +} + +interface CnAcceptedItem { + difficulty?: string; + count?: number; +} + +interface CnProfileShape { + userProfilePublicProfile?: { + siteRanking?: number; + profile?: { + userSlug?: string; + realName?: string; + }; + } | null; + userProfileUserQuestionProgress?: { + numAcceptedQuestions?: CnAcceptedItem[]; + } | null; +} + +interface CnSkillScore { + score?: number; + topicArea?: { + name?: string; + slug?: string; + } | null; +} + +interface CnSkillShape { + userProfilePublicProfile?: { + profile?: { + skillSet?: { + topicAreaScores?: CnSkillScore[]; + } | null; + } | null; + } | null; +} + +function toTitleCaseDifficulty(difficulty?: string): Problem['difficulty'] { + const value = (difficulty ?? '').toLowerCase(); + if (value === 'easy') return 'Easy'; + if (value === 'hard') return 'Hard'; + return 'Medium'; +} + +function toStatus(status: string | null | undefined): Problem['status'] { + const value = (status ?? '').toLowerCase(); + if (value === 'ac') return 'ac'; + if (value === 'notac' || value === 'tried') return 'notac'; + if (value === 'not_started') return null; + return null; +} + +function toSlug(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function toProblem(question: CnQuestion): Problem { + const title = question.titleCn || question.title || 'Unknown Problem'; + const titleSlug = question.titleSlug || toSlug(title); + const tags = (question.topicTags ?? []).map((tag) => { + const tagName = tag.nameTranslated || tag.name || 'Tag'; + return { + name: tagName, + slug: tag.id !== undefined ? String(tag.id) : toSlug(tagName), + }; + }); + + return { + questionId: String(question.questionId ?? ''), + questionFrontendId: String(question.frontendQuestionId ?? question.questionFrontendId ?? ''), + title, + titleSlug, + difficulty: toTitleCaseDifficulty(question.difficulty), + isPaidOnly: Boolean(question.paidOnly ?? question.isPaidOnly), + acRate: Number(question.acRate ?? 0), + topicTags: tags, + status: toStatus(question.status), + }; +} + +function toProblemFromListEntry(question: CnProblemListItem): Problem { + const title = question.titleCn || question.title || 'Unknown Problem'; + const titleSlug = question.titleSlug || toSlug(title); + const tags = (question.topicTags ?? []).map((tag) => { + const tagName = tag.nameTranslated || tag.name || 'Tag'; + return { + name: tagName, + slug: tag.slug || (tag.id !== undefined ? String(tag.id) : toSlug(tagName)), + }; + }); + + return { + questionId: String(question.frontendQuestionId ?? ''), + questionFrontendId: String(question.frontendQuestionId ?? ''), + title, + titleSlug, + difficulty: toTitleCaseDifficulty(question.difficulty), + isPaidOnly: Boolean(question.paidOnly), + acRate: Number(question.acRate ?? 0), + topicTags: tags, + status: toStatus(question.status), + }; +} + +export function normalizeCnDailyChallenge(input: { todayRecord?: CnDailyRecord[] }): DailyChallenge { + const record = input.todayRecord?.[0]; + if (!record || !record.question) { + throw new Error('No daily challenge found for leetcode.cn'); + } + + const problem = toProblem(record.question); + + return { + date: record.date ?? new Date().toISOString().slice(0, 10), + link: record.link || `/problems/${problem.titleSlug}/`, + question: problem, + }; +} + +export function normalizeCnProblemList(input: { + problemsetQuestionList: { total: number; questions: CnProblemListItem[] }; +}): { total: number; problems: Problem[] } { + return { + total: input.problemsetQuestionList.total, + problems: input.problemsetQuestionList.questions.map((question) => toProblemFromListEntry(question)), + }; +} + +export function normalizeCnProblemDetail(input: CnProblemDetailShape): ProblemDetail { + const question = input.question; + const title = question.translatedTitle || question.title || 'Unknown Problem'; + const titleSlug = question.titleSlug || toSlug(title); + const topicTags = (question.topicTags ?? []).map((tag) => ({ + name: tag.translatedName || tag.name || 'Tag', + slug: tag.slug || toSlug(tag.translatedName || tag.name || 'tag'), + })); + + return { + questionId: String(question.questionId ?? ''), + questionFrontendId: String(question.questionFrontendId ?? ''), + title, + titleSlug, + content: question.translatedContent ?? null, + difficulty: toTitleCaseDifficulty(question.difficulty), + isPaidOnly: Boolean(question.isPaidOnly), + acRate: Number(question.acRate ?? 0), + topicTags, + codeSnippets: question.codeSnippets ?? null, + sampleTestCase: question.sampleTestCase ?? '', + exampleTestcases: question.exampleTestcases ?? '', + hints: question.hints ?? [], + companyTags: null, + stats: question.stats ?? '{}', + status: toStatus(question.status), + }; +} + +export function normalizeCnUserProfile( + username: string, + input: CnProfileShape +): { + username: string; + realName: string; + ranking: number; + acSubmissionNum: Array<{ difficulty: string; count: number }>; + streak: number; + totalActiveDays: number; + submissionCalendar: string; +} { + const publicProfile = input.userProfilePublicProfile?.profile; + const accepted = input.userProfileUserQuestionProgress?.numAcceptedQuestions ?? []; + const countMap = new Map([ + ['All', 0], + ['Easy', 0], + ['Medium', 0], + ['Hard', 0], + ]); + + for (const item of accepted) { + const key = toTitleCaseDifficulty(item.difficulty); + const value = Number(item.count ?? 0); + countMap.set(key, value); + } + + const all = (countMap.get('Easy') ?? 0) + (countMap.get('Medium') ?? 0) + (countMap.get('Hard') ?? 0); + countMap.set('All', Math.max(countMap.get('All') ?? 0, all)); + + return { + username: publicProfile?.userSlug || username, + realName: publicProfile?.realName || username, + ranking: Number(input.userProfilePublicProfile?.siteRanking ?? 0), + acSubmissionNum: [ + { difficulty: 'All', count: countMap.get('All') ?? 0 }, + { difficulty: 'Easy', count: countMap.get('Easy') ?? 0 }, + { difficulty: 'Medium', count: countMap.get('Medium') ?? 0 }, + { difficulty: 'Hard', count: countMap.get('Hard') ?? 0 }, + ], + streak: 0, + totalActiveDays: 0, + submissionCalendar: '', + }; +} + +export function normalizeCnSkillStats(input: CnSkillShape): { + fundamental: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; + intermediate: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; + advanced: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; +} { + const topicScores = input.userProfilePublicProfile?.profile?.skillSet?.topicAreaScores ?? []; + const ordered = topicScores + .map((entry) => ({ + tagName: entry.topicArea?.name || 'Topic', + tagSlug: entry.topicArea?.slug || toSlug(entry.topicArea?.name || 'topic'), + problemsSolved: Math.max(0, Math.round(Number(entry.score ?? 0))), + })) + .sort((a, b) => b.problemsSolved - a.problemsSolved); + + if (ordered.length === 0) { + return { fundamental: [], intermediate: [], advanced: [] }; + } + + const third = Math.max(1, Math.ceil(ordered.length / 3)); + const advanced = ordered.slice(0, third); + const intermediate = ordered.slice(third, third * 2); + const fundamental = ordered.slice(third * 2); + + return { fundamental, intermediate, advanced }; +} diff --git a/src/api/adapters/index.ts b/src/api/adapters/index.ts new file mode 100644 index 0000000..013258e --- /dev/null +++ b/src/api/adapters/index.ts @@ -0,0 +1 @@ +export * from './cn.js'; diff --git a/src/api/client.ts b/src/api/client.ts index bbfb34e..583b1e2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,59 +2,120 @@ import got, { Got } from 'got'; import { z } from 'zod'; import type { + DailyChallenge, LeetCodeCredentials, + LeetCodeSite, Problem, ProblemDetail, ProblemListFilters, - DailyChallenge, - SubmissionResult, - TestResult, Submission, SubmissionDetails, + SubmissionResult, + TestResult, } from '../types.js'; import { - ProblemSchema, - ProblemDetailSchema, + CnProblemDetailSchema, + CnProblemListSchema, + CnDailyChallengeSchema, + CnSkillStatsSchema, + CnUserProfileSchema, DailyChallengeSchema, - SubmissionSchema, + ProblemDetailSchema, + ProblemSchema, SubmissionDetailsSchema, SubmissionResultSchema, + SubmissionSchema, TestResultSchema, - UserStatusSchema, UserProfileSchema, + UserStatusSchema, } from '../schemas/api.js'; +import { getQueryPack } from './query-resolver.js'; +import type { QueryPack } from './queries.global.js'; import { - PROBLEM_LIST_QUERY, - PROBLEM_DETAIL_QUERY, - RANDOM_PROBLEM_QUERY, - USER_STATUS_QUERY, - USER_PROFILE_QUERY, - SKILL_STATS_QUERY, - DAILY_CHALLENGE_QUERY, - SUBMISSION_LIST_QUERY, - SUBMISSION_DETAILS_QUERY, -} from './queries.js'; - -const LEETCODE_BASE_URL = 'https://leetcode.com'; + normalizeCnDailyChallenge, + normalizeCnProblemDetail, + normalizeCnProblemList, + normalizeCnSkillStats, + normalizeCnUserProfile, +} from './adapters/index.js'; + +const BASE_URLS: Record = { + 'leetcode.com': 'https://leetcode.com', + 'leetcode.cn': 'https://leetcode.cn', +}; + +type GraphQLOperation = + | 'USER_STATUS' + | 'PROBLEM_LIST' + | 'PROBLEM_DETAIL' + | 'DAILY_CHALLENGE' + | 'RANDOM_PROBLEM' + | 'USER_PROFILE' + | 'SKILL_STATS' + | 'SUBMISSION_LIST' + | 'SUBMISSION_DETAILS'; + +const OPERATION_LABEL: Record = { + USER_STATUS: 'user status', + PROBLEM_LIST: 'problem list', + PROBLEM_DETAIL: 'problem detail', + DAILY_CHALLENGE: 'daily challenge', + RANDOM_PROBLEM: 'random problem', + USER_PROFILE: 'user profile', + SKILL_STATS: 'skill stats', + SUBMISSION_LIST: 'submission list', + SUBMISSION_DETAILS: 'submission details', +}; + +function isSchemaMismatchError(message: string): boolean { + return /(cannot query field|unknown argument|unknown type|did you mean|validation error)/i.test(message); +} export class LeetCodeClient { private client: Got; private credentials: LeetCodeCredentials | null = null; + private site: LeetCodeSite; + private queries: QueryPack; - constructor() { - this.client = got.extend({ - prefixUrl: LEETCODE_BASE_URL, + constructor(site: LeetCodeSite = 'leetcode.com') { + this.site = site; + this.queries = getQueryPack(site); + this.client = this.createHttpClient(site); + } + + private createHttpClient(site: LeetCodeSite): Got { + const baseUrl = BASE_URLS[site]; + return got.extend({ + prefixUrl: baseUrl, headers: { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', - Origin: LEETCODE_BASE_URL, - Referer: `${LEETCODE_BASE_URL}/`, + Origin: baseUrl, + Referer: `${baseUrl}/`, }, timeout: { request: 30000 }, retry: { limit: 2 }, }); } + setSite(site: LeetCodeSite): void { + if (site === this.site) { + return; + } + + this.site = site; + this.queries = getQueryPack(site); + this.client = this.createHttpClient(site); + + if (this.credentials) { + this.setCredentials(this.credentials); + } + } + + getSite(): LeetCodeSite { + return this.site; + } + setCredentials(credentials: LeetCodeCredentials): void { this.credentials = credentials; this.client = this.client.extend({ @@ -69,24 +130,74 @@ export class LeetCodeClient { return this.credentials; } - private async graphql(query: string, variables: Record = {}): Promise { - const response = await this.client - .post('graphql', { - json: { query, variables }, - }) - .json<{ data: T; errors?: Array<{ message: string }> }>(); + private resolveGraphQLEndpoints(operation: GraphQLOperation): readonly string[] { + if (this.site === 'leetcode.cn') { + if (operation === 'SUBMISSION_LIST' || operation === 'SUBMISSION_DETAILS') { + return ['graphql/noj-go/', 'graphql/']; + } + return ['graphql/']; + } + + return ['graphql']; + } - if (response.errors?.length) { - throw new Error(`GraphQL Error: ${response.errors[0].message}`); + private formatGraphQLError(operation: GraphQLOperation, message: string): string { + const label = OPERATION_LABEL[operation]; + + if (this.site === 'leetcode.cn') { + if (isSchemaMismatchError(message)) { + return `LeetCode CN schema mismatch for ${label}: ${message}. If you intended Global LeetCode, run: leetcode config --site leetcode.com`; + } + return `LeetCode CN API error for ${label}: ${message}`; } - return response.data; + return `GraphQL Error (${label}): ${message}`; + } + + private async graphql( + operation: GraphQLOperation, + query: string, + variables: Record = {} + ): Promise { + const endpoints = this.resolveGraphQLEndpoints(operation); + let lastError: unknown = null; + + for (const endpoint of endpoints) { + try { + const response = await this.client + .post(endpoint, { + json: { query, variables }, + }) + .json<{ data?: T; errors?: Array<{ message: string }> }>(); + + if (response.errors?.length) { + const message = response.errors.map((entry) => entry.message).join('; '); + lastError = new Error(this.formatGraphQLError(operation, message)); + continue; + } + + if (response.data === undefined) { + lastError = new Error(this.formatGraphQLError(operation, 'Empty GraphQL response data')); + continue; + } + + return response.data; + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) { + throw lastError; + } + + throw new Error(`Failed to fetch ${OPERATION_LABEL[operation]}`); } async checkAuth(): Promise<{ isSignedIn: boolean; username: string | null }> { const data = await this.graphql<{ userStatus: { isSignedIn: boolean; username: string | null }; - }>(USER_STATUS_QUERY); + }>('USER_STATUS', this.queries.USER_STATUS_QUERY); const validated = UserStatusSchema.parse(data.userStatus); return validated; @@ -115,9 +226,15 @@ export class LeetCodeClient { (variables.filters as Record).searchKeywords = filters.searchKeywords; } + if (this.site === 'leetcode.cn') { + const data = await this.graphql('PROBLEM_LIST', this.queries.PROBLEM_LIST_QUERY, variables); + const validated = CnProblemListSchema.parse(data); + return normalizeCnProblemList(validated); + } + const data = await this.graphql<{ problemsetQuestionList: { total: number; questions: Problem[] }; - }>(PROBLEM_LIST_QUERY, variables); + }>('PROBLEM_LIST', this.queries.PROBLEM_LIST_QUERY, variables); const validatedProblems = z.array(ProblemSchema).parse(data.problemsetQuestionList.questions); @@ -128,7 +245,15 @@ export class LeetCodeClient { } async getProblem(titleSlug: string): Promise { - const data = await this.graphql<{ question: ProblemDetail }>(PROBLEM_DETAIL_QUERY, { + if (this.site === 'leetcode.cn') { + const data = await this.graphql('PROBLEM_DETAIL', this.queries.PROBLEM_DETAIL_QUERY, { + titleSlug, + }); + const validated = CnProblemDetailSchema.parse(data); + return normalizeCnProblemDetail(validated); + } + + const data = await this.graphql<{ question: ProblemDetail }>('PROBLEM_DETAIL', this.queries.PROBLEM_DETAIL_QUERY, { titleSlug, }); @@ -137,7 +262,26 @@ export class LeetCodeClient { } async getProblemById(id: string): Promise { - // First get the title slug from the problem list + if (this.site === 'leetcode.cn') { + const limit = 50; + let skip = 0; + let total = 0; + let problem: Problem | undefined; + + do { + const result = await this.getProblems({ searchKeywords: id, limit, skip }); + total = result.total; + problem = result.problems.find((p) => p.questionFrontendId === id); + skip += limit; + } while (!problem && skip < total); + + if (!problem) { + throw new Error(`Problem #${id} not found`); + } + + return this.getProblem(problem.titleSlug); + } + const { problems } = await this.getProblems({ searchKeywords: id, limit: 10 }); const problem = problems.find((p) => p.questionFrontendId === id); @@ -149,9 +293,15 @@ export class LeetCodeClient { } async getDailyChallenge(): Promise { + if (this.site === 'leetcode.cn') { + const data = await this.graphql('DAILY_CHALLENGE', this.queries.DAILY_CHALLENGE_QUERY); + const validated = CnDailyChallengeSchema.parse(data); + return normalizeCnDailyChallenge(validated); + } + const data = await this.graphql<{ activeDailyCodingChallengeQuestion: DailyChallenge; - }>(DAILY_CHALLENGE_QUERY); + }>('DAILY_CHALLENGE', this.queries.DAILY_CHALLENGE_QUERY); const validated = DailyChallengeSchema.parse(data.activeDailyCodingChallengeQuestion); return validated as DailyChallenge; @@ -172,7 +322,7 @@ export class LeetCodeClient { const data = await this.graphql<{ randomQuestion: { titleSlug: string }; - }>(RANDOM_PROBLEM_QUERY, variables); + }>('RANDOM_PROBLEM', this.queries.RANDOM_PROBLEM_QUERY, variables); const validated = z.object({ titleSlug: z.string() }).parse(data.randomQuestion); return validated.titleSlug; @@ -187,6 +337,14 @@ export class LeetCodeClient { totalActiveDays: number; submissionCalendar: string; }> { + if (this.site === 'leetcode.cn') { + const data = await this.graphql('USER_PROFILE', this.queries.USER_PROFILE_QUERY, { + username, + }); + const validated = CnUserProfileSchema.parse(data); + return normalizeCnUserProfile(username, validated); + } + const data = await this.graphql<{ matchedUser: { username: string; @@ -196,7 +354,7 @@ export class LeetCodeClient { }; userCalendar: { streak: number; totalActiveDays: number; submissionCalendar: string }; }; - }>(USER_PROFILE_QUERY, { username }); + }>('USER_PROFILE', this.queries.USER_PROFILE_QUERY, { username }); const user = data.matchedUser; const validated = UserProfileSchema.parse(user); @@ -217,6 +375,12 @@ export class LeetCodeClient { intermediate: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; advanced: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; }> { + if (this.site === 'leetcode.cn') { + const data = await this.graphql('SKILL_STATS', this.queries.SKILL_STATS_QUERY, { username }); + const validated = CnSkillStatsSchema.parse(data); + return normalizeCnSkillStats(validated); + } + const data = await this.graphql<{ matchedUser: { tagProblemCounts: { @@ -225,7 +389,7 @@ export class LeetCodeClient { advanced: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; }; }; - }>(SKILL_STATS_QUERY, { username }); + }>('SKILL_STATS', this.queries.SKILL_STATS_QUERY, { username }); return data.matchedUser.tagProblemCounts; } @@ -237,7 +401,7 @@ export class LeetCodeClient { ): Promise { const data = await this.graphql<{ questionSubmissionList: { submissions: Submission[] }; - }>(SUBMISSION_LIST_QUERY, { questionSlug: slug, limit, offset }); + }>('SUBMISSION_LIST', this.queries.SUBMISSION_LIST_QUERY, { questionSlug: slug, limit, offset }); const validated = z.array(SubmissionSchema).parse(data.questionSubmissionList.submissions); return validated; @@ -246,7 +410,7 @@ export class LeetCodeClient { async getSubmissionDetails(submissionId: number): Promise { const data = await this.graphql<{ submissionDetails: SubmissionDetails; - }>(SUBMISSION_DETAILS_QUERY, { submissionId }); + }>('SUBMISSION_DETAILS', this.queries.SUBMISSION_DETAILS_QUERY, { submissionId }); const validated = SubmissionDetailsSchema.parse(data.submissionDetails); return validated; @@ -259,7 +423,6 @@ export class LeetCodeClient { testcases: string, questionId: string ): Promise { - // Interpret endpoint for running tests const response = await this.client .post(`problems/${titleSlug}/interpret_solution/`, { json: { @@ -271,7 +434,6 @@ export class LeetCodeClient { }) .json<{ interpret_id: string }>(); - // Poll for results return this.pollSubmission(response.interpret_id, 'interpret', TestResultSchema); } @@ -291,7 +453,6 @@ export class LeetCodeClient { }) .json<{ submission_id: number }>(); - // Poll for results return this.pollSubmission( response.submission_id.toString(), 'submission', @@ -334,5 +495,4 @@ export class LeetCodeClient { } } -// Singleton instance export const leetcodeClient = new LeetCodeClient(); diff --git a/src/api/queries.cn.ts b/src/api/queries.cn.ts new file mode 100644 index 0000000..450628a --- /dev/null +++ b/src/api/queries.cn.ts @@ -0,0 +1,172 @@ +// GraphQL queries for leetcode.cn (China schema) +import { + DAILY_CHALLENGE_QUERY as DAILY_CHALLENGE_QUERY_GLOBAL, + RANDOM_PROBLEM_QUERY, + SUBMISSION_DETAILS_QUERY, + SUBMISSION_LIST_QUERY, + USER_STATUS_QUERY, + type QueryPack, +} from './queries.global.js'; + +export const PROBLEM_LIST_QUERY_CN = ` + query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + total + questions { + frontendQuestionId + title + titleCn + titleSlug + difficulty + paidOnly + acRate + status + topicTags { + name + nameTranslated + id + slug + } + } + } + } +`; + +export const PROBLEM_DETAIL_QUERY_CN = ` + query questionData($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionId + questionFrontendId + boundTopicId + title + titleSlug + content + translatedTitle + translatedContent + difficulty + isPaidOnly + acRate + likes + dislikes + isLiked + similarQuestions + exampleTestcases + contributors { + username + profileUrl + avatarUrl + } + status + topicTags { + name + slug + translatedName + } + companyTagStats + codeSnippets { + lang + langSlug + code + } + stats + hints + solution { + id + canSeeDetail + } + sampleTestCase + metaData + judgerAvailable + judgeType + mysqlSchemas + enableRunCode + enableTestMode + libraryUrl + note + } + } +`; + +export const DAILY_CHALLENGE_QUERY_CN = ` + query questionOfToday { + todayRecord { + date + userStatus + question { + questionId + frontendQuestionId: questionFrontendId + difficulty + title + titleCn: translatedTitle + titleSlug + paidOnly: isPaidOnly + acRate + status + topicTags { + name + nameTranslated: translatedName + id + } + } + lastSubmission { + id + } + } + } +`; + +export const USER_PROFILE_QUERY_CN = ` + query getUserProfile($username: String!) { + userProfileUserQuestionProgress(userSlug: $username) { + numAcceptedQuestions { + count + difficulty + } + } + userProfilePublicProfile(userSlug: $username) { + siteRanking + profile { + userSlug + realName + } + } + } +`; + +export const SKILL_STATS_QUERY_CN = ` + query skillStats($username: String!) { + userProfilePublicProfile(userSlug: $username) { + profile { + skillSet { + topicAreaScores { + score + topicArea { + name + slug + } + } + } + } + } + } +`; + +export const CN_QUERY_PACK: QueryPack = { + PROBLEM_LIST_QUERY: PROBLEM_LIST_QUERY_CN, + PROBLEM_DETAIL_QUERY: PROBLEM_DETAIL_QUERY_CN, + USER_STATUS_QUERY, + USER_PROFILE_QUERY: USER_PROFILE_QUERY_CN, + SKILL_STATS_QUERY: SKILL_STATS_QUERY_CN, + DAILY_CHALLENGE_QUERY: DAILY_CHALLENGE_QUERY_CN, + SUBMISSION_LIST_QUERY, + RANDOM_PROBLEM_QUERY, + SUBMISSION_DETAILS_QUERY, +}; + +// Export for testing or targeted use +export const CN_FALLBACK_DAILY_QUERY = DAILY_CHALLENGE_QUERY_GLOBAL; diff --git a/src/api/queries.global.ts b/src/api/queries.global.ts new file mode 100644 index 0000000..9a82665 --- /dev/null +++ b/src/api/queries.global.ts @@ -0,0 +1,201 @@ +// GraphQL queries for leetcode.com (global schema) + +export const PROBLEM_LIST_QUERY = ` + query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList: questionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + total: totalNum + questions: data { + questionId + questionFrontendId + title + titleSlug + difficulty + isPaidOnly + acRate + topicTags { + name + slug + } + status + } + } + } +`; + +export const PROBLEM_DETAIL_QUERY = ` + query questionData($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionId + questionFrontendId + title + titleSlug + content + difficulty + isPaidOnly + topicTags { + name + slug + } + codeSnippets { + lang + langSlug + code + } + sampleTestCase + exampleTestcases + hints + companyTags { + name + slug + } + stats + status + } + } +`; + +export const USER_STATUS_QUERY = ` + query globalData { + userStatus { + isSignedIn + username + } + } +`; + +export const USER_PROFILE_QUERY = ` + query userPublicProfile($username: String!) { + matchedUser(username: $username) { + username + profile { + realName + ranking + } + submitStatsGlobal { + acSubmissionNum { + difficulty + count + } + } + userCalendar { + streak + totalActiveDays + submissionCalendar + } + } + } +`; + +export const SKILL_STATS_QUERY = ` + query skillStats($username: String!) { + matchedUser(username: $username) { + tagProblemCounts { + fundamental { + tagName + tagSlug + problemsSolved + } + intermediate { + tagName + tagSlug + problemsSolved + } + advanced { + tagName + tagSlug + problemsSolved + } + } + } + } +`; + +export const DAILY_CHALLENGE_QUERY = ` + query questionOfToday { + activeDailyCodingChallengeQuestion { + date + link + question { + questionId + questionFrontendId + title + titleSlug + difficulty + isPaidOnly + acRate + topicTags { + name + slug + } + status + } + } + } +`; + +export const SUBMISSION_LIST_QUERY = ` + query submissionList($questionSlug: String!, $limit: Int, $offset: Int) { + questionSubmissionList( + questionSlug: $questionSlug + limit: $limit + offset: $offset + ) { + submissions { + id + statusDisplay + lang + runtime + timestamp + memory + } + } + } +`; + +export const RANDOM_PROBLEM_QUERY = ` + query randomQuestion($categorySlug: String, $filters: QuestionListFilterInput) { + randomQuestion(categorySlug: $categorySlug, filters: $filters) { + titleSlug + } + } +`; + +export const SUBMISSION_DETAILS_QUERY = ` + query submissionDetails($submissionId: Int!) { + submissionDetails(submissionId: $submissionId) { + code + lang { + name + } + } + } +`; + +export interface QueryPack { + PROBLEM_LIST_QUERY: string; + PROBLEM_DETAIL_QUERY: string; + USER_STATUS_QUERY: string; + USER_PROFILE_QUERY: string; + SKILL_STATS_QUERY: string; + DAILY_CHALLENGE_QUERY: string; + SUBMISSION_LIST_QUERY: string; + RANDOM_PROBLEM_QUERY: string; + SUBMISSION_DETAILS_QUERY: string; +} + +export const GLOBAL_QUERY_PACK: QueryPack = { + PROBLEM_LIST_QUERY, + PROBLEM_DETAIL_QUERY, + USER_STATUS_QUERY, + USER_PROFILE_QUERY, + SKILL_STATS_QUERY, + DAILY_CHALLENGE_QUERY, + SUBMISSION_LIST_QUERY, + RANDOM_PROBLEM_QUERY, + SUBMISSION_DETAILS_QUERY, +}; diff --git a/src/api/queries.ts b/src/api/queries.ts index a28b541..bec7a5a 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -1,177 +1,4 @@ -// GraphQL Queries for LeetCode API - -export const PROBLEM_LIST_QUERY = ` - query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { - problemsetQuestionList: questionList( - categorySlug: $categorySlug - limit: $limit - skip: $skip - filters: $filters - ) { - total: totalNum - questions: data { - questionId - questionFrontendId - title - titleSlug - difficulty - isPaidOnly - acRate - topicTags { - name - slug - } - status - } - } - } -`; - -export const PROBLEM_DETAIL_QUERY = ` - query questionData($titleSlug: String!) { - question(titleSlug: $titleSlug) { - questionId - questionFrontendId - title - titleSlug - content - difficulty - isPaidOnly - topicTags { - name - slug - } - codeSnippets { - lang - langSlug - code - } - sampleTestCase - exampleTestcases - hints - companyTags { - name - slug - } - stats - status - } - } -`; - -export const USER_STATUS_QUERY = ` - query globalData { - userStatus { - isSignedIn - username - } - } -`; - -export const USER_PROFILE_QUERY = ` - query userPublicProfile($username: String!) { - matchedUser(username: $username) { - username - profile { - realName - ranking - } - submitStatsGlobal { - acSubmissionNum { - difficulty - count - } - } - userCalendar { - streak - totalActiveDays - submissionCalendar - } - } - } -`; - -export const SKILL_STATS_QUERY = ` - query skillStats($username: String!) { - matchedUser(username: $username) { - tagProblemCounts { - fundamental { - tagName - tagSlug - problemsSolved - } - intermediate { - tagName - tagSlug - problemsSolved - } - advanced { - tagName - tagSlug - problemsSolved - } - } - } - } -`; - -export const DAILY_CHALLENGE_QUERY = ` - query questionOfToday { - activeDailyCodingChallengeQuestion { - date - link - question { - questionId - questionFrontendId - title - titleSlug - difficulty - isPaidOnly - acRate - topicTags { - name - slug - } - status - } - } - } -`; - -export const SUBMISSION_LIST_QUERY = ` - query submissionList($questionSlug: String!, $limit: Int, $offset: Int) { - questionSubmissionList( - questionSlug: $questionSlug - limit: $limit - offset: $offset - ) { - submissions { - id - statusDisplay - lang - runtime - timestamp - memory - } - } - } -`; - -export const RANDOM_PROBLEM_QUERY = ` - query randomQuestion($categorySlug: String, $filters: QuestionListFilterInput) { - randomQuestion(categorySlug: $categorySlug, filters: $filters) { - titleSlug - } - } -`; - -export const SUBMISSION_DETAILS_QUERY = ` - query submissionDetails($submissionId: Int!) { - submissionDetails(submissionId: $submissionId) { - code - lang { - name - } - } - } -`; +// Backward-compatible exports for global query constants. +export * from './queries.global.js'; +export * from './queries.cn.js'; +export * from './query-resolver.js'; diff --git a/src/api/query-resolver.ts b/src/api/query-resolver.ts new file mode 100644 index 0000000..7d9ca54 --- /dev/null +++ b/src/api/query-resolver.ts @@ -0,0 +1,10 @@ +import type { LeetCodeSite } from '../types.js'; +import { CN_QUERY_PACK } from './queries.cn.js'; +import { GLOBAL_QUERY_PACK, type QueryPack } from './queries.global.js'; + +export function getQueryPack(site: LeetCodeSite): QueryPack { + if (site === 'leetcode.cn') { + return CN_QUERY_PACK; + } + return GLOBAL_QUERY_PACK; +} diff --git a/src/commands/config.ts b/src/commands/config.ts index 66fcd9a..1bb5e24 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -4,24 +4,29 @@ import chalk from 'chalk'; import { config } from '../storage/config.js'; import { credentials } from '../storage/credentials.js'; import { SUPPORTED_LANGUAGES, normalizeLanguageInput } from '../utils/languages.js'; +import { + DEFAULT_LEETCODE_SITE, + getLeetCodeSiteLabel, + normalizeLeetCodeSiteInput, + SUPPORTED_LEETCODE_SITES, +} from '../utils/site.js'; interface ConfigOptions { lang?: string; editor?: string; workdir?: string; repo?: string | boolean; + site?: string; } export async function configCommand(options: ConfigOptions): Promise { const hasRepoOption = options.repo !== undefined; - // If no options provided, show current config - if (!options.lang && !options.editor && !options.workdir && !hasRepoOption) { + if (!options.lang && !options.editor && !options.workdir && !hasRepoOption && !options.site) { await showCurrentConfig(); return; } - // Set options if (options.lang) { const normalizedLanguage = normalizeLanguageInput(options.lang); if (!normalizedLanguage) { @@ -53,11 +58,46 @@ export async function configCommand(options: ConfigOptions): Promise { console.log(chalk.green(`✓ Repository URL set to ${options.repo}`)); } } + + if (options.site) { + const normalizedSite = normalizeLeetCodeSiteInput(options.site); + if (!normalizedSite) { + console.log(chalk.red(`Unsupported site: ${options.site}`)); + console.log(chalk.gray(`Supported: ${SUPPORTED_LEETCODE_SITES.join(', ')}`)); + return; + } + + const currentSite = config.getSite(); + if (currentSite !== normalizedSite) { + if (process.stdout.isTTY) { + const confirm = await inquirer.prompt([ + { + type: 'confirm', + name: 'proceed', + message: chalk.yellow(`Warning: Switching sites will clear your credentials. Proceed?`), + default: false, + }, + ]); + + if (!confirm.proceed) { + console.log(chalk.gray('Change aborted. Staying on ' + currentSite)); + return; + } + } + + await credentials.clear(); + console.log(chalk.yellow(`⚠️ Logged out — run "leetcode login" to authenticate with ${normalizedSite}.`)); + } + + config.setSite(normalizedSite); + console.log(chalk.green(`✓ Site set to ${normalizedSite}`)); + } } export async function configInteractiveCommand(): Promise { const currentConfig = config.getConfig(); const workspace = config.getActiveWorkspace(); + const currentSite = normalizeLeetCodeSiteInput(currentConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE; console.log(); console.log(chalk.bold.cyan(`📁 Configuring workspace: ${workspace}`)); @@ -71,6 +111,16 @@ export async function configInteractiveCommand(): Promise { choices: SUPPORTED_LANGUAGES, default: currentConfig.language, }, + { + type: 'list', + name: 'site', + message: 'LeetCode site:', + choices: SUPPORTED_LEETCODE_SITES.map((site) => ({ + name: getLeetCodeSiteLabel(site), + value: site, + })), + default: currentSite, + }, { type: 'input', name: 'editor', @@ -100,6 +150,29 @@ export async function configInteractiveCommand(): Promise { config.deleteRepo(); } + if (answers.site !== currentSite) { + const confirm = await inquirer.prompt([ + { + type: 'confirm', + name: 'proceed', + message: chalk.yellow(`Warning: Switching sites will clear your credentials. Proceed?`), + default: false, + }, + ]); + + if (confirm.proceed) { + config.setSite(answers.site); + await credentials.clear(); + console.log(); + console.log(chalk.yellow(`⚠️ Logged out — run "leetcode login" to authenticate with ${answers.site}.`)); + } else { + console.log(); + console.log(chalk.gray('Site change aborted. Staying on ' + currentSite)); + } + } else { + config.setSite(answers.site); + } + console.log(); console.log(chalk.green('✓ Configuration saved')); await showCurrentConfig(); @@ -109,6 +182,7 @@ async function showCurrentConfig(): Promise { const currentConfig = config.getConfig(); const creds = await credentials.get(); const workspace = config.getActiveWorkspace(); + const site = normalizeLeetCodeSiteInput(currentConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE; console.log(); console.log(chalk.bold.cyan(`📁 Workspace: ${workspace}`)); @@ -117,6 +191,7 @@ async function showCurrentConfig(): Promise { console.log(chalk.gray('Config file:'), config.getPath()); console.log(); console.log(chalk.gray('Language: '), chalk.white(currentConfig.language)); + console.log(chalk.gray('Site: '), chalk.white(site)); console.log(chalk.gray('Editor: '), chalk.white(currentConfig.editor ?? '(not set)')); console.log(chalk.gray('Work Dir: '), chalk.white(currentConfig.workDir)); console.log(chalk.gray('Repo URL: '), chalk.white(currentConfig.repo ?? '(not set)')); diff --git a/src/commands/login.ts b/src/commands/login.ts index 05d7e92..1fa2191 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -3,14 +3,58 @@ import ora from 'ora'; import chalk from 'chalk'; import { leetcodeClient } from '../api/client.js'; import * as credentialStore from '../storage/credentials.js'; -import type { LeetCodeCredentials } from '../types.js'; +import type { LeetCodeCredentials, LeetCodeSite } from '../types.js'; +import { config } from '../storage/config.js'; +import { + DEFAULT_LEETCODE_SITE, + getLeetCodeSiteLabel, + normalizeLeetCodeSiteInput, + SUPPORTED_LEETCODE_SITES, +} from '../utils/site.js'; +import { configureLeetCodeClientSite } from '../utils/auth.js'; const { credentials } = credentialStore; +function getConfiguredSite(): LeetCodeSite { + const configWithSite = config as unknown as { + getSite?: () => string; + getConfig?: () => { site?: string }; + }; + + const fromGetter = + typeof configWithSite.getSite === 'function' ? configWithSite.getSite() : undefined; + if (typeof fromGetter === 'string') { + return normalizeLeetCodeSiteInput(fromGetter) ?? DEFAULT_LEETCODE_SITE; + } + + const fromConfig = configWithSite.getConfig?.()?.site; + if (typeof fromConfig === 'string') { + return normalizeLeetCodeSiteInput(fromConfig) ?? DEFAULT_LEETCODE_SITE; + } + + return DEFAULT_LEETCODE_SITE; +} + +function persistConfiguredSite(site: LeetCodeSite): void { + const configWithSite = config as unknown as { + setSite?: (value: LeetCodeSite) => void; + }; + + if (typeof configWithSite.setSite === 'function') { + configWithSite.setSite(site); + } +} + +function getDomainForSite(site: LeetCodeSite): string { + return site === 'leetcode.cn' ? 'leetcode.cn' : 'leetcode.com'; +} + export async function loginCommand(): Promise { const currentStatus = await credentials.status(); if (currentStatus.mode === 'env-readonly') { + configureLeetCodeClientSite(); + const envCreds = await credentials.get(); if (!envCreds) { console.log(chalk.yellow('Environment credential mode is active but credentials are unavailable.')); @@ -53,10 +97,33 @@ export async function loginCommand(): Promise { console.log(chalk.cyan('LeetCode CLI Login')); console.log(chalk.gray('─'.repeat(40))); console.log(); - console.log(chalk.yellow('To login, you need to provide your LeetCode session cookies.')); - console.log(chalk.gray('1. Open https://leetcode.com in your browser')); + + const currentSite = getConfiguredSite(); + const siteAnswer = await inquirer.prompt([ + { + type: 'list', + name: 'site', + message: 'LeetCode site:', + choices: SUPPORTED_LEETCODE_SITES.map((site) => ({ + name: getLeetCodeSiteLabel(site), + value: site, + })), + default: currentSite, + }, + ]); + + const selectedSite = + normalizeLeetCodeSiteInput(String(siteAnswer.site ?? currentSite)) ?? DEFAULT_LEETCODE_SITE; + + persistConfiguredSite(selectedSite); + configureLeetCodeClientSite(selectedSite); + + const domain = getDomainForSite(selectedSite); + + console.log(chalk.yellow('To login, provide your LeetCode session cookies.')); + console.log(chalk.gray(`1. Open https://${domain} in your browser`)); console.log(chalk.gray('2. Login to your account')); - console.log(chalk.gray('3. Open DevTools (F12) → Application → Cookies → leetcode.com')); + console.log(chalk.gray(`3. Open DevTools (F12) → Application → Cookies → ${domain}`)); console.log(chalk.gray('4. Copy the values of LEETCODE_SESSION and csrftoken')); console.log(); @@ -101,7 +168,7 @@ export async function loginCommand(): Promise { return; } - spinner.succeed(`Logged in as ${chalk.green(username)}`); + spinner.succeed(`Logged in to ${chalk.cyan(selectedSite)} as ${chalk.green(username)}`); console.log(); if (result.source === 'keychain') { console.log(chalk.green('✓ Credentials saved to system keychain.')); @@ -128,6 +195,8 @@ export async function logoutCommand(): Promise { } export async function whoamiCommand(): Promise { + configureLeetCodeClientSite(); + const creds = await credentials.get(); if (!creds) { diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index c85ba81..b799765 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -15,6 +15,7 @@ export async function workspaceCurrentCommand(): Promise { console.log(chalk.gray('─'.repeat(40))); console.log(` workDir: ${chalk.white(config.workDir)}`); console.log(` lang: ${chalk.white(config.lang)}`); + console.log(` site: ${chalk.white(config.site ?? 'leetcode.com')}`); if (config.editor) console.log(` editor: ${chalk.white(config.editor)}`); if (config.syncRepo) console.log(` syncRepo: ${chalk.white(config.syncRepo)}`); console.log(); @@ -33,7 +34,7 @@ export async function workspaceListCommand(): Promise { const marker = ws === active ? chalk.green('▸ ') : ' '; const name = ws === active ? chalk.green.bold(ws) : ws; console.log(`${marker}${name}`); - console.log(` ${chalk.gray(config.workDir)}`); + console.log(` ${chalk.gray(config.workDir)} ${chalk.gray(`(${config.site ?? 'leetcode.com'})`)}`); } console.log(); } @@ -59,6 +60,7 @@ export async function workspaceCreateCommand( const config: WorkspaceConfig = { workDir, lang: 'typescript', + site: 'leetcode.com', }; const success = workspaceStorage.create(workspaceName, config); diff --git a/src/index.ts b/src/index.ts index 3014c14..0628314 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,7 @@ program .name('leetcode') .usage('[command] [options]') .description(chalk.bold.cyan('🔥 A modern LeetCode CLI built with TypeScript')) - .version('3.0.1', '-v, --version', 'Output the version number') + .version('3.1.0', '-v, --version', 'Output the version number') .helpOption('-h, --help', 'Display help for command') .addHelpText( 'after', @@ -89,11 +89,16 @@ program 'after', ` ${chalk.yellow('How to login:')} - 1. Open ${chalk.cyan('https://leetcode.com')} in your browser + 1. Run ${chalk.cyan('leetcode login')} and choose site (${chalk.cyan('leetcode.com')} or ${chalk.cyan('leetcode.cn')}) 2. Login to your account 3. Open Developer Tools (F12) → Application → Cookies 4. Copy values of ${chalk.green('LEETCODE_SESSION')} and ${chalk.green('csrftoken')} 5. Paste when prompted by this command + +${chalk.yellow('Storage Options:')} + ${chalk.cyan('1. System Keychain (Default):')} Securely stores credentials in your OS keychain. + ${chalk.cyan('2. Encrypted File:')} Export ${chalk.green('LEETCODECLI_CREDENTIAL_BACKEND=file')} (AES-256). + ${chalk.cyan('3. Environment Variables:')} Export ${chalk.green('LEETCODE_SESSION')} & ${chalk.green('LEETCODE_CSRF_TOKEN')}. ` ) .action(loginCommand); @@ -402,6 +407,7 @@ program .command('config') .description('View or set configuration') .option('-l, --lang ', 'Set default programming language') + .option('-s, --site ', 'Set LeetCode site (leetcode.com or leetcode.cn)') .option('-e, --editor ', 'Set editor command') .option('-w, --workdir ', 'Set working directory for solutions') .option('-r, --repo [url]', 'Set Git repository URL (omit value to clear)') @@ -412,6 +418,7 @@ program ${chalk.yellow('Examples:')} ${chalk.cyan('$ leetcode config')} View current config ${chalk.cyan('$ leetcode config -l python3')} Set language to Python + ${chalk.cyan('$ leetcode config -s leetcode.cn')} Use LeetCode China site ${chalk.cyan('$ leetcode config -e "code"')} Set editor to VS Code ${chalk.cyan('$ leetcode config -w ~/leetcode')} Set solutions folder ${chalk.cyan('$ leetcode config -r https://...')} Set git repository @@ -612,6 +619,7 @@ program.showHelpAfterError('(add --help for additional information)'); // Check for updates on startup (non-blocking) const shouldCheckUpdates = process.argv.length > 2 && + process.stdout.isTTY && !['update', 'changelog', '--version', '-v', '--help', '-h'].includes(process.argv[2]); if (shouldCheckUpdates) { diff --git a/src/schemas/api.ts b/src/schemas/api.ts index 497e1d3..6aa2d46 100644 --- a/src/schemas/api.ts +++ b/src/schemas/api.ts @@ -51,6 +51,107 @@ export const DailyChallengeSchema = z.object({ question: ProblemSchema, }); +export const CnDailyChallengeSchema = z.object({ + todayRecord: z + .array( + z.object({ + date: z.string().optional(), + link: z.string().optional(), + question: z + .object({ + questionId: z.union([z.string(), z.number()]).optional(), + frontendQuestionId: z.union([z.string(), z.number()]).optional(), + questionFrontendId: z.union([z.string(), z.number()]).optional(), + difficulty: z.string().optional(), + title: z.string().optional(), + titleCn: z.string().optional(), + titleSlug: z.string().optional(), + paidOnly: z.boolean().optional(), + isPaidOnly: z.boolean().optional(), + acRate: z.union([z.number(), z.string()]).optional(), + status: z.union([z.literal('ac'), z.literal('notac'), z.null()]).optional(), + topicTags: z + .array( + z.object({ + name: z.string().optional(), + nameTranslated: z.string().optional(), + id: z.union([z.string(), z.number()]).optional(), + }) + ) + .optional(), + }) + .optional(), + }) + ) + .optional(), +}); + +export const CnProblemListSchema = z.object({ + problemsetQuestionList: z.object({ + total: z.number(), + questions: z.array( + z.object({ + frontendQuestionId: z.union([z.string(), z.number()]).optional(), + title: z.string().optional(), + titleCn: z.string().optional(), + titleSlug: z.string().optional(), + difficulty: z.string().optional(), + paidOnly: z.boolean().optional(), + acRate: z.union([z.number(), z.string()]).optional(), + status: z.string().nullable().optional(), + topicTags: z + .array( + z.object({ + name: z.string().optional(), + nameTranslated: z.string().optional(), + id: z.union([z.string(), z.number()]).optional(), + slug: z.string().optional(), + }) + ) + .optional(), + }) + ), + }), +}); + +export const CnProblemDetailSchema = z.object({ + question: z.object({ + questionId: z.union([z.string(), z.number()]).optional(), + questionFrontendId: z.union([z.string(), z.number()]).optional(), + title: z.string().optional(), + translatedTitle: z.string().optional(), + titleSlug: z.string().optional(), + translatedContent: z.string().nullable().optional(), + difficulty: z.string().optional(), + isPaidOnly: z.boolean().optional(), + acRate: z.union([z.number(), z.string()]).optional(), + status: z.string().nullable().optional(), + topicTags: z + .array( + z.object({ + name: z.string().optional(), + slug: z.string().optional(), + translatedName: z.string().optional(), + }) + ) + .optional(), + codeSnippets: z + .array( + z.object({ + lang: z.string(), + langSlug: z.string(), + code: z.string(), + }) + ) + .nullable() + .optional(), + sampleTestCase: z.string().optional(), + exampleTestcases: z.string().optional(), + hints: z.array(z.string()).optional(), + stats: z.string().optional(), + }), +}); + // --- Submission Schemas --- export const SubmissionSchema = z.object({ @@ -124,6 +225,66 @@ export const UserProfileSchema = z.object({ }), }); +export const CnUserProfileSchema = z.object({ + userProfilePublicProfile: z + .object({ + siteRanking: z.number().optional(), + profile: z + .object({ + userSlug: z.string().optional(), + realName: z.string().optional(), + }) + .optional(), + }) + .nullable() + .optional(), + userProfileUserQuestionProgress: z + .object({ + numAcceptedQuestions: z + .array( + z.object({ + difficulty: z.string().optional(), + count: z.number().optional(), + }) + ) + .optional(), + }) + .nullable() + .optional(), +}); + +export const CnSkillStatsSchema = z.object({ + userProfilePublicProfile: z + .object({ + profile: z + .object({ + skillSet: z + .object({ + topicAreaScores: z + .array( + z.object({ + score: z.number().optional(), + topicArea: z + .object({ + name: z.string().optional(), + slug: z.string().optional(), + }) + .nullable() + .optional(), + }) + ) + .optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), +}); + // --- User Status --- export const UserStatusSchema = z.object({ @@ -140,3 +301,8 @@ export type ValidatedSubmission = z.infer; export type ValidatedSubmissionDetails = z.infer; export type ValidatedTestResult = z.infer; export type ValidatedSubmissionResult = z.infer; +export type ValidatedCnDailyChallenge = z.infer; +export type ValidatedCnProblemList = z.infer; +export type ValidatedCnProblemDetail = z.infer; +export type ValidatedCnUserProfile = z.infer; +export type ValidatedCnSkillStats = z.infer; diff --git a/src/storage/config.ts b/src/storage/config.ts index 88dc066..a577226 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -1,7 +1,8 @@ // Configuration management - delegates to workspace storage import { join } from 'path'; -import type { SupportedLanguage, UserConfig } from '../types.js'; +import type { LeetCodeSite, SupportedLanguage, UserConfig } from '../types.js'; import { workspaceStorage } from './workspaces.js'; +import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../utils/site.js'; export const config = { getConfig(): UserConfig { @@ -11,6 +12,7 @@ export const config = { editor: wsConfig.editor, workDir: wsConfig.workDir, repo: wsConfig.syncRepo, + site: normalizeLeetCodeSiteInput(wsConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE, }; }, @@ -30,6 +32,10 @@ export const config = { workspaceStorage.setConfig({ syncRepo: repo }); }, + setSite(site: LeetCodeSite): void { + workspaceStorage.setConfig({ site }); + }, + deleteRepo(): void { const wsConfig = workspaceStorage.getConfig(); delete wsConfig.syncRepo; @@ -52,6 +58,10 @@ export const config = { return workspaceStorage.getConfig().syncRepo; }, + getSite(): LeetCodeSite { + return normalizeLeetCodeSiteInput(workspaceStorage.getConfig().site ?? '') ?? DEFAULT_LEETCODE_SITE; + }, + getPath(): string { return join(workspaceStorage.getWorkspaceDir(), 'config.json'); }, diff --git a/src/storage/workspaces.ts b/src/storage/workspaces.ts index 69ee82c..0247cff 100644 --- a/src/storage/workspaces.ts +++ b/src/storage/workspaces.ts @@ -3,12 +3,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { isValidWorkspaceName } from '../utils/validation.js'; +import type { LeetCodeSite } from '../types.js'; export interface WorkspaceConfig { workDir: string; lang: string; editor?: string; syncRepo?: string; + site?: LeetCodeSite; } export interface WorkspaceRegistry { @@ -64,6 +66,7 @@ export const workspaceStorage = { this.create('default', { workDir: join(homedir(), 'leetcode'), lang: 'typescript', + site: 'leetcode.com', }); registry.workspaces = ['default']; registry.active = 'default'; @@ -219,6 +222,7 @@ export const workspaceStorage = { return { workDir: join(homedir(), 'leetcode'), lang: 'typescript', + site: 'leetcode.com', }; }, diff --git a/src/tui/commands/effects.ts b/src/tui/commands/effects.ts index 1444f6b..8434abe 100644 --- a/src/tui/commands/effects.ts +++ b/src/tui/commands/effects.ts @@ -16,6 +16,8 @@ import { existsSync } from 'fs'; import * as path from 'path'; import { requestExit } from '../runtime.js'; import got from 'got'; +import { configureLeetCodeClientSite } from '../../utils/auth.js'; +import { normalizeLeetCodeSiteInput } from '../../utils/site.js'; const RELEASES_URL = 'https://raw.githubusercontent.com/night-slayer18/leetcode-cli/main/docs/releases.md'; @@ -26,6 +28,8 @@ const { credentials } = credentialStore; let timerInterval: NodeJS.Timeout | null = null; export function executeCommand(cmd: Command, dispatch: Dispatch): void { + configureLeetCodeClientSite(); + switch (cmd.type) { case 'CMD_NONE': return; @@ -119,7 +123,7 @@ export function executeCommand(cmd: Command, dispatch: Dispatch): void { return; case 'CMD_LOGIN': - login(cmd.session, cmd.csrf, dispatch); + login(cmd.session, cmd.csrf, cmd.site, dispatch); return; default: @@ -242,6 +246,17 @@ function saveConfig(key: string, value: string): void { case 'language': config.setLanguage(value as any); break; + case 'site': { + const site = normalizeLeetCodeSiteInput(value); + if (site) { + const previousSite = config.getSite(); + config.setSite(site); + if (previousSite !== site) { + void credentials.clear(); + } + } + break; + } case 'editor': config.setEditor(value); break; @@ -543,7 +558,12 @@ async function logout(dispatch: Dispatch): Promise { dispatch({ type: 'AUTH_CHECK_COMPLETE', user: null }); } -async function login(session: string, csrf: string, dispatch: Dispatch): Promise { +async function login( + session: string, + csrf: string, + site: import('../../types.js').LeetCodeSite, + dispatch: Dispatch +): Promise { const credentialStatus = await credentials.status(); if (credentialStatus.mode === 'env-readonly') { dispatch({ @@ -562,6 +582,9 @@ async function login(session: string, csrf: string, dispatch: Dispatch): Promise return; } + config.setSite(site); + configureLeetCodeClientSite(site); + const creds = { session, csrfToken: csrf }; leetcodeClient.setCredentials(creds); diff --git a/src/tui/screens/config/index.ts b/src/tui/screens/config/index.ts index ff49241..b5a6299 100644 --- a/src/tui/screens/config/index.ts +++ b/src/tui/screens/config/index.ts @@ -1,6 +1,7 @@ import type { ConfigScreenModel, ConfigMsg, Command } from '../../types.js'; import { Cmd } from '../../types.js'; import { config } from '../../../storage/config.js'; +import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../utils/site.js'; type ConfigOption = ConfigScreenModel['options'][number]; @@ -12,6 +13,12 @@ function buildOptions(currentConfig: ReturnType): Confi description: 'Language for new solutions (example: typescript, python3, sql)', value: currentConfig.language, }, + { + id: 'site', + label: 'LeetCode Site', + description: 'Target site for API operations (leetcode.com or leetcode.cn)', + value: currentConfig.site || DEFAULT_LEETCODE_SITE, + }, { id: 'editor', label: 'Editor Command', @@ -45,6 +52,9 @@ function validate(option: ConfigOption, value: string): string | null { if (option.id === 'workdir' && !trimmed) { return 'Working directory cannot be empty'; } + if (option.id === 'site' && !normalizeLeetCodeSiteInput(trimmed)) { + return 'Site must be leetcode.com or leetcode.cn'; + } return null; } @@ -60,6 +70,8 @@ export function createInitialModel(): ConfigScreenModel { validationError: null, isDirty: false, config: currentConfig, + showSiteConfirm: false, + pendingSite: null, }; } @@ -77,29 +89,34 @@ function withSelectedOption(model: ConfigScreenModel, selectedOption: number): C isEditing: false, isDirty: false, validationError: null, + showSiteConfirm: false, }; } export function update(msg: ConfigMsg, model: ConfigScreenModel): [ConfigScreenModel, Command] { switch (msg.type) { case 'CONFIG_OPTION_UP': - if (model.isEditing) return [model, Cmd.none()]; + if (model.isEditing || model.showSiteConfirm) return [model, Cmd.none()]; return [withSelectedOption(model, model.selectedOption - 1), Cmd.none()]; case 'CONFIG_OPTION_DOWN': - if (model.isEditing) return [model, Cmd.none()]; + if (model.isEditing || model.showSiteConfirm) return [model, Cmd.none()]; return [withSelectedOption(model, model.selectedOption + 1), Cmd.none()]; case 'CONFIG_FOCUS_LIST': + if (model.showSiteConfirm) return [model, Cmd.none()]; return [{ ...model, paneFocus: 'list', isEditing: false, isDirty: false }, Cmd.none()]; case 'CONFIG_FOCUS_EDITOR': + if (model.showSiteConfirm) return [model, Cmd.none()]; return [{ ...model, paneFocus: 'editor' }, Cmd.none()]; case 'CONFIG_TOGGLE_FOCUS': + if (model.showSiteConfirm) return [model, Cmd.none()]; return [{ ...model, paneFocus: model.paneFocus === 'list' ? 'editor' : 'list' }, Cmd.none()]; case 'CONFIG_EDIT_START': { + if (model.showSiteConfirm) return [model, Cmd.none()]; const option = getSelectedOption(model); return [ { @@ -160,6 +177,18 @@ export function update(msg: ConfigMsg, model: ConfigScreenModel): [ConfigScreenM return [{ ...model, validationError: error }, Cmd.none()]; } + if (option.id === 'site' && model.draftValue !== option.value) { + return [ + { + ...model, + isEditing: false, + showSiteConfirm: true, + pendingSite: model.draftValue, + }, + Cmd.none() + ]; + } + const newOptions = [...model.options]; newOptions[model.selectedOption] = { ...option, value: model.draftValue }; @@ -175,6 +204,43 @@ export function update(msg: ConfigMsg, model: ConfigScreenModel): [ConfigScreenM ]; } + case 'CONFIG_SITE_CONFIRM': { + if (!model.showSiteConfirm || !model.pendingSite) return [model, Cmd.none()]; + + const option = getSelectedOption(model); + const newOptions = [...model.options]; + newOptions[model.selectedOption] = { ...option, value: model.pendingSite }; + + return [ + { + ...model, + options: newOptions, + showSiteConfirm: false, + pendingSite: null, + draftValue: model.pendingSite, + }, + Cmd.batch( + Cmd.saveConfig(option.id, model.pendingSite), + Cmd.logout() + ), + ]; + } + + case 'CONFIG_SITE_CANCEL': { + if (!model.showSiteConfirm) return [model, Cmd.none()]; + + const option = getSelectedOption(model); + return [ + { + ...model, + showSiteConfirm: false, + pendingSite: null, + draftValue: option.value, + }, + Cmd.none(), + ]; + } + default: return [model, Cmd.none()]; } diff --git a/src/tui/screens/config/view.ts b/src/tui/screens/config/view.ts index b389677..dce9bf4 100644 --- a/src/tui/screens/config/view.ts +++ b/src/tui/screens/config/view.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import type { ConfigScreenModel } from '../../types.js'; import { + keyHint, renderFooterHints, renderScreenTitle, renderSectionHeader, @@ -12,6 +13,7 @@ import { colors, borders, icons } from '../../theme.js'; const EXAMPLES: Record = { language: 'Example: typescript, python3, cpp, sql', + site: 'Example: leetcode.com or leetcode.cn', editor: 'Example: code, vim, nvim', workdir: 'Example: /Users/name/leetcode', repo: 'Example: https://github.com/user/leetcode.git', @@ -91,7 +93,7 @@ function renderOptionDetails(model: ConfigScreenModel, width: number): string[] lines.push(chalk.hex(colors.textMuted)(truncate(EXAMPLES[option.id] || '', paneWidth - 2))); lines.push(''); - if (option.id === 'language' || option.id === 'workdir') { + if (option.id === 'language' || option.id === 'workdir' || option.id === 'site') { lines.push(chalk.hex(colors.textMuted)('Validation: required')); } else { lines.push(chalk.hex(colors.textMuted)('Validation: optional')); @@ -109,6 +111,13 @@ function renderOptionDetails(model: ConfigScreenModel, width: number): string[] } function renderFooter(model: ConfigScreenModel, width: number): string[] { + if (model.showSiteConfirm) { + return [ + chalk.bgYellow.black(` Warning: Switching sites clears credentials. Proceed? `), + `${keyHint('Enter', 'Yes, logout & switch')} ${keyHint('Esc', 'Cancel')}`, + ]; + } + return renderFooterHints( [ { key: '↑/↓', label: model.paneFocus === 'list' ? 'Move option' : 'Move option' }, diff --git a/src/tui/screens/login/index.ts b/src/tui/screens/login/index.ts index e5e57e4..000ddcd 100644 --- a/src/tui/screens/login/index.ts +++ b/src/tui/screens/login/index.ts @@ -1,12 +1,17 @@ -import { AppModel, Cmd, Command, LoginScreenModel, LoginMsg } from '../../types.js'; -import { leetcodeClient } from '../../../api/client.js'; -import { credentials } from '../../../storage/credentials.js'; +import { Cmd, Command, LoginScreenModel, LoginMsg } from '../../types.js'; +import { config } from '../../../storage/config.js'; +import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../utils/site.js'; export { view } from './view.js'; +function getConfiguredSite() { + return normalizeLeetCodeSiteInput(config.getSite?.() ?? config.getConfig?.().site ?? '') ?? DEFAULT_LEETCODE_SITE; +} + export function init(): [LoginScreenModel, Command] { const model: LoginScreenModel = { step: 'instructions', + site: getConfiguredSite(), sessionToken: '', csrfToken: '', focusedField: 'session', @@ -17,6 +22,16 @@ export function init(): [LoginScreenModel, Command] { export function update(msg: LoginMsg, model: LoginScreenModel): [LoginScreenModel, Command] { switch (msg.type) { + case 'LOGIN_SWITCH_SITE': + return [ + { + ...model, + site: model.site === 'leetcode.cn' ? 'leetcode.com' : 'leetcode.cn', + error: null, + }, + Cmd.none(), + ]; + case 'LOGIN_SESSION_INPUT': return [{ ...model, sessionToken: msg.value }, Cmd.none()]; @@ -33,18 +48,24 @@ export function update(msg: LoginMsg, model: LoginScreenModel): [LoginScreenMode return [{ ...model, focusedField: msg.field }, Cmd.none()]; case 'LOGIN_BACK': + if (model.step === 'input' || model.step === 'error' || model.step === 'verifying') { + return [{ ...model, step: 'site', focusedField: 'session', error: null }, Cmd.none()]; + } return [{ ...model, step: 'instructions', focusedField: 'session', error: null }, Cmd.none()]; case 'LOGIN_SUBMIT': if (model.step === 'instructions') { - return [{ ...model, step: 'input', focusedField: 'session' }, Cmd.none()]; + return [{ ...model, step: 'site', focusedField: 'session', error: null }, Cmd.none()]; + } + if (model.step === 'site') { + return [{ ...model, step: 'input', focusedField: 'session', error: null }, Cmd.none()]; } if (!model.sessionToken || !model.csrfToken) { return [{ ...model, error: 'Both fields are required' }, Cmd.none()]; } return [ { ...model, step: 'verifying', error: null }, - Cmd.login(model.sessionToken, model.csrfToken), + Cmd.login(model.sessionToken, model.csrfToken, model.site), ]; case 'LOGIN_SUCCESS': diff --git a/src/tui/screens/login/view.ts b/src/tui/screens/login/view.ts index c70a2c2..4a865e9 100644 --- a/src/tui/screens/login/view.ts +++ b/src/tui/screens/login/view.ts @@ -32,13 +32,9 @@ export function view(model: LoginScreenModel, width: number, height: number): st const instructions = [ chalk.hex(colors.warning).bold('How to Login:'), '', - '1. Open https://leetcode.com in your browser', - '2. Login to your account', - '3. Open DevTools (F12) → Application → Cookies → leetcode.com', - '4. Copy the values of ' + - chalk.bold.cyan('LEETCODE_SESSION') + - ' and ' + - chalk.bold.cyan('csrftoken'), + '1. Continue to choose your LeetCode site', + '2. We will show the correct cookie domain on the next screen', + '3. Copy the values of ' + chalk.bold.cyan('LEETCODE_SESSION') + ' and ' + chalk.bold.cyan('csrftoken'), '', 'Default storage: system keychain.', 'Use LEETCODECLI_CREDENTIAL_BACKEND=file + LEETCODECLI_MASTER_KEY for encrypted file mode.', @@ -54,10 +50,34 @@ export function view(model: LoginScreenModel, width: number, height: number): st boxed.forEach((l) => contentLines.push(center(l, width))); contentLines.push(''); // Spacing - contentLines.push(center(keyHint('Enter', 'Continue to Login'), width)); + contentLines.push(center(keyHint('Enter', 'Choose Site'), width)); + } else if (model.step === 'site') { + const contentWidth = Math.max(28, Math.min(100, width - 4)); + const options = [ + model.site === 'leetcode.com' + ? chalk.hex(colors.primary)('> leetcode.com (Global)') + : ' leetcode.com (Global)', + model.site === 'leetcode.cn' + ? chalk.hex(colors.primary)('> leetcode.cn (China)') + : ' leetcode.cn (China)', + '', + chalk.gray('Use Up/Down/Tab to switch sites.'), + ]; + + const boxed = box(options, contentWidth, { + title: 'Choose Site', + borderColor: colors.primary, + padding: 1, + borderStyle: 'round', + }); + + boxed.forEach((l) => contentLines.push(center(l, width))); + contentLines.push(''); + contentLines.push(center(keyHint('Enter', 'Continue') + ' ' + keyHint('Esc', 'Back'), width)); } else if (model.step === 'input' || model.step === 'verifying' || model.step === 'error') { const contentWidth = Math.max(28, Math.min(100, width - 4)); const boxLines = []; + const domain = model.site === 'leetcode.cn' ? 'leetcode.cn' : 'leetcode.com'; const isSessionFocused = model.focusedField === 'session'; const isCsrfFocused = model.focusedField === 'csrf'; @@ -95,6 +115,8 @@ export function view(model: LoginScreenModel, width: number, height: number): st : ' ' + csrfVal + (model.csrfToken ? chalk.green(' ✔') : '') ); + boxLines.push(''); + boxLines.push(chalk.gray(`Cookie source: https://${domain} → DevTools → Application → Cookies → ${domain}`)); boxLines.push(''); if (model.error) { boxLines.push(center(chalk.red(model.error), contentWidth - 4)); diff --git a/src/tui/screens/workspace/index.ts b/src/tui/screens/workspace/index.ts index 0f98818..0df0549 100644 --- a/src/tui/screens/workspace/index.ts +++ b/src/tui/screens/workspace/index.ts @@ -250,6 +250,7 @@ export function update( const created = workspaceStorage.create(name, { workDir: '', lang: 'typescript', + site: 'leetcode.com', }); if (!created) { return [{ ...model, error: 'Failed to create workspace' }, Cmd.none()]; diff --git a/src/tui/types.ts b/src/tui/types.ts index 72ffadb..a0068ba 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -4,6 +4,7 @@ import type { ProblemListFilters as RootProblemListFilters, DailyChallenge, } from '../types.js'; +import type { LeetCodeSite } from '../types.js'; export type Problem = RootProblem; export type ProblemDetail = RootProblemDetail; @@ -126,6 +127,8 @@ export interface ConfigScreenModel { readonly validationError: string | null; readonly isDirty: boolean; readonly config: import('../types.js').UserConfig | null; + readonly showSiteConfirm: boolean; + readonly pendingSite: string | null; } export interface HelpScreenModel { @@ -133,7 +136,8 @@ export interface HelpScreenModel { } export interface LoginScreenModel { - readonly step: 'instructions' | 'input' | 'verifying' | 'success' | 'error'; + readonly step: 'instructions' | 'site' | 'input' | 'verifying' | 'success' | 'error'; + readonly site: LeetCodeSite; readonly sessionToken: string; readonly csrfToken: string; readonly focusedField: 'session' | 'csrf'; @@ -343,7 +347,9 @@ export type ConfigMsg = | { readonly type: 'CONFIG_EDIT_INPUT'; readonly char: string } | { readonly type: 'CONFIG_EDIT_BACKSPACE' } | { readonly type: 'CONFIG_EDIT_SAVE' } - | { readonly type: 'CONFIG_EDIT_CANCEL' }; + | { readonly type: 'CONFIG_EDIT_CANCEL' } + | { readonly type: 'CONFIG_SITE_CONFIRM' } + | { readonly type: 'CONFIG_SITE_CANCEL' }; export type HelpMsg = | { readonly type: 'HELP_SCROLL_UP' } @@ -356,6 +362,7 @@ export type HelpMsg = export type LoginMsg = | { readonly type: 'LOGIN_SESSION_INPUT'; readonly value: string } | { readonly type: 'LOGIN_CSRF_INPUT'; readonly value: string } + | { readonly type: 'LOGIN_SWITCH_SITE' } | { readonly type: 'LOGIN_SUBMIT' } | { readonly type: 'LOGIN_BACK' } | { readonly type: 'LOGIN_SWITCH_FOCUS' } @@ -438,7 +445,7 @@ export type Command = | { readonly type: 'CMD_SWITCH_WORKSPACE'; readonly name: string } | { readonly type: 'CMD_FETCH_CHANGELOG' } | { readonly type: 'CMD_LOGOUT' } - | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string }; + | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string; readonly site: LeetCodeSite }; export const Cmd = { none: (): Command => ({ type: 'CMD_NONE' }), @@ -482,7 +489,12 @@ export const Cmd = { switchWorkspace: (name: string): Command => ({ type: 'CMD_SWITCH_WORKSPACE', name }), fetchChangelog: (): Command => ({ type: 'CMD_FETCH_CHANGELOG' }), logout: (): Command => ({ type: 'CMD_LOGOUT' }), - login: (session: string, csrf: string): Command => ({ type: 'CMD_LOGIN', session, csrf }), + login: (session: string, csrf: string, site: LeetCodeSite): Command => ({ + type: 'CMD_LOGIN', + session, + csrf, + site, + }), }; export function createInitialModel(username?: string): AppModel { diff --git a/src/tui/update.ts b/src/tui/update.ts index e7948cd..2145957 100644 --- a/src/tui/update.ts +++ b/src/tui/update.ts @@ -127,13 +127,7 @@ export function update(msg: AppMsg, model: AppModel): [AppModel, Command] { user: null, screenState: { screen: 'login', - model: { - step: 'instructions', - sessionToken: '', - csrfToken: '', - focusedField: 'session', - error: null, - }, + model: LoginScreen.init()[0], }, needsRender: true, }, @@ -775,7 +769,13 @@ function handleGenericKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command const configModel = screenState.model as import('./types.js').ConfigScreenModel; let configMsg: import('./types.js').ConfigMsg | null = null; - if (configModel.isEditing) { + if (configModel.showSiteConfirm) { + if (key.name === 'y' || key.name === 'return' || key.name === 'enter') { + configMsg = { type: 'CONFIG_SITE_CONFIRM' }; + } else if (key.name === 'n' || key.name === 'escape') { + configMsg = { type: 'CONFIG_SITE_CANCEL' }; + } + } else if (configModel.isEditing) { if (key.name === 'escape') configMsg = { type: 'CONFIG_EDIT_CANCEL' }; else if (key.name === 'return' || key.name === 'enter') configMsg = { type: 'CONFIG_EDIT_SAVE' }; @@ -973,6 +973,14 @@ function handleLoginKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command] } else if (key.name === 'escape') { return [model, Cmd.exit()]; } + } else if (loginModel.step === 'site') { + if (key.name === 'escape') { + return updateLogin(model, { type: 'LOGIN_BACK' }); + } else if (key.name === 'tab' || key.name === 'down' || key.name === 'up' || key.name === 'left' || key.name === 'right') { + return updateLogin(model, { type: 'LOGIN_SWITCH_SITE' }); + } else if (key.name === 'return' || key.name === 'enter') { + return updateLogin(model, { type: 'LOGIN_SUBMIT' }); + } } else if (loginModel.step === 'input' || loginModel.step === 'error') { if (key.name === 'escape') { return updateLogin(model, { type: 'LOGIN_BACK' }); diff --git a/src/types.ts b/src/types.ts index 0cd7bf2..6695f19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,9 +145,12 @@ export type SupportedLanguage = | 'swift' | 'sql'; +export type LeetCodeSite = 'leetcode.com' | 'leetcode.cn'; + export interface UserConfig { language: SupportedLanguage; editor?: string; workDir: string; repo?: string; + site?: LeetCodeSite; } diff --git a/src/utils/auth.ts b/src/utils/auth.ts index a725e9e..7de628a 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,10 +1,49 @@ import chalk from 'chalk'; import { leetcodeClient } from '../api/client.js'; import * as credentialStore from '../storage/credentials.js'; +import { config } from '../storage/config.js'; +import type { LeetCodeSite } from '../types.js'; +import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from './site.js'; const { credentials } = credentialStore; +function readConfiguredSite(): LeetCodeSite { + const configWithSite = config as unknown as { + getSite?: () => string; + getConfig?: () => { site?: string }; + }; + + const fromGetter = + typeof configWithSite.getSite === 'function' ? configWithSite.getSite() : undefined; + + if (typeof fromGetter === 'string') { + return normalizeLeetCodeSiteInput(fromGetter) ?? DEFAULT_LEETCODE_SITE; + } + + const fromConfig = configWithSite.getConfig?.()?.site; + if (typeof fromConfig === 'string') { + return normalizeLeetCodeSiteInput(fromConfig) ?? DEFAULT_LEETCODE_SITE; + } + + return DEFAULT_LEETCODE_SITE; +} + +export function configureLeetCodeClientSite(site?: LeetCodeSite): LeetCodeSite { + const resolvedSite = site ?? readConfiguredSite(); + const siteAwareClient = leetcodeClient as unknown as { + setSite?: (nextSite: LeetCodeSite) => void; + }; + + if (typeof siteAwareClient.setSite === 'function') { + siteAwareClient.setSite(resolvedSite); + } + + return resolvedSite; +} + export async function validateSession(): Promise { + configureLeetCodeClientSite(); + const creds = await credentials.get(); if (!creds) return false; @@ -18,6 +57,8 @@ export async function validateSession(): Promise { } export async function requireAuth(): Promise<{ authorized: boolean; username?: string }> { + configureLeetCodeClientSite(); + const creds = await credentials.get(); if (!creds) { @@ -51,6 +92,8 @@ export async function requireAuth(): Promise<{ authorized: boolean; username?: s } export async function setupClientIfLoggedIn(): Promise { + configureLeetCodeClientSite(); + const creds = await credentials.get(); if (!creds) { @@ -62,6 +105,8 @@ export async function setupClientIfLoggedIn(): Promise { } export async function getCurrentUsername(): Promise { + configureLeetCodeClientSite(); + const creds = await credentials.get(); if (!creds) return null; diff --git a/src/utils/site.ts b/src/utils/site.ts new file mode 100644 index 0000000..ccb14ae --- /dev/null +++ b/src/utils/site.ts @@ -0,0 +1,26 @@ +import type { LeetCodeSite } from '../types.js'; + +export const DEFAULT_LEETCODE_SITE: LeetCodeSite = 'leetcode.com'; +export const SUPPORTED_LEETCODE_SITES: readonly LeetCodeSite[] = ['leetcode.com', 'leetcode.cn']; + +const SITE_ALIAS_MAP: Record = { + 'leetcode.com': 'leetcode.com', + leetcodecom: 'leetcode.com', + com: 'leetcode.com', + global: 'leetcode.com', + 'leetcode.cn': 'leetcode.cn', + leetcodecn: 'leetcode.cn', + cn: 'leetcode.cn', + china: 'leetcode.cn', +}; + +export function normalizeLeetCodeSiteInput(input: string): LeetCodeSite | null { + return SITE_ALIAS_MAP[input.trim().toLowerCase()] ?? null; +} + +export function getLeetCodeSiteLabel(site: LeetCodeSite): string { + if (site === 'leetcode.cn') { + return 'leetcode.cn (China)'; + } + return 'leetcode.com (Global)'; +}