#!/usr/bin/env bun /** * Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three * work/ buckets (see work.ts): a first solve under work/1, or the 3-day and * 7-day blind re-solves under work/3 and work/7. Tab cycles the sections. * * The problem index lives in the local SQLite cache (see db.ts), refreshed at * most once per 24h; campaign problem descriptions from README.md are * backfilled into the same db on the way. The new-problem section hides * anything that already has a numbered file under work/1 — re-picking it would * only clobber the existing scaffold — and each review section hides whatever * its own bucket already holds, so a finished re-solve leaves the list. */ import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { styleText } from "node:util"; import { etDate } from "../api/src/srs.ts"; import { fetchDescription, problemDb, readmeSlugs } from "./db"; import { ROOT, WINDOWS, bucketDir, reviewQueues, scanBucket, type Bucket } from "./work"; const db = problemDb(); // ── problem index (24h TTL) ────────────────────────────────────── if (db.indexStale()) { const s = spinner(); s.start("Fetching problem index from leetcode.com"); try { s.stop(`Indexed ${await db.refreshIndex()} problems`); } catch (err) { if (db.all().length === 0) { s.error(`Fetch failed: ${err}`); process.exit(1); } s.stop(`Index refresh failed (${err}) — using cached copy`); } } // ── description backfill for the campaign problems in README.md ── const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text())); if (missing.length > 0) { const s = spinner(); s.start(`Caching ${missing.length} problem descriptions`); let fetched = 0; let failed = 0; await Promise.all( Array.from({ length: 6 }, async () => { for (let slug = missing.shift(); slug; slug = missing.shift()) { try { const html = await fetchDescription(slug); if (html !== null) db.saveDescription(slug, html); s.message(`Caching problem descriptions (${++fetched})`); } catch { failed++; // left NULL — retried on the next run } } }), ); s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`); } // ── the three sections ─────────────────────────────────────────── /** What Enter scaffolds: this problem, into this bucket. */ interface Choice { bucket: Bucket; lc: number; slug: string; } interface Section { label: string; options: { value: Choice; label: string; hint: string }[]; } const first = scanBucket(1); const problems = db.all(); const titles = new Map(problems.map((p) => [p.id, p.title] as const)); db.close(); const queues = reviewQueues(etDate(new Date())); const sections: Section[] = [ { label: "new", options: problems .filter((p) => !first.has(p.id)) .map((p) => ({ value: { bucket: 1 as Bucket, lc: p.id, slug: p.slug }, label: `${p.id}. ${p.title}`, hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty, })), }, ...WINDOWS.map((window) => ({ label: `${window}-day`, options: queues.get(window)!.map((owed) => ({ value: { bucket: window as Bucket, lc: owed.lc, slug: owed.slug }, label: `${owed.lc}. ${titles.get(owed.lc) ?? owed.slug}`, hint: `${owed.difficulty} · ${owed.category} · solved ${owed.solved}, ${owed.days}d ago`, })), })), ]; let active = 0; /** * The slice of clack's AutocompletePrompt this picker drives. @clack/prompts * re-exports neither the class nor its type, and apps/cli does not depend on * @clack/core directly, so the contract is spelled out structurally. */ interface Autocomplete { userInput: string; on(event: "key", cb: (char: string | undefined, key: { name?: string }) => void): void; emit(event: "userInput", input: string): void; } /** Search text that cannot match any option — see refresh() below. */ const NO_MATCH = "\u0000"; let hooked = false; /** * Bind Tab to the section switch, once per prompt. * * clack has no hook for extra keys, but it calls `options` with the prompt as * `this`, which is all a subscription needs. Switching sections also has to * re-filter the list, and the prompt only recomputes `filteredOptions` when * the search text *changes* — hence the pair of userInput events: an * unmatchable string, then the real one, which recomputes against the new * section, resets the cursor onto its first match and leaves the typed query * on screen untouched. clack repaints as soon as the key handlers return. * * No `placeholder` is passed to the prompt on purpose: clack's own Tab binding * (fill the input with the placeholder) is inert without one. */ function hookTab(prompt: Autocomplete): void { if (hooked) return; hooked = true; prompt.on("key", (_char, key) => { if (key.name !== "tab") return; active = (active + 1) % sections.length; prompt.emit("userInput", NO_MATCH); prompt.emit("userInput", prompt.userInput); }); } // Widened, not asserted: clack resolves `undefined` when Enter lands on an // empty list, which its own types do not admit. const picked: Choice | symbol | undefined = await autocomplete({ // Getter, not a string: clack reads `message` on every repaint, so the tab // bar tracks the active section. get message(): string { const bar = sections .map((section, i) => { const text = `${section.label} (${section.options.length})`; return i === active ? styleText("cyan", `▸ ${text}`) : styleText("dim", text); }) .join(styleText("dim", " · ")); return `Pick a problem ${bar} ${styleText("dim", "Tab: next section")}`; }, maxItems: 12, options(this: Autocomplete) { hookTab(this); return sections[active]!.options; }, filter: (search, option) => { const haystack = option.label!.toLowerCase(); return search .toLowerCase() .split(/\s+/) .every((token) => haystack.includes(token)); }, }); if (isCancel(picked)) { cancel("Nothing picked."); process.exit(0); } if (picked === undefined) { // Enter on an empty section: nothing was focused, so there is nothing to do. cancel(`Nothing owed in the ${sections[active]!.label} section.`); process.exit(0); } // ── scaffold into the picked bucket ────────────────────────────── // The active workspace names the config file; everything else about it belongs // to leetcode-cli. const registry = join(homedir(), ".leetcode", "workspaces.json"); let workspace = "default"; if (existsSync(registry)) { const parsed: unknown = JSON.parse(readFileSync(registry, "utf8")); if (parsed && typeof parsed === "object" && "active" in parsed && typeof parsed.active === "string") { workspace = parsed.active || workspace; } } const configPath = join(homedir(), ".leetcode", "workspaces", workspace, "config.json"); if (!existsSync(configPath)) { log.error(`No leetcode-cli config at ${configPath} — run \`leetcode config -i\` once.`); process.exit(1); } /** * Point leetcode-cli at one bucket. It reads its output directory from the * active workspace config and from nowhere else — no flag, no environment * override — so `workDir` is the whole handover. * * Each write MERGES the file as it is on disk at that moment; a saved copy is * never played back. The CLI owns this file too (theme, language, whatever a * `leetcode login` adds), and replaying stale bytes over it would silently * undo what it wrote while the child ran. After the pick, `workDir` is set to * work/1 instead of to whatever it was before: the first-solve bucket is the * one resting value that is always right, so even a killed run leaves a bare * `leetcode pick` on-layout. */ function setWorkDir(target: string): void { const raw: unknown = JSON.parse(readFileSync(configPath, "utf8")); if (raw === null || typeof raw !== "object") { log.error(`${configPath} holds no JSON object — run \`leetcode config -i\` to rewrite it.`); process.exit(1); } // 2-space JSON with a trailing newline: leetcode-cli's own format. writeFileSync(configPath, `${JSON.stringify({ ...raw, workDir: target }, null, 2)}\n`); } const dir = bucketDir(picked.bucket); mkdirSync(dir, { recursive: true }); setWorkDir(dir); let code: number; try { const proc = Bun.spawn( // bun installs workspace-dep bins into this package's node_modules, not the root's [join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug], { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, ); code = await proc.exited; } finally { setWorkDir(bucketDir(1)); } // leetcode-cli exits 0 even when it wrote nothing (an expired session only // prints a warning), so the scaffold itself is the acceptance test: without a // file for this problem in the bucket, the pick failed. if (!scanBucket(picked.bucket).has(picked.lc)) { log.error( `leetcode-cli scaffolded nothing for LC ${picked.lc} under work/${picked.bucket}/.\n` + "If it printed “Session expired”, run `leetcode login` and pick again.", ); process.exit(code === 0 ? 1 : code); } process.exit(code);