sunnah 1.3.6 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +258 -254
  3. package/bin/index.js +1022 -1604
  4. package/books/jami-al-tirmidhi/README.md +201 -0
  5. package/books/jami-al-tirmidhi/bin/index.js +165 -0
  6. package/books/jami-al-tirmidhi/examples/express/server.js +7 -0
  7. package/books/jami-al-tirmidhi/examples/node-commonjs/example.js +7 -0
  8. package/books/jami-al-tirmidhi/examples/node-esm/example.mjs +6 -0
  9. package/books/jami-al-tirmidhi/examples/react/HadithExample.jsx +17 -0
  10. package/books/jami-al-tirmidhi/package.json +58 -0
  11. package/books/jami-al-tirmidhi/src/index.cjs +52 -0
  12. package/books/jami-al-tirmidhi/src/index.js +35 -0
  13. package/books/jami-al-tirmidhi/src/index.node.js +18 -0
  14. package/books/jami-al-tirmidhi/types/index.d.ts +28 -0
  15. package/books/sahih-al-bukhari/LICENSE +661 -0
  16. package/books/sahih-al-bukhari/README.md +551 -0
  17. package/books/sahih-al-bukhari/bin/index.js +306 -0
  18. package/books/sahih-al-bukhari/data/bukhari.json.gz +0 -0
  19. package/books/sahih-al-bukhari/examples/express/server.js +49 -0
  20. package/books/sahih-al-bukhari/examples/node-commonjs/example.js +21 -0
  21. package/books/sahih-al-bukhari/examples/node-esm/example.mjs +24 -0
  22. package/books/sahih-al-bukhari/examples/react/HadithExample.jsx +73 -0
  23. package/books/sahih-al-bukhari/package.json +54 -0
  24. package/books/sahih-al-bukhari/src/index.cjs +55 -0
  25. package/books/sahih-al-bukhari/src/index.js +35 -0
  26. package/books/sahih-al-bukhari/src/index.node.js +21 -0
  27. package/books/sahih-al-bukhari/types/index.d.ts +35 -0
  28. package/books/sahih-muslim/LICENSE +661 -0
  29. package/books/sahih-muslim/README.md +547 -0
  30. package/books/sahih-muslim/bin/index.js +183 -0
  31. package/books/sahih-muslim/data/muslim.json.gz +0 -0
  32. package/books/sahih-muslim/examples/express/server.js +16 -0
  33. package/books/sahih-muslim/examples/node-commonjs/example.js +11 -0
  34. package/books/sahih-muslim/examples/node-esm/example.mjs +9 -0
  35. package/books/sahih-muslim/examples/react/HadithExample.jsx +28 -0
  36. package/books/sahih-muslim/package.json +58 -0
  37. package/books/sahih-muslim/src/index.cjs +52 -0
  38. package/books/sahih-muslim/src/index.js +35 -0
  39. package/books/sahih-muslim/src/index.node.js +18 -0
  40. package/books/sahih-muslim/types/index.d.ts +28 -0
  41. package/books/sunan-abi-dawud/LICENSE +661 -0
  42. package/books/sunan-abi-dawud/README.md +149 -0
  43. package/books/sunan-abi-dawud/bin/index.js +183 -0
  44. package/books/sunan-abi-dawud/data/dawud.json.gz +0 -0
  45. package/books/sunan-abi-dawud/examples/express/server.js +7 -0
  46. package/books/sunan-abi-dawud/examples/node-commonjs/example.js +7 -0
  47. package/books/sunan-abi-dawud/examples/node-esm/example.mjs +6 -0
  48. package/books/sunan-abi-dawud/examples/react/HadithExample.jsx +17 -0
  49. package/books/sunan-abi-dawud/package.json +58 -0
  50. package/books/sunan-abi-dawud/src/index.cjs +52 -0
  51. package/books/sunan-abi-dawud/src/index.js +35 -0
  52. package/books/sunan-abi-dawud/src/index.node.js +18 -0
  53. package/books/sunan-abi-dawud/types/index.d.ts +28 -0
  54. package/books/sunan-ibn-majah/README.md +198 -0
  55. package/books/sunan-ibn-majah/bin/index.js +138 -0
  56. package/books/sunan-ibn-majah/examples/express/server.js +8 -0
  57. package/books/sunan-ibn-majah/examples/node-commonjs/example.js +7 -0
  58. package/books/sunan-ibn-majah/examples/node-esm/example.mjs +6 -0
  59. package/books/sunan-ibn-majah/examples/react/HadithExample.jsx +17 -0
  60. package/books/sunan-ibn-majah/package.json +58 -0
  61. package/books/sunan-ibn-majah/src/index.cjs +52 -0
  62. package/books/sunan-ibn-majah/src/index.js +35 -0
  63. package/books/sunan-ibn-majah/src/index.node.js +18 -0
  64. package/books/sunan-ibn-majah/types/index.d.ts +28 -0
  65. package/package.json +39 -27
package/bin/index.js CHANGED
@@ -1,1604 +1,1022 @@
1
- #!/usr/bin/env node
2
-
3
- import { fileURLToPath } from "url";
4
- import path from "path";
5
- import fs from "fs";
6
- import os from "os";
7
- import { execSync, spawnSync, spawn } from "child_process";
8
- import readline from "readline";
9
-
10
- const __filename = fileURLToPath(import.meta.url);
11
- const __dirname = path.dirname(__filename);
12
- const pkg = JSON.parse(
13
- fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
14
- );
15
-
16
- // ── Windows compatibility ─────────────────────────────────────────────────────
17
- const isWin = process.platform === "win32";
18
-
19
- // ── Safe npm runner zero warnings on all platforms ─────────────────────────
20
- // DEP0190 fires when shell:true is combined with a separate args array because
21
- // Node concatenates them unsafely. EINVAL fires on Windows when you try to
22
- // spawn npm.cmd without a shell.
23
- //
24
- // Solution: on Windows pass a single pre-joined command string to shell:true
25
- // (no array = no concatenation = no DEP0190). On Unix use an args array with
26
- // no shell at all. pkgName values come from our own PACKAGES constant so
27
- // there is no injection risk.
28
- function npmSync(args, opts = {}) {
29
- if (isWin) {
30
- // Single string → cmd.exe handles it; no args array → no DEP0190
31
- const result = spawnSync("npm " + args.join(" "), [], {
32
- encoding: "utf8",
33
- timeout: opts.timeout ?? 15000,
34
- stdio: opts.stdio ?? ["ignore", "pipe", "pipe"],
35
- shell: true,
36
- });
37
- if (result.error) throw result.error;
38
- return result.stdout ?? "";
39
- } else {
40
- const result = spawnSync("npm", args, {
41
- encoding: "utf8",
42
- timeout: opts.timeout ?? 15000,
43
- stdio: opts.stdio ?? ["ignore", "pipe", "pipe"],
44
- });
45
- if (result.error) throw result.error;
46
- return result.stdout ?? "";
47
- }
48
- }
49
-
50
- // ── Persistence: remember last selections ────────────────────────────────────
51
- const STATE_FILE = path.join(os.homedir(), ".sunnah-state.json");
52
-
53
- function loadPersistedState() {
54
- try {
55
- const raw = fs.readFileSync(STATE_FILE, "utf8");
56
- return JSON.parse(raw);
57
- } catch {
58
- return {};
59
- }
60
- }
61
-
62
- function savePersistedState(data) {
63
- try {
64
- const existing = loadPersistedState();
65
- fs.writeFileSync(
66
- STATE_FILE,
67
- JSON.stringify({ ...existing, ...data }, null, 2),
68
- );
69
- } catch (_e) {}
70
- }
71
-
72
- // ── Colors ────────────────────────────────────────────────────────────────────
73
- const c = {
74
- reset: "\x1b[0m",
75
- bold: "\x1b[1m",
76
- dim: "\x1b[2m",
77
- green: "\x1b[32m",
78
- yellow: "\x1b[33m",
79
- cyan: "\x1b[36m",
80
- magenta: "\x1b[35m",
81
- blue: "\x1b[34m",
82
- red: "\x1b[31m",
83
- gray: "\x1b[90m",
84
- white: "\x1b[97m",
85
- };
86
-
87
- const clr = (color, text) => `${color}${text}${c.reset}`;
88
- const bold = (t) => clr(c.bold, t);
89
- const green = (t) => clr(c.green, t);
90
- const yellow = (t) => clr(c.yellow, t);
91
- const cyan = (t) => clr(c.cyan, t);
92
- const magenta = (t) => clr(c.magenta, t);
93
- const gray = (t) => clr(c.gray, t);
94
- const red = (t) => clr(c.red, t);
95
- const dim = (t) => clr(c.dim, t);
96
- const white = (t) => clr(c.white, t);
97
- const blue = (t) => clr(c.blue, t);
98
-
99
- // ── Available packages ────────────────────────────────────────────────────────
100
- const PACKAGES = [
101
- {
102
- name: "sahih-al-bukhari",
103
- label: "Sahih al-Bukhari",
104
- author: "Imam Muhammad ibn Ismail al-Bukhari",
105
- desc: "The most authentic collection of hadith, widely regarded as the most sahih after the Quran.",
106
- hadiths: "7,563",
107
- cmd: "bukhari",
108
- hook: "useBukhari",
109
- },
110
- {
111
- name: "sahih-muslim",
112
- label: "Sahih Muslim",
113
- author: "Imam Muslim ibn al-Hajjaj",
114
- desc: "Second most authentic hadith collection, known for its strict methodology and chain verification.",
115
- hadiths: "7,470",
116
- cmd: "muslim",
117
- hook: "useMuslim",
118
- },
119
- {
120
- name: "sunan-abi-dawud",
121
- label: "Sunan Abi Dawud",
122
- author: "Imam Abu Dawud Sulayman ibn al-Ash'ath",
123
- desc: "One of the six canonical hadith collections, focused on legal rulings and jurisprudence.",
124
- hadiths: "5,274",
125
- cmd: "dawud",
126
- hook: "useDawud",
127
- },
128
- {
129
- name: "jami-al-tirmidhi",
130
- label: "Jami al-Tirmidhi",
131
- author: "Imam Abu Isa Muhammad al-Tirmidhi",
132
- desc: "Part of the six major hadith collections, unique for grading each hadith's authenticity.",
133
- hadiths: "3,956",
134
- cmd: "tirmidhi",
135
- hook: "useTirmidhi",
136
- },
137
- ];
138
-
139
- // cmd package lookup
140
- const CMD_MAP = Object.fromEntries(PACKAGES.map((p) => [p.cmd, p]));
141
-
142
- // ── Terminal: alternate screen buffer = no scroll ─────────────────────────────
143
- const W = () => process.stdout.columns || 80;
144
- const H = () => process.stdout.rows || 24;
145
-
146
- function enterAltScreen() {
147
- process.stdout.write("\x1b[?1049h");
148
- }
149
- function leaveAltScreen() {
150
- process.stdout.write("\x1b[?1049l");
151
- }
152
- function clearScreen() {
153
- process.stdout.write("\x1b[2J\x1b[H");
154
- }
155
- function moveTo(r, col) {
156
- process.stdout.write(`\x1b[${r};${col}H`);
157
- }
158
- function hideCursor() {
159
- process.stdout.write("\x1b[?25l");
160
- }
161
- function showCursor() {
162
- process.stdout.write("\x1b[?25h");
163
- }
164
- function clearToEOL() {
165
- process.stdout.write("\x1b[K");
166
- }
167
-
168
- function writeLine(row, text) {
169
- moveTo(row, 1);
170
- clearToEOL();
171
- process.stdout.write(text);
172
- }
173
-
174
- // ── Progress bar ──────────────────────────────────────────────────────────────
175
- function drawBar(label, percent, barWidth = 40) {
176
- const filled = Math.round((percent / 100) * barWidth);
177
- const empty = barWidth - filled;
178
- const bar =
179
- c.green + "█".repeat(filled) + c.gray + "".repeat(empty) + c.reset;
180
- const pct = cyan(String(Math.round(percent)).padStart(3) + "%");
181
- return ` ${bar} ${pct} ${dim(label)}`;
182
- }
183
-
184
- // ── Animate install with real npm running behind ──────────────────────────────
185
- function animateInstall(pkgName) {
186
- return new Promise((resolve) => {
187
- const stages = [
188
- { label: "Resolving packages…", end: 12, ms: 80 },
189
- { label: "Fetching metadata…", end: 30, ms: 60 },
190
- { label: "Downloading tarball…", end: 75, ms: 22 },
191
- { label: "Extracting files…", end: 90, ms: 50 },
192
- { label: "Linking binaries…", end: 98, ms: 80 },
193
- ];
194
- let percent = 0;
195
- let stageIdx = 0;
196
- let npmDone = false;
197
-
198
- process.stdout.write(drawBar(stages[0].label, 0));
199
-
200
- const tick = () => {
201
- const stage = stages[stageIdx];
202
- if (!stage) return;
203
- const prevEnd = stageIdx > 0 ? stages[stageIdx - 1].end : 0;
204
- const step = (stage.end - prevEnd) / 24;
205
- percent = Math.min(percent + step, stage.end);
206
- process.stdout.write("\r\x1b[K" + drawBar(stage.label, percent));
207
- if (percent >= stage.end) {
208
- stageIdx++;
209
- if (stageIdx >= stages.length) {
210
- const poll = setInterval(() => {
211
- if (npmDone) {
212
- clearInterval(poll);
213
- process.stdout.write(
214
- "\r\x1b[K" + drawBar("Complete!", 100) + "\n",
215
- );
216
- resolve();
217
- }
218
- }, 80);
219
- return;
220
- }
221
- }
222
- setTimeout(tick, stages[stageIdx]?.ms ?? 50);
223
- };
224
-
225
- setTimeout(tick, stages[0].ms);
226
- // Windows: shell:true required for npm.cmd, but passing an args array alongside
227
- // shell:true triggers DEP0190. Fix: pass a single pre-joined string so Node
228
- // hands it to cmd.exe as-is — no concatenation, no warning, no EINVAL.
229
- // Unix: spawn directly without shell — clean and safe.
230
- const proc = isWin
231
- ? spawn("npm install -g " + pkgName, [], {
232
- stdio: ["ignore", "pipe", "pipe"],
233
- shell: true,
234
- })
235
- : spawn("npm", ["install", "-g", pkgName], {
236
- stdio: ["ignore", "pipe", "pipe"],
237
- });
238
- proc.on("error", () => {
239
- npmDone = true;
240
- });
241
- proc.on("close", () => {
242
- npmDone = true;
243
- });
244
- });
245
- }
246
-
247
- // ── Installed cache ONE npm call at startup, Map lookup during render ───────
248
- function buildInstalledCache() {
249
- const cache = new Map();
250
- // Pre-mark all as false so the UI never hangs waiting
251
- for (const p of PACKAGES) cache.set(p.name, false);
252
- let out = "";
253
- try {
254
- // Use --json for faster, reliable parsing; short timeout so UI opens fast
255
- out = npmSync(["list", "-g", "--depth=0", "--json"], { timeout: 6000 });
256
- const parsed = JSON.parse(out);
257
- const deps = parsed?.dependencies ?? {};
258
- for (const p of PACKAGES) {
259
- cache.set(p.name, p.name in deps);
260
- }
261
- } catch (e) {
262
- // Fallback: plain text parse on error/timeout
263
- const text = (e && e.stdout) || out || "";
264
- for (const p of PACKAGES) {
265
- cache.set(p.name, text.includes(p.name));
266
- }
267
- }
268
- return cache;
269
- }
270
-
271
- function getLatestVersion(name) {
272
- try {
273
- // Use --json for cleaner parsing
274
- const out = npmSync(["show", name, "version", "--json"], { timeout: 6000 });
275
- return JSON.parse(out.trim());
276
- } catch {
277
- // Fallback: plain text
278
- try {
279
- return npmSync(["show", name, "version"], { timeout: 6000 }).trim();
280
- } catch {
281
- return null;
282
- }
283
- }
284
- }
285
-
286
- function getInstalledVersion(name) {
287
- try {
288
- // Use --json for reliable, fast parsing
289
- const out = npmSync(["list", "-g", name, "--depth=0", "--json"], {
290
- timeout: 6000,
291
- });
292
- const parsed = JSON.parse(out);
293
- return parsed?.dependencies?.[name]?.version ?? null;
294
- } catch (e) {
295
- // Fallback: text parse
296
- const text = (e && e.stdout) || "";
297
- const match = text.match(new RegExp(name + "@([\\d.]+)"));
298
- return match ? match[1] : null;
299
- }
300
- }
301
-
302
- let installedCache = new Map();
303
- const isInstalled = (name) => installedCache.get(name) ?? false;
304
-
305
- // ── Update cache — fetched async so UI stays responsive ───────────────────────
306
- // Map<pkgName, { current: string|null, latest: string|null, hasUpdate: boolean }>
307
- let updateCache = new Map();
308
- let updateCacheReady = false;
309
-
310
- async function prefetchUpdateCache() {
311
- const installed = PACKAGES.filter((p) => isInstalled(p.name));
312
- if (!installed.length) {
313
- updateCacheReady = true;
314
- return;
315
- }
316
-
317
- // Step 1: get ALL installed versions in ONE npm call (fast)
318
- const currentVersions = new Map();
319
- await new Promise((resolve) => {
320
- let out = "";
321
- const proc = isWin
322
- ? spawn("npm list -g --depth=0 --json", [], {
323
- stdio: ["ignore", "pipe", "pipe"],
324
- shell: true,
325
- })
326
- : spawn("npm", ["list", "-g", "--depth=0", "--json"], {
327
- stdio: ["ignore", "pipe", "pipe"],
328
- });
329
- proc.stdout?.on("data", (d) => {
330
- out += d;
331
- });
332
- proc.on("error", () => resolve());
333
- proc.on("close", () => {
334
- try {
335
- const deps = JSON.parse(out)?.dependencies ?? {};
336
- for (const p of installed) {
337
- currentVersions.set(p.name, deps[p.name]?.version ?? null);
338
- }
339
- } catch (_e) {}
340
- resolve();
341
- });
342
- setTimeout(() => {
343
- try {
344
- proc.kill();
345
- } catch (_e) {}
346
- resolve();
347
- }, 6000);
348
- });
349
-
350
- // Step 2: get latest versions one spawn per package, all in parallel
351
- await Promise.all(
352
- installed.map(
353
- (p) =>
354
- new Promise((resolve) => {
355
- let out = "";
356
- const proc = isWin
357
- ? spawn("npm show " + p.name + " version", [], {
358
- stdio: ["ignore", "pipe", "pipe"],
359
- shell: true,
360
- })
361
- : spawn("npm", ["show", p.name, "version"], {
362
- stdio: ["ignore", "pipe", "pipe"],
363
- });
364
- proc.stdout?.on("data", (d) => {
365
- out += d;
366
- });
367
- proc.on("error", () => resolve());
368
- proc.on("close", () => {
369
- const latest = out.trim() || null;
370
- const current = currentVersions.get(p.name) ?? null;
371
- updateCache.set(p.name, {
372
- current,
373
- latest,
374
- hasUpdate: !!(current && latest && current !== latest),
375
- });
376
- resolve();
377
- });
378
- setTimeout(() => {
379
- try {
380
- proc.kill();
381
- } catch (_e) {}
382
- resolve();
383
- }, 6000);
384
- }),
385
- ),
386
- );
387
-
388
- updateCacheReady = true;
389
- }
390
-
391
- // ── Clipboard ─────────────────────────────────────────────────────────────────
392
- function copyToClipboard(text) {
393
- try {
394
- if (isWin) {
395
- execSync("clip", { input: text, shell: true });
396
- } else if (process.platform === "darwin") {
397
- execSync("pbcopy", { input: text });
398
- } else {
399
- execSync("xclip -selection clipboard || xsel --clipboard --input", {
400
- input: text,
401
- shell: true,
402
- });
403
- }
404
- return true;
405
- } catch {
406
- return false;
407
- }
408
- }
409
-
410
- // ── React hook generator ──────────────────────────────────────────────────────
411
- function generateUnifiedHook(books) {
412
- const cwd = process.cwd();
413
- const srcDir = path.join(cwd, "src");
414
- const hooksDir = path.join(srcDir, "hooks");
415
-
416
- const pkgPath = path.join(cwd, "package.json");
417
- if (!fs.existsSync(pkgPath)) {
418
- console.error(
419
- red("\n No package.json found. Run inside your React project.\n"),
420
- );
421
- process.exit(1);
422
- }
423
- const projectPkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
424
- const deps = { ...projectPkg.dependencies, ...projectPkg.devDependencies };
425
- if (!deps["react"]) {
426
- console.error(red("\n ✗ React not found in package.json.\n"));
427
- process.exit(1);
428
- }
429
- if (!fs.existsSync(srcDir)) {
430
- console.error(red("\n ✗ No src/ directory found.\n"));
431
- process.exit(1);
432
- }
433
- if (!fs.existsSync(hooksDir)) {
434
- fs.mkdirSync(hooksDir, { recursive: true });
435
- console.log(green(" ✓ Created src/hooks/"));
436
- }
437
-
438
- // Build per-book loader blocks
439
- const loaderBlocks = books
440
- .map((p) => {
441
- const CDN = `https://cdn.jsdelivr.net/npm/${p.name}@latest/chapters`;
442
- return `
443
- // ── ${p.label} ──
444
- const _${p.cmd}CDN = '${CDN}';
445
- let _${p.cmd}Cache = null;
446
- let _${p.cmd}Prom = null;
447
- const _${p.cmd}Subs = new Set();
448
-
449
- function _load${p.hook.replace("use", "")}() {
450
- if (_${p.cmd}Cache) return Promise.resolve(_${p.cmd}Cache);
451
- if (_${p.cmd}Prom) return _${p.cmd}Prom;
452
- _${p.cmd}Prom = fetch(_${p.cmd}CDN + '/meta.json')
453
- .then(r => r.json())
454
- .then(meta => Promise.all(
455
- meta.chapters.map(c => fetch(_${p.cmd}CDN + '/' + c.id + '.json').then(r => r.json()))
456
- ).then(results => {
457
- const hadiths = results.flat();
458
- const _byId = new Map();
459
- hadiths.forEach(h => _byId.set(h.id, h));
460
- _${p.cmd}Cache = Object.assign([], hadiths, {
461
- metadata: meta.metadata,
462
- chapters: meta.chapters,
463
- get: (id) => _byId.get(id),
464
- getByChapter: (id) => hadiths.filter(h => h.chapterId === id),
465
- search: (q, limit = 0) => {
466
- const ql = q.toLowerCase();
467
- const r = hadiths.filter(h =>
468
- h.english?.text?.toLowerCase().includes(ql) ||
469
- h.english?.narrator?.toLowerCase().includes(ql)
470
- );
471
- return limit > 0 ? r.slice(0, limit) : r;
472
- },
473
- getRandom: () => hadiths[Math.floor(Math.random() * hadiths.length)],
474
- });
475
- _${p.cmd}Subs.forEach(fn => fn(_${p.cmd}Cache));
476
- _${p.cmd}Subs.clear();
477
- return _${p.cmd}Cache;
478
- }));
479
- return _${p.cmd}Prom;
480
- }
481
- _load${p.hook.replace("use", "")}();
482
-
483
- export function ${p.hook}() {
484
- const [data, setData] = useState(_${p.cmd}Cache);
485
- useEffect(() => {
486
- if (_${p.cmd}Cache) { setData(_${p.cmd}Cache); }
487
- else { _${p.cmd}Subs.add(setData); return () => _${p.cmd}Subs.delete(setData); }
488
- }, []);
489
- return data;
490
- }`;
491
- })
492
- .join("\n");
493
-
494
- const hookNames = books.map((p) => p.hook).join(", ");
495
-
496
- const hookSrc = `// Auto-generated by: sunnah --react
497
- // Re-run to regenerate after installing more sunnah packages.
498
- //
499
- // Included books: ${books.map((p) => p.label).join(", ")}
500
- //
501
- // Usage:
502
- ${books.map((p) => `// import { ${p.hook} } from '../hooks/useSunnah';`).join("\n")}
503
- //
504
- // const bukhari = useBukhari();
505
- // if (!bukhari) return <p>Loading...</p>;
506
- // bukhari.get(1) // hadith by ID
507
- // bukhari.search('prayer', 5) // top 5 results
508
- // bukhari.getRandom() // random hadith
509
- // bukhari.getByChapter(1) // all hadiths in chapter
510
-
511
- import { useState, useEffect } from 'react';
512
- ${loaderBlocks}
513
-
514
- // Unified hook loads all books in parallel
515
- export function useSunnah() {
516
- const hooks = [${books.map((p) => `${p.hook}()`).join(", ")}];
517
- if (hooks.some(h => !h)) return null;
518
- return { ${books.map((p) => p.cmd + ": hooks[" + books.indexOf(p) + "]").join(", ")} };
519
- }
520
-
521
- export default useSunnah;
522
- `;
523
-
524
- const hookFile = path.join(hooksDir, "useSunnah.js");
525
- fs.writeFileSync(hookFile, hookSrc, "utf8");
526
-
527
- const div = gray("─".repeat(60));
528
- console.log("\n" + div);
529
- console.log(bold(cyan(" Generated: src/hooks/useSunnah.js")));
530
- console.log(div);
531
- console.log("\n " + gray("Included books:"));
532
- books.forEach((p) =>
533
- console.log(
534
- " " + green("▸") + " " + bold(p.label) + gray(" " + p.hook + "()"),
535
- ),
536
- );
537
- console.log("\n " + gray("Usage:"));
538
- console.log(
539
- " " +
540
- cyan("import { useSunnah") +
541
- (books.length > 1
542
- ? ", " +
543
- books
544
- .slice(0, 2)
545
- .map((p) => p.hook)
546
- .join(", ")
547
- : "") +
548
- cyan(" } from '../hooks/useSunnah';"),
549
- );
550
- console.log("");
551
- console.log(" " + gray("// Unified all books at once"));
552
- console.log(" " + gray("const sunnah = useSunnah();"));
553
- console.log(" " + gray("if (!sunnah) return <p>Loading...</p>;"));
554
- if (books[0])
555
- console.log(" " + gray(`sunnah.${books[0].cmd}.getRandom()`));
556
- console.log("");
557
- console.log(" " + gray("// Per-book hooks still work"));
558
- if (books[0]) {
559
- console.log(" " + gray(`const ${books[0].cmd} = ${books[0].hook}();`));
560
- console.log(" " + gray(`${books[0].cmd}.get(1).english.text`));
561
- }
562
- console.log("\n" + div + "\n");
563
- }
564
-
565
- // ── Personalized suggestions based on what's installed ───────────────────────
566
- function getPersonalizedSuggestions() {
567
- const installed = PACKAGES.filter((p) => isInstalled(p.name));
568
- const missing = PACKAGES.filter((p) => !isInstalled(p.name));
569
- const tips = [];
570
-
571
- if (installed.length === 0) {
572
- tips.push(
573
- cyan(" ▸") +
574
- " Run " +
575
- bold("sunnah") +
576
- " to open the interactive installer",
577
- );
578
- tips.push(
579
- cyan(" ▸") + " Or install directly: " + bold("sunnah install bukhari"),
580
- );
581
- } else {
582
- installed.forEach((p) => {
583
- tips.push(
584
- cyan(" ") +
585
- " " +
586
- bold(p.cmd + " --random") +
587
- gray(" — read a random " + p.label + " hadith"),
588
- );
589
- tips.push(
590
- cyan(" ▸") +
591
- " " +
592
- bold(p.cmd + ' --search "prayer"') +
593
- gray(" search " + p.label),
594
- );
595
- });
596
- if (installed.length > 1) {
597
- tips.push(
598
- cyan("") +
599
- " " +
600
- bold("sunnah --react") +
601
- gray(" — generate useSunnah() hook for all installed books"),
602
- );
603
- } else if (installed.length === 1) {
604
- tips.push(
605
- cyan(" ▸") +
606
- " " +
607
- bold("sunnah --react " + installed[0].cmd) +
608
- gray(" generate React hook for " + installed[0].label),
609
- );
610
- }
611
- if (missing.length > 0) {
612
- tips.push(
613
- cyan(" ▸") +
614
- " " +
615
- bold("sunnah") +
616
- gray(
617
- " install more books (" +
618
- missing.map((p) => p.label).join(", ") +
619
- ")",
620
- ),
621
- );
622
- }
623
- tips.push(
624
- cyan(" ▸") +
625
- " " +
626
- bold("sunnah --update") +
627
- gray(" check for updates"),
628
- );
629
- }
630
-
631
- return tips;
632
- }
633
-
634
- // ── Modes ─────────────────────────────────────────────────────────────────────
635
- const MODE = {
636
- LIST: "list",
637
- CONFIRM_UNINSTALL: "confirm_uninstall",
638
- UPDATE: "update",
639
- };
640
-
641
- // ── Interactive render ────────────────────────────────────────────────────────
642
- function render(state) {
643
- const { cursor, selected, mode, confirmTarget, statusMsg } = state;
644
- const divW = Math.min(W() - 2, 72);
645
- const div = gray("─".repeat(divW));
646
- const div2 = gray("".repeat(divW));
647
-
648
- clearScreen();
649
- let row = 1;
650
-
651
- writeLine(row++, div2);
652
- writeLine(
653
- row++,
654
- bold(cyan(" 📚 Sunnah Package Manager")) + gray(" v" + pkg.version),
655
- );
656
- writeLine(
657
- row++,
658
- gray(" ↑↓") +
659
- " nav " +
660
- gray("space") +
661
- " select " +
662
- gray("a") +
663
- " all " +
664
- gray("i") +
665
- " info " +
666
- gray("u") +
667
- " uninstall " +
668
- gray("U") +
669
- " update " +
670
- gray("enter") +
671
- " install " +
672
- gray("q") +
673
- " quit",
674
- );
675
- writeLine(row++, div2);
676
- row++;
677
-
678
- PACKAGES.forEach((p, i) => {
679
- const isCursor = i === cursor;
680
- const isSel = selected.has(i);
681
- const inst = isInstalled(p.name);
682
-
683
- const checkbox = isSel ? green("[✓]") : gray("[ ]");
684
- const arrow = isCursor ? cyan("▶") : " ";
685
- const label = isCursor
686
- ? bold(white(p.label))
687
- : isSel
688
- ? green(p.label)
689
- : white(p.label);
690
- const badge = inst
691
- ? dim(green(" ● installed")) +
692
- (updateCache.get(p.name)?.hasUpdate
693
- ? " " + yellow("↑ update available")
694
- : updateCacheReady && inst
695
- ? " " + dim(gray("(up to date)"))
696
- : "")
697
- : dim(gray(" ○ not installed"));
698
-
699
- writeLine(row++, ` ${arrow} ${checkbox} ${label}${badge}`);
700
-
701
- if (isCursor) {
702
- writeLine(row++, ` ${dim(p.author)}`);
703
- writeLine(row++, ` ${gray(p.desc)}`);
704
- const uc = updateCache.get(p.name);
705
- const verStr = inst
706
- ? uc
707
- ? uc.hasUpdate
708
- ? gray(" v") + yellow(uc.current) + gray(" → ") + green(uc.latest)
709
- : gray(" v") + cyan(uc.current || "?")
710
- : gray(" (checking…)")
711
- : "";
712
- writeLine(
713
- row++,
714
- ` ${gray("Hadiths: ")}${yellow(p.hadiths)}` +
715
- ` ${gray("CLI: ")}${cyan(p.cmd + " --help")}` +
716
- verStr +
717
- (inst ? ` ${gray("try: ")}${cyan(p.cmd + " --random")}` : ""),
718
- );
719
- row++;
720
- }
721
- });
722
-
723
- row++;
724
- writeLine(row++, div);
725
-
726
- if (statusMsg) {
727
- writeLine(row++, ` ${yellow("⚠")} ${yellow(statusMsg)}`);
728
- } else if (selected.size > 0) {
729
- const names = [...selected].map((i) => cyan(PACKAGES[i].name)).join(", ");
730
- writeLine(
731
- row++,
732
- ` ${green("")} ${bold(String(selected.size))} selected: ${names}`,
733
- );
734
- writeLine(row++, ` ${dim("enter = install u = uninstall i = info")}`);
735
- } else {
736
- writeLine(
737
- row++,
738
- ` ${gray("Nothing selected — press space to select, enter to install focused")}`,
739
- );
740
- }
741
-
742
- writeLine(row++, div);
743
-
744
- if (mode === MODE.CONFIRM_UNINSTALL && confirmTarget !== null) {
745
- const p = PACKAGES[confirmTarget];
746
- row++;
747
- writeLine(
748
- row++,
749
- ` ${red("⚠ Uninstall ")}${bold(white(p.label))}${red("?")}`,
750
- );
751
- writeLine(
752
- row++,
753
- ` ${green("y")} ${gray("confirm")} ${red("n")} ${gray("cancel")}`,
754
- );
755
- }
756
- }
757
-
758
- // ── --list ────────────────────────────────────────────────────────────────────
759
- function cmdList() {
760
- installedCache = buildInstalledCache();
761
- const div = gray("".repeat(60));
762
- console.log("\n" + div);
763
- console.log(bold(cyan(" Available Sunnah Packages")));
764
- console.log(div);
765
- PACKAGES.forEach((p) => {
766
- const inst = isInstalled(p.name);
767
- const badge = inst ? green(" ✓ installed") : red(" ✗ not installed");
768
- let versionStr = "";
769
- if (inst) {
770
- const current = getInstalledVersion(p.name);
771
- const latest = getLatestVersion(p.name);
772
- if (current && latest) {
773
- versionStr =
774
- current === latest
775
- ? dim(gray(" v" + current + " (up to date)"))
776
- : yellow(" v" + current) +
777
- gray(" → ") +
778
- green("v" + latest) +
779
- yellow(" update available");
780
- } else if (current) {
781
- versionStr = dim(gray(" v" + current));
782
- }
783
- }
784
- console.log(`\n ${bold(white(p.label))}${badge}${versionStr}`);
785
- console.log(` ${cyan("npm install -g " + p.name)}`);
786
- console.log(` ${dim(p.desc)}`);
787
- console.log(
788
- ` ${gray("Hadiths: ")}${yellow(p.hadiths)} ${gray("Author: ")}${magenta(p.author)}`,
789
- );
790
- if (inst) {
791
- console.log(
792
- ` ${gray("CLI: ")}${cyan(p.cmd + " --help")} ${gray("React: ")}${cyan("sunnah --react " + p.cmd)}`,
793
- );
794
- }
795
- });
796
- console.log("\n" + div + "\n");
797
- }
798
-
799
- // ── --update ──────────────────────────────────────────────────────────────────
800
- async function cmdUpdate(autoInstall = false) {
801
- installedCache = buildInstalledCache();
802
- const installed = PACKAGES.filter((p) => isInstalled(p.name));
803
- if (!installed.length) {
804
- console.log(
805
- "\n " +
806
- yellow("No sunnah packages installed. Run ") +
807
- bold("sunnah") +
808
- yellow(" to install.\n"),
809
- );
810
- return;
811
- }
812
- const div = gray("─".repeat(60));
813
- const div2 = gray("═".repeat(60));
814
- console.log("\n" + div2);
815
- console.log(bold(cyan(" Checking for updates…")));
816
- console.log(div2 + "\n");
817
-
818
- const updates = [];
819
- for (const p of installed) {
820
- process.stdout.write(
821
- " " + gray("Checking ") + white(p.label) + gray("…\r"),
822
- );
823
- const current = getInstalledVersion(p.name);
824
- const latest = getLatestVersion(p.name);
825
- process.stdout.write("\x1b[K");
826
- if (!current || !latest) {
827
- console.log(
828
- ` ${yellow("?")} ${bold(p.label)} ${gray("(could not check)")}`,
829
- );
830
- continue;
831
- }
832
- if (current === latest) {
833
- console.log(
834
- ` ${green("✓")} ${bold(p.label)} ${gray("v" + current + " — up to date")}`,
835
- );
836
- } else {
837
- updates.push({ p, current, latest });
838
- console.log(
839
- ` ${yellow("↑")} ${bold(p.label)} ${gray("v" + current)} ${gray("→")} ${green("v" + latest)} ${yellow("(update available)")}`,
840
- );
841
- }
842
- }
843
-
844
- console.log("\n" + div);
845
-
846
- if (!updates.length) {
847
- console.log(" " + green("✓ All packages are up to date."));
848
- console.log(div + "\n");
849
- return;
850
- }
851
-
852
- console.log(
853
- " " +
854
- yellow(String(updates.length)) +
855
- " update" +
856
- (updates.length > 1 ? "s" : "") +
857
- " available.",
858
- );
859
-
860
- if (autoInstall) {
861
- console.log(bold(cyan("\n Installing updates…")));
862
- console.log(div + "\n");
863
- for (let i = 0; i < updates.length; i++) {
864
- const { p, current, latest } = updates[i];
865
- console.log(
866
- " " +
867
- cyan("[" + (i + 1) + "/" + updates.length + "]") +
868
- " " +
869
- bold(white(p.label)) +
870
- gray(" v" + current + " → v" + latest) +
871
- "\n",
872
- );
873
- await animateInstall(p.name);
874
- console.log(
875
- " " +
876
- green("✓") +
877
- " " +
878
- bold(green(p.label)) +
879
- gray(" updated to v" + latest),
880
- );
881
- }
882
- console.log("\n" + div2);
883
- console.log(" " + green("✓ All updates installed."));
884
- console.log(div2 + "\n");
885
- } else {
886
- console.log(
887
- " Run " +
888
- bold(cyan("sunnah --update --install")) +
889
- gray(" to install all updates automatically."),
890
- );
891
- console.log(" Or update individually:");
892
- updates.forEach(({ p }) =>
893
- console.log(" " + dim("sunnah install " + p.cmd)),
894
- );
895
- console.log(div + "\n");
896
- }
897
- }
898
-
899
- // ── sunnah install <cmd> ──────────────────────────────────────────────────────
900
- async function cmdInstall(targets) {
901
- if (!targets.length) {
902
- console.error(
903
- red(
904
- "\n Usage: sunnah install <name> (e.g. sunnah install bukhari)\n",
905
- ),
906
- );
907
- console.log(
908
- " Available: " + PACKAGES.map((p) => cyan(p.cmd)).join(", ") + "\n",
909
- );
910
- process.exit(1);
911
- }
912
-
913
- // Resolve names accept cmd alias or full npm name
914
- const toInstall = [];
915
- for (const t of targets) {
916
- const byCmd = CMD_MAP[t.toLowerCase()];
917
- const byName = PACKAGES.find((p) => p.name === t);
918
- const found = byCmd || byName;
919
- if (!found) {
920
- console.log(yellow(`\n ⚠ Unknown package: "${t}"`));
921
- console.log(
922
- " Available: " +
923
- PACKAGES.map((p) => cyan(p.cmd) + gray(" (" + p.name + ")")).join(
924
- ", ",
925
- ),
926
- );
927
- process.exit(1);
928
- }
929
- toInstall.push(found);
930
- }
931
-
932
- const divW = Math.min(W() - 2, 72);
933
- const div2 = gray("".repeat(divW));
934
-
935
- console.log("\n" + div2);
936
- console.log(
937
- bold(cyan(" Installing ")) +
938
- bold(yellow(String(toInstall.length))) +
939
- bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")),
940
- );
941
- console.log(div2);
942
-
943
- for (let i = 0; i < toInstall.length; i++) {
944
- const p = toInstall[i];
945
- console.log(
946
- "\n " +
947
- cyan("[" + (i + 1) + "/" + toInstall.length + "]") +
948
- " " +
949
- bold(white(p.label)),
950
- );
951
- console.log(" " + dim("npm install -g " + p.name) + "\n");
952
- await animateInstall(p.name);
953
- installedCache.set(p.name, true);
954
- console.log(" " + green("✓") + " " + bold(green(p.label)) + " installed");
955
- console.log(" " + gray("Usage: ") + cyan(p.cmd + " --help"));
956
- }
957
-
958
- console.log("\n" + div2);
959
- console.log(
960
- " " +
961
- green("✓ Done! ") +
962
- toInstall.map((p) => bold(cyan(p.cmd))).join(", ") +
963
- gray(" ready."),
964
- );
965
- console.log(div2 + "\n");
966
- }
967
-
968
- // ── sunnah uninstall <cmd> ────────────────────────────────────────────────────
969
- function cmdUninstall(targets) {
970
- if (!targets.length) {
971
- console.error(
972
- red(
973
- "\n Usage: sunnah uninstall <name> (e.g. sunnah uninstall bukhari)\n",
974
- ),
975
- );
976
- process.exit(1);
977
- }
978
-
979
- installedCache = buildInstalledCache();
980
- const divW = Math.min(W() - 2, 72);
981
- const div2 = gray("".repeat(divW));
982
-
983
- console.log("\n" + div2);
984
-
985
- for (const t of targets) {
986
- const p =
987
- CMD_MAP[t.toLowerCase()] || PACKAGES.find((pkg) => pkg.name === t);
988
- if (!p) {
989
- console.log(yellow(` ⚠ Unknown: "${t}"`) + "\n");
990
- continue;
991
- }
992
- if (!isInstalled(p.name)) {
993
- console.log(gray(` ○ ${p.label} is not installed, skipping.`));
994
- continue;
995
- }
996
- console.log(yellow(" Uninstalling ") + bold(white(p.label)) + yellow("…"));
997
- try {
998
- npmSync(["uninstall", "-g", p.name], { stdio: "inherit" });
999
- installedCache.set(p.name, false);
1000
- console.log(
1001
- green(" ✓ ") + bold(green(p.label)) + green(" uninstalled.\n"),
1002
- );
1003
- } catch {
1004
- console.log(red(" ✗ Failed to uninstall " + p.label + "\n"));
1005
- }
1006
- }
1007
-
1008
- console.log(div2 + "\n");
1009
- }
1010
-
1011
- // ── --version (personalized) ──────────────────────────────────────────────────
1012
- function cmdVersion() {
1013
- installedCache = buildInstalledCache();
1014
- const installed = PACKAGES.filter((p) => isInstalled(p.name));
1015
- const div = gray("─".repeat(60));
1016
-
1017
- console.log("\n" + div);
1018
- console.log(bold(cyan(" 📿 sunnah")) + gray(" v" + pkg.version));
1019
- console.log(div);
1020
- console.log(
1021
- " " + gray("Available packages : ") + yellow(String(PACKAGES.length)),
1022
- );
1023
- console.log(
1024
- " " +
1025
- gray("Installed : ") +
1026
- green(String(installed.length)) +
1027
- gray(" / " + PACKAGES.length),
1028
- );
1029
-
1030
- if (installed.length > 0) {
1031
- console.log(
1032
- " " +
1033
- gray("Your collection : ") +
1034
- installed.map((p) => cyan(p.label)).join(gray(", ")),
1035
- );
1036
- const totalHadiths = installed.reduce(
1037
- (acc, p) => acc + parseInt(p.hadiths.replace(/,/g, "")),
1038
- 0,
1039
- );
1040
- console.log(
1041
- " " +
1042
- gray("Total hadiths : ") +
1043
- bold(yellow(totalHadiths.toLocaleString())),
1044
- );
1045
- }
1046
-
1047
- console.log("\n" + div + "\n");
1048
- }
1049
-
1050
- // ── --help (personalized) ─────────────────────────────────────────────────────
1051
- function cmdHelp() {
1052
- installedCache = buildInstalledCache();
1053
- const div = gray("─".repeat(60));
1054
-
1055
- console.log("\n" + div);
1056
- console.log(
1057
- bold(cyan(" 📿 Sunnah Package Manager")) + gray(" v" + pkg.version),
1058
- );
1059
- console.log(div);
1060
-
1061
- console.log("\n " + bold("Commands:"));
1062
- console.log(
1063
- " " +
1064
- cyan("sunnah") +
1065
- gray(" Open interactive installer UI"),
1066
- );
1067
- console.log(
1068
- " " +
1069
- cyan("sunnah install") +
1070
- yellow(" <name>") +
1071
- gray(" Install a package directly"),
1072
- );
1073
- console.log(
1074
- " " +
1075
- cyan("sunnah uninstall") +
1076
- yellow(" <name>") +
1077
- gray(" Uninstall a package"),
1078
- );
1079
- console.log(
1080
- " " +
1081
- cyan("sunnah --react") +
1082
- gray(" Generate unified useSunnah() React hook"),
1083
- );
1084
- console.log(
1085
- " " +
1086
- cyan("sunnah --react") +
1087
- yellow(" <books>") +
1088
- gray(" Generate hook for specific books"),
1089
- );
1090
- console.log(
1091
- " " +
1092
- cyan("sunnah --list") +
1093
- gray(" List all packages with install status"),
1094
- );
1095
- console.log(
1096
- " " +
1097
- cyan("sunnah --update") +
1098
- gray(" Check installed packages for updates"),
1099
- );
1100
- console.log(
1101
- " " +
1102
- cyan("sunnah --update --install") +
1103
- gray(" Auto-install all available updates"),
1104
- );
1105
- console.log(
1106
- " " +
1107
- cyan("sunnah -v") +
1108
- gray(" Version + your collection stats"),
1109
- );
1110
- console.log(
1111
- " " +
1112
- cyan("sunnah -h") +
1113
- gray(" This help + personalized tips"),
1114
- );
1115
-
1116
- console.log("\n " + bold("Package names (use any form):"));
1117
- PACKAGES.forEach((p) => {
1118
- console.log(
1119
- " " +
1120
- cyan(p.cmd.padEnd(12)) +
1121
- gray(p.name.padEnd(24)) +
1122
- yellow(p.hadiths + " hadiths"),
1123
- );
1124
- });
1125
-
1126
- console.log("\n " + bold("Examples:"));
1127
- console.log(" " + dim("sunnah install bukhari"));
1128
- console.log(" " + dim("sunnah install bukhari muslim tirmidhi"));
1129
- console.log(" " + dim("sunnah uninstall dawud"));
1130
- console.log(" " + dim("sunnah --react"));
1131
- console.log(" " + dim("sunnah --react bukhari muslim"));
1132
- console.log(" " + dim("sunnah --update"));
1133
-
1134
- console.log("\n " + bold("Interactive UI controls:"));
1135
- console.log(" " + green("↑ ↓") + gray(" Navigate packages"));
1136
- console.log(" " + green("space") + gray(" Toggle select"));
1137
- console.log(" " + green("a") + gray(" Select all / deselect all"));
1138
- console.log(
1139
- " " + green("i") + gray(" Show info + installed version"),
1140
- );
1141
- console.log(" " + green("u") + gray(" Uninstall selected"));
1142
- console.log(
1143
- " " +
1144
- green("U") +
1145
- gray(" Update selected (if newer version available)"),
1146
- );
1147
- console.log(
1148
- " " +
1149
- green("enter") +
1150
- gray(" Install selected (or focused if none selected)"),
1151
- );
1152
- console.log(" " + green("q") + gray(" Quit"));
1153
-
1154
- // Personalized tips
1155
- const tips = getPersonalizedSuggestions();
1156
- if (tips.length) {
1157
- console.log("\n" + div);
1158
- console.log(bold(" 💡 Suggested for you:"));
1159
- tips.forEach((t) => console.log(t));
1160
- }
1161
-
1162
- console.log("\n" + div + "\n");
1163
- }
1164
-
1165
- // ── Main ──────────────────────────────────────────────────────────────────────
1166
- async function main() {
1167
- const rawArgs = process.argv.slice(2);
1168
- const flags = rawArgs.filter((a) => a.startsWith("-"));
1169
- const positional = rawArgs.filter((a) => !a.startsWith("-"));
1170
-
1171
- // sunnah -v / --version
1172
- if (flags.some((f) => f === "-v" || f === "--version")) {
1173
- cmdVersion();
1174
- process.exit(0);
1175
- }
1176
-
1177
- // sunnah -h / --help
1178
- if (flags.some((f) => f === "-h" || f === "--help")) {
1179
- cmdHelp();
1180
- process.exit(0);
1181
- }
1182
-
1183
- // sunnah --list / -l
1184
- if (flags.some((f) => f === "--list" || f === "-l")) {
1185
- cmdList();
1186
- process.exit(0);
1187
- }
1188
-
1189
- // sunnah --update [--install]
1190
- if (flags.some((f) => f === "--update")) {
1191
- const autoInstall = flags.some((f) => f === "--install");
1192
- await cmdUpdate(autoInstall);
1193
- process.exit(0);
1194
- }
1195
-
1196
- // sunnah install <names...>
1197
- if (positional[0] === "install") {
1198
- await cmdInstall(positional.slice(1));
1199
- process.exit(0);
1200
- }
1201
-
1202
- // sunnah uninstall <names...>
1203
- if (positional[0] === "uninstall") {
1204
- cmdUninstall(positional.slice(1));
1205
- process.exit(0);
1206
- }
1207
-
1208
- // sunnah --react [book1 book2 ...]
1209
- if (flags.some((f) => f === "--react")) {
1210
- installedCache = buildInstalledCache();
1211
- // If specific books given after --react, use those; else use all installed
1212
- const requestedCmds = positional; // e.g. ["bukhari", "muslim"]
1213
- let books;
1214
- if (requestedCmds.length > 0) {
1215
- books = requestedCmds
1216
- .map((cmd) => CMD_MAP[cmd.toLowerCase()])
1217
- .filter(Boolean);
1218
- const unknown = requestedCmds.filter(
1219
- (cmd) => !CMD_MAP[cmd.toLowerCase()],
1220
- );
1221
- if (unknown.length) {
1222
- console.log(yellow("\n ⚠ Unknown books: " + unknown.join(", ")));
1223
- console.log(" Available: " + Object.keys(CMD_MAP).join(", ") + "\n");
1224
- }
1225
- } else {
1226
- books = PACKAGES.filter((p) => isInstalled(p.name));
1227
- if (!books.length) {
1228
- console.log(yellow("\n ⚠ No sunnah packages installed yet."));
1229
- console.log(
1230
- " Run " +
1231
- bold("sunnah") +
1232
- " to install some first, or specify books explicitly:",
1233
- );
1234
- console.log(" " + dim("sunnah --react bukhari muslim") + "\n");
1235
- process.exit(1);
1236
- }
1237
- }
1238
- if (!books.length) {
1239
- console.log(red("\n ✗ No valid books specified.\n"));
1240
- process.exit(1);
1241
- }
1242
- generateUnifiedHook(books);
1243
- process.exit(0);
1244
- }
1245
-
1246
- // ── Interactive mode ────────────────────────────────────────────────────────
1247
- if (!process.stdin.isTTY) {
1248
- console.error(red("\n ✗ Interactive mode requires a TTY terminal.\n"));
1249
- process.exit(1);
1250
- }
1251
-
1252
- // Build cache
1253
- process.stdout.write("\n " + gray("Checking installed packages…"));
1254
- installedCache = buildInstalledCache();
1255
- process.stdout.write("\r\x1b[K");
1256
-
1257
- // Load persisted selection
1258
- const persisted = loadPersistedState();
1259
- const lastSelected = new Set(
1260
- (persisted.lastSelected || []).filter((i) => i < PACKAGES.length),
1261
- );
1262
-
1263
- enterAltScreen();
1264
- hideCursor();
1265
-
1266
- const state = {
1267
- cursor: 0,
1268
- selected: lastSelected, // pre-check last session's selections
1269
- mode: MODE.LIST,
1270
- confirmTarget: null,
1271
- statusMsg: "",
1272
- };
1273
-
1274
- let statusTimer = null;
1275
-
1276
- function setStatus(msg, ms = 2500) {
1277
- state.statusMsg = msg;
1278
- render(state);
1279
- if (statusTimer) clearTimeout(statusTimer);
1280
- statusTimer = setTimeout(() => {
1281
- state.statusMsg = "";
1282
- render(state);
1283
- }, ms);
1284
- }
1285
-
1286
- render(state);
1287
-
1288
- // Prefetch update info in background — re-render when ready so badges appear
1289
- prefetchUpdateCache().then(() => render(state));
1290
-
1291
- const cleanup = () => {
1292
- // Persist current selection before exiting
1293
- savePersistedState({ lastSelected: [...state.selected] });
1294
- showCursor();
1295
- leaveAltScreen();
1296
- try {
1297
- process.stdin.setRawMode(false);
1298
- } catch (_e) {}
1299
- process.stdin.pause();
1300
- };
1301
-
1302
- process.on("SIGINT", () => {
1303
- cleanup();
1304
- process.exit(0);
1305
- });
1306
-
1307
- readline.emitKeypressEvents(process.stdin);
1308
- process.stdin.setRawMode(true);
1309
-
1310
- let busy = false; // prevent keypress re-entry during install/uninstall/update
1311
-
1312
- const keypressHandler = async (str, key) => {
1313
- if (!key) return;
1314
- // Q or Ctrl+C always exits immediately, even during operations
1315
- if (str === "q" || str === "Q" || (key.ctrl && key.name === "c")) {
1316
- cleanup();
1317
- process.exit(0);
1318
- }
1319
- if (busy) return;
1320
-
1321
- // ── Confirm uninstall mode ────────────────────────────────────────────────
1322
- if (state.mode === MODE.CONFIRM_UNINSTALL) {
1323
- if (str === "y" || str === "Y") {
1324
- const p = PACKAGES[state.confirmTarget];
1325
- state.mode = MODE.LIST;
1326
- state.confirmTarget = null;
1327
- busy = true;
1328
- cleanup();
1329
- console.log(
1330
- "\n " +
1331
- yellow("Uninstalling ") +
1332
- bold(white(p.label)) +
1333
- yellow("…\n"),
1334
- );
1335
- try {
1336
- npmSync(["uninstall", "-g", p.name], { stdio: "inherit" });
1337
- installedCache.set(p.name, false);
1338
- state.selected.delete(PACKAGES.indexOf(p));
1339
- console.log(
1340
- "\n " +
1341
- green("✓ ") +
1342
- bold(green(p.label)) +
1343
- green(" uninstalled.\n"),
1344
- );
1345
- } catch {
1346
- console.log("\n " + red("✗ Failed to uninstall " + p.label) + "\n");
1347
- }
1348
- busy = false;
1349
- reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
1350
- } else {
1351
- state.mode = MODE.LIST;
1352
- state.confirmTarget = null;
1353
- render(state);
1354
- }
1355
- return;
1356
- }
1357
-
1358
- // ── Normal mode ───────────────────────────────────────────────────────────
1359
-
1360
- if (key.name === "up") {
1361
- state.cursor = (state.cursor - 1 + PACKAGES.length) % PACKAGES.length;
1362
- render(state);
1363
- return;
1364
- }
1365
- if (key.name === "down") {
1366
- state.cursor = (state.cursor + 1) % PACKAGES.length;
1367
- render(state);
1368
- return;
1369
- }
1370
-
1371
- if (str === " ") {
1372
- if (state.selected.has(state.cursor)) state.selected.delete(state.cursor);
1373
- else state.selected.add(state.cursor);
1374
- render(state);
1375
- return;
1376
- }
1377
-
1378
- if (str === "a" || str === "A") {
1379
- if (state.selected.size === PACKAGES.length) state.selected.clear();
1380
- else PACKAGES.forEach((_, i) => state.selected.add(i));
1381
- render(state);
1382
- return;
1383
- }
1384
-
1385
- // i = info
1386
- if (str === "i" || str === "I") {
1387
- const p = PACKAGES[state.cursor];
1388
- const inst = isInstalled(p.name);
1389
- const ver = inst ? getInstalledVersion(p.name) : null;
1390
- setStatus(
1391
- `${p.label} | ${p.hadiths} hadiths | ${p.author} | ${inst ? "v" + ver + " installed CLI: " + p.cmd + " --help" : "not installed"}`,
1392
- 4000,
1393
- );
1394
- return;
1395
- }
1396
-
1397
- // u = uninstall
1398
- if (str === "u" || str === "U") {
1399
- const targets =
1400
- state.selected.size > 0 ? [...state.selected] : [state.cursor];
1401
- const toRemove = targets.filter((i) => isInstalled(PACKAGES[i].name));
1402
- if (!toRemove.length) {
1403
- setStatus("No installed packages selected.");
1404
- return;
1405
- }
1406
- state.mode = MODE.CONFIRM_UNINSTALL;
1407
- state.confirmTarget = toRemove[0];
1408
- render(state);
1409
- return;
1410
- }
1411
-
1412
- // U = update (only packages with updates available)
1413
- if (str === "U") {
1414
- const targets =
1415
- state.selected.size > 0 ? [...state.selected] : [state.cursor];
1416
- const toUpdate = targets.filter((i) => {
1417
- const p = PACKAGES[i];
1418
- return isInstalled(p.name) && updateCache.get(p.name)?.hasUpdate;
1419
- });
1420
- if (!toUpdate.length) {
1421
- setStatus(
1422
- updateCacheReady
1423
- ? "All selected packages are up to date."
1424
- : "Update info still loading — try again shortly.",
1425
- );
1426
- return;
1427
- }
1428
- busy = true;
1429
- cleanup();
1430
- const divW = Math.min(W() - 2, 72);
1431
- const div2 = gray("═".repeat(divW));
1432
- console.log("\n" + div2);
1433
- console.log(
1434
- bold(cyan(" Updating ")) +
1435
- bold(yellow(String(toUpdate.length))) +
1436
- bold(cyan(" package" + (toUpdate.length > 1 ? "s" : "") + "…")),
1437
- );
1438
- console.log(div2);
1439
- for (let i = 0; i < toUpdate.length; i++) {
1440
- const p = PACKAGES[toUpdate[i]];
1441
- const uc = updateCache.get(p.name);
1442
- console.log(
1443
- "\n " +
1444
- cyan("[" + (i + 1) + "/" + toUpdate.length + "]") +
1445
- " " +
1446
- bold(white(p.label)),
1447
- );
1448
- console.log(
1449
- " " + dim(gray("v" + uc.current + " → v" + uc.latest)) + "\n",
1450
- );
1451
- await animateInstall(p.name);
1452
- updateCache.set(p.name, {
1453
- current: uc.latest,
1454
- latest: uc.latest,
1455
- hasUpdate: false,
1456
- });
1457
- console.log(
1458
- " " +
1459
- green("✓") +
1460
- " " +
1461
- bold(green(p.label)) +
1462
- gray(" updated to v" + uc.latest),
1463
- );
1464
- }
1465
- console.log("\n" + div2 + "\n");
1466
- busy = false;
1467
- reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
1468
- return;
1469
- }
1470
-
1471
- // enter = install
1472
- if (key.name === "return") {
1473
- const targets =
1474
- state.selected.size > 0
1475
- ? [...state.selected].map((i) => PACKAGES[i])
1476
- : [PACKAGES[state.cursor]];
1477
- const toInstall = targets.filter((p) => !isInstalled(p.name));
1478
-
1479
- if (!toInstall.length) {
1480
- setStatus("All selected packages are already installed.");
1481
- return;
1482
- }
1483
-
1484
- busy = true;
1485
- cleanup();
1486
- const divW = Math.min(W() - 2, 72);
1487
- const div = gray("─".repeat(divW));
1488
- const div2 = gray("═".repeat(divW));
1489
-
1490
- console.log("\n" + div2);
1491
- console.log(
1492
- bold(cyan(" Installing ")) +
1493
- bold(yellow(String(toInstall.length))) +
1494
- bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")),
1495
- );
1496
- console.log(div2);
1497
-
1498
- for (let i = 0; i < toInstall.length; i++) {
1499
- const p = toInstall[i];
1500
- console.log(
1501
- "\n " +
1502
- cyan("[" + (i + 1) + "/" + toInstall.length + "]") +
1503
- " " +
1504
- bold(white(p.label)),
1505
- );
1506
- console.log(" " + dim("npm install -g " + p.name) + "\n");
1507
- await animateInstall(p.name);
1508
- installedCache.set(p.name, true);
1509
- console.log(
1510
- " " + green("✓") + " " + bold(green(p.label)) + " installed",
1511
- );
1512
- // Show version from updateCache (fetched in background)
1513
- const uc = updateCache.get(p.name);
1514
- if (uc && uc.current) {
1515
- console.log(
1516
- " " +
1517
- gray("Version: ") +
1518
- cyan("v" + uc.current) +
1519
- (uc.hasUpdate
1520
- ? " " +
1521
- gray("Latest: ") +
1522
- green("v" + uc.latest) +
1523
- " " +
1524
- yellow("↑ update available")
1525
- : uc.latest
1526
- ? " " + dim(gray("(up to date)"))
1527
- : ""),
1528
- );
1529
- }
1530
- console.log(" " + gray("Usage: ") + cyan(p.cmd + " --help"));
1531
- }
1532
-
1533
- console.log("\n" + div2);
1534
- console.log(
1535
- " " +
1536
- green("✓ All done! ") +
1537
- bold(yellow(String(toInstall.length))) +
1538
- " package" +
1539
- (toInstall.length > 1 ? "s" : "") +
1540
- " installed globally.",
1541
- );
1542
- console.log("");
1543
- toInstall.forEach((p) =>
1544
- console.log(
1545
- " " +
1546
- cyan("▸") +
1547
- " " +
1548
- bold(p.cmd) +
1549
- gray(" --help") +
1550
- " " +
1551
- dim(p.label),
1552
- ),
1553
- );
1554
-
1555
- // Personalized next steps
1556
- const installed = PACKAGES.filter((p) => isInstalled(p.name));
1557
- if (installed.length > 1) {
1558
- console.log(
1559
- "\n " +
1560
- dim("Tip: run ") +
1561
- bold("sunnah --react") +
1562
- dim(" to generate a unified React hook for all your books"),
1563
- );
1564
- }
1565
-
1566
- // Auto-return to menu after install
1567
- await sleep(800);
1568
- busy = false;
1569
- reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
1570
- }
1571
- };
1572
- process.stdin.on("keypress", keypressHandler);
1573
- }
1574
-
1575
- function sleep(ms) {
1576
- return new Promise((r) => setTimeout(r, ms));
1577
- }
1578
-
1579
- // ── Re-enter the interactive menu cleanly ───────────────────────────────────
1580
- function reEnterMenu(state, render, prefetchUpdateCache, keypressHandler) {
1581
- installedCache = buildInstalledCache();
1582
- updateCacheReady = false;
1583
- updateCache.clear();
1584
- process.stdin.removeAllListeners("keypress");
1585
- process.stdin.pause();
1586
- enterAltScreen();
1587
- hideCursor();
1588
- render(state);
1589
- prefetchUpdateCache().then(() => render(state));
1590
- readline.emitKeypressEvents(process.stdin);
1591
- try {
1592
- process.stdin.setRawMode(true);
1593
- } catch (_e) {}
1594
- process.stdin.resume();
1595
- // Re-attach the keypress handler — this is what was missing
1596
- process.stdin.on("keypress", keypressHandler);
1597
- }
1598
-
1599
- main().catch((err) => {
1600
- showCursor();
1601
- leaveAltScreen();
1602
- console.error(red("\n ✗ " + err.message + "\n"));
1603
- process.exit(1);
1604
- });
1
+ #!/usr/bin/env node
2
+
3
+ import { fileURLToPath } from "url";
4
+ import path from "path";
5
+ import fs from "fs";
6
+ import os from "os";
7
+ import { execSync, spawnSync, spawn } from "child_process";
8
+ import readline from "readline";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
13
+
14
+ // ── Windows compatibility ─────────────────────────────────────────────────────
15
+ const isWin = process.platform === "win32";
16
+
17
+ function npmSync(args, opts = {}) {
18
+ if (isWin) {
19
+ const r = spawnSync("npm " + args.join(" "), [], {
20
+ encoding: "utf8", timeout: opts.timeout ?? 15000,
21
+ stdio: opts.stdio ?? ["ignore", "pipe", "pipe"], shell: true,
22
+ });
23
+ if (r.error) throw r.error;
24
+ return r.stdout ?? "";
25
+ }
26
+ const r = spawnSync("npm", args, {
27
+ encoding: "utf8", timeout: opts.timeout ?? 15000,
28
+ stdio: opts.stdio ?? ["ignore", "pipe", "pipe"],
29
+ });
30
+ if (r.error) throw r.error;
31
+ return r.stdout ?? "";
32
+ }
33
+
34
+ function pipSync(args, opts = {}) {
35
+ const cmd = isWin ? "pip" : "pip3";
36
+ if (isWin) {
37
+ const r = spawnSync(cmd + " " + args.join(" "), [], {
38
+ encoding: "utf8", timeout: opts.timeout ?? 15000,
39
+ stdio: opts.stdio ?? ["ignore", "pipe", "pipe"], shell: true,
40
+ });
41
+ if (r.error) return ""; return r.stdout ?? "";
42
+ }
43
+ const r = spawnSync(cmd, args, {
44
+ encoding: "utf8", timeout: opts.timeout ?? 15000,
45
+ stdio: opts.stdio ?? ["ignore", "pipe", "pipe"],
46
+ });
47
+ if (r.error) return ""; return r.stdout ?? "";
48
+ }
49
+
50
+ // ── Tool availability checks ─────────────────────────────────────────────────
51
+ function hasNpm() {
52
+ try {
53
+ const r = isWin
54
+ ? spawnSync("npm --version", [], { encoding:"utf8", timeout:4000, stdio:["ignore","pipe","pipe"], shell:true })
55
+ : spawnSync("npm", ["--version"], { encoding:"utf8", timeout:4000, stdio:["ignore","pipe","pipe"] });
56
+ return !r.error && r.status === 0;
57
+ } catch { return false; }
58
+ }
59
+ function hasPip() {
60
+ try {
61
+ const cmd = isWin ? "pip" : "pip3";
62
+ const r = isWin
63
+ ? spawnSync(cmd + " --version", [], { encoding:"utf8", timeout:4000, stdio:["ignore","pipe","pipe"], shell:true })
64
+ : spawnSync(cmd, ["--version"], { encoding:"utf8", timeout:4000, stdio:["ignore","pipe","pipe"] });
65
+ return !r.error && r.status === 0;
66
+ } catch { return false; }
67
+ }
68
+ let _npmAvail = null, _pipAvail = null;
69
+ const npmAvailable = () => { if (_npmAvail === null) _npmAvail = hasNpm(); return _npmAvail; };
70
+ const pipAvailable = () => { if (_pipAvail === null) _pipAvail = hasPip(); return _pipAvail; };
71
+
72
+ function warnNoNpm() {
73
+ const div = gray("─".repeat(60));
74
+ console.log("\n" + div);
75
+ console.log(yellow(" ⚠ npm is not installed or not found in PATH."));
76
+ console.log(gray(" npm packages require Node.js — install it from:"));
77
+ console.log(cyan(" https://nodejs.org"));
78
+ console.log(div + "\n");
79
+ }
80
+ function warnNoPip() {
81
+ const div = gray("".repeat(60));
82
+ console.log("\n" + div);
83
+ console.log(yellow(" ⚠ pip / pip3 is not installed or not found in PATH."));
84
+ console.log(gray(" Python packages require Python — install it from:"));
85
+ console.log(cyan(" https://python.org"));
86
+ console.log(gray(" Then run: ") + cyan("pip install sunnah"));
87
+ console.log(div + "\n");
88
+ }
89
+
90
+ // ── Persistence ───────────────────────────────────────────────────────────────
91
+ const STATE_FILE = path.join(os.homedir(), ".sunnah-state.json");
92
+ function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); } catch { return {}; } }
93
+ function saveState(d) { try { fs.writeFileSync(STATE_FILE, JSON.stringify({ ...loadState(), ...d }, null, 2)); } catch {} }
94
+
95
+ // ── Colors ────────────────────────────────────────────────────────────────────
96
+ const c = {
97
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
98
+ green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m",
99
+ magenta: "\x1b[35m", blue: "\x1b[34m", red: "\x1b[31m",
100
+ gray: "\x1b[90m", white: "\x1b[97m",
101
+ };
102
+ const clr = (col, t) => `${col}${t}${c.reset}`;
103
+ const bold = t => clr(c.bold, t);
104
+ const green = t => clr(c.green, t);
105
+ const yellow = t => clr(c.yellow, t);
106
+ const cyan = t => clr(c.cyan, t);
107
+ const magenta = t => clr(c.magenta, t);
108
+ const gray = t => clr(c.gray, t);
109
+ const red = t => clr(c.red, t);
110
+ const dim = t => clr(c.dim, t);
111
+ const white = t => clr(c.white, t);
112
+
113
+ // ── Package registry ──────────────────────────────────────────────────────────
114
+ const PACKAGES = [
115
+ { name: "sahih-al-bukhari", pip: "sahih-al-bukhari", label: "Sahih al-Bukhari", author: "Imam Muhammad ibn Ismail al-Bukhari", desc: "The most authentic collection of hadith, widely regarded as the most sahih after the Quran.", hadiths: "7,563", cmd: "bukhari", hook: "useBukhari", pyClass: "Bukhari", pyMod: "sahih_al_bukhari" },
116
+ { name: "sahih-muslim", pip: "sahih-muslim", label: "Sahih Muslim", author: "Imam Muslim ibn al-Hajjaj", desc: "Second most authentic hadith collection, known for its strict methodology and chain verification.", hadiths: "7,470", cmd: "muslim", hook: "useMuslim", pyClass: "Muslim", pyMod: "sahih_muslim" },
117
+ { name: "sunan-abi-dawud", pip: "sunan-abi-dawud", label: "Sunan Abi Dawud", author: "Imam Abu Dawud Sulayman ibn al-Ash'ath", desc: "One of the six canonical hadith collections, focused on legal rulings and jurisprudence.", hadiths: "5,274", cmd: "dawud", hook: "useDawud", pyClass: "Dawud", pyMod: "sunan_abi_dawud" },
118
+ { name: "jami-al-tirmidhi", pip: "jami-al-tirmidhi", label: "Jami al-Tirmidhi", author: "Imam Abu Isa Muhammad al-Tirmidhi", desc: "Part of the six major hadith collections, unique for grading each hadith's authenticity.", hadiths: "3,956", cmd: "tirmidhi", hook: "useTirmidhi", pyClass: "Tirmidhi", pyMod: "jami_al_tirmidhi" },
119
+ { name: "sunan-ibn-majah", pip: "sunan-ibn-majah", label: "Sunan Ibn Majah", author: "Imam Muhammad ibn Yazid Ibn Majah", desc: "Sixth of the six major canonical hadith collections.", hadiths: "4,341", cmd: "majah", hook: "useMajah", pyClass: "Majah", pyMod: "sunan_ibn_majah" },
120
+ { name: "sunan-al-nasai", pip: "sunan-al-nasai", label: "Sunan al-Nasa'i", author: "Imam Ahmad ibn Shu'ayb al-Nasa'i", desc: "Known for its strict standards in accepting transmitters.", hadiths: "5,768", cmd: "nasai", hook: "useNasai", pyClass: "Nasai", pyMod: "sunan_al_nasai" },
121
+ ];
122
+
123
+ const CMD_MAP = Object.fromEntries(PACKAGES.map(p => [p.cmd, p]));
124
+ const NAME_MAP = Object.fromEntries(PACKAGES.map(p => [p.name, p]));
125
+
126
+ // ── Terminal helpers ──────────────────────────────────────────────────────────
127
+ const W = () => process.stdout.columns || 80;
128
+ const H = () => process.stdout.rows || 24;
129
+ const enterAltScreen = () => process.stdout.write("\x1b[?1049h");
130
+ const leaveAltScreen = () => process.stdout.write("\x1b[?1049l");
131
+ const clearScreen = () => process.stdout.write("\x1b[2J\x1b[H");
132
+ const moveTo = (r, col) => process.stdout.write(`\x1b[${r};${col}H`);
133
+ const hideCursor = () => process.stdout.write("\x1b[?25l");
134
+ const showCursor = () => process.stdout.write("\x1b[?25h");
135
+ const clearToEOL = () => process.stdout.write("\x1b[K");
136
+ function writeLine(row, text) { moveTo(row, 1); clearToEOL(); process.stdout.write(text); }
137
+
138
+ // ── Progress bar ──────────────────────────────────────────────────────────────
139
+ function drawBar(label, percent, barWidth = 40) {
140
+ const filled = Math.round((percent / 100) * barWidth);
141
+ const empty = barWidth - filled;
142
+ const bar = c.green + "█".repeat(filled) + c.gray + "░".repeat(empty) + c.reset;
143
+ const pct = cyan(String(Math.round(percent)).padStart(3) + "%");
144
+ return ` ${bar} ${pct} ${dim(label)}`;
145
+ }
146
+
147
+ function animateInstall(pkgName, isPip = false) {
148
+ return new Promise(resolve => {
149
+ const stages = [
150
+ { label: "Resolving packages…", end: 12, ms: 80 },
151
+ { label: "Fetching metadata…", end: 30, ms: 60 },
152
+ { label: "Downloading tarball…", end: 75, ms: 22 },
153
+ { label: "Extracting files…", end: 90, ms: 50 },
154
+ { label: "Linking binaries…", end: 98, ms: 80 },
155
+ ];
156
+ let percent = 0, stageIdx = 0, npmDone = false;
157
+ process.stdout.write(drawBar(stages[0].label, 0));
158
+ const tick = () => {
159
+ const stage = stages[stageIdx]; if (!stage) return;
160
+ const prev = stageIdx > 0 ? stages[stageIdx - 1].end : 0;
161
+ percent = Math.min(percent + (stage.end - prev) / 24, stage.end);
162
+ process.stdout.write("\r\x1b[K" + drawBar(stage.label, percent));
163
+ if (percent >= stage.end) {
164
+ stageIdx++;
165
+ if (stageIdx >= stages.length) {
166
+ const poll = setInterval(() => {
167
+ if (npmDone) { clearInterval(poll); process.stdout.write("\r\x1b[K" + drawBar("Complete!", 100) + "\n"); resolve(); }
168
+ }, 80);
169
+ return;
170
+ }
171
+ }
172
+ setTimeout(tick, stages[stageIdx]?.ms ?? 50);
173
+ };
174
+ setTimeout(tick, stages[0].ms);
175
+ let proc;
176
+ if (isPip) {
177
+ const pipCmd = isWin ? "pip" : "pip3";
178
+ proc = isWin
179
+ ? spawn(`${pipCmd} install ${pkgName}`, [], { stdio: ["ignore", "pipe", "pipe"], shell: true })
180
+ : spawn(pipCmd, ["install", pkgName], { stdio: ["ignore", "pipe", "pipe"] });
181
+ } else {
182
+ proc = isWin
183
+ ? spawn("npm install -g " + pkgName, [], { stdio: ["ignore", "pipe", "pipe"], shell: true })
184
+ : spawn("npm", ["install", "-g", pkgName], { stdio: ["ignore", "pipe", "pipe"] });
185
+ }
186
+ proc.on("error", () => { npmDone = true; });
187
+ proc.on("close", () => { npmDone = true; });
188
+ });
189
+ }
190
+
191
+ // ── Installed caches ──────────────────────────────────────────────────────────
192
+ function buildInstalledCache() {
193
+ const cache = new Map();
194
+ for (const p of PACKAGES) cache.set(p.name, false);
195
+ if (!npmAvailable()) return cache;
196
+ let out = "";
197
+ try {
198
+ out = npmSync(["list", "-g", "--depth=0", "--json"], { timeout: 6000 });
199
+ const deps = JSON.parse(out)?.dependencies ?? {};
200
+ for (const p of PACKAGES) cache.set(p.name, p.name in deps);
201
+ } catch (e) {
202
+ const text = (e && e.stdout) || out || "";
203
+ for (const p of PACKAGES) cache.set(p.name, text.includes(p.name));
204
+ }
205
+ return cache;
206
+ }
207
+
208
+ function buildPipCache() {
209
+ const cache = new Map();
210
+ for (const p of PACKAGES) cache.set(p.pip, false);
211
+ if (!pipAvailable()) return cache;
212
+ try {
213
+ const out = pipSync(["list", "--format=json"], { timeout: 8000 });
214
+ const inst = JSON.parse(out).map(x => x.name.toLowerCase());
215
+ for (const p of PACKAGES) cache.set(p.pip, inst.includes(p.pip.toLowerCase()));
216
+ } catch {
217
+ for (const p of PACKAGES) {
218
+ try { const o = pipSync(["show", p.pip], { timeout: 4000 }); cache.set(p.pip, o.includes("Name:")); }
219
+ catch { cache.set(p.pip, false); }
220
+ }
221
+ }
222
+ return cache;
223
+ }
224
+
225
+ function getVersion(name, pip = false) {
226
+ try {
227
+ if (pip) {
228
+ const o = pipSync(["show", name], { timeout: 4000 });
229
+ const m = o.match(/^Version:\s+([\d.]+)/m);
230
+ return m ? m[1] : null;
231
+ }
232
+ const o = npmSync(["list", "-g", name, "--depth=0", "--json"], { timeout: 6000 });
233
+ return JSON.parse(o)?.dependencies?.[name]?.version ?? null;
234
+ } catch { return null; }
235
+ }
236
+
237
+ function getLatest(name, pip = false) {
238
+ try {
239
+ if (pip) {
240
+ const o = pipSync(["index", "versions", name], { timeout: 8000 });
241
+ const m = o.match(/LATEST:\s+([\d.]+)/);
242
+ return m ? m[1] : null;
243
+ }
244
+ const o = npmSync(["show", name, "version", "--json"], { timeout: 6000 });
245
+ return JSON.parse(o.trim());
246
+ } catch {
247
+ try { return pip ? null : npmSync(["show", name, "version"], { timeout: 6000 }).trim(); }
248
+ catch { return null; }
249
+ }
250
+ }
251
+
252
+ let installedCache = new Map(), pipCache = new Map();
253
+ const isInstalled = n => installedCache.get(n) ?? false;
254
+ const isPipInst = n => pipCache.get(n) ?? false;
255
+
256
+ // ── Update cache (background) ─────────────────────────────────────────────────
257
+ let updateCache = new Map(), updateReady = false;
258
+
259
+ async function prefetchUpdateCache() {
260
+ // refresh pip cache in background too
261
+ if (pipAvailable()) { const pc = buildPipCache(); for (const [k,v] of pc) pipCache.set(k,v); }
262
+ const installed = PACKAGES.filter(p => isInstalled(p.name));
263
+ if (!installed.length) { updateReady = true; return; }
264
+ let out = "";
265
+ await new Promise(resolve => {
266
+ const proc = isWin
267
+ ? spawn("npm list -g --depth=0 --json", [], { stdio: ["ignore", "pipe", "pipe"], shell: true })
268
+ : spawn("npm", ["list", "-g", "--depth=0", "--json"], { stdio: ["ignore", "pipe", "pipe"] });
269
+ proc.stdout?.on("data", d => { out += d; });
270
+ proc.on("error", () => resolve()); proc.on("close", () => resolve());
271
+ setTimeout(() => { try { proc.kill(); } catch {} resolve(); }, 6000);
272
+ });
273
+ const curVers = new Map();
274
+ try { const deps = JSON.parse(out)?.dependencies ?? {}; for (const p of installed) curVers.set(p.name, deps[p.name]?.version ?? null); } catch {}
275
+ // npm + pip share same version number — check all packages (npm or pip installed)
276
+ await Promise.all(PACKAGES.map(p => new Promise(resolve => {
277
+ const npmI = isInstalled(p.name), pipI = isPipInst(p.pip);
278
+ if (!npmI && !pipI) { resolve(); return; }
279
+ if (npmI) {
280
+ // fetch latest from npm registry
281
+ let v = "";
282
+ const proc = isWin
283
+ ? spawn("npm show " + p.name + " version", [], { stdio: ["ignore", "pipe", "pipe"], shell: true })
284
+ : spawn("npm", ["show", p.name, "version"], { stdio: ["ignore", "pipe", "pipe"] });
285
+ proc.stdout?.on("data", d => { v += d; });
286
+ proc.on("error", () => resolve());
287
+ proc.on("close", () => {
288
+ const latest = v.trim() || null, current = curVers.get(p.name) ?? null;
289
+ updateCache.set(p.name, { current, latest, hasUpdate: !!(current && latest && current !== latest) });
290
+ resolve();
291
+ });
292
+ setTimeout(() => { try { proc.kill(); } catch {} resolve(); }, 6000);
293
+ } else {
294
+ // pip-only: get installed version, mark for checking
295
+ const cur = getVersion(p.pip, true);
296
+ updateCache.set(p.name, { current: cur, latest: null, hasUpdate: false });
297
+ resolve();
298
+ }
299
+ })));
300
+ updateReady = true;
301
+ }
302
+
303
+ // ── Clipboard ─────────────────────────────────────────────────────────────────
304
+ function copyToClipboard(text) {
305
+ try {
306
+ if (isWin) execSync("clip", { input: text, shell: true });
307
+ else if (process.platform === "darwin") execSync("pbcopy", { input: text });
308
+ else execSync("xclip -selection clipboard || xsel --clipboard --input", { input: text, shell: true });
309
+ return true;
310
+ } catch { return false; }
311
+ }
312
+
313
+ // ── React hook generator ──────────────────────────────────────────────────────
314
+ function generateUnifiedHook(books) {
315
+ const cwd = process.cwd(), srcDir = path.join(cwd, "src"), hooksDir = path.join(srcDir, "hooks");
316
+ const pkgPath = path.join(cwd, "package.json");
317
+ if (!fs.existsSync(pkgPath)) { console.error(red("\n ✗ No package.json found. Run inside your React project.\n")); process.exit(1); }
318
+ const deps = { ...JSON.parse(fs.readFileSync(pkgPath, "utf8")).dependencies, ...JSON.parse(fs.readFileSync(pkgPath, "utf8")).devDependencies };
319
+ if (!deps["react"]) { console.error(red("\n ✗ React not found in package.json.\n")); process.exit(1); }
320
+ if (!fs.existsSync(srcDir)) { console.error(red("\n ✗ No src/ directory found.\n")); process.exit(1); }
321
+ if (!fs.existsSync(hooksDir)) { fs.mkdirSync(hooksDir, { recursive: true }); console.log(green(" ✓ Created src/hooks/")); }
322
+
323
+ const loaderBlocks = books.map(p => {
324
+ const CDN = `https://cdn.jsdelivr.net/npm/${p.name}@latest/chapters`;
325
+ return `\n// ── ${p.label} ──\nconst _${p.cmd}CDN='${CDN}';let _${p.cmd}Cache=null,_${p.cmd}Prom=null;const _${p.cmd}Subs=new Set();\nfunction _load${p.hook.replace("use","")}(){if(_${p.cmd}Cache)return Promise.resolve(_${p.cmd}Cache);if(_${p.cmd}Prom)return _${p.cmd}Prom;_${p.cmd}Prom=fetch(_${p.cmd}CDN+'/meta.json').then(r=>r.json()).then(meta=>Promise.all(meta.chapters.map(c=>fetch(_${p.cmd}CDN+'/'+c.id+'.json').then(r=>r.json()))).then(results=>{const hadiths=results.flat();const _byId=new Map();hadiths.forEach(h=>_byId.set(h.id,h));_${p.cmd}Cache=Object.assign([],hadiths,{metadata:meta.metadata,chapters:meta.chapters,get:id=>_byId.get(id),getByChapter:id=>hadiths.filter(h=>h.chapterId===id),search:(q,limit=0)=>{const ql=q.toLowerCase();const r=hadiths.filter(h=>h.english?.text?.toLowerCase().includes(ql)||h.english?.narrator?.toLowerCase().includes(ql));return limit>0?r.slice(0,limit):r;},getRandom:()=>hadiths[Math.floor(Math.random()*hadiths.length)]});_${p.cmd}Subs.forEach(fn=>fn(_${p.cmd}Cache));_${p.cmd}Subs.clear();return _${p.cmd}Cache;}));return _${p.cmd}Prom;}_load${p.hook.replace("use","")}();\nexport function ${p.hook}(){const[data,setData]=useState(_${p.cmd}Cache);useEffect(()=>{if(_${p.cmd}Cache){setData(_${p.cmd}Cache);}else{_${p.cmd}Subs.add(setData);return()=>_${p.cmd}Subs.delete(setData);}},[]);return data;}`;
326
+ }).join("\n");
327
+
328
+ const hookSrc = `// Auto-generated by: sunnah --react\n// Books: ${books.map(p => p.label).join(", ")}\nimport { useState, useEffect } from 'react';\n${loaderBlocks}\nexport function useSunnah(){const hooks=[${books.map(p => `${p.hook}()`).join(",")}];if(hooks.some(h=>!h))return null;return{${books.map((p,i) => `${p.cmd}:hooks[${i}]`).join(",")}};}\nexport default useSunnah;\n`;
329
+
330
+ const hookFile = path.join(hooksDir, "useSunnah.js");
331
+ fs.writeFileSync(hookFile, hookSrc, "utf8");
332
+ const div = gray("".repeat(60));
333
+ console.log("\n" + div);
334
+ console.log(bold(cyan(" ✓ Generated: src/hooks/useSunnah.js")));
335
+ console.log(div);
336
+ console.log("\n " + gray("Included books:"));
337
+ books.forEach(p => console.log(" " + green("▸") + " " + bold(p.label) + gray(" " + p.hook + "()")));
338
+ console.log("\n " + gray("Usage:") + "\n " + cyan("import { useSunnah } from '../hooks/useSunnah';"));
339
+ console.log(" " + gray("const sunnah = useSunnah();"));
340
+ if (books[0]) console.log(" " + gray(`sunnah.${books[0].cmd}.getRandom()`));
341
+ console.log("\n" + div + "\n");
342
+ }
343
+
344
+ // ── Personalized suggestions ──────────────────────────────────────────────────
345
+ function getPersonalizedSuggestions() {
346
+ const installed = PACKAGES.filter(p => isInstalled(p.name));
347
+ const missing = PACKAGES.filter(p => !isInstalled(p.name));
348
+ const tips = [];
349
+ if (installed.length === 0) {
350
+ tips.push(cyan(" ▸") + " Run " + bold("sunnah") + " to open the interactive installer");
351
+ tips.push(cyan(" ▸") + " Or install directly: " + bold("sunnah install bukhari"));
352
+ } else {
353
+ installed.slice(0, 2).forEach(p => {
354
+ tips.push(cyan(" ▸") + " " + bold(p.cmd + " --random") + gray(" — random " + p.label + " hadith"));
355
+ tips.push(cyan(" ▸") + " " + bold(`sunnah search "prayer"`) + gray(" — search all installed books"));
356
+ });
357
+ if (installed.length > 1) tips.push(cyan(" ▸") + " " + bold("sunnah --react") + gray(" — generate useSunnah() React hook"));
358
+ if (missing.length > 0) tips.push(cyan("") + " " + bold("sunnah") + gray(" — install more books (" + missing.map(p => p.label).join(", ") + ")"));
359
+ tips.push(cyan(" ▸") + " " + bold("sunnah --update") + gray(" — check for updates"));
360
+ }
361
+ return tips;
362
+ }
363
+
364
+ // ── Interactive TUI mode ──────────────────────────────────────────────────────
365
+ const MODE = { LIST: "list", CONFIRM_UNINSTALL: "confirm_uninstall" };
366
+
367
+ function render(state) {
368
+ const { cursor, selected, mode, confirmTarget, statusMsg } = state;
369
+ const divW = Math.min(W() - 2, 72);
370
+ const div = gray("─".repeat(divW));
371
+ const div2 = gray("═".repeat(divW));
372
+
373
+ clearScreen();
374
+ let row = 1;
375
+
376
+ writeLine(row++, div2);
377
+ writeLine(row++, bold(cyan(" 📚 Sunnah Package Manager")) + gray(" v" + pkg.version));
378
+ writeLine(row++,
379
+ gray(" ↑↓") + " nav " + gray("space") + " select " + gray("a") + " all " +
380
+ gray("enter") + " npm " + gray("p") + " pip " +
381
+ gray("u") + " uninstall " + gray("U") + " update " +
382
+ gray("i") + " info " + gray("q") + " quit"
383
+ );
384
+ writeLine(row++, div2);
385
+ if (!npmAvailable()) writeLine(row++, " " + yellow("⚠ npm not found") + gray(" — npm packages unavailable ") + dim("nodejs.org"));
386
+ if (!pipAvailable()) writeLine(row++, " " + yellow("⚠ pip not found") + gray(" — pip packages unavailable ") + dim("python.org"));
387
+ row++;
388
+
389
+ PACKAGES.forEach((p, i) => {
390
+ const isCursor = i === cursor;
391
+ const isSel = selected.has(i);
392
+ const inst = isInstalled(p.name);
393
+
394
+ const checkbox = isSel ? green("[✓]") : gray("[ ]");
395
+ const arrow = isCursor ? cyan("") : " ";
396
+ const label = isCursor ? bold(white(p.label)) : isSel ? green(p.label) : white(p.label);
397
+ const npmInst = isInstalled(p.name);
398
+ const pipInst = isPipInst(p.pip);
399
+ const anyInst = npmInst || pipInst;
400
+ // build compact badge: only show what IS installed
401
+ const labels = [...(npmInst ? ["npm"] : []), ...(pipInst ? ["pip"] : [])];
402
+ const instStr = labels.length ? "(" + labels.join(" + ") + ")" : "";
403
+ const uc = updateCache.get(p.name);
404
+ const updStr = uc?.hasUpdate ? " " + yellow("↑ update available") : (updateReady && anyInst && uc ? " " + dim(gray("(up to date)")) : "");
405
+ const badge = anyInst
406
+ ? dim(green(" ● ")) + dim(green(instStr)) + updStr
407
+ : dim(gray(" ○ not installed"));
408
+
409
+ writeLine(row++, ` ${arrow} ${checkbox} ${label}${badge}`);
410
+
411
+ if (isCursor) {
412
+ writeLine(row++, ` ${dim(p.author)}`);
413
+ writeLine(row++, ` ${gray(p.desc)}`);
414
+ const uc = updateCache.get(p.name);
415
+ // version — npm and pip share the same version number
416
+ const verInfo = anyInst
417
+ ? (uc ? (uc.hasUpdate
418
+ ? gray(" v") + yellow(uc.current) + gray(" → ") + green(uc.latest)
419
+ : gray(" v") + cyan(uc.current || "?"))
420
+ : gray(" (checking…)"))
421
+ : "";
422
+ const pkgDetail = [npmInst ? dim(gray("npm: " + p.name)) : "", pipInst ? dim(gray("pip: " + p.pip)) : ""].filter(Boolean).join(" ");
423
+ writeLine(row++,
424
+ ` ${gray("Hadiths: ")}${yellow(p.hadiths)}` +
425
+ ` ${gray("CLI: ")}${cyan(p.cmd + " --help")}` +
426
+ verInfo +
427
+ (pkgDetail ? ` ${pkgDetail}` : "") +
428
+ (anyInst ? ` ${gray("try: ")}${cyan(p.cmd + " --random")}` : "")
429
+ );
430
+ row++;
431
+ }
432
+ });
433
+
434
+ row++;
435
+ writeLine(row++, div);
436
+
437
+ if (statusMsg) {
438
+ writeLine(row++, ` ${yellow("⚠")} ${yellow(statusMsg)}`);
439
+ } else if (selected.size > 0) {
440
+ const names = [...selected].map(i => cyan(PACKAGES[i].name)).join(", ");
441
+ writeLine(row++, ` ${green("●")} ${bold(String(selected.size))} selected: ${names}`);
442
+ writeLine(row++, ` ${dim("enter = npm install p = pip install u = uninstall")}`);
443
+ } else {
444
+ writeLine(row++, ` ${gray("Nothing selected — press space to select, enter to install focused")}`);
445
+ }
446
+
447
+ writeLine(row++, div);
448
+
449
+ if (mode === MODE.CONFIRM_UNINSTALL && confirmTarget !== null) {
450
+ const p = PACKAGES[confirmTarget];
451
+ row++;
452
+ writeLine(row++, ` ${red("⚠ Uninstall ")}${bold(white(p.label))}${red("?")}`);
453
+ writeLine(row++, ` ${green("y")} ${gray("confirm")} ${red("n")} ${gray("cancel")}`);
454
+ }
455
+ }
456
+
457
+ // ── Non-interactive commands ──────────────────────────────────────────────────
458
+
459
+ function cmdList() {
460
+ installedCache = buildInstalledCache(); pipCache = buildPipCache();
461
+ const div = gray("─".repeat(60));
462
+ console.log("\n" + div);
463
+ console.log(bold(cyan(" Available Sunnah Packages")));
464
+ console.log(div);
465
+ PACKAGES.forEach(p => {
466
+ const n = isInstalled(p.name), pi = isPipInst(p.pip);
467
+ const nv = n ? getVersion(p.name) : null;
468
+ const nl = n ? getLatest(p.name) : null;
469
+ const vStr = n && nv && nl
470
+ ? (nv === nl ? dim(gray(" v" + nv + " (up to date)")) : yellow(" v" + nv) + gray(" → ") + green("v" + nl) + yellow(" ↑"))
471
+ : (n && nv ? dim(gray(" v" + nv)) : "");
472
+ console.log(`\n ${bold(white(p.label))}${n ? green(" ✓ npm") : red(" ✗ npm")}${pi ? green(" ✓ pip") : gray(" ○ pip")}${vStr}`);
473
+ console.log(` ${cyan("npm install -g " + p.name)} ${dim("pip install " + p.pip)}`);
474
+ console.log(` ${dim(p.desc)}`);
475
+ console.log(` ${gray("Hadiths: ")}${yellow(p.hadiths)} ${gray("Author: ")}${magenta(p.author)}`);
476
+ if (n) console.log(` ${gray("CLI: ")}${cyan(p.cmd + " --help")} ${gray("React: ")}${cyan("sunnah --react " + p.cmd)}`);
477
+ if (pi) console.log(` ${gray("Python: ")}${dim("from " + p.pyMod + " import " + p.pyClass)}`);
478
+ });
479
+ console.log("\n" + div + "\n");
480
+ }
481
+
482
+ function cmdInfo(target) {
483
+ const p = CMD_MAP[target?.toLowerCase()] || NAME_MAP[target];
484
+ if (!p) { console.log(red(`\n Unknown book: "${target}"\n`)); console.log(" Available: " + PACKAGES.map(x => cyan(x.cmd)).join(", ") + "\n"); process.exit(1); }
485
+ installedCache = buildInstalledCache(); pipCache = buildPipCache();
486
+ const n = isInstalled(p.name), pi = isPipInst(p.pip);
487
+ const nv = n ? getVersion(p.name) : null;
488
+ const pv = pi ? getVersion(p.pip, true) : null;
489
+ const div = gray("─".repeat(60)), div2 = gray("═".repeat(60));
490
+ console.log("\n" + div2 + "\n " + bold(cyan(p.label)) + "\n" + div2);
491
+ console.log(" " + gray("Author: ") + magenta(p.author));
492
+ console.log(" " + gray("Hadiths: ") + yellow(p.hadiths));
493
+ console.log(" " + gray("npm: ") + cyan(p.name) + (n ? green(" ✓ v" + nv) : red(" ✗ not installed")));
494
+ console.log(" " + gray("pip: ") + cyan(p.pip) + (pi ? green(" v" + pv) : red(" ✗ not installed")));
495
+ console.log(" " + gray("CLI: ") + cyan(p.cmd + " --help"));
496
+ console.log(" " + gray("React: ") + cyan("sunnah --react " + p.cmd));
497
+ console.log(" " + gray("Python: ") + dim("from " + p.pyMod + " import " + p.pyClass));
498
+ console.log(" " + gray("Desc: ") + p.desc);
499
+ console.log(div);
500
+ if (!n) console.log(" " + yellow("npm:") + " sunnah install " + p.cmd);
501
+ if (!pi) console.log(" " + yellow("pip:") + " sunnah pip install " + p.cmd);
502
+ console.log(div2 + "\n");
503
+ }
504
+
505
+ async function cmdRandom(bookCmd = null) {
506
+ installedCache = buildInstalledCache();
507
+ const inst = PACKAGES.filter(p => isInstalled(p.name));
508
+ if (!inst.length) { console.log(yellow("\n No packages installed. Run sunnah to install some.\n")); process.exit(0); }
509
+ const target = bookCmd ? CMD_MAP[bookCmd] : inst[Math.floor(Math.random() * inst.length)];
510
+ if (!target) { console.log(red(`\n Unknown book: ${bookCmd}\n`)); process.exit(1); }
511
+ try {
512
+ const mod = await import(target.name).catch(() => null);
513
+ if (!mod) { console.log(red("\n Could not load " + target.label + "\n")); process.exit(1); }
514
+ const h = mod.default.getRandom(), div2 = gray("═".repeat(60));
515
+ console.log("\n" + div2);
516
+ console.log(` ${bold(cyan("Hadith #" + h.id))} ${gray("|")} ${bold(target.label)}`);
517
+ console.log(div2);
518
+ if (h.english?.narrator) console.log(" " + bold(yellow("Narrator: ")) + magenta(h.english.narrator));
519
+ if (h.english?.text) { console.log(""); console.log(" " + h.english.text); }
520
+ console.log("\n" + div2 + "\n");
521
+ console.log(" " + gray("Try: ") + cyan(target.cmd + " " + h.id + " -b") + gray(" (Arabic + English)\n"));
522
+ } catch (_e) { console.log(red("\n Could not load " + target.label + ".\n")); }
523
+ }
524
+
525
+ async function cmdSearch(query, all = false) {
526
+ if (!query) { console.error(red('\n Usage: sunnah search "<query>" [--all]\n')); process.exit(1); }
527
+ installedCache = buildInstalledCache();
528
+ const inst = PACKAGES.filter(p => isInstalled(p.name));
529
+ if (!inst.length) { console.log(yellow("\n No packages installed.\n")); process.exit(0); }
530
+ const div = gray("─".repeat(60)), div2 = gray("═".repeat(60));
531
+ console.log("\n" + div2);
532
+ console.log(bold(cyan(" Searching across ")) + yellow(String(inst.length)) + bold(cyan(" book" + (inst.length > 1 ? "s" : "") + "…")));
533
+ console.log(div2 + "\n");
534
+ let total = 0;
535
+ for (const p of inst) {
536
+ process.stdout.write(" " + gray("Loading ") + white(p.label) + gray("…\r"));
537
+ try {
538
+ const mod = await import(p.name).catch(() => null);
539
+ if (!mod) { process.stdout.write("\x1b[K"); console.log(" " + gray("○ " + p.label + " — could not load")); continue; }
540
+ const results = mod.default.search ? mod.default.search(query, 0) : [];
541
+ process.stdout.write("\x1b[K");
542
+ if (!results.length) { console.log(" " + dim(gray(" " + p.label + " — no results"))); continue; }
543
+ total += results.length;
544
+ const limit = all ? results.length : Math.min(3, results.length);
545
+ console.log(" " + green("▸") + " " + bold(p.label) + gray(" " + results.length + " results"));
546
+ console.log(div);
547
+ results.slice(0, limit).forEach((h, i) => {
548
+ console.log("\n " + bold(green("#" + (i + 1))) + gray(" Hadith " + h.id));
549
+ if (h.english?.narrator) console.log(" " + bold(yellow("Narrator: ")) + magenta(h.english.narrator));
550
+ if (h.english?.text) {
551
+ const txt = h.english.text.slice(0, 200) + (h.english.text.length > 200 ? "…" : "");
552
+ const hi = txt.replace(new RegExp("(" + query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "gi"), "\x1b[1m\x1b[33m$1\x1b[0m");
553
+ console.log(" " + hi);
554
+ }
555
+ console.log(dim(gray(" " + p.cmd + " " + h.id + " -b")));
556
+ });
557
+ if (!all && results.length > 3) console.log("\n " + dim("Showing 3 of " + results.length + ". ") + yellow(`sunnah search "${query}" --all`));
558
+ console.log(div);
559
+ } catch { process.stdout.write("\x1b[K"); console.log(" " + gray("○ " + p.label + " — skipped")); }
560
+ }
561
+ console.log("\n" + div2);
562
+ console.log(" " + green("✓") + " " + bold(String(total)) + gray(" total results across ") + yellow(String(inst.length)) + gray(" book" + (inst.length > 1 ? "s" : "") + "."));
563
+ console.log(div2 + "\n");
564
+ }
565
+
566
+ async function cmdPipInstall(targets) {
567
+ if (!targets.length) { console.error(red("\n Usage: sunnah pip install <book>\n")); process.exit(1); }
568
+ if (!pipAvailable()) { warnNoPip(); process.exit(1); }
569
+ const to = [];
570
+ for (const t of targets) {
571
+ const p = CMD_MAP[t.toLowerCase()] || NAME_MAP[t];
572
+ if (!p) { console.log(yellow(`\n Unknown: "${t}"`)); process.exit(1); }
573
+ to.push(p);
574
+ }
575
+ const div2 = gray("".repeat(60));
576
+ console.log("\n" + div2);
577
+ console.log(bold(cyan(" Installing ")) + bold(yellow(String(to.length))) + bold(cyan(" Python package" + (to.length > 1 ? "s" : "") + "…")));
578
+ console.log(div2);
579
+ for (let i = 0; i < to.length; i++) {
580
+ const p = to[i];
581
+ console.log(`\n ${cyan("[" + (i + 1) + "/" + to.length + "]")} ${bold(white(p.label))}`);
582
+ console.log(" " + dim("pip install " + p.pip) + "\n");
583
+ await animateInstall(p.pip, true);
584
+ console.log(" " + green("✓") + " " + bold(green(p.label)) + " (Python) installed");
585
+ console.log(" " + gray("Usage: ") + dim("from " + p.pyMod + " import " + p.pyClass));
586
+ }
587
+ console.log("\n" + div2 + "\n");
588
+ }
589
+
590
+ function cmdPipList() {
591
+ if (!pipAvailable()) { warnNoPip(); return; }
592
+ pipCache = buildPipCache(); const div = gray("".repeat(60));
593
+ console.log("\n" + div + "\n " + bold(cyan(" Python (pip) Packages")) + "\n" + div);
594
+ PACKAGES.forEach(p => {
595
+ const i = isPipInst(p.pip), v = i ? getVersion(p.pip, true) : null;
596
+ console.log(`\n ${bold(white(p.label))}${i ? green(" ✓ v" + (v || "?")) : red(" ✗ not installed")}`);
597
+ console.log(` ${dim("pip install " + p.pip)}`);
598
+ if (i) console.log(` ${gray("from ")}${cyan(p.pyMod)}${gray(" import ")}${cyan(p.pyClass)}`);
599
+ });
600
+ console.log("\n" + div + "\n");
601
+ }
602
+
603
+ async function cmdPipUpdate() {
604
+ if (!pipAvailable()) { warnNoPip(); return; }
605
+ pipCache = buildPipCache();
606
+ const inst = PACKAGES.filter(p => isPipInst(p.pip));
607
+ if (!inst.length) { console.log(yellow("\n No pip packages installed. Run: sunnah pip install <book>\n")); return; }
608
+ const div2 = gray("═".repeat(60));
609
+ console.log("\n" + div2 + "\n " + bold(cyan(" Checking Python packages for updates…")) + "\n" + div2 + "\n");
610
+ const ups = [];
611
+ for (const p of inst) {
612
+ process.stdout.write(" " + gray("Checking ") + white(p.label) + gray("…\r"));
613
+ const cur = getVersion(p.pip, true), lat = getLatest(p.pip, true);
614
+ process.stdout.write("\x1b[K");
615
+ if (!cur || !lat) { console.log(` ${yellow("?")} ${bold(p.label)} ${gray("(could not check)")}`); continue; }
616
+ if (cur === lat) { console.log(` ${green("✓")} ${bold(p.label)} ${gray("v" + cur + " — up to date")}`); }
617
+ else { ups.push({ p, cur, lat }); console.log(` ${yellow("↑")} ${bold(p.label)} ${gray("v" + cur)} ${green("v" + lat)} ${yellow("(update available)")}`); }
618
+ }
619
+ console.log("\n" + div2);
620
+ if (!ups.length) { console.log(" " + green("✓ All Python packages are up to date.")); console.log(div2 + "\n"); return; }
621
+ console.log(" " + yellow(String(ups.length)) + " update" + (ups.length > 1 ? "s" : "") + " available.");
622
+ console.log(" Run " + bold(cyan("sunnah pip update --install")) + gray(" to install all."));
623
+ console.log(div2 + "\n");
624
+ }
625
+
626
+ async function cmdUpdate(autoInstall = false) {
627
+ if (!npmAvailable()) { warnNoNpm(); return; }
628
+ installedCache = buildInstalledCache();
629
+ const inst = PACKAGES.filter(p => isInstalled(p.name));
630
+ if (!inst.length) { console.log("\n " + yellow("No sunnah packages installed. Run ") + bold("sunnah") + yellow(" to install.\n")); return; }
631
+ const div = gray("─".repeat(60)), div2 = gray("═".repeat(60));
632
+ console.log("\n" + div2 + "\n " + bold(cyan(" Checking for updates…")) + "\n" + div2 + "\n");
633
+ const ups = [];
634
+ for (const p of inst) {
635
+ process.stdout.write(" " + gray("Checking ") + white(p.label) + gray("…\r"));
636
+ const cur = getVersion(p.name), lat = getLatest(p.name);
637
+ process.stdout.write("\x1b[K");
638
+ if (!cur || !lat) { console.log(` ${yellow("?")} ${bold(p.label)} ${gray("(could not check)")}`); continue; }
639
+ if (cur === lat) { console.log(` ${green("✓")} ${bold(p.label)} ${gray("v" + cur + " — up to date")}`); }
640
+ else { ups.push({ p, cur, lat }); console.log(` ${yellow("↑")} ${bold(p.label)} ${gray("v" + cur)} → ${green("v" + lat)} ${yellow("(update available)")}`); }
641
+ }
642
+ console.log("\n" + div);
643
+ if (!ups.length) { console.log(" " + green("✓ All packages are up to date.")); console.log(div + "\n"); return; }
644
+ console.log(" " + yellow(String(ups.length)) + " update" + (ups.length > 1 ? "s" : "") + " available.");
645
+ if (autoInstall) {
646
+ console.log(bold(cyan("\n Installing updates…"))); console.log(div + "\n");
647
+ for (let i = 0; i < ups.length; i++) {
648
+ const { p, cur, lat } = ups[i];
649
+ console.log(" " + cyan("[" + (i + 1) + "/" + ups.length + "]") + " " + bold(white(p.label)) + gray(" v" + cur + " → v" + lat) + "\n");
650
+ await animateInstall(p.name);
651
+ console.log(" " + green("✓") + " " + bold(green(p.label)) + gray(" updated to v" + lat));
652
+ }
653
+ console.log("\n" + div2 + "\n " + green("✓ All updates installed.") + "\n" + div2 + "\n");
654
+ } else {
655
+ console.log(" Run " + bold(cyan("sunnah --update --install")) + gray(" to install all updates automatically."));
656
+ ups.forEach(({ p }) => console.log(" " + dim("sunnah install " + p.cmd)));
657
+ console.log(div + "\n");
658
+ }
659
+ }
660
+
661
+ async function cmdInstall(targets) {
662
+ if (!targets.length) {
663
+ console.error(red("\n ✗ Usage: sunnah install <book> (e.g. sunnah install bukhari)\n"));
664
+ console.log(" Available: " + PACKAGES.map(p => cyan(p.cmd)).join(", ") + "\n");
665
+ process.exit(1);
666
+ }
667
+ if (!npmAvailable()) { warnNoNpm(); process.exit(1); }
668
+ const to = [];
669
+ for (const t of targets) {
670
+ const p = CMD_MAP[t.toLowerCase()] || NAME_MAP[t];
671
+ if (!p) { console.log(yellow(`\n ⚠ Unknown package: "${t}"`)); process.exit(1); }
672
+ to.push(p);
673
+ }
674
+ const div2 = gray("═".repeat(60));
675
+ console.log("\n" + div2);
676
+ console.log(bold(cyan(" Installing ")) + bold(yellow(String(to.length))) + bold(cyan(" package" + (to.length > 1 ? "s" : "") + "…")));
677
+ console.log(div2);
678
+ for (let i = 0; i < to.length; i++) {
679
+ const p = to[i];
680
+ console.log(`\n ${cyan("[" + (i + 1) + "/" + to.length + "]")} ${bold(white(p.label))}`);
681
+ console.log(" " + dim("npm install -g " + p.name) + "\n");
682
+ await animateInstall(p.name);
683
+ installedCache.set(p.name, true);
684
+ console.log(" " + green("✓") + " " + bold(green(p.label)) + " installed");
685
+ console.log(" " + gray("Usage: ") + cyan(p.cmd + " --help"));
686
+ console.log(" " + gray("Python: ") + dim("pip install " + p.pip));
687
+ }
688
+ console.log("\n" + div2);
689
+ console.log(" " + green("✓ Done! ") + to.map(p => bold(cyan(p.cmd))).join(", ") + gray(" ready."));
690
+ console.log(div2 + "\n");
691
+ }
692
+
693
+ function cmdUninstall(targets) {
694
+ if (!targets.length) { console.error(red("\n ✗ Usage: sunnah uninstall <book>\n")); process.exit(1); }
695
+ if (!npmAvailable()) { warnNoNpm(); process.exit(1); }
696
+ installedCache = buildInstalledCache();
697
+ const div2 = gray("".repeat(60));
698
+ console.log("\n" + div2);
699
+ for (const t of targets) {
700
+ const p = CMD_MAP[t.toLowerCase()] || NAME_MAP[t];
701
+ if (!p) { console.log(yellow(` ⚠ Unknown: "${t}"\n`)); continue; }
702
+ if (!isInstalled(p.name)) { console.log(gray(`${p.label} is not installed, skipping.`)); continue; }
703
+ console.log(yellow(" Uninstalling ") + bold(white(p.label)) + yellow("…"));
704
+ try { npmSync(["uninstall", "-g", p.name], { stdio: "inherit" }); installedCache.set(p.name, false); console.log(green(" ✓ ") + bold(green(p.label)) + green(" uninstalled.\n")); }
705
+ catch { console.log(red(" ✗ Failed to uninstall " + p.label + "\n")); }
706
+ }
707
+ console.log(div2 + "\n");
708
+ }
709
+
710
+ function cmdVersion() {
711
+ installedCache = buildInstalledCache(); pipCache = buildPipCache();
712
+ const n = PACKAGES.filter(p => isInstalled(p.name)), pi = PACKAGES.filter(p => isPipInst(p.pip));
713
+ const div = gray("─".repeat(60));
714
+ console.log("\n" + div + "\n " + bold(cyan(" 📿 sunnah")) + gray(" v" + pkg.version) + "\n" + div);
715
+ console.log(" " + gray("Available : ") + yellow(String(PACKAGES.length)));
716
+ console.log(" " + gray("npm inst. : ") + green(String(n.length)) + gray(" / " + PACKAGES.length));
717
+ console.log(" " + gray("pip inst. : ") + (pi.length ? green(String(pi.length)) : gray("0")) + gray(" / " + PACKAGES.length));
718
+ if (n.length) {
719
+ console.log(" " + gray("Collection : ") + n.map(p => cyan(p.label)).join(gray(", ")));
720
+ const tot = n.reduce((a, p) => a + parseInt(p.hadiths.replace(/,/g, "")), 0);
721
+ console.log(" " + gray("Hadiths : ") + bold(yellow(tot.toLocaleString())));
722
+ }
723
+ console.log("\n" + div + "\n");
724
+ }
725
+
726
+ function cmdHelp() {
727
+ installedCache = buildInstalledCache();
728
+ const div = gray("─".repeat(60));
729
+ console.log("\n" + div + "\n " + bold(cyan(" 📿 Sunnah Package Manager")) + gray(" v" + pkg.version) + "\n" + div);
730
+ console.log("\n " + bold("Commands:"));
731
+ [
732
+ ["sunnah", "Open interactive installer UI ← arrow keys, space, enter"],
733
+ ["sunnah install <book>", "Install npm package(s)"],
734
+ ["sunnah uninstall <book>", "Uninstall npm package(s)"],
735
+ ["sunnah pip install <book>", "Install Python (pip) package(s)"],
736
+ ["sunnah pip list", "List Python package status"],
737
+ ["sunnah pip update", "Check Python packages for updates"],
738
+ ['sunnah search "<query>"', "Search across ALL installed books"],
739
+ ["sunnah random [book]", "Random hadith from installed books"],
740
+ ["sunnah info <book>", "Detailed info for a book"],
741
+ ["sunnah --react [books]", "Generate unified useSunnah() React hook"],
742
+ ["sunnah --list", "List all packages with install status"],
743
+ ["sunnah --update", "Check npm packages for updates"],
744
+ ["sunnah --update --install", "Auto-install all available updates"],
745
+ ["sunnah -v", "Version + collection stats"],
746
+ ["sunnah -h", "This help"],
747
+ ].forEach(([cmd, desc]) => console.log(" " + cyan(cmd.padEnd(34)) + gray(desc)));
748
+ console.log("\n " + bold("Book names (cmd alias or full npm name):"));
749
+ PACKAGES.forEach(p => {
750
+ const inst = isInstalled(p.name);
751
+ console.log(" " + cyan(p.cmd.padEnd(12)) + gray(p.name.padEnd(26)) + yellow(p.hadiths + " hadiths") + (inst ? green(" ✓") : ""));
752
+ });
753
+ console.log("\n " + bold("Interactive UI controls:"));
754
+ [["↑ ↓", "Navigate"], ["space", "Toggle select"], ["a", "Select all / deselect all"],
755
+ ["i", "Show info + installed version"], ["u", "Uninstall selected"],
756
+ ["U", "Update selected"], ["enter", "Install selected"], ["q", "Quit"]
757
+ ].forEach(([k, d]) => console.log(" " + green(k.padEnd(8)) + gray(d)));
758
+ console.log("\n " + bold("Examples:"));
759
+ ["sunnah install bukhari", "sunnah install bukhari muslim nasai", "sunnah pip install bukhari",
760
+ 'sunnah search "prayer"', "sunnah random", "sunnah --react", "sunnah --update"
761
+ ].forEach(e => console.log(" " + dim(e)));
762
+ const tips = getPersonalizedSuggestions();
763
+ if (tips.length) { console.log("\n" + div + "\n " + bold("💡 Suggested for you:")); tips.forEach(t => console.log(t)); }
764
+ console.log("\n" + div + "\n");
765
+ }
766
+
767
+ // ── Main ──────────────────────────────────────────────────────────────────────
768
+ async function main() {
769
+ const rawArgs = process.argv.slice(2);
770
+ const flags = rawArgs.filter(a => a.startsWith("-"));
771
+ const positional = rawArgs.filter(a => !a.startsWith("-"));
772
+
773
+ if (flags.some(f => f === "-v" || f === "--version")) { cmdVersion(); process.exit(0); }
774
+ if (flags.some(f => f === "-h" || f === "--help")) { cmdHelp(); process.exit(0); }
775
+ if (flags.some(f => f === "--list" || f === "-l")) { cmdList(); process.exit(0); }
776
+ if (flags.some(f => f === "--update")) { await cmdUpdate(flags.some(f => f === "--install")); process.exit(0); }
777
+
778
+ if (positional[0] === "install") { await cmdInstall(positional.slice(1)); process.exit(0); }
779
+ if (positional[0] === "uninstall") { cmdUninstall(positional.slice(1)); process.exit(0); }
780
+ if (positional[0] === "info") { cmdInfo(positional[1]); process.exit(0); }
781
+ if (positional[0] === "random") { await cmdRandom(positional[1] || null); process.exit(0); }
782
+ if (positional[0] === "search") { await cmdSearch(positional.slice(1).join(" "), flags.some(f => f === "--all")); process.exit(0); }
783
+
784
+ if (positional[0] === "pip") {
785
+ const sub = positional[1];
786
+ if (sub === "install") { await cmdPipInstall(positional.slice(2)); process.exit(0); }
787
+ if (sub === "list") { cmdPipList(); process.exit(0); }
788
+ if (sub === "update") { await cmdPipUpdate(); process.exit(0); }
789
+ console.error(red(`\n ✗ Unknown pip command: "${sub}". Use: install, list, update\n`)); process.exit(1);
790
+ }
791
+
792
+ if (flags.some(f => f === "--react")) {
793
+ installedCache = buildInstalledCache();
794
+ const requestedCmds = positional;
795
+ let books;
796
+ if (requestedCmds.length > 0) {
797
+ books = requestedCmds.map(cmd => CMD_MAP[cmd.toLowerCase()]).filter(Boolean);
798
+ const unknown = requestedCmds.filter(cmd => !CMD_MAP[cmd.toLowerCase()]);
799
+ if (unknown.length) { console.log(yellow("\n ⚠ Unknown books: " + unknown.join(", "))); console.log(" Available: " + Object.keys(CMD_MAP).join(", ") + "\n"); }
800
+ } else {
801
+ books = PACKAGES.filter(p => isInstalled(p.name));
802
+ if (!books.length) { console.log(yellow("\n ⚠ No sunnah packages installed yet.\n Run ") + bold("sunnah") + yellow(" to install some first, or specify books:\n ") + dim("sunnah --react bukhari muslim") + "\n"); process.exit(1); }
803
+ }
804
+ if (!books.length) { console.log(red("\n ✗ No valid books specified.\n")); process.exit(1); }
805
+ generateUnifiedHook(books); process.exit(0);
806
+ }
807
+
808
+ // ── Interactive TUI (default no args given) ─────────────────────────────
809
+ process.stdout.write("\n " + gray("Checking installed packages…"));
810
+ installedCache = buildInstalledCache();
811
+ process.stdout.write("\r\x1b[K");
812
+
813
+ const persisted = loadState();
814
+ const lastSelected = new Set((persisted.lastSelected || []).filter(i => i < PACKAGES.length));
815
+
816
+ enterAltScreen();
817
+ hideCursor();
818
+
819
+ const state = {
820
+ cursor: 0,
821
+ selected: lastSelected,
822
+ mode: MODE.LIST,
823
+ confirmTarget: null,
824
+ statusMsg: "",
825
+ };
826
+
827
+ let statusTimer = null;
828
+
829
+ function setStatus(msg, ms = 2500) {
830
+ state.statusMsg = msg;
831
+ render(state);
832
+ if (statusTimer) clearTimeout(statusTimer);
833
+ statusTimer = setTimeout(() => { state.statusMsg = ""; render(state); }, ms);
834
+ }
835
+
836
+ render(state);
837
+ prefetchUpdateCache().then(() => render(state));
838
+
839
+ const cleanup = () => {
840
+ saveState({ lastSelected: [...state.selected] });
841
+ showCursor();
842
+ leaveAltScreen();
843
+ try { process.stdin.setRawMode(false); } catch {}
844
+ process.stdin.pause();
845
+ };
846
+
847
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
848
+
849
+ // ── stdin setup — handles Windows PowerShell + cmd + Unix ─────────────────
850
+ process.stdin.resume();
851
+ readline.emitKeypressEvents(process.stdin);
852
+ try { process.stdin.setRawMode(true); } catch {}
853
+
854
+ let busy = false;
855
+
856
+ const keypressHandler = async (str, key) => {
857
+ if (!key) return;
858
+ if (str === "q" || str === "Q" || (key.ctrl && key.name === "c")) { cleanup(); process.exit(0); }
859
+ if (busy) return;
860
+
861
+ // ── Confirm uninstall mode ──────────────────────────────────────────────
862
+ if (state.mode === MODE.CONFIRM_UNINSTALL) {
863
+ if (str === "y" || str === "Y") {
864
+ const p = PACKAGES[state.confirmTarget];
865
+ state.mode = MODE.LIST; state.confirmTarget = null; busy = true;
866
+ cleanup();
867
+ console.log("\n " + yellow("Uninstalling ") + bold(white(p.label)) + yellow("…\n"));
868
+ try {
869
+ if (npmAvailable() && isInstalled(p.name)) { npmSync(["uninstall", "-g", p.name], { stdio: "inherit" }); installedCache.set(p.name, false); console.log(" " + green("✓") + " " + bold(p.label) + gray(" (npm) uninstalled")); }
870
+ if (pipAvailable() && isPipInst(p.pip)) { pipSync(["uninstall", "-y", p.pip], { stdio: "inherit" }); pipCache.set(p.pip, false); console.log(" " + green("✓") + " " + bold(p.label) + gray(" (pip) uninstalled")); }
871
+ state.selected.delete(PACKAGES.indexOf(p));
872
+ console.log("");
873
+ } catch { console.log("\n " + red("✗ Failed to uninstall " + p.label) + "\n"); }
874
+ busy = false;
875
+ reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
876
+ } else { state.mode = MODE.LIST; state.confirmTarget = null; render(state); }
877
+ return;
878
+ }
879
+
880
+ // ── Normal navigation ───────────────────────────────────────────────────
881
+ if (key.name === "up") { state.cursor = (state.cursor - 1 + PACKAGES.length) % PACKAGES.length; render(state); return; }
882
+ if (key.name === "down") { state.cursor = (state.cursor + 1) % PACKAGES.length; render(state); return; }
883
+
884
+ if (str === " ") {
885
+ state.selected.has(state.cursor) ? state.selected.delete(state.cursor) : state.selected.add(state.cursor);
886
+ render(state); return;
887
+ }
888
+
889
+ if (str === "a" || str === "A") {
890
+ state.selected.size === PACKAGES.length ? state.selected.clear() : PACKAGES.forEach((_, i) => state.selected.add(i));
891
+ render(state); return;
892
+ }
893
+
894
+ if (str === "i" || str === "I") {
895
+ const p = PACKAGES[state.cursor], inst = isInstalled(p.name), ver = inst ? getVersion(p.name) : null;
896
+ setStatus(`${p.label} | ${p.hadiths} hadiths | ${p.author} | ${inst ? "v" + ver + " installed CLI: " + p.cmd + " --help" : "not installed"}`, 4000);
897
+ return;
898
+ }
899
+
900
+ // p = pip install
901
+ if (str === "p" || str === "P") {
902
+ if (!pipAvailable()) { setStatus("pip not found — install Python from https://python.org", 5000); return; }
903
+ const targets = state.selected.size > 0 ? [...state.selected].map(i => PACKAGES[i]) : [PACKAGES[state.cursor]];
904
+ const toInstall = targets.filter(p => !isPipInst(p.pip));
905
+ if (!toInstall.length) { setStatus("All selected already installed via pip."); return; }
906
+ busy = true; cleanup();
907
+ const div2 = gray("═".repeat(Math.min(W() - 2, 72)));
908
+ console.log("\n" + div2);
909
+ console.log(bold(cyan(" pip installing ")) + bold(yellow(String(toInstall.length))) + bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")));
910
+ console.log(div2);
911
+ for (let i = 0; i < toInstall.length; i++) {
912
+ const p = toInstall[i];
913
+ console.log("\n " + cyan("[" + (i + 1) + "/" + toInstall.length + "]") + " " + bold(white(p.label)));
914
+ console.log(" " + dim("pip install " + p.pip) + "\n");
915
+ await animateInstall(p.pip, true);
916
+ pipCache.set(p.pip, true);
917
+ console.log(" " + green("✓") + " " + bold(green(p.label)) + " (pip) installed");
918
+ console.log(" " + gray("Python: ") + dim("from " + p.pyMod + " import " + p.pyClass));
919
+ }
920
+ console.log("\n" + div2 + "\n");
921
+ await sleep(600);
922
+ busy = false; reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
923
+ return;
924
+ }
925
+
926
+ if (str === "u") {
927
+ const targets = state.selected.size > 0 ? [...state.selected] : [state.cursor];
928
+ const toRemove = targets.filter(i => isInstalled(PACKAGES[i].name) || isPipInst(PACKAGES[i].pip));
929
+ if (!toRemove.length) { setStatus("No installed packages selected."); return; }
930
+ state.mode = MODE.CONFIRM_UNINSTALL; state.confirmTarget = toRemove[0]; render(state); return;
931
+ }
932
+
933
+ if (str === "U") {
934
+ const targets = state.selected.size > 0 ? [...state.selected] : [state.cursor];
935
+ const toUpdate = targets.filter(i => (isInstalled(PACKAGES[i].name) || isPipInst(PACKAGES[i].pip)) && updateCache.get(PACKAGES[i].name)?.hasUpdate);
936
+ if (!toUpdate.length) { setStatus(updateReady ? "All selected packages are up to date." : "Update info still loading — try again shortly."); return; }
937
+ busy = true; cleanup();
938
+ const divW = Math.min(W() - 2, 72), div2 = gray("═".repeat(divW));
939
+ console.log("\n" + div2);
940
+ console.log(bold(cyan(" Updating ")) + bold(yellow(String(toUpdate.length))) + bold(cyan(" package" + (toUpdate.length > 1 ? "s" : "") + "…")));
941
+ console.log(div2);
942
+ for (let i = 0; i < toUpdate.length; i++) {
943
+ const p = PACKAGES[toUpdate[i]], uc = updateCache.get(p.name);
944
+ console.log("\n " + cyan("[" + (i + 1) + "/" + toUpdate.length + "]") + " " + bold(white(p.label)));
945
+ console.log(" " + dim(gray("v" + uc.current + " → v" + uc.latest)) + "\n");
946
+ await animateInstall(p.name);
947
+ updateCache.set(p.name, { current: uc.latest, latest: uc.latest, hasUpdate: false });
948
+ console.log(" " + green("✓") + " " + bold(green(p.label)) + gray(" updated to v" + uc.latest));
949
+ }
950
+ console.log("\n" + div2 + "\n");
951
+ busy = false; reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
952
+ return;
953
+ }
954
+
955
+ if (key.name === "return") {
956
+ if (!npmAvailable()) { setStatus("npm not found — install Node.js from https://nodejs.org", 5000); return; }
957
+ const targets = state.selected.size > 0 ? [...state.selected].map(i => PACKAGES[i]) : [PACKAGES[state.cursor]];
958
+ const toInstall = targets.filter(p => !isInstalled(p.name));
959
+
960
+ if (!toInstall.length) { setStatus("All selected already installed via npm. Press p to pip install."); return; }
961
+
962
+ busy = true; cleanup();
963
+ const divW = Math.min(W() - 2, 72);
964
+ const div = gray("─".repeat(divW)), div2 = gray("═".repeat(divW));
965
+
966
+ console.log("\n" + div2);
967
+ console.log(bold(cyan(" Installing ")) + bold(yellow(String(toInstall.length))) + bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")));
968
+ console.log(div2);
969
+
970
+ for (let i = 0; i < toInstall.length; i++) {
971
+ const p = toInstall[i];
972
+ console.log("\n " + cyan("[" + (i + 1) + "/" + toInstall.length + "]") + " " + bold(white(p.label)));
973
+ console.log(" " + dim("npm install -g " + p.name) + "\n");
974
+ await animateInstall(p.name);
975
+ installedCache.set(p.name, true);
976
+ console.log(" " + green("✓") + " " + bold(green(p.label)) + " installed");
977
+ const uc = updateCache.get(p.name);
978
+ if (uc && uc.current) {
979
+ console.log(" " + gray("Version: ") + cyan("v" + uc.current) + (uc.hasUpdate ? " " + gray("Latest: ") + green("v" + uc.latest) + " " + yellow("↑ update available") : uc.latest ? " " + dim(gray("(up to date)")) : ""));
980
+ }
981
+ console.log(" " + gray("Usage: ") + cyan(p.cmd + " --help"));
982
+ console.log(" " + gray("Python: ") + dim("pip install " + p.pip));
983
+ }
984
+
985
+ console.log("\n" + div2);
986
+ console.log(" " + green("✓ All done! ") + bold(yellow(String(toInstall.length))) + " package" + (toInstall.length > 1 ? "s" : "") + " installed globally.");
987
+ console.log("");
988
+ toInstall.forEach(p => console.log(" " + cyan("▸") + " " + bold(p.cmd) + gray(" --help") + " " + dim(p.label)));
989
+
990
+ const allInstalled = PACKAGES.filter(p => isInstalled(p.name));
991
+ if (allInstalled.length > 1) {
992
+ console.log("\n " + dim("Tip: run ") + bold("sunnah --react") + dim(" to generate a unified React hook for all your books"));
993
+ }
994
+
995
+ await sleep(800);
996
+ busy = false;
997
+ reEnterMenu(state, render, prefetchUpdateCache, keypressHandler);
998
+ }
999
+ };
1000
+
1001
+ process.stdin.on("keypress", keypressHandler);
1002
+ }
1003
+
1004
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
1005
+
1006
+ function reEnterMenu(state, render, prefetchUpdateCache, keypressHandler) {
1007
+ installedCache = buildInstalledCache();
1008
+ updateReady = false;
1009
+ updateCache.clear();
1010
+ process.stdin.removeAllListeners("keypress");
1011
+ process.stdin.pause();
1012
+ enterAltScreen();
1013
+ hideCursor();
1014
+ render(state);
1015
+ prefetchUpdateCache().then(() => render(state));
1016
+ process.stdin.resume();
1017
+ readline.emitKeypressEvents(process.stdin);
1018
+ try { process.stdin.setRawMode(true); } catch {}
1019
+ process.stdin.on("keypress", keypressHandler);
1020
+ }
1021
+
1022
+ main().catch(err => { showCursor(); leaveAltScreen(); console.error(red("\n ✗ " + err.message + "\n")); process.exit(1); });