sunnah 1.1.4 → 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 +978 -204
  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",
@@ -41,6 +64,7 @@ const gray = (t) => clr(c.gray, t);
41
64
  const red = (t) => clr(c.red, t);
42
65
  const dim = (t) => clr(c.dim, t);
43
66
  const white = (t) => clr(c.white, t);
67
+ const blue = (t) => clr(c.blue, t);
44
68
 
45
69
  // ── Available packages ────────────────────────────────────────────────────────
46
70
  const PACKAGES = [
@@ -51,6 +75,7 @@ const PACKAGES = [
51
75
  desc: "The most authentic collection of hadith, widely regarded as the most sahih after the Quran.",
52
76
  hadiths: "7,563",
53
77
  cmd: "bukhari",
78
+ hook: "useBukhari",
54
79
  },
55
80
  {
56
81
  name: "sahih-muslim",
@@ -59,6 +84,7 @@ const PACKAGES = [
59
84
  desc: "Second most authentic hadith collection, known for its strict methodology and chain verification.",
60
85
  hadiths: "7,470",
61
86
  cmd: "muslim",
87
+ hook: "useMuslim",
62
88
  },
63
89
  {
64
90
  name: "sunan-abi-dawud",
@@ -67,6 +93,7 @@ const PACKAGES = [
67
93
  desc: "One of the six canonical hadith collections, focused on legal rulings and jurisprudence.",
68
94
  hadiths: "5,274",
69
95
  cmd: "dawud",
96
+ hook: "useDawud",
70
97
  },
71
98
  {
72
99
  name: "jami-al-tirmidhi",
@@ -75,29 +102,47 @@ const PACKAGES = [
75
102
  desc: "Part of the six major hadith collections, unique for grading each hadith's authenticity.",
76
103
  hadiths: "3,956",
77
104
  cmd: "tirmidhi",
105
+ hook: "useTirmidhi",
78
106
  },
79
107
  ];
80
108
 
81
- // ── Terminal helpers ──────────────────────────────────────────────────────────
109
+ // cmd package lookup
110
+ const CMD_MAP = Object.fromEntries(PACKAGES.map((p) => [p.cmd, p]));
111
+
112
+ // ── Terminal: alternate screen buffer = no scroll ─────────────────────────────
82
113
  const W = () => process.stdout.columns || 80;
114
+ const H = () => process.stdout.rows || 24;
83
115
 
84
- function clearLine() {
85
- process.stdout.write("\r\x1b[K");
116
+ function enterAltScreen() {
117
+ process.stdout.write("\x1b[?1049h");
86
118
  }
87
-
88
- function moveUp(n) {
89
- if (n > 0) process.stdout.write(`\x1b[${n}A`);
119
+ function leaveAltScreen() {
120
+ process.stdout.write("\x1b[?1049l");
121
+ }
122
+ function clearScreen() {
123
+ process.stdout.write("\x1b[2J\x1b[H");
124
+ }
125
+ function moveTo(r, col) {
126
+ process.stdout.write(`\x1b[${r};${col}H`);
90
127
  }
91
-
92
128
  function hideCursor() {
93
129
  process.stdout.write("\x1b[?25l");
94
130
  }
95
131
  function showCursor() {
96
132
  process.stdout.write("\x1b[?25h");
97
133
  }
134
+ function clearToEOL() {
135
+ process.stdout.write("\x1b[K");
136
+ }
137
+
138
+ function writeLine(row, text) {
139
+ moveTo(row, 1);
140
+ clearToEOL();
141
+ process.stdout.write(text);
142
+ }
98
143
 
99
144
  // ── Progress bar ──────────────────────────────────────────────────────────────
100
- function drawBar(label, percent, barWidth = 38) {
145
+ function drawBar(label, percent, barWidth = 40) {
101
146
  const filled = Math.round((percent / 100) * barWidth);
102
147
  const empty = barWidth - filled;
103
148
  const bar =
@@ -106,75 +151,62 @@ function drawBar(label, percent, barWidth = 38) {
106
151
  return ` ${bar} ${pct} ${dim(label)}`;
107
152
  }
108
153
 
109
- // ── Install a package with animated progress bar ──────────────────────────────
154
+ // ── Animate install with real npm running behind ──────────────────────────────
110
155
  function animateInstall(pkgName) {
111
156
  return new Promise((resolve) => {
112
157
  const stages = [
113
- { label: "Resolving packages…", end: 12, ms: 90 },
114
- { label: "Fetching metadata…", end: 28, ms: 70 },
115
- { label: "Downloading tarball…", end: 72, ms: 25 },
116
- { label: "Extracting files…", end: 88, ms: 55 },
117
- { label: "Linking binaries…", end: 98, ms: 90 },
158
+ { label: "Resolving packages…", end: 12, ms: 80 },
159
+ { label: "Fetching metadata…", end: 30, ms: 60 },
160
+ { label: "Downloading tarball…", end: 75, ms: 22 },
161
+ { label: "Extracting files…", end: 90, ms: 50 },
162
+ { label: "Linking binaries…", end: 98, ms: 80 },
118
163
  ];
119
-
120
164
  let percent = 0;
121
165
  let stageIdx = 0;
122
166
  let npmDone = false;
123
167
 
124
- process.stdout.write(drawBar(stages[0].label, 0) + "\n");
168
+ process.stdout.write(drawBar(stages[0].label, 0));
125
169
 
126
170
  const tick = () => {
127
171
  const stage = stages[stageIdx];
128
172
  if (!stage) return;
129
-
130
173
  const prevEnd = stageIdx > 0 ? stages[stageIdx - 1].end : 0;
131
- const step = (stage.end - prevEnd) / 22;
174
+ const step = (stage.end - prevEnd) / 24;
132
175
  percent = Math.min(percent + step, stage.end);
133
-
134
- process.stdout.write("\r\x1b[K");
135
- process.stdout.write(drawBar(stage.label, percent));
136
-
176
+ process.stdout.write("\r\x1b[K" + drawBar(stage.label, percent));
137
177
  if (percent >= stage.end) {
138
178
  stageIdx++;
139
179
  if (stageIdx >= stages.length) {
140
180
  const poll = setInterval(() => {
141
181
  if (npmDone) {
142
182
  clearInterval(poll);
143
- process.stdout.write("\r\x1b[K");
144
- process.stdout.write(drawBar("Complete!", 100));
145
- process.stdout.write("\n");
183
+ process.stdout.write(
184
+ "\r\x1b[K" + drawBar("Complete!", 100) + "\n",
185
+ );
146
186
  resolve();
147
187
  }
148
188
  }, 80);
149
189
  return;
150
190
  }
151
191
  }
152
-
153
192
  setTimeout(tick, stages[stageIdx]?.ms ?? 50);
154
193
  };
155
194
 
156
195
  setTimeout(tick, stages[0].ms);
157
-
158
- // spawn npm.cmd on Windows, npm on Unix
159
196
  const proc = spawn(NPM, ["install", "-g", pkgName], {
160
197
  stdio: ["ignore", "pipe", "pipe"],
161
198
  shell: isWin,
162
199
  });
163
-
164
200
  proc.on("error", () => {
165
- // resolve anyway so the UI doesn't hang on spawn failure
166
201
  npmDone = true;
167
202
  });
168
-
169
203
  proc.on("close", () => {
170
204
  npmDone = true;
171
205
  });
172
206
  });
173
207
  }
174
208
 
175
- // ── Check installed status ONCE at startup, cache forever ───────────────────
176
- // Calling npm on every render was freezing the terminal. We run one
177
- // 'npm list -g' at startup, parse the output, and never shell out again.
209
+ // ── Installed cache ONE npm call at startup, Map lookup during render ───────
178
210
  function buildInstalledCache() {
179
211
  const cache = new Map();
180
212
  let out = "";
@@ -182,7 +214,7 @@ function buildInstalledCache() {
182
214
  out = execSync(`${NPM} list -g --depth=0`, {
183
215
  encoding: "utf8",
184
216
  shell: isWin,
185
- timeout: 8000,
217
+ timeout: 10000,
186
218
  stdio: ["ignore", "pipe", "ignore"],
187
219
  });
188
220
  } catch (e) {
@@ -194,169 +226,800 @@ function buildInstalledCache() {
194
226
  return cache;
195
227
  }
196
228
 
229
+ function getLatestVersion(name) {
230
+ try {
231
+ return execSync(`${NPM} show ${name} version`, {
232
+ encoding: "utf8",
233
+ shell: isWin,
234
+ timeout: 8000,
235
+ stdio: ["ignore", "pipe", "ignore"],
236
+ }).trim();
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ function getInstalledVersion(name) {
243
+ try {
244
+ const out = execSync(`${NPM} list -g ${name} --depth=0`, {
245
+ encoding: "utf8",
246
+ shell: isWin,
247
+ timeout: 8000,
248
+ stdio: ["ignore", "pipe", "ignore"],
249
+ });
250
+ const match = out.match(new RegExp(name + "@([\\d.]+)"));
251
+ return match ? match[1] : null;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
197
257
  let installedCache = new Map();
198
258
  const isInstalled = (name) => installedCache.get(name) ?? false;
199
259
 
200
- // ── Render the interactive list ───────────────────────────────────────────────
201
- const DIV_W = () => Math.min(W() - 2, 70);
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
+
503
+ // ── Modes ─────────────────────────────────────────────────────────────────────
504
+ const MODE = { LIST: "list", CONFIRM_UNINSTALL: "confirm_uninstall" };
505
+
506
+ // ── Interactive render ────────────────────────────────────────────────────────
507
+ function render(state) {
508
+ const { cursor, selected, mode, confirmTarget, statusMsg } = state;
509
+ const divW = Math.min(W() - 2, 72);
510
+ const div = gray("─".repeat(divW));
511
+ const div2 = gray("═".repeat(divW));
202
512
 
203
- function renderList(selected, cursor) {
204
- const div = gray("─".repeat(DIV_W()));
205
- const div2 = gray("═".repeat(DIV_W()));
206
- const lines = [];
513
+ clearScreen();
514
+ let row = 1;
207
515
 
208
- lines.push("");
209
- lines.push(div2);
210
- lines.push(
516
+ writeLine(row++, div2);
517
+ writeLine(
518
+ row++,
211
519
  bold(cyan(" 📚 Sunnah Package Manager")) + gray(" v" + pkg.version),
212
520
  );
213
- lines.push(
214
- gray(" ↑↓ navigate ") +
215
- gray("space select ") +
216
- gray("a all ") +
217
- gray("enter install ") +
218
- gray("q quit"),
521
+ writeLine(
522
+ row++,
523
+ gray(" ↑↓") +
524
+ " nav " +
525
+ gray("space") +
526
+ " select " +
527
+ gray("a") +
528
+ " all " +
529
+ gray("i") +
530
+ " info " +
531
+ gray("u") +
532
+ " uninstall " +
533
+ gray("enter") +
534
+ " install " +
535
+ gray("q") +
536
+ " quit",
219
537
  );
220
- lines.push(div2);
221
- lines.push("");
538
+ writeLine(row++, div2);
539
+ row++;
222
540
 
223
541
  PACKAGES.forEach((p, i) => {
224
542
  const isCursor = i === cursor;
225
- const isSelected = selected.has(i);
226
- const installed = isInstalled(p.name);
543
+ const isSel = selected.has(i);
544
+ const inst = isInstalled(p.name);
227
545
 
228
- const checkbox = isSelected ? green("[✓]") : gray("[ ]");
546
+ const checkbox = isSel ? green("[✓]") : gray("[ ]");
229
547
  const arrow = isCursor ? cyan("▶") : " ";
230
548
  const label = isCursor
231
549
  ? bold(white(p.label))
232
- : isSelected
550
+ : isSel
233
551
  ? green(p.label)
234
552
  : white(p.label);
235
- const badge = installed ? dim(gray(" (installed)")) : "";
553
+ const badge = inst
554
+ ? dim(green(" ● installed"))
555
+ : dim(gray(" ○ not installed"));
236
556
 
237
- lines.push(` ${arrow} ${checkbox} ${label}${badge}`);
557
+ writeLine(row++, ` ${arrow} ${checkbox} ${label}${badge}`);
238
558
 
239
559
  if (isCursor) {
240
- lines.push(` ${dim(p.author)}`);
241
- lines.push(` ${gray(p.desc)}`);
242
- lines.push(
243
- ` ${gray("Hadiths: ")}${yellow(p.hadiths)} ${gray("CLI: ")}${cyan(p.cmd + " --help")}`,
560
+ writeLine(row++, ` ${dim(p.author)}`);
561
+ writeLine(row++, ` ${gray(p.desc)}`);
562
+ writeLine(
563
+ row++,
564
+ ` ${gray("Hadiths: ")}${yellow(p.hadiths)}` +
565
+ ` ${gray("CLI: ")}${cyan(p.cmd + " --help")}` +
566
+ (inst ? ` ${gray("try: ")}${cyan(p.cmd + " --random")}` : ""),
244
567
  );
245
- lines.push("");
568
+ row++;
246
569
  }
247
570
  });
248
571
 
249
- lines.push("");
250
- lines.push(div);
572
+ row++;
573
+ writeLine(row++, div);
251
574
 
252
- const count = selected.size;
253
- if (count > 0) {
575
+ if (statusMsg) {
576
+ writeLine(row++, ` ${yellow("⚠")} ${yellow(statusMsg)}`);
577
+ } else if (selected.size > 0) {
254
578
  const names = [...selected].map((i) => cyan(PACKAGES[i].name)).join(", ");
255
- lines.push(` ${green("●")} ${bold(String(count))} selected: ${names}`);
579
+ writeLine(
580
+ row++,
581
+ ` ${green("●")} ${bold(String(selected.size))} selected: ${names}`,
582
+ );
583
+ writeLine(row++, ` ${dim("enter = install u = uninstall i = info")}`);
256
584
  } else {
257
- lines.push(
258
- ` ${gray("Nothing selected — press space to select a package")}`,
585
+ writeLine(
586
+ row++,
587
+ ` ${gray("Nothing selected — press space to select, enter to install focused")}`,
259
588
  );
260
589
  }
261
- lines.push(div);
262
- lines.push("");
263
590
 
264
- return lines;
265
- }
591
+ writeLine(row++, div);
266
592
 
267
- function printLines(lines) {
268
- process.stdout.write(lines.join("\n") + "\n");
593
+ if (mode === MODE.CONFIRM_UNINSTALL && confirmTarget !== null) {
594
+ const p = PACKAGES[confirmTarget];
595
+ row++;
596
+ writeLine(
597
+ row++,
598
+ ` ${red("⚠ Uninstall ")}${bold(white(p.label))}${red("?")}`,
599
+ );
600
+ writeLine(
601
+ row++,
602
+ ` ${green("y")} ${gray("confirm")} ${red("n")} ${gray("cancel")}`,
603
+ );
604
+ }
269
605
  }
270
606
 
271
- function eraseLines(n) {
272
- for (let i = 0; i < n; i++) {
273
- clearLine();
274
- if (i < n - 1) moveUp(1);
275
- }
276
- clearLine();
607
+ // ── --list ────────────────────────────────────────────────────────────────────
608
+ function cmdList() {
609
+ installedCache = buildInstalledCache();
610
+ const div = gray("─".repeat(60));
611
+ console.log("\n" + div);
612
+ console.log(bold(cyan(" Available Sunnah Packages")));
613
+ console.log(div);
614
+ PACKAGES.forEach((p) => {
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}`);
621
+ console.log(` ${cyan("npm install -g " + p.name)}`);
622
+ console.log(` ${dim(p.desc)}`);
623
+ console.log(
624
+ ` ${gray("Hadiths: ")}${yellow(p.hadiths)} ${gray("Author: ")}${magenta(p.author)}`,
625
+ );
626
+ if (inst) {
627
+ console.log(
628
+ ` ${gray("CLI: ")}${cyan(p.cmd + " --help")} ${gray("React: ")}${cyan("sunnah --react " + p.cmd)}`,
629
+ );
630
+ }
631
+ });
632
+ console.log("\n" + div + "\n");
277
633
  }
278
634
 
279
- // ── Main ──────────────────────────────────────────────────────────────────────
280
- async function main() {
281
- const rawArgs = process.argv.slice(2);
282
- const flags = rawArgs.filter((a) => a.startsWith("-"));
635
+ // ── --update ──────────────────────────────────────────────────────────────────
636
+ function cmdUpdate() {
637
+ installedCache = buildInstalledCache();
638
+ const installed = PACKAGES.filter((p) => isInstalled(p.name));
639
+ if (!installed.length) {
640
+ console.log(
641
+ "\n " +
642
+ yellow("No sunnah packages installed. Run ") +
643
+ bold("sunnah") +
644
+ yellow(" to install.\n"),
645
+ );
646
+ return;
647
+ }
648
+ const div = gray("─".repeat(60));
649
+ console.log("\n" + div);
650
+ console.log(bold(cyan(" Checking for updates…")));
651
+ console.log(div + "\n");
652
+
653
+ let hasUpdates = false;
654
+ for (const p of installed) {
655
+ const current = getInstalledVersion(p.name);
656
+ const latest = getLatestVersion(p.name);
657
+ if (!current || !latest) {
658
+ console.log(
659
+ ` ${yellow("?")} ${bold(p.label)} ${gray("(could not check)")}`,
660
+ );
661
+ continue;
662
+ }
663
+ if (current === latest) {
664
+ console.log(
665
+ ` ${green("✓")} ${bold(p.label)} ${gray(current + " — up to date")}`,
666
+ );
667
+ } else {
668
+ hasUpdates = true;
669
+ console.log(
670
+ ` ${yellow("↑")} ${bold(p.label)} ${gray(current)} ${gray("→")} ${green(latest)}`,
671
+ );
672
+ console.log(` ${dim("sunnah install " + p.cmd)}`);
673
+ }
674
+ }
283
675
 
284
- // --version
285
- if (flags.some((f) => f === "-v" || f === "--version")) {
286
- console.log("");
287
- console.log(" " + bold(cyan("sunnah")) + gray(" v" + pkg.version));
676
+ if (hasUpdates) {
288
677
  console.log(
289
- " " + gray("Available packages: ") + yellow(String(PACKAGES.length)),
678
+ "\n " +
679
+ yellow("Updates available. Run ") +
680
+ bold("sunnah install <name>") +
681
+ yellow(" to update."),
290
682
  );
291
- console.log("");
292
- process.exit(0);
293
683
  }
684
+ console.log("\n" + div + "\n");
685
+ }
294
686
 
295
- // --help
296
- if (flags.some((f) => f === "-h" || f === "--help")) {
297
- const div = gray("─".repeat(60));
298
- console.log("");
299
- console.log(div);
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
+ );
300
695
  console.log(
301
- bold(cyan(" Sunnah Package Manager")) + gray(" v" + pkg.version),
696
+ " Available: " + PACKAGES.map((p) => cyan(p.cmd)).join(", ") + "\n",
302
697
  );
303
- console.log(div);
304
- console.log("");
305
- console.log(" " + bold("Usage:"));
698
+ process.exit(1);
699
+ }
700
+
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];
306
733
  console.log(
307
- " " +
308
- cyan("sunnah") +
309
- gray(" Open interactive installer"),
734
+ "\n " +
735
+ cyan("[" + (i + 1) + "/" + toInstall.length + "]") +
736
+ " " +
737
+ bold(white(p.label)),
310
738
  );
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
+ ),
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) {
311
822
  console.log(
312
- " " +
313
- cyan("sunnah") +
314
- green(" --list") +
315
- gray(" List all available packages"),
823
+ " " +
824
+ gray("Your collection : ") +
825
+ installed.map((p) => cyan(p.label)).join(gray(", ")),
826
+ );
827
+ const totalHadiths = installed.reduce(
828
+ (acc, p) => acc + parseInt(p.hadiths.replace(/,/g, "")),
829
+ 0,
316
830
  );
317
831
  console.log(
318
- " " + cyan("sunnah") + green(" -v") + gray(" Show version"),
832
+ " " +
833
+ gray("Total hadiths : ") +
834
+ bold(yellow(totalHadiths.toLocaleString())),
319
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) => {
320
904
  console.log(
321
905
  " " +
322
- cyan("sunnah") +
323
- green(" -h") +
324
- gray(" Show this help"),
906
+ cyan(p.cmd.padEnd(12)) +
907
+ gray(p.name.padEnd(24)) +
908
+ yellow(p.hadiths + " hadiths"),
325
909
  );
326
- console.log("");
327
- console.log(" " + bold("Controls (interactive mode):"));
328
- console.log(" " + green("↑ ↓") + gray(" Navigate packages"));
329
- console.log(" " + green("space") + gray(" Toggle selection"));
330
- console.log(" " + green("a") + gray(" Toggle all / deselect all"));
331
- console.log(" " + green("enter") + gray(" Install selected packages"));
332
- console.log(" " + green("q") + gray(" Quit"));
333
- console.log("");
334
- console.log(div);
335
- console.log("");
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();
955
+ process.exit(0);
956
+ }
957
+
958
+ // sunnah -h / --help
959
+ if (flags.some((f) => f === "-h" || f === "--help")) {
960
+ cmdHelp();
336
961
  process.exit(0);
337
962
  }
338
963
 
339
- // --list
964
+ // sunnah --list / -l
340
965
  if (flags.some((f) => f === "--list" || f === "-l")) {
966
+ cmdList();
967
+ process.exit(0);
968
+ }
969
+
970
+ // sunnah --update
971
+ if (flags.some((f) => f === "--update")) {
972
+ cmdUpdate();
973
+ process.exit(0);
974
+ }
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")) {
341
990
  installedCache = buildInstalledCache();
342
- const div = gray("─".repeat(60));
343
- console.log("");
344
- console.log(div);
345
- console.log(bold(cyan(" Available Sunnah Packages")));
346
- console.log(div);
347
- PACKAGES.forEach((p) => {
348
- const inst = isInstalled(p.name) ? green(" ✓ installed") : "";
349
- console.log("");
350
- console.log(` ${bold(white(p.label))}${inst}`);
351
- console.log(` ${cyan("npm install -g " + p.name)}`);
352
- console.log(` ${dim(p.desc)}`);
353
- console.log(
354
- ` ${gray("Hadiths: ")}${yellow(p.hadiths)} ${gray("Author: ")}${magenta(p.author)}`,
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()],
355
1000
  );
356
- });
357
- console.log("");
358
- console.log(div);
359
- console.log("");
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);
360
1023
  process.exit(0);
361
1024
  }
362
1025
 
@@ -366,32 +1029,47 @@ async function main() {
366
1029
  process.exit(1);
367
1030
  }
368
1031
 
369
- // Build installed cache once — never call npm during rendering
370
- process.stdout.write(
371
- "\n " + "\x1b[90m" + "Checking installed packages…" + "\x1b[0m",
372
- );
1032
+ // Build cache
1033
+ process.stdout.write("\n " + gray("Checking installed packages…"));
373
1034
  installedCache = buildInstalledCache();
374
1035
  process.stdout.write("\r\x1b[K");
375
1036
 
376
- readline.emitKeypressEvents(process.stdin);
377
- process.stdin.setRawMode(true);
378
- hideCursor();
1037
+ // Load persisted selection
1038
+ const persisted = loadPersistedState();
1039
+ const lastSelected = new Set(
1040
+ (persisted.lastSelected || []).filter((i) => i < PACKAGES.length),
1041
+ );
379
1042
 
380
- let cursor = 0;
381
- let selected = new Set();
382
- let prevCount = 0;
1043
+ enterAltScreen();
1044
+ hideCursor();
383
1045
 
384
- const draw = () => {
385
- const lines = renderList(selected, cursor);
386
- if (prevCount > 0) eraseLines(prevCount);
387
- printLines(lines);
388
- prevCount = lines.length;
1046
+ const state = {
1047
+ cursor: 0,
1048
+ selected: lastSelected, // pre-check last session's selections
1049
+ mode: MODE.LIST,
1050
+ confirmTarget: null,
1051
+ statusMsg: "",
389
1052
  };
390
1053
 
391
- draw();
1054
+ let statusTimer = null;
1055
+
1056
+ function setStatus(msg, ms = 2500) {
1057
+ state.statusMsg = msg;
1058
+ render(state);
1059
+ if (statusTimer) clearTimeout(statusTimer);
1060
+ statusTimer = setTimeout(() => {
1061
+ state.statusMsg = "";
1062
+ render(state);
1063
+ }, ms);
1064
+ }
1065
+
1066
+ render(state);
392
1067
 
393
1068
  const cleanup = () => {
1069
+ // Persist current selection before exiting
1070
+ savePersistedState({ lastSelected: [...state.selected] });
394
1071
  showCursor();
1072
+ leaveAltScreen();
395
1073
  try {
396
1074
  process.stdin.setRawMode(false);
397
1075
  } catch {}
@@ -400,112 +1078,208 @@ async function main() {
400
1078
 
401
1079
  process.on("SIGINT", () => {
402
1080
  cleanup();
403
- console.log("");
404
1081
  process.exit(0);
405
1082
  });
406
1083
 
1084
+ readline.emitKeypressEvents(process.stdin);
1085
+ process.stdin.setRawMode(true);
1086
+
407
1087
  process.stdin.on("keypress", async (str, key) => {
408
1088
  if (!key) return;
409
1089
 
410
- // Quit
1090
+ // ── Confirm uninstall mode ────────────────────────────────────────────────
1091
+ if (state.mode === MODE.CONFIRM_UNINSTALL) {
1092
+ if (str === "y" || str === "Y") {
1093
+ const p = PACKAGES[state.confirmTarget];
1094
+ state.mode = MODE.LIST;
1095
+ state.confirmTarget = null;
1096
+ cleanup();
1097
+ console.log(
1098
+ "\n " +
1099
+ yellow("Uninstalling ") +
1100
+ bold(white(p.label)) +
1101
+ yellow("…\n"),
1102
+ );
1103
+ try {
1104
+ execSync(`${NPM} uninstall -g ${p.name}`, {
1105
+ stdio: "inherit",
1106
+ shell: isWin,
1107
+ });
1108
+ installedCache.set(p.name, false);
1109
+ state.selected.delete(PACKAGES.indexOf(p));
1110
+ console.log(
1111
+ "\n " +
1112
+ green("✓ ") +
1113
+ bold(green(p.label)) +
1114
+ green(" uninstalled.\n"),
1115
+ );
1116
+ } catch {
1117
+ console.log("\n " + red("✗ Failed to uninstall " + p.label) + "\n");
1118
+ }
1119
+ await sleep(1200);
1120
+ enterAltScreen();
1121
+ hideCursor();
1122
+ readline.emitKeypressEvents(process.stdin);
1123
+ process.stdin.setRawMode(true);
1124
+ render(state);
1125
+ } else {
1126
+ state.mode = MODE.LIST;
1127
+ state.confirmTarget = null;
1128
+ render(state);
1129
+ }
1130
+ return;
1131
+ }
1132
+
1133
+ // ── Normal mode ───────────────────────────────────────────────────────────
1134
+
411
1135
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
412
1136
  cleanup();
413
- if (prevCount > 0) eraseLines(prevCount);
414
1137
  console.log("\n " + gray("Goodbye.\n"));
415
1138
  process.exit(0);
416
1139
  }
417
1140
 
418
- // Navigate
419
1141
  if (key.name === "up") {
420
- cursor = (cursor - 1 + PACKAGES.length) % PACKAGES.length;
421
- draw();
1142
+ state.cursor = (state.cursor - 1 + PACKAGES.length) % PACKAGES.length;
1143
+ render(state);
422
1144
  return;
423
1145
  }
424
1146
  if (key.name === "down") {
425
- cursor = (cursor + 1) % PACKAGES.length;
426
- draw();
1147
+ state.cursor = (state.cursor + 1) % PACKAGES.length;
1148
+ render(state);
427
1149
  return;
428
1150
  }
429
1151
 
430
- // Toggle selection
431
1152
  if (str === " ") {
432
- if (selected.has(cursor)) selected.delete(cursor);
433
- else selected.add(cursor);
434
- draw();
1153
+ if (state.selected.has(state.cursor)) state.selected.delete(state.cursor);
1154
+ else state.selected.add(state.cursor);
1155
+ render(state);
435
1156
  return;
436
1157
  }
437
1158
 
438
- // Toggle all
439
1159
  if (str === "a" || str === "A") {
440
- if (selected.size === PACKAGES.length) selected.clear();
441
- else PACKAGES.forEach((_, i) => selected.add(i));
442
- draw();
1160
+ if (state.selected.size === PACKAGES.length) state.selected.clear();
1161
+ else PACKAGES.forEach((_, i) => state.selected.add(i));
1162
+ render(state);
443
1163
  return;
444
1164
  }
445
1165
 
446
- // Install
1166
+ // i = info
1167
+ if (str === "i" || str === "I") {
1168
+ const p = PACKAGES[state.cursor];
1169
+ const inst = isInstalled(p.name);
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
+ );
1175
+ return;
1176
+ }
1177
+
1178
+ // u = uninstall
1179
+ if (str === "u" || str === "U") {
1180
+ const targets =
1181
+ state.selected.size > 0 ? [...state.selected] : [state.cursor];
1182
+ const toRemove = targets.filter((i) => isInstalled(PACKAGES[i].name));
1183
+ if (!toRemove.length) {
1184
+ setStatus("No installed packages selected.");
1185
+ return;
1186
+ }
1187
+ state.mode = MODE.CONFIRM_UNINSTALL;
1188
+ state.confirmTarget = toRemove[0];
1189
+ render(state);
1190
+ return;
1191
+ }
1192
+
1193
+ // enter = install
447
1194
  if (key.name === "return") {
448
- if (selected.size === 0) return;
1195
+ const targets =
1196
+ state.selected.size > 0
1197
+ ? [...state.selected].map((i) => PACKAGES[i])
1198
+ : [PACKAGES[state.cursor]];
1199
+ const toInstall = targets.filter((p) => !isInstalled(p.name));
1200
+
1201
+ if (!toInstall.length) {
1202
+ setStatus("All selected packages are already installed.");
1203
+ return;
1204
+ }
449
1205
 
450
1206
  cleanup();
451
- if (prevCount > 0) eraseLines(prevCount);
1207
+ const divW = Math.min(W() - 2, 72);
1208
+ const div = gray("─".repeat(divW));
1209
+ const div2 = gray("═".repeat(divW));
452
1210
 
453
- const toInstall = [...selected].map((i) => PACKAGES[i]);
454
- const total = toInstall.length;
455
- const div = gray("─".repeat(DIV_W()));
456
- const div2 = gray("═".repeat(DIV_W()));
457
-
458
- console.log("");
459
- console.log(div2);
1211
+ console.log("\n" + div2);
460
1212
  console.log(
461
1213
  bold(cyan(" Installing ")) +
462
- bold(yellow(String(total))) +
463
- bold(cyan(" package" + (total > 1 ? "s" : "") + "…")),
1214
+ bold(yellow(String(toInstall.length))) +
1215
+ bold(cyan(" package" + (toInstall.length > 1 ? "s" : "") + "…")),
464
1216
  );
465
1217
  console.log(div2);
466
1218
 
467
1219
  for (let i = 0; i < toInstall.length; i++) {
468
1220
  const p = toInstall[i];
469
- console.log("");
470
1221
  console.log(
471
- ` ${cyan("[" + (i + 1) + "/" + total + "]")} ${bold(white(p.label))}`,
1222
+ "\n " +
1223
+ cyan("[" + (i + 1) + "/" + toInstall.length + "]") +
1224
+ " " +
1225
+ bold(white(p.label)),
472
1226
  );
473
- console.log(` ${dim("npm install -g " + p.name)}`);
474
- console.log("");
475
-
1227
+ console.log(" " + dim("npm install -g " + p.name) + "\n");
476
1228
  await animateInstall(p.name);
477
- installedCache.set(p.name, true); // update cache
478
-
1229
+ installedCache.set(p.name, true);
479
1230
  console.log(
480
- ` ${green("✓")} ${bold(green(p.label))} installed successfully`,
1231
+ " " + green("✓") + " " + bold(green(p.label)) + " installed",
481
1232
  );
482
- console.log(` ${gray("Usage: ")}${cyan(p.cmd + " --help")}`);
1233
+ console.log(" " + gray("Usage: ") + cyan(p.cmd + " --help"));
483
1234
  }
484
1235
 
485
- console.log("");
486
- console.log(div2);
1236
+ console.log("\n" + div2);
487
1237
  console.log(
488
- ` ${green("✓")} All done! ` +
489
- bold(yellow(String(total))) +
490
- ` package${total > 1 ? "s" : ""} installed globally.`,
1238
+ " " +
1239
+ green("✓ All done! ") +
1240
+ bold(yellow(String(toInstall.length))) +
1241
+ " package" +
1242
+ (toInstall.length > 1 ? "s" : "") +
1243
+ " installed globally.",
491
1244
  );
492
1245
  console.log("");
493
- toInstall.forEach((p) => {
1246
+ toInstall.forEach((p) =>
1247
+ console.log(
1248
+ " " +
1249
+ cyan("▸") +
1250
+ " " +
1251
+ bold(p.cmd) +
1252
+ gray(" --help") +
1253
+ " " +
1254
+ dim(p.label),
1255
+ ),
1256
+ );
1257
+
1258
+ // Personalized next steps
1259
+ const installed = PACKAGES.filter((p) => isInstalled(p.name));
1260
+ if (installed.length > 1) {
494
1261
  console.log(
495
- ` ${cyan("▸")} ${bold(p.cmd)} ${gray("--help")} ${dim("·")} ${dim(p.label)}`,
1262
+ "\n " +
1263
+ dim("Tip: run ") +
1264
+ bold("sunnah --react") +
1265
+ dim(" to generate a unified React hook for all your books"),
496
1266
  );
497
- });
498
- console.log(div2);
499
- console.log("");
1267
+ }
500
1268
 
1269
+ console.log(div2 + "\n");
501
1270
  showCursor();
502
1271
  process.exit(0);
503
1272
  }
504
1273
  });
505
1274
  }
506
1275
 
1276
+ function sleep(ms) {
1277
+ return new Promise((r) => setTimeout(r, ms));
1278
+ }
1279
+
507
1280
  main().catch((err) => {
508
1281
  showCursor();
1282
+ leaveAltScreen();
509
1283
  console.error(red("\n ✗ " + err.message + "\n"));
510
1284
  process.exit(1);
511
1285
  });