sunnah 1.2.0 → 1.2.1

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.

Potentially problematic release.


This version of sunnah might be problematic. Click here for more details.

Files changed (3) hide show
  1. package/README.md +158 -111
  2. package/bin/index.js +675 -132
  3. package/package.json +1 -1
package/bin/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { fileURLToPath } from "url";
4
4
  import path from "path";
5
5
  import fs from "fs";
6
+ import os from "os";
6
7
  import { execSync, spawn } from "child_process";
7
8
  import readline from "readline";
8
9
 
@@ -16,6 +17,28 @@ const pkg = JSON.parse(
16
17
  const isWin = process.platform === "win32";
17
18
  const NPM = isWin ? "npm.cmd" : "npm";
18
19
 
20
+ // ── Persistence: remember last selections ────────────────────────────────────
21
+ const STATE_FILE = path.join(os.homedir(), ".sunnah-state.json");
22
+
23
+ function loadPersistedState() {
24
+ try {
25
+ const raw = fs.readFileSync(STATE_FILE, "utf8");
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ function savePersistedState(data) {
33
+ try {
34
+ const existing = loadPersistedState();
35
+ fs.writeFileSync(
36
+ STATE_FILE,
37
+ JSON.stringify({ ...existing, ...data }, null, 2),
38
+ );
39
+ } catch {}
40
+ }
41
+
19
42
  // ── Colors ────────────────────────────────────────────────────────────────────
20
43
  const c = {
21
44
  reset: "\x1b[0m",
@@ -52,6 +75,7 @@ const PACKAGES = [
52
75
  desc: "The most authentic collection of hadith, widely regarded as the most sahih after the Quran.",
53
76
  hadiths: "7,563",
54
77
  cmd: "bukhari",
78
+ hook: "useBukhari",
55
79
  },
56
80
  {
57
81
  name: "sahih-muslim",
@@ -60,6 +84,7 @@ const PACKAGES = [
60
84
  desc: "Second most authentic hadith collection, known for its strict methodology and chain verification.",
61
85
  hadiths: "7,470",
62
86
  cmd: "muslim",
87
+ hook: "useMuslim",
63
88
  },
64
89
  {
65
90
  name: "sunan-abi-dawud",
@@ -68,6 +93,7 @@ const PACKAGES = [
68
93
  desc: "One of the six canonical hadith collections, focused on legal rulings and jurisprudence.",
69
94
  hadiths: "5,274",
70
95
  cmd: "dawud",
96
+ hook: "useDawud",
71
97
  },
72
98
  {
73
99
  name: "jami-al-tirmidhi",
@@ -76,10 +102,14 @@ const PACKAGES = [
76
102
  desc: "Part of the six major hadith collections, unique for grading each hadith's authenticity.",
77
103
  hadiths: "3,956",
78
104
  cmd: "tirmidhi",
105
+ hook: "useTirmidhi",
79
106
  },
80
107
  ];
81
108
 
82
- // ── Terminal: use alternate screen buffer to avoid scroll issues ──────────────
109
+ // cmd package lookup
110
+ const CMD_MAP = Object.fromEntries(PACKAGES.map((p) => [p.cmd, p]));
111
+
112
+ // ── Terminal: alternate screen buffer = no scroll ─────────────────────────────
83
113
  const W = () => process.stdout.columns || 80;
84
114
  const H = () => process.stdout.rows || 24;
85
115
 
@@ -92,8 +122,8 @@ function leaveAltScreen() {
92
122
  function clearScreen() {
93
123
  process.stdout.write("\x1b[2J\x1b[H");
94
124
  }
95
- function moveTo(row, col) {
96
- process.stdout.write(`\x1b[${row};${col}H`);
125
+ function moveTo(r, col) {
126
+ process.stdout.write(`\x1b[${r};${col}H`);
97
127
  }
98
128
  function hideCursor() {
99
129
  process.stdout.write("\x1b[?25l");
@@ -121,7 +151,7 @@ function drawBar(label, percent, barWidth = 40) {
121
151
  return ` ${bar} ${pct} ${dim(label)}`;
122
152
  }
123
153
 
124
- // ── Install a package with animated single progress bar ───────────────────────
154
+ // ── Animate install with real npm running behind ──────────────────────────────
125
155
  function animateInstall(pkgName) {
126
156
  return new Promise((resolve) => {
127
157
  const stages = [
@@ -131,29 +161,22 @@ function animateInstall(pkgName) {
131
161
  { label: "Extracting files…", end: 90, ms: 50 },
132
162
  { label: "Linking binaries…", end: 98, ms: 80 },
133
163
  ];
134
-
135
164
  let percent = 0;
136
165
  let stageIdx = 0;
137
166
  let npmDone = false;
138
167
 
139
- // Write bar ONCE — all updates overwrite this same line with \r
140
168
  process.stdout.write(drawBar(stages[0].label, 0));
141
169
 
142
170
  const tick = () => {
143
171
  const stage = stages[stageIdx];
144
172
  if (!stage) return;
145
-
146
173
  const prevEnd = stageIdx > 0 ? stages[stageIdx - 1].end : 0;
147
174
  const step = (stage.end - prevEnd) / 24;
148
175
  percent = Math.min(percent + step, stage.end);
149
-
150
- // Overwrite the SAME line — no \n, just \r
151
176
  process.stdout.write("\r\x1b[K" + drawBar(stage.label, percent));
152
-
153
177
  if (percent >= stage.end) {
154
178
  stageIdx++;
155
179
  if (stageIdx >= stages.length) {
156
- // Spin until npm actually finishes
157
180
  const poll = setInterval(() => {
158
181
  if (npmDone) {
159
182
  clearInterval(poll);
@@ -166,13 +189,10 @@ function animateInstall(pkgName) {
166
189
  return;
167
190
  }
168
191
  }
169
-
170
192
  setTimeout(tick, stages[stageIdx]?.ms ?? 50);
171
193
  };
172
194
 
173
195
  setTimeout(tick, stages[0].ms);
174
-
175
- // Actually run npm install -g
176
196
  const proc = spawn(NPM, ["install", "-g", pkgName], {
177
197
  stdio: ["ignore", "pipe", "pipe"],
178
198
  shell: isWin,
@@ -186,7 +206,7 @@ function animateInstall(pkgName) {
186
206
  });
187
207
  }
188
208
 
189
- // ── Installed cache — built ONCE, never during render ────────────────────────
209
+ // ── Installed cache — ONE npm call at startup, Map lookup during render ───────
190
210
  function buildInstalledCache() {
191
211
  const cache = new Map();
192
212
  let out = "";
@@ -206,7 +226,6 @@ function buildInstalledCache() {
206
226
  return cache;
207
227
  }
208
228
 
209
- // Get latest version from npm registry (fast, single HTTP call)
210
229
  function getLatestVersion(name) {
211
230
  try {
212
231
  return execSync(`${NPM} show ${name} version`, {
@@ -220,7 +239,6 @@ function getLatestVersion(name) {
220
239
  }
221
240
  }
222
241
 
223
- // Get currently installed version
224
242
  function getInstalledVersion(name) {
225
243
  try {
226
244
  const out = execSync(`${NPM} list -g ${name} --depth=0`, {
@@ -239,10 +257,253 @@ function getInstalledVersion(name) {
239
257
  let installedCache = new Map();
240
258
  const isInstalled = (name) => installedCache.get(name) ?? false;
241
259
 
260
+ // ── Clipboard ─────────────────────────────────────────────────────────────────
261
+ function copyToClipboard(text) {
262
+ try {
263
+ if (isWin) {
264
+ execSync("clip", { input: text, shell: true });
265
+ } else if (process.platform === "darwin") {
266
+ execSync("pbcopy", { input: text });
267
+ } else {
268
+ execSync("xclip -selection clipboard || xsel --clipboard --input", {
269
+ input: text,
270
+ shell: true,
271
+ });
272
+ }
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ // ── React hook generator ──────────────────────────────────────────────────────
280
+ function generateUnifiedHook(books) {
281
+ const cwd = process.cwd();
282
+ const srcDir = path.join(cwd, "src");
283
+ const hooksDir = path.join(srcDir, "hooks");
284
+
285
+ const pkgPath = path.join(cwd, "package.json");
286
+ if (!fs.existsSync(pkgPath)) {
287
+ console.error(
288
+ red("\n ✗ No package.json found. Run inside your React project.\n"),
289
+ );
290
+ process.exit(1);
291
+ }
292
+ const projectPkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
293
+ const deps = { ...projectPkg.dependencies, ...projectPkg.devDependencies };
294
+ if (!deps["react"]) {
295
+ console.error(red("\n ✗ React not found in package.json.\n"));
296
+ process.exit(1);
297
+ }
298
+ if (!fs.existsSync(srcDir)) {
299
+ console.error(red("\n ✗ No src/ directory found.\n"));
300
+ process.exit(1);
301
+ }
302
+ if (!fs.existsSync(hooksDir)) {
303
+ fs.mkdirSync(hooksDir, { recursive: true });
304
+ console.log(green(" ✓ Created src/hooks/"));
305
+ }
306
+
307
+ // Build per-book loader blocks
308
+ const loaderBlocks = books
309
+ .map((p) => {
310
+ const CDN = `https://cdn.jsdelivr.net/npm/${p.name}@latest/chapters`;
311
+ return `
312
+ // ── ${p.label} ──
313
+ const _${p.cmd}CDN = '${CDN}';
314
+ let _${p.cmd}Cache = null;
315
+ let _${p.cmd}Prom = null;
316
+ const _${p.cmd}Subs = new Set();
317
+
318
+ function _load${p.hook.replace("use", "")}() {
319
+ if (_${p.cmd}Cache) return Promise.resolve(_${p.cmd}Cache);
320
+ if (_${p.cmd}Prom) return _${p.cmd}Prom;
321
+ _${p.cmd}Prom = fetch(_${p.cmd}CDN + '/meta.json')
322
+ .then(r => r.json())
323
+ .then(meta => Promise.all(
324
+ meta.chapters.map(c => fetch(_${p.cmd}CDN + '/' + c.id + '.json').then(r => r.json()))
325
+ ).then(results => {
326
+ const hadiths = results.flat();
327
+ const _byId = new Map();
328
+ hadiths.forEach(h => _byId.set(h.id, h));
329
+ _${p.cmd}Cache = Object.assign([], hadiths, {
330
+ metadata: meta.metadata,
331
+ chapters: meta.chapters,
332
+ get: (id) => _byId.get(id),
333
+ getByChapter: (id) => hadiths.filter(h => h.chapterId === id),
334
+ search: (q, limit = 0) => {
335
+ const ql = q.toLowerCase();
336
+ const r = hadiths.filter(h =>
337
+ h.english?.text?.toLowerCase().includes(ql) ||
338
+ h.english?.narrator?.toLowerCase().includes(ql)
339
+ );
340
+ return limit > 0 ? r.slice(0, limit) : r;
341
+ },
342
+ getRandom: () => hadiths[Math.floor(Math.random() * hadiths.length)],
343
+ });
344
+ _${p.cmd}Subs.forEach(fn => fn(_${p.cmd}Cache));
345
+ _${p.cmd}Subs.clear();
346
+ return _${p.cmd}Cache;
347
+ }));
348
+ return _${p.cmd}Prom;
349
+ }
350
+ _load${p.hook.replace("use", "")}();
351
+
352
+ export function ${p.hook}() {
353
+ const [data, setData] = useState(_${p.cmd}Cache);
354
+ useEffect(() => {
355
+ if (_${p.cmd}Cache) { setData(_${p.cmd}Cache); }
356
+ else { _${p.cmd}Subs.add(setData); return () => _${p.cmd}Subs.delete(setData); }
357
+ }, []);
358
+ return data;
359
+ }`;
360
+ })
361
+ .join("\n");
362
+
363
+ const hookNames = books.map((p) => p.hook).join(", ");
364
+
365
+ const hookSrc = `// Auto-generated by: sunnah --react
366
+ // Re-run to regenerate after installing more sunnah packages.
367
+ //
368
+ // Included books: ${books.map((p) => p.label).join(", ")}
369
+ //
370
+ // Usage:
371
+ ${books.map((p) => `// import { ${p.hook} } from '../hooks/useSunnah';`).join("\n")}
372
+ //
373
+ // const bukhari = useBukhari();
374
+ // if (!bukhari) return <p>Loading...</p>;
375
+ // bukhari.get(1) // hadith by ID
376
+ // bukhari.search('prayer', 5) // top 5 results
377
+ // bukhari.getRandom() // random hadith
378
+ // bukhari.getByChapter(1) // all hadiths in chapter
379
+
380
+ import { useState, useEffect } from 'react';
381
+ ${loaderBlocks}
382
+
383
+ // Unified hook — loads all books in parallel
384
+ export function useSunnah() {
385
+ const hooks = [${books.map((p) => `${p.hook}()`).join(", ")}];
386
+ if (hooks.some(h => !h)) return null;
387
+ return { ${books.map((p) => p.cmd + ": hooks[" + books.indexOf(p) + "]").join(", ")} };
388
+ }
389
+
390
+ export default useSunnah;
391
+ `;
392
+
393
+ const hookFile = path.join(hooksDir, "useSunnah.js");
394
+ fs.writeFileSync(hookFile, hookSrc, "utf8");
395
+
396
+ const div = gray("─".repeat(60));
397
+ console.log("\n" + div);
398
+ console.log(bold(cyan(" ✓ Generated: src/hooks/useSunnah.js")));
399
+ console.log(div);
400
+ console.log("\n " + gray("Included books:"));
401
+ books.forEach((p) =>
402
+ console.log(
403
+ " " + green("▸") + " " + bold(p.label) + gray(" " + p.hook + "()"),
404
+ ),
405
+ );
406
+ console.log("\n " + gray("Usage:"));
407
+ console.log(
408
+ " " +
409
+ cyan("import { useSunnah") +
410
+ (books.length > 1
411
+ ? ", " +
412
+ books
413
+ .slice(0, 2)
414
+ .map((p) => p.hook)
415
+ .join(", ")
416
+ : "") +
417
+ cyan(" } from '../hooks/useSunnah';"),
418
+ );
419
+ console.log("");
420
+ console.log(" " + gray("// Unified — all books at once"));
421
+ console.log(" " + gray("const sunnah = useSunnah();"));
422
+ console.log(" " + gray("if (!sunnah) return <p>Loading...</p>;"));
423
+ if (books[0])
424
+ console.log(" " + gray(`sunnah.${books[0].cmd}.getRandom()`));
425
+ console.log("");
426
+ console.log(" " + gray("// Per-book hooks still work"));
427
+ if (books[0]) {
428
+ console.log(" " + gray(`const ${books[0].cmd} = ${books[0].hook}();`));
429
+ console.log(" " + gray(`${books[0].cmd}.get(1).english.text`));
430
+ }
431
+ console.log("\n" + div + "\n");
432
+ }
433
+
434
+ // ── Personalized suggestions based on what's installed ───────────────────────
435
+ function getPersonalizedSuggestions() {
436
+ const installed = PACKAGES.filter((p) => isInstalled(p.name));
437
+ const missing = PACKAGES.filter((p) => !isInstalled(p.name));
438
+ const tips = [];
439
+
440
+ if (installed.length === 0) {
441
+ tips.push(
442
+ cyan(" ▸") +
443
+ " Run " +
444
+ bold("sunnah") +
445
+ " to open the interactive installer",
446
+ );
447
+ tips.push(
448
+ cyan(" ▸") + " Or install directly: " + bold("sunnah install bukhari"),
449
+ );
450
+ } else {
451
+ installed.forEach((p) => {
452
+ tips.push(
453
+ cyan(" ▸") +
454
+ " " +
455
+ bold(p.cmd + " --random") +
456
+ gray(" — read a random " + p.label + " hadith"),
457
+ );
458
+ tips.push(
459
+ cyan(" ▸") +
460
+ " " +
461
+ bold(p.cmd + ' --search "prayer"') +
462
+ gray(" — search " + p.label),
463
+ );
464
+ });
465
+ if (installed.length > 1) {
466
+ tips.push(
467
+ cyan(" ▸") +
468
+ " " +
469
+ bold("sunnah --react") +
470
+ gray(" — generate useSunnah() hook for all installed books"),
471
+ );
472
+ } else if (installed.length === 1) {
473
+ tips.push(
474
+ cyan(" ▸") +
475
+ " " +
476
+ bold("sunnah --react " + installed[0].cmd) +
477
+ gray(" — generate React hook for " + installed[0].label),
478
+ );
479
+ }
480
+ if (missing.length > 0) {
481
+ tips.push(
482
+ cyan(" ▸") +
483
+ " " +
484
+ bold("sunnah") +
485
+ gray(
486
+ " — install more books (" +
487
+ missing.map((p) => p.label).join(", ") +
488
+ ")",
489
+ ),
490
+ );
491
+ }
492
+ tips.push(
493
+ cyan(" ▸") +
494
+ " " +
495
+ bold("sunnah --update") +
496
+ gray(" — check for updates"),
497
+ );
498
+ }
499
+
500
+ return tips;
501
+ }
502
+
242
503
  // ── Modes ─────────────────────────────────────────────────────────────────────
243
504
  const MODE = { LIST: "list", CONFIRM_UNINSTALL: "confirm_uninstall" };
244
505
 
245
- // ── Render: full-screen, uses alt buffer so no scroll ever ───────────────────
506
+ // ── Interactive render ────────────────────────────────────────────────────────
246
507
  function render(state) {
247
508
  const { cursor, selected, mode, confirmTarget, statusMsg } = state;
248
509
  const divW = Math.min(W() - 2, 72);
@@ -250,10 +511,8 @@ function render(state) {
250
511
  const div2 = gray("═".repeat(divW));
251
512
 
252
513
  clearScreen();
253
-
254
514
  let row = 1;
255
515
 
256
- // Header
257
516
  writeLine(row++, div2);
258
517
  writeLine(
259
518
  row++,
@@ -262,7 +521,7 @@ function render(state) {
262
521
  writeLine(
263
522
  row++,
264
523
  gray(" ↑↓") +
265
- " navigate " +
524
+ " nav " +
266
525
  gray("space") +
267
526
  " select " +
268
527
  gray("a") +
@@ -277,9 +536,8 @@ function render(state) {
277
536
  " quit",
278
537
  );
279
538
  writeLine(row++, div2);
280
- row++; // blank
539
+ row++;
281
540
 
282
- // Package list
283
541
  PACKAGES.forEach((p, i) => {
284
542
  const isCursor = i === cursor;
285
543
  const isSel = selected.has(i);
@@ -305,16 +563,15 @@ function render(state) {
305
563
  row++,
306
564
  ` ${gray("Hadiths: ")}${yellow(p.hadiths)}` +
307
565
  ` ${gray("CLI: ")}${cyan(p.cmd + " --help")}` +
308
- (inst ? ` ${gray("run: ")}${cyan(p.cmd + " 1")}` : ""),
566
+ (inst ? ` ${gray("try: ")}${cyan(p.cmd + " --random")}` : ""),
309
567
  );
310
- row++; // blank after expanded
568
+ row++;
311
569
  }
312
570
  });
313
571
 
314
- row++; // blank
572
+ row++;
315
573
  writeLine(row++, div);
316
574
 
317
- // Status / selection footer
318
575
  if (statusMsg) {
319
576
  writeLine(row++, ` ${yellow("⚠")} ${yellow(statusMsg)}`);
320
577
  } else if (selected.size > 0) {
@@ -323,20 +580,16 @@ function render(state) {
323
580
  row++,
324
581
  ` ${green("●")} ${bold(String(selected.size))} selected: ${names}`,
325
582
  );
326
- writeLine(
327
- row++,
328
- ` ${dim("Press enter to install, u to uninstall selected")}`,
329
- );
583
+ writeLine(row++, ` ${dim("enter = install u = uninstall i = info")}`);
330
584
  } else {
331
585
  writeLine(
332
586
  row++,
333
- ` ${gray("Nothing selected — press space to select a package")}`,
587
+ ` ${gray("Nothing selected — press space to select, enter to install focused")}`,
334
588
  );
335
589
  }
336
590
 
337
591
  writeLine(row++, div);
338
592
 
339
- // Confirm uninstall overlay
340
593
  if (mode === MODE.CONFIRM_UNINSTALL && confirmTarget !== null) {
341
594
  const p = PACKAGES[confirmTarget];
342
595
  row++;
@@ -351,37 +604,45 @@ function render(state) {
351
604
  }
352
605
  }
353
606
 
354
- // ── Non-interactive: --list ───────────────────────────────────────────────────
607
+ // ── --list ────────────────────────────────────────────────────────────────────
355
608
  function cmdList() {
356
609
  installedCache = buildInstalledCache();
357
610
  const div = gray("─".repeat(60));
358
- console.log("");
359
- console.log(div);
611
+ console.log("\n" + div);
360
612
  console.log(bold(cyan(" Available Sunnah Packages")));
361
613
  console.log(div);
362
614
  PACKAGES.forEach((p) => {
363
- const inst = isInstalled(p.name)
364
- ? green(" ✓ installed")
365
- : red(" ✗ not installed");
366
- console.log("");
367
- console.log(` ${bold(white(p.label))}${inst}`);
615
+ const inst = isInstalled(p.name);
616
+ const badge = inst ? green(" ✓ installed") : red(" ✗ not installed");
617
+ const version = inst
618
+ ? dim(gray(" " + (getInstalledVersion(p.name) || "")))
619
+ : "";
620
+ console.log(`\n ${bold(white(p.label))}${badge}${version}`);
368
621
  console.log(` ${cyan("npm install -g " + p.name)}`);
369
622
  console.log(` ${dim(p.desc)}`);
370
623
  console.log(
371
624
  ` ${gray("Hadiths: ")}${yellow(p.hadiths)} ${gray("Author: ")}${magenta(p.author)}`,
372
625
  );
626
+ if (inst) {
627
+ console.log(
628
+ ` ${gray("CLI: ")}${cyan(p.cmd + " --help")} ${gray("React: ")}${cyan("sunnah --react " + p.cmd)}`,
629
+ );
630
+ }
373
631
  });
374
- console.log("");
375
- console.log(div);
376
- console.log("");
632
+ console.log("\n" + div + "\n");
377
633
  }
378
634
 
379
- // ── Non-interactive: --update ─────────────────────────────────────────────────
635
+ // ── --update ──────────────────────────────────────────────────────────────────
380
636
  function cmdUpdate() {
381
637
  installedCache = buildInstalledCache();
382
638
  const installed = PACKAGES.filter((p) => isInstalled(p.name));
383
639
  if (!installed.length) {
384
- console.log("\n " + yellow("No sunnah packages installed.\n"));
640
+ console.log(
641
+ "\n " +
642
+ yellow("No sunnah packages installed. Run ") +
643
+ bold("sunnah") +
644
+ yellow(" to install.\n"),
645
+ );
385
646
  return;
386
647
  }
387
648
  const div = gray("─".repeat(60));
@@ -389,11 +650,14 @@ function cmdUpdate() {
389
650
  console.log(bold(cyan(" Checking for updates…")));
390
651
  console.log(div + "\n");
391
652
 
653
+ let hasUpdates = false;
392
654
  for (const p of installed) {
393
655
  const current = getInstalledVersion(p.name);
394
656
  const latest = getLatestVersion(p.name);
395
657
  if (!current || !latest) {
396
- console.log(` ${yellow("?")} ${p.label} ${gray("(could not check)")}`);
658
+ console.log(
659
+ ` ${yellow("?")} ${bold(p.label)} ${gray("(could not check)")}`,
660
+ );
397
661
  continue;
398
662
  }
399
663
  if (current === latest) {
@@ -401,105 +665,387 @@ function cmdUpdate() {
401
665
  ` ${green("✓")} ${bold(p.label)} ${gray(current + " — up to date")}`,
402
666
  );
403
667
  } else {
668
+ hasUpdates = true;
404
669
  console.log(
405
- ` ${yellow("↑")} ${bold(p.label)} ${gray(current)} ${green(latest)} ${dim("(run: npm install -g " + p.name + ")")}`,
670
+ ` ${yellow("↑")} ${bold(p.label)} ${gray(current)} ${gray("→")} ${green(latest)}`,
406
671
  );
672
+ console.log(` ${dim("sunnah install " + p.cmd)}`);
407
673
  }
408
674
  }
675
+
676
+ if (hasUpdates) {
677
+ console.log(
678
+ "\n " +
679
+ yellow("Updates available. Run ") +
680
+ bold("sunnah install <name>") +
681
+ yellow(" to update."),
682
+ );
683
+ }
409
684
  console.log("\n" + div + "\n");
410
685
  }
411
686
 
412
- // ── Main ──────────────────────────────────────────────────────────────────────
413
- async function main() {
414
- const rawArgs = process.argv.slice(2);
415
- const flags = rawArgs.filter((a) => a.startsWith("-"));
416
-
417
- // --version
418
- if (flags.some((f) => f === "-v" || f === "--version")) {
419
- console.log("\n " + bold(cyan("sunnah")) + gray(" v" + pkg.version));
687
+ // ── sunnah install <cmd> ──────────────────────────────────────────────────────
688
+ async function cmdInstall(targets) {
689
+ if (!targets.length) {
690
+ console.error(
691
+ red(
692
+ "\n Usage: sunnah install <name> (e.g. sunnah install bukhari)\n",
693
+ ),
694
+ );
420
695
  console.log(
421
- " " + gray("Packages: ") + yellow(String(PACKAGES.length)) + "\n",
696
+ " Available: " + PACKAGES.map((p) => cyan(p.cmd)).join(", ") + "\n",
422
697
  );
423
- process.exit(0);
698
+ process.exit(1);
424
699
  }
425
700
 
426
- // --help
427
- if (flags.some((f) => f === "-h" || f === "--help")) {
428
- const div = gray("─".repeat(60));
429
- console.log("\n" + div);
701
+ // Resolve names — accept cmd alias or full npm name
702
+ const toInstall = [];
703
+ for (const t of targets) {
704
+ const byCmd = CMD_MAP[t.toLowerCase()];
705
+ const byName = PACKAGES.find((p) => p.name === t);
706
+ const found = byCmd || byName;
707
+ if (!found) {
708
+ console.log(yellow(`\n ⚠ Unknown package: "${t}"`));
709
+ console.log(
710
+ " Available: " +
711
+ PACKAGES.map((p) => cyan(p.cmd) + gray(" (" + p.name + ")")).join(
712
+ ", ",
713
+ ),
714
+ );
715
+ process.exit(1);
716
+ }
717
+ toInstall.push(found);
718
+ }
719
+
720
+ const divW = Math.min(W() - 2, 72);
721
+ const div2 = gray("═".repeat(divW));
722
+
723
+ console.log("\n" + div2);
724
+ console.log(
725
+ bold(cyan(" Installing ")) +
726
+ bold(yellow(String(toInstall.length))) +
727
+ bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")),
728
+ );
729
+ console.log(div2);
730
+
731
+ for (let i = 0; i < toInstall.length; i++) {
732
+ const p = toInstall[i];
430
733
  console.log(
431
- bold(cyan(" Sunnah Package Manager")) + gray(" v" + pkg.version),
734
+ "\n " +
735
+ cyan("[" + (i + 1) + "/" + toInstall.length + "]") +
736
+ " " +
737
+ bold(white(p.label)),
432
738
  );
433
- console.log(div + "\n");
434
- console.log(" " + bold("Usage:"));
435
- console.log(
436
- " " + cyan("sunnah") + gray(" Open interactive UI"),
739
+ console.log(" " + dim("npm install -g " + p.name) + "\n");
740
+ await animateInstall(p.name);
741
+ installedCache.set(p.name, true);
742
+ console.log(" " + green("") + " " + bold(green(p.label)) + " installed");
743
+ console.log(" " + gray("Usage: ") + cyan(p.cmd + " --help"));
744
+ }
745
+
746
+ console.log("\n" + div2);
747
+ console.log(
748
+ " " +
749
+ green("✓ Done! ") +
750
+ toInstall.map((p) => bold(cyan(p.cmd))).join(", ") +
751
+ gray(" ready."),
752
+ );
753
+ console.log(div2 + "\n");
754
+ }
755
+
756
+ // ── sunnah uninstall <cmd> ────────────────────────────────────────────────────
757
+ function cmdUninstall(targets) {
758
+ if (!targets.length) {
759
+ console.error(
760
+ red(
761
+ "\n ✗ Usage: sunnah uninstall <name> (e.g. sunnah uninstall bukhari)\n",
762
+ ),
437
763
  );
764
+ process.exit(1);
765
+ }
766
+
767
+ installedCache = buildInstalledCache();
768
+ const divW = Math.min(W() - 2, 72);
769
+ const div2 = gray("═".repeat(divW));
770
+
771
+ console.log("\n" + div2);
772
+
773
+ for (const t of targets) {
774
+ const p =
775
+ CMD_MAP[t.toLowerCase()] || PACKAGES.find((pkg) => pkg.name === t);
776
+ if (!p) {
777
+ console.log(yellow(` ⚠ Unknown: "${t}"`) + "\n");
778
+ continue;
779
+ }
780
+ if (!isInstalled(p.name)) {
781
+ console.log(gray(` ○ ${p.label} is not installed, skipping.`));
782
+ continue;
783
+ }
784
+ console.log(yellow(" Uninstalling ") + bold(white(p.label)) + yellow("…"));
785
+ try {
786
+ execSync(`${NPM} uninstall -g ${p.name}`, {
787
+ stdio: "inherit",
788
+ shell: isWin,
789
+ });
790
+ installedCache.set(p.name, false);
791
+ console.log(
792
+ green(" ✓ ") + bold(green(p.label)) + green(" uninstalled.\n"),
793
+ );
794
+ } catch {
795
+ console.log(red(" ✗ Failed to uninstall " + p.label + "\n"));
796
+ }
797
+ }
798
+
799
+ console.log(div2 + "\n");
800
+ }
801
+
802
+ // ── --version (personalized) ──────────────────────────────────────────────────
803
+ function cmdVersion() {
804
+ installedCache = buildInstalledCache();
805
+ const installed = PACKAGES.filter((p) => isInstalled(p.name));
806
+ const div = gray("─".repeat(60));
807
+
808
+ console.log("\n" + div);
809
+ console.log(bold(cyan(" 📿 sunnah")) + gray(" v" + pkg.version));
810
+ console.log(div);
811
+ console.log(
812
+ " " + gray("Available packages : ") + yellow(String(PACKAGES.length)),
813
+ );
814
+ console.log(
815
+ " " +
816
+ gray("Installed : ") +
817
+ green(String(installed.length)) +
818
+ gray(" / " + PACKAGES.length),
819
+ );
820
+
821
+ if (installed.length > 0) {
438
822
  console.log(
439
- " " +
440
- cyan("sunnah") +
441
- green(" --list") +
442
- gray(" List all packages + install status"),
823
+ " " +
824
+ gray("Your collection : ") +
825
+ installed.map((p) => cyan(p.label)).join(gray(", ")),
443
826
  );
444
- console.log(
445
- " " +
446
- cyan("sunnah") +
447
- green(" --update") +
448
- gray(" Check all installed packages for updates"),
827
+ const totalHadiths = installed.reduce(
828
+ (acc, p) => acc + parseInt(p.hadiths.replace(/,/g, "")),
829
+ 0,
449
830
  );
450
831
  console.log(
451
- " " + cyan("sunnah") + green(" -v") + gray(" Show version"),
832
+ " " +
833
+ gray("Total hadiths : ") +
834
+ bold(yellow(totalHadiths.toLocaleString())),
452
835
  );
836
+ }
837
+
838
+ console.log("\n" + div + "\n");
839
+ }
840
+
841
+ // ── --help (personalized) ─────────────────────────────────────────────────────
842
+ function cmdHelp() {
843
+ installedCache = buildInstalledCache();
844
+ const div = gray("─".repeat(60));
845
+
846
+ console.log("\n" + div);
847
+ console.log(
848
+ bold(cyan(" 📿 Sunnah Package Manager")) + gray(" v" + pkg.version),
849
+ );
850
+ console.log(div);
851
+
852
+ console.log("\n " + bold("Commands:"));
853
+ console.log(
854
+ " " +
855
+ cyan("sunnah") +
856
+ gray(" Open interactive installer UI"),
857
+ );
858
+ console.log(
859
+ " " +
860
+ cyan("sunnah install") +
861
+ yellow(" <name>") +
862
+ gray(" Install a package directly"),
863
+ );
864
+ console.log(
865
+ " " +
866
+ cyan("sunnah uninstall") +
867
+ yellow(" <name>") +
868
+ gray(" Uninstall a package"),
869
+ );
870
+ console.log(
871
+ " " +
872
+ cyan("sunnah --react") +
873
+ gray(" Generate unified useSunnah() React hook"),
874
+ );
875
+ console.log(
876
+ " " +
877
+ cyan("sunnah --react") +
878
+ yellow(" <books>") +
879
+ gray(" Generate hook for specific books"),
880
+ );
881
+ console.log(
882
+ " " +
883
+ cyan("sunnah --list") +
884
+ gray(" List all packages with install status"),
885
+ );
886
+ console.log(
887
+ " " +
888
+ cyan("sunnah --update") +
889
+ gray(" Check installed packages for updates"),
890
+ );
891
+ console.log(
892
+ " " +
893
+ cyan("sunnah -v") +
894
+ gray(" Version + your collection stats"),
895
+ );
896
+ console.log(
897
+ " " +
898
+ cyan("sunnah -h") +
899
+ gray(" This help + personalized tips"),
900
+ );
901
+
902
+ console.log("\n " + bold("Package names (use any form):"));
903
+ PACKAGES.forEach((p) => {
453
904
  console.log(
454
905
  " " +
455
- cyan("sunnah") +
456
- green(" -h") +
457
- gray(" Show this help"),
458
- );
459
- console.log("\n " + bold("Interactive controls:"));
460
- console.log(" " + green("↑ ↓") + gray(" Navigate"));
461
- console.log(" " + green("space") + gray(" Toggle select"));
462
- console.log(
463
- " " + green("a") + gray(" Select all / deselect all"),
906
+ cyan(p.cmd.padEnd(12)) +
907
+ gray(p.name.padEnd(24)) +
908
+ yellow(p.hadiths + " hadiths"),
464
909
  );
465
- console.log(" " + green("i") + gray(" Show package info"));
466
- console.log(" " + green("u") + gray(" Uninstall selected"));
467
- console.log(" " + green("enter") + gray(" Install selected"));
468
- console.log(" " + green("q") + gray(" Quit"));
469
- console.log("\n" + div + "\n");
910
+ });
911
+
912
+ console.log("\n " + bold("Examples:"));
913
+ console.log(" " + dim("sunnah install bukhari"));
914
+ console.log(" " + dim("sunnah install bukhari muslim tirmidhi"));
915
+ console.log(" " + dim("sunnah uninstall dawud"));
916
+ console.log(" " + dim("sunnah --react"));
917
+ console.log(" " + dim("sunnah --react bukhari muslim"));
918
+ console.log(" " + dim("sunnah --update"));
919
+
920
+ console.log("\n " + bold("Interactive UI controls:"));
921
+ console.log(" " + green("↑ ↓") + gray(" Navigate packages"));
922
+ console.log(" " + green("space") + gray(" Toggle select"));
923
+ console.log(" " + green("a") + gray(" Select all / deselect all"));
924
+ console.log(
925
+ " " + green("i") + gray(" Show info + installed version"),
926
+ );
927
+ console.log(" " + green("u") + gray(" Uninstall selected"));
928
+ console.log(
929
+ " " +
930
+ green("enter") +
931
+ gray(" Install selected (or focused if none selected)"),
932
+ );
933
+ console.log(" " + green("q") + gray(" Quit"));
934
+
935
+ // Personalized tips
936
+ const tips = getPersonalizedSuggestions();
937
+ if (tips.length) {
938
+ console.log("\n" + div);
939
+ console.log(bold(" 💡 Suggested for you:"));
940
+ tips.forEach((t) => console.log(t));
941
+ }
942
+
943
+ console.log("\n" + div + "\n");
944
+ }
945
+
946
+ // ── Main ──────────────────────────────────────────────────────────────────────
947
+ async function main() {
948
+ const rawArgs = process.argv.slice(2);
949
+ const flags = rawArgs.filter((a) => a.startsWith("-"));
950
+ const positional = rawArgs.filter((a) => !a.startsWith("-"));
951
+
952
+ // sunnah -v / --version
953
+ if (flags.some((f) => f === "-v" || f === "--version")) {
954
+ cmdVersion();
470
955
  process.exit(0);
471
956
  }
472
957
 
473
- // --list
958
+ // sunnah -h / --help
959
+ if (flags.some((f) => f === "-h" || f === "--help")) {
960
+ cmdHelp();
961
+ process.exit(0);
962
+ }
963
+
964
+ // sunnah --list / -l
474
965
  if (flags.some((f) => f === "--list" || f === "-l")) {
475
966
  cmdList();
476
967
  process.exit(0);
477
968
  }
478
969
 
479
- // --update
970
+ // sunnah --update
480
971
  if (flags.some((f) => f === "--update")) {
481
972
  cmdUpdate();
482
973
  process.exit(0);
483
974
  }
484
975
 
976
+ // sunnah install <names...>
977
+ if (positional[0] === "install") {
978
+ await cmdInstall(positional.slice(1));
979
+ process.exit(0);
980
+ }
981
+
982
+ // sunnah uninstall <names...>
983
+ if (positional[0] === "uninstall") {
984
+ cmdUninstall(positional.slice(1));
985
+ process.exit(0);
986
+ }
987
+
988
+ // sunnah --react [book1 book2 ...]
989
+ if (flags.some((f) => f === "--react")) {
990
+ installedCache = buildInstalledCache();
991
+ // If specific books given after --react, use those; else use all installed
992
+ const requestedCmds = positional; // e.g. ["bukhari", "muslim"]
993
+ let books;
994
+ if (requestedCmds.length > 0) {
995
+ books = requestedCmds
996
+ .map((cmd) => CMD_MAP[cmd.toLowerCase()])
997
+ .filter(Boolean);
998
+ const unknown = requestedCmds.filter(
999
+ (cmd) => !CMD_MAP[cmd.toLowerCase()],
1000
+ );
1001
+ if (unknown.length) {
1002
+ console.log(yellow("\n ⚠ Unknown books: " + unknown.join(", ")));
1003
+ console.log(" Available: " + Object.keys(CMD_MAP).join(", ") + "\n");
1004
+ }
1005
+ } else {
1006
+ books = PACKAGES.filter((p) => isInstalled(p.name));
1007
+ if (!books.length) {
1008
+ console.log(yellow("\n ⚠ No sunnah packages installed yet."));
1009
+ console.log(
1010
+ " Run " +
1011
+ bold("sunnah") +
1012
+ " to install some first, or specify books explicitly:",
1013
+ );
1014
+ console.log(" " + dim("sunnah --react bukhari muslim") + "\n");
1015
+ process.exit(1);
1016
+ }
1017
+ }
1018
+ if (!books.length) {
1019
+ console.log(red("\n ✗ No valid books specified.\n"));
1020
+ process.exit(1);
1021
+ }
1022
+ generateUnifiedHook(books);
1023
+ process.exit(0);
1024
+ }
1025
+
485
1026
  // ── Interactive mode ────────────────────────────────────────────────────────
486
1027
  if (!process.stdin.isTTY) {
487
1028
  console.error(red("\n ✗ Interactive mode requires a TTY terminal.\n"));
488
1029
  process.exit(1);
489
1030
  }
490
1031
 
491
- // Build cache before entering alt screen
1032
+ // Build cache
492
1033
  process.stdout.write("\n " + gray("Checking installed packages…"));
493
1034
  installedCache = buildInstalledCache();
494
1035
  process.stdout.write("\r\x1b[K");
495
1036
 
496
- // Enter alternate screen buffer — this completely prevents scroll issues
1037
+ // Load persisted selection
1038
+ const persisted = loadPersistedState();
1039
+ const lastSelected = new Set(
1040
+ (persisted.lastSelected || []).filter((i) => i < PACKAGES.length),
1041
+ );
1042
+
497
1043
  enterAltScreen();
498
1044
  hideCursor();
499
1045
 
500
1046
  const state = {
501
1047
  cursor: 0,
502
- selected: new Set(),
1048
+ selected: lastSelected, // pre-check last session's selections
503
1049
  mode: MODE.LIST,
504
1050
  confirmTarget: null,
505
1051
  statusMsg: "",
@@ -507,7 +1053,7 @@ async function main() {
507
1053
 
508
1054
  let statusTimer = null;
509
1055
 
510
- function setStatus(msg, ms = 2000) {
1056
+ function setStatus(msg, ms = 2500) {
511
1057
  state.statusMsg = msg;
512
1058
  render(state);
513
1059
  if (statusTimer) clearTimeout(statusTimer);
@@ -520,6 +1066,8 @@ async function main() {
520
1066
  render(state);
521
1067
 
522
1068
  const cleanup = () => {
1069
+ // Persist current selection before exiting
1070
+ savePersistedState({ lastSelected: [...state.selected] });
523
1071
  showCursor();
524
1072
  leaveAltScreen();
525
1073
  try {
@@ -539,13 +1087,12 @@ async function main() {
539
1087
  process.stdin.on("keypress", async (str, key) => {
540
1088
  if (!key) return;
541
1089
 
542
- // ── Confirm uninstall mode ──────────────────────────────────────────────
1090
+ // ── Confirm uninstall mode ────────────────────────────────────────────────
543
1091
  if (state.mode === MODE.CONFIRM_UNINSTALL) {
544
1092
  if (str === "y" || str === "Y") {
545
1093
  const p = PACKAGES[state.confirmTarget];
546
1094
  state.mode = MODE.LIST;
547
1095
  state.confirmTarget = null;
548
-
549
1096
  cleanup();
550
1097
  console.log(
551
1098
  "\n " +
@@ -553,7 +1100,6 @@ async function main() {
553
1100
  bold(white(p.label)) +
554
1101
  yellow("…\n"),
555
1102
  );
556
-
557
1103
  try {
558
1104
  execSync(`${NPM} uninstall -g ${p.name}`, {
559
1105
  stdio: "inherit",
@@ -568,12 +1114,9 @@ async function main() {
568
1114
  green(" uninstalled.\n"),
569
1115
  );
570
1116
  } catch {
571
- console.log("\n " + red("✗ Failed to uninstall " + p.label + "\n"));
1117
+ console.log("\n " + red("✗ Failed to uninstall " + p.label) + "\n");
572
1118
  }
573
-
574
1119
  await sleep(1200);
575
-
576
- // Re-enter interactive UI
577
1120
  enterAltScreen();
578
1121
  hideCursor();
579
1122
  readline.emitKeypressEvents(process.stdin);
@@ -587,16 +1130,14 @@ async function main() {
587
1130
  return;
588
1131
  }
589
1132
 
590
- // ── Normal list mode ────────────────────────────────────────────────────
1133
+ // ── Normal mode ───────────────────────────────────────────────────────────
591
1134
 
592
- // Quit
593
1135
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
594
1136
  cleanup();
595
1137
  console.log("\n " + gray("Goodbye.\n"));
596
1138
  process.exit(0);
597
1139
  }
598
1140
 
599
- // Navigate
600
1141
  if (key.name === "up") {
601
1142
  state.cursor = (state.cursor - 1 + PACKAGES.length) % PACKAGES.length;
602
1143
  render(state);
@@ -608,7 +1149,6 @@ async function main() {
608
1149
  return;
609
1150
  }
610
1151
 
611
- // Toggle select
612
1152
  if (str === " ") {
613
1153
  if (state.selected.has(state.cursor)) state.selected.delete(state.cursor);
614
1154
  else state.selected.add(state.cursor);
@@ -616,7 +1156,6 @@ async function main() {
616
1156
  return;
617
1157
  }
618
1158
 
619
- // Select all / deselect all
620
1159
  if (str === "a" || str === "A") {
621
1160
  if (state.selected.size === PACKAGES.length) state.selected.clear();
622
1161
  else PACKAGES.forEach((_, i) => state.selected.add(i));
@@ -624,42 +1163,39 @@ async function main() {
624
1163
  return;
625
1164
  }
626
1165
 
627
- // Info
1166
+ // i = info
628
1167
  if (str === "i" || str === "I") {
629
1168
  const p = PACKAGES[state.cursor];
630
1169
  const inst = isInstalled(p.name);
631
- const version = inst ? getInstalledVersion(p.name) : null;
632
- const msg = `${p.label} | ${p.hadiths} hadiths | ${inst ? "v" + version + " installed" : "not installed"}`;
633
- setStatus(msg, 3000);
1170
+ const ver = inst ? getInstalledVersion(p.name) : null;
1171
+ setStatus(
1172
+ `${p.label} | ${p.hadiths} hadiths | ${p.author} | ${inst ? "v" + ver + " installed CLI: " + p.cmd + " --help" : "not installed"}`,
1173
+ 4000,
1174
+ );
634
1175
  return;
635
1176
  }
636
1177
 
637
- // Uninstall
1178
+ // u = uninstall
638
1179
  if (str === "u" || str === "U") {
639
1180
  const targets =
640
1181
  state.selected.size > 0 ? [...state.selected] : [state.cursor];
641
-
642
- // Only uninstall packages that are actually installed
643
1182
  const toRemove = targets.filter((i) => isInstalled(PACKAGES[i].name));
644
1183
  if (!toRemove.length) {
645
- setStatus("No installed packages selected to uninstall.");
1184
+ setStatus("No installed packages selected.");
646
1185
  return;
647
1186
  }
648
-
649
- // Confirm one by one
650
1187
  state.mode = MODE.CONFIRM_UNINSTALL;
651
1188
  state.confirmTarget = toRemove[0];
652
1189
  render(state);
653
1190
  return;
654
1191
  }
655
1192
 
656
- // Install
1193
+ // enter = install
657
1194
  if (key.name === "return") {
658
1195
  const targets =
659
1196
  state.selected.size > 0
660
1197
  ? [...state.selected].map((i) => PACKAGES[i])
661
1198
  : [PACKAGES[state.cursor]];
662
-
663
1199
  const toInstall = targets.filter((p) => !isInstalled(p.name));
664
1200
 
665
1201
  if (!toInstall.length) {
@@ -668,8 +1204,6 @@ async function main() {
668
1204
  }
669
1205
 
670
1206
  cleanup();
671
-
672
- const total = toInstall.length;
673
1207
  const divW = Math.min(W() - 2, 72);
674
1208
  const div = gray("─".repeat(divW));
675
1209
  const div2 = gray("═".repeat(divW));
@@ -677,8 +1211,8 @@ async function main() {
677
1211
  console.log("\n" + div2);
678
1212
  console.log(
679
1213
  bold(cyan(" Installing ")) +
680
- bold(yellow(String(total))) +
681
- bold(cyan(" package" + (total > 1 ? "s" : "") + "…")),
1214
+ bold(yellow(String(toInstall.length))) +
1215
+ bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")),
682
1216
  );
683
1217
  console.log(div2);
684
1218
 
@@ -686,15 +1220,13 @@ async function main() {
686
1220
  const p = toInstall[i];
687
1221
  console.log(
688
1222
  "\n " +
689
- cyan("[" + (i + 1) + "/" + total + "]") +
1223
+ cyan("[" + (i + 1) + "/" + toInstall.length + "]") +
690
1224
  " " +
691
1225
  bold(white(p.label)),
692
1226
  );
693
1227
  console.log(" " + dim("npm install -g " + p.name) + "\n");
694
-
695
1228
  await animateInstall(p.name);
696
1229
  installedCache.set(p.name, true);
697
-
698
1230
  console.log(
699
1231
  " " + green("✓") + " " + bold(green(p.label)) + " installed",
700
1232
  );
@@ -705,10 +1237,10 @@ async function main() {
705
1237
  console.log(
706
1238
  " " +
707
1239
  green("✓ All done! ") +
708
- bold(yellow(String(total))) +
1240
+ bold(yellow(String(toInstall.length))) +
709
1241
  " package" +
710
- (total > 1 ? "s" : "") +
711
- " installed.",
1242
+ (toInstall.length > 1 ? "s" : "") +
1243
+ " installed globally.",
712
1244
  );
713
1245
  console.log("");
714
1246
  toInstall.forEach((p) =>
@@ -722,8 +1254,19 @@ async function main() {
722
1254
  dim(p.label),
723
1255
  ),
724
1256
  );
725
- console.log(div2 + "\n");
726
1257
 
1258
+ // Personalized next steps
1259
+ const installed = PACKAGES.filter((p) => isInstalled(p.name));
1260
+ if (installed.length > 1) {
1261
+ console.log(
1262
+ "\n " +
1263
+ dim("Tip: run ") +
1264
+ bold("sunnah --react") +
1265
+ dim(" to generate a unified React hook for all your books"),
1266
+ );
1267
+ }
1268
+
1269
+ console.log(div2 + "\n");
727
1270
  showCursor();
728
1271
  process.exit(0);
729
1272
  }