2026-08-25 11:19:26 -04:00
|
|
|
#!/usr/bin/env bun
|
|
|
|
|
/**
|
2026-08-31 10:41:21 -04:00
|
|
|
* 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.
|
2026-08-26 15:25:35 -04:00
|
|
|
*
|
|
|
|
|
* 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
|
2026-08-31 10:41:21 -04:00
|
|
|
* 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.
|
2026-08-25 11:19:26 -04:00
|
|
|
*/
|
2026-08-31 10:41:21 -04:00
|
|
|
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";
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
const db = problemDb();
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
// ── problem index (24h TTL) ──────────────────────────────────────
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
if (db.indexStale()) {
|
2026-08-25 11:19:26 -04:00
|
|
|
const s = spinner();
|
|
|
|
|
s.start("Fetching problem index from leetcode.com");
|
2026-08-26 15:25:35 -04:00
|
|
|
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`);
|
2026-08-25 11:19:26 -04:00
|
|
|
}
|
2026-08-26 15:25:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 10:41:21 -04:00
|
|
|
// ── the three sections ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** What Enter scaffolds: this problem, into this bucket. */
|
|
|
|
|
interface Choice {
|
|
|
|
|
bucket: Bucket;
|
|
|
|
|
lc: number;
|
|
|
|
|
slug: string;
|
|
|
|
|
}
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-31 10:41:21 -04:00
|
|
|
interface Section {
|
|
|
|
|
label: string;
|
|
|
|
|
options: { value: Choice; label: string; hint: string }[];
|
2026-08-25 11:19:26 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-31 10:41:21 -04:00
|
|
|
const first = scanBucket(1);
|
|
|
|
|
const problems = db.all();
|
|
|
|
|
const titles = new Map(problems.map((p) => [p.id, p.title] as const));
|
2026-08-26 15:25:35 -04:00
|
|
|
db.close();
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-31 10:41:21 -04:00
|
|
|
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`,
|
|
|
|
|
})),
|
2026-08-25 11:19:26 -04:00
|
|
|
})),
|
2026-08-31 10:41:21 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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<Choice>({
|
|
|
|
|
// 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;
|
|
|
|
|
},
|
2026-08-25 11:19:26 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-31 10:41:21 -04:00
|
|
|
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 ──────────────────────────────
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-31 10:41:21 -04:00
|
|
|
// 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);
|