From 5137b536db34106a33774e865a0717dd3fd21ea6 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Thu, 27 Aug 2026 15:41:44 +0530 Subject: [PATCH 1/3] fix(sync): load credentials before API calls and fix SubmissionDetails schema types Signed-off-by: night-slayer18 --- src/commands/submissions.ts | 2 +- src/commands/sync.ts | 9 ++++++--- src/schemas/api.ts | 8 +++----- src/types.ts | 8 +++----- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/commands/submissions.ts b/src/commands/submissions.ts index 82d2db6..9c62a0a 100644 --- a/src/commands/submissions.ts +++ b/src/commands/submissions.ts @@ -92,7 +92,7 @@ export async function submissionsCommand( await mkdir(targetDir, { recursive: true }); } - const langSlug = details.lang.name.toLowerCase(); + const langSlug = (details.lang?.name ?? lastAC.lang).toLowerCase(); const supportedLang = LANG_SLUG_MAP[langSlug] ?? 'txt'; const ext = LANGUAGE_EXTENSIONS[supportedLang as SupportedLanguage] ?? langSlug; diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 8039bf8..ebbf1b5 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -5,6 +5,7 @@ import inquirer from 'inquirer'; import ora from 'ora'; import { config } from '../storage/config.js'; import { leetcodeClient } from '../api/client.js'; +import { setupClientIfLoggedIn } from '../utils/auth.js'; import path from 'path'; function sanitizeRepoName(name: string): string { @@ -167,6 +168,8 @@ async function setupRemote(workDir: string): Promise { } export async function syncCommand(): Promise { + await setupClientIfLoggedIn(); + const workDir = config.getWorkDir(); if (!existsSync(workDir)) { @@ -209,7 +212,7 @@ export async function syncCommand(): Promise { const lines = status.trim().split('\n'); const count = lines.length; const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); - + // Fetch stats for all changed solution files. // To prevent duplicate queries and rate limiting, we track processed title slugs. const solutionsList: string[] = []; @@ -227,7 +230,7 @@ export async function syncCommand(): Promise { const [, problemId, titleSlug] = match; if (!processedTitleSlugs.has(titleSlug)) { processedTitleSlugs.add(titleSlug); - + try { const submissions = await leetcodeClient.getSubmissionList(titleSlug, 5); const lastAC = submissions.find((s) => s.statusDisplay === 'Accepted'); @@ -237,7 +240,7 @@ export async function syncCommand(): Promise { const memoryStr = details.memoryDisplay || details.memory || 'N/A'; const runtimeBeats = details.runtimePercentile ? ` (beats ${details.runtimePercentile.toFixed(2)}%)` : ''; const memoryBeats = details.memoryPercentile ? ` (beats ${details.memoryPercentile.toFixed(2)}%)` : ''; - + solutionsList.push(`- [${problemId}. ${titleSlug}] Runtime: ${runtimeStr}${runtimeBeats}, Memory: ${memoryStr}${memoryBeats}`); } else { solutionsList.push(`- [${problemId}. ${titleSlug}] No accepted submission stats found`); diff --git a/src/schemas/api.ts b/src/schemas/api.ts index 57b76cd..84b6ba8 100644 --- a/src/schemas/api.ts +++ b/src/schemas/api.ts @@ -213,16 +213,14 @@ export const SubmissionSchema = z.object({ export const SubmissionDetailsSchema = z.object({ code: z.string(), - runtime: z.string().optional().nullable(), + runtime: z.union([z.number(), z.string()]).optional().nullable(), runtimeDisplay: z.string().optional().nullable(), runtimePercentile: z.number().optional().nullable(), - memory: z.string().optional().nullable(), + memory: z.union([z.number(), z.string()]).optional().nullable(), memoryDisplay: z.string().optional().nullable(), memoryPercentile: z.number().optional().nullable(), statusDisplay: z.string().optional().nullable(), - lang: z.object({ - name: z.string(), - }), + lang: z.object({ name: z.string() }).nullable().optional(), }); export const TestResultSchema = z.object({ diff --git a/src/types.ts b/src/types.ts index 8698ac9..b7bd0a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,16 +140,14 @@ export interface Submission { export interface SubmissionDetails { code: string; - runtime?: string | null; + runtime?: number | string | null; runtimeDisplay?: string | null; runtimePercentile?: number | null; - memory?: string | null; + memory?: number | string | null; memoryDisplay?: string | null; memoryPercentile?: number | null; statusDisplay?: string | null; - lang: { - name: string; - }; + lang?: { name: string } | null; } export interface ProblemListFilters { From 5bf9b2c56e6f8c8d0aa13429d4ea749999bd3213 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Thu, 27 Aug 2026 15:41:52 +0530 Subject: [PATCH 2/3] test(sync): add credential loading tests and regression for stats unavailable Signed-off-by: night-slayer18 --- src/__tests__/commands/sync.test.ts | 109 ++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/__tests__/commands/sync.test.ts b/src/__tests__/commands/sync.test.ts index ce03ca7..947c3fc 100644 --- a/src/__tests__/commands/sync.test.ts +++ b/src/__tests__/commands/sync.test.ts @@ -8,6 +8,11 @@ vi.mock('../../storage/credentials.js', () => ({ }, })); +vi.mock('../../utils/auth.js', () => ({ + setupClientIfLoggedIn: vi.fn().mockResolvedValue(true), + configureLeetCodeClientSite: vi.fn(), +})); + vi.mock('../../storage/config.js', () => ({ config: { getConfig: vi.fn(() => ({ @@ -74,6 +79,7 @@ vi.mock('inquirer', () => ({ import { syncCommand } from '../../commands/sync.js'; import { config } from '../../storage/config.js'; import { leetcodeClient } from '../../api/client.js'; +import { setupClientIfLoggedIn } from '../../utils/auth.js'; import { execSync, execFileSync } from 'child_process'; import { existsSync } from 'fs'; @@ -85,6 +91,7 @@ describe('Sync Command', () => { vi.mocked(config.getWorkDir).mockReturnValue('/tmp/leetcode'); vi.mocked(config.getRepo).mockReturnValue('https://github.com/user/repo.git'); vi.mocked(execSync).mockReturnValue(Buffer.from('')); + vi.mocked(setupClientIfLoggedIn).mockResolvedValue(true); vi.mocked(leetcodeClient.getSubmissionList).mockResolvedValue([ { id: '12345', statusDisplay: 'Accepted', lang: 'typescript', runtime: '56ms', timestamp: '1720000000', memory: '42.1 MB' }, ]); @@ -98,6 +105,108 @@ describe('Sync Command', () => { }); }); + // ─── Credential loading (the "Stats unavailable" root-cause fix) ────────── + + describe('credential loading', () => { + it('should call setupClientIfLoggedIn before any API call', async () => { + // Track call order + const order: string[] = []; + vi.mocked(setupClientIfLoggedIn).mockImplementation(async () => { + order.push('auth'); + return true; + }); + vi.mocked(leetcodeClient.getSubmissionList).mockImplementation(async () => { + order.push('api'); + return [{ id: '1', statusDisplay: 'Accepted', lang: 'typescript', runtime: '0ms', timestamp: '0', memory: '0 MB' }]; + }); + vi.mocked(execSync).mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd === 'git status --porcelain') { + return ' M Easy/Array/1.two-sum.ts\n'; + } + return Buffer.from(''); + }); + + await syncCommand(); + + expect(order[0]).toBe('auth'); + expect(order[1]).toBe('api'); + }); + + it('should call setupClientIfLoggedIn even when there are no changes to sync', async () => { + vi.mocked(execSync).mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd === 'git status --porcelain') { + return ''; + } + return Buffer.from(''); + }); + + await syncCommand(); + + expect(setupClientIfLoggedIn).toHaveBeenCalledOnce(); + }); + + it('should call setupClientIfLoggedIn even when work directory does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + await syncCommand(); + + expect(setupClientIfLoggedIn).toHaveBeenCalledOnce(); + }); + + it('should still fetch stats and commit when setupClientIfLoggedIn returns false (not logged in)', async () => { + // Even if credentials aren't found, sync should proceed — the API will + // fall back to "Stats unavailable" rather than crashing the whole command. + vi.mocked(setupClientIfLoggedIn).mockResolvedValue(false); + vi.mocked(leetcodeClient.getSubmissionList).mockRejectedValue(new Error('Unauthorized')); + vi.mocked(execSync).mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd === 'git status --porcelain') { + return ' M Easy/Array/1.two-sum.ts\n'; + } + return Buffer.from(''); + }); + + await syncCommand(); + + // Should still commit, just with "Stats unavailable" body + expect(execFileSync).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['commit', '-m', expect.stringContaining('Sync:'), '-m', expect.stringContaining('Stats unavailable')]), + expect.any(Object) + ); + }); + + it('should produce correct stats in commit body when credentials are loaded', async () => { + // This is the regression test for the original bug: + // before the fix, stats were always "Stats unavailable" because credentials + // were never loaded. With the fix, the commit body should contain real stats. + vi.mocked(execSync).mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd === 'git status --porcelain') { + return ' M Easy/Array/20.valid-parentheses.ts\n M Easy/String/125.valid-palindrome.ts\n'; + } + return Buffer.from(''); + }); + + await syncCommand(); + + expect(setupClientIfLoggedIn).toHaveBeenCalledOnce(); + expect(leetcodeClient.getSubmissionList).toHaveBeenCalledTimes(2); + + const commitCall = vi.mocked(execFileSync).mock.calls.find( + (call) => call[0] === 'git' && Array.isArray(call[1]) && (call[1] as string[]).includes('commit') + ); + const args = commitCall?.[1] as string[]; + const bodyIdx = args.lastIndexOf('-m'); + const body = args[bodyIdx + 1]; + + // Both problems should have real stats, NOT "Stats unavailable" + expect(body).toContain('valid-parentheses'); + expect(body).toContain('valid-palindrome'); + expect(body).not.toContain('Stats unavailable'); + expect(body).toContain('Runtime: 56ms (beats 84.50%)'); + expect(body).toContain('Memory: 42.1MB (beats 76.20%)'); + }); + }); + // ─── Early exit guards ──────────────────────────────────────────────────── describe('early exit guards', () => { From 7b7c44ca2ec964cf1092aff71e78f0bf647affc3 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Thu, 27 Aug 2026 15:41:59 +0530 Subject: [PATCH 3/3] chore(release): bump version to 3.5.1 and add release script Signed-off-by: night-slayer18 --- docs/releases.md | 30 +++++++++++++ package-lock.json | 4 +- package.json | 5 ++- release.mjs | 111 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 +- 5 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 release.mjs diff --git a/docs/releases.md b/docs/releases.md index 6b88a0c..13fd00d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,35 @@ # Release Notes +## v3.5.1 + +> **Release Date**: 2026-08-27 +> **Focus**: Sync Stats Hotfix — credential loading + API schema fix + +### 🐛 Bug Fixes + +#### `leetcode sync` always showed "Stats unavailable" + +Two root causes were identified and fixed via live API probe: + +- **Missing credential loading**: `syncCommand()` called the LeetCode submission API without loading stored session credentials onto the HTTP client. Git authentication (SSH / PAT) is independent of the LeetCode session, so the command could push to GitHub while still being unauthenticated against the LeetCode API. Fixed by calling `setupClientIfLoggedIn()` at the start of `syncCommand()`. + +- **Schema type mismatch**: `SubmissionDetailsSchema` declared `runtime` and `memory` as `string`, but the LeetCode API returns them as `number` (raw milliseconds / bytes). Zod rejected every response, causing the `catch` block to fire for every user on every problem, regardless of auth status. Fixed by accepting `number | string` for both fields and making `lang` nullable to handle old submissions. + +#### Files changed +- `src/commands/sync.ts` — `setupClientIfLoggedIn()` called at entry +- `src/schemas/api.ts` — `SubmissionDetailsSchema` corrected field types +- `src/types.ts` — `SubmissionDetails` interface updated to match + +### 🧪 Testing + +- Added `credential loading` test suite (5 tests) to `sync.test.ts`: + - Verifies `setupClientIfLoggedIn` is called before any API call + - Verifies auth runs even when there are no changes or work directory is missing + - Regression test using the exact problem slugs (`valid-parentheses`, `valid-palindrome`) from the original bug report — asserts commit body contains real stats, not "Stats unavailable" +- Total sync tests: 20 (was 15) + +--- + ## v3.5.0 > **Release Date**: 2026-08-14 diff --git a/package-lock.json b/package-lock.json index 945374b..132063a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@night-slayer18/leetcode-cli", - "version": "3.5.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@night-slayer18/leetcode-cli", - "version": "3.5.0", + "version": "3.5.1", "license": "Apache-2.0", "dependencies": { "@supabase/supabase-js": "^2.90.1", diff --git a/package.json b/package.json index 8ab5076..f63d44e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@night-slayer18/leetcode-cli", - "version": "3.5.0", + "version": "3.5.1", "description": "A modern LeetCode CLI built with TypeScript", "type": "module", "main": "dist/index.js", @@ -21,7 +21,8 @@ "lint:fix": "eslint src --ext .ts --fix", "typecheck": "tsc --noEmit", "format": "prettier --write \"**/*.{ts,json,md,yml,yaml}\"", - "format:check": "prettier --check \"**/*.{ts,json,md,yml,yaml}\"" + "format:check": "prettier --check \"**/*.{ts,json,md,yml,yaml}\"", + "release": "node release.mjs" }, "keywords": [ "leetcode", diff --git a/release.mjs b/release.mjs new file mode 100644 index 0000000..3f725a0 --- /dev/null +++ b/release.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * release.mjs — prepares a release: + * + * 1. Bumps version in package.json and src/index.ts + * 2. Runs npm install (updates package-lock.json) + * 3. Typecheck → lint → build → tests + * + * Usage: + * npm run release patch # 3.5.1 → 3.5.2 + * npm run release minor # 3.5.1 → 3.6.0 + * npm run release major # 3.5.1 → 4.0.0 + * npm run release 3.6.0 # explicit version + */ + +import { execSync } from 'child_process'; +import { readFileSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; + +const ROOT = new URL('.', import.meta.url).pathname; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function run(cmd) { + console.log(` $ ${cmd}`); + execSync(cmd, { stdio: 'inherit', cwd: ROOT }); +} + +function bump(current, type) { + const [major, minor, patch] = current.split('.').map(Number); + if (type === 'major') return `${major + 1}.0.0`; + if (type === 'minor') return `${major}.${minor + 1}.0`; + if (type === 'patch') return `${major}.${minor}.${patch + 1}`; + if (/^\d+\.\d+\.\d+$/.test(type)) return type; + throw new Error(`Unknown bump type: "${type}". Use patch / minor / major / x.y.z`); +} + +function readJson(file) { + return JSON.parse(readFileSync(resolve(ROOT, file), 'utf-8')); +} + +function writeJson(file, data) { + writeFileSync(resolve(ROOT, file), JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +function readText(file) { + return readFileSync(resolve(ROOT, file), 'utf-8'); +} + +function writeText(file, content) { + writeFileSync(resolve(ROOT, file), content, 'utf-8'); +} + +// ── main ────────────────────────────────────────────────────────────────────── + +const bumpType = process.argv[2]; +if (!bumpType) { + console.error('Usage: npm run release '); + process.exit(1); +} + +// 1. Determine new version +const pkg = readJson('package.json'); +const currentVersion = pkg.version; +const newVersion = bump(currentVersion, bumpType); + +if (newVersion === currentVersion) { + console.error(`❌ New version (${newVersion}) is the same as current (${currentVersion})`); + process.exit(1); +} + +console.log(`\n🔖 ${currentVersion} → ${newVersion}\n`); + +// 1a. package.json +pkg.version = newVersion; +writeJson('package.json', pkg); +console.log(' ✓ package.json'); + +// 1b. src/index.ts +const indexPath = 'src/index.ts'; +const indexContent = readText(indexPath); +const updatedIndex = indexContent.replace(/\.version\('[\d.]+',/, `.version('${newVersion}',`); +if (updatedIndex === indexContent) { + console.error('❌ Could not find .version(...) in src/index.ts — update it manually.'); + process.exit(1); +} +writeText(indexPath, updatedIndex); +console.log(' ✓ src/index.ts'); + +// 2. npm install → refreshes package-lock.json +console.log('\n📦 npm install…'); +run('npm install'); + +// 3. Quality gates +console.log('\n🔎 typecheck…'); +run('npm run typecheck'); + +console.log('\n🔍 lint…'); +run('npm run lint'); + +console.log('\n🏗️ build…'); +run('npm run build'); + +console.log('\n🧪 tests…'); +run('npm test'); + +console.log(`\n✅ v${newVersion} is ready to release!\n`); +console.log('Remaining steps:'); +console.log(` 1. Update docs/releases.md with the v${newVersion} changelog`); +console.log(` 2. git add -A && git commit -s -m "chore(release): bump version to ${newVersion}"`); +console.log(` 3. git tag v${newVersion} && git push && git push --tags`); diff --git a/src/index.ts b/src/index.ts index 7f185d4..cdd5fee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,7 +71,7 @@ program .name('leetcode') .usage('[command] [options]') .description(chalk.bold.cyan('🔥 A modern LeetCode CLI built with TypeScript')) - .version('3.5.0', '-v, --version', 'Output the version number') + .version('3.5.1', '-v, --version', 'Output the version number') .helpOption('-h, --help', 'Display help for command') .addHelpText( 'after',