nekostream 2026.4.1 → 2026.4.2

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 (70) hide show
  1. package/README.md +169 -90
  2. package/dist/cli/application.js +24 -0
  3. package/dist/cli/domain-startup.js +85 -0
  4. package/dist/cli/feedback.js +87 -0
  5. package/dist/cli/flows/anime-flow.js +225 -0
  6. package/dist/cli/flows/auth-flow.js +39 -0
  7. package/dist/cli/flows/provider-lists.js +72 -0
  8. package/dist/cli/menus/history-menu.js +65 -0
  9. package/dist/cli/menus/home-menu.js +198 -0
  10. package/dist/cli/menus/settings-menu.js +215 -0
  11. package/dist/cli/update-check.js +82 -0
  12. package/dist/index.js +9 -817
  13. package/dist/player-policy.js +96 -0
  14. package/dist/player.js +7 -1
  15. package/dist/prompts-wrapper.js +9 -6
  16. package/dist/provider-types.js +28 -0
  17. package/dist/providers.js +17 -17
  18. package/dist/scrapers/adblock-policy.js +148 -0
  19. package/dist/scrapers/anilist-api.js +1 -25
  20. package/dist/scrapers/anilist.js +11 -66
  21. package/dist/scrapers/auth/anime47/client.js +224 -0
  22. package/dist/scrapers/auth/anime47/lists.js +185 -0
  23. package/dist/scrapers/auth/anime47/login.js +230 -0
  24. package/dist/scrapers/auth/anime47/stream-interceptor.js +169 -0
  25. package/dist/scrapers/auth/animevietsub/lists.js +209 -0
  26. package/dist/scrapers/auth/animevietsub/login.js +89 -0
  27. package/dist/scrapers/auth/animevietsub/parsers.js +147 -0
  28. package/dist/scrapers/auth/auth-session-service.js +27 -0
  29. package/dist/scrapers/auth/auth-types.js +2 -0
  30. package/dist/scrapers/auth/browser-login.js +173 -0
  31. package/dist/scrapers/auth-service.js +28 -1208
  32. package/dist/scrapers/base.js +1 -1
  33. package/dist/scrapers/crawler.js +2 -17
  34. package/dist/scrapers/domain-registry.js +225 -0
  35. package/dist/scrapers/domain-resolver.js +308 -0
  36. package/dist/scrapers/errors.js +119 -0
  37. package/dist/scrapers/fingerprint.js +4 -14
  38. package/dist/scrapers/html-utils.js +59 -0
  39. package/dist/scrapers/http-client.js +58 -0
  40. package/dist/scrapers/interceptor.js +3 -3
  41. package/dist/scrapers/providers/anime47/parsers/cards.js +243 -0
  42. package/dist/scrapers/providers/anime47/parsers/detail.js +174 -0
  43. package/dist/scrapers/providers/anime47/parsers/episodes.js +160 -0
  44. package/dist/scrapers/providers/anime47/streams/extractors.js +109 -0
  45. package/dist/scrapers/providers/anime47.js +58 -603
  46. package/dist/scrapers/providers/animevietsub/parsers/cards.js +222 -0
  47. package/dist/scrapers/providers/animevietsub/parsers/detail.js +373 -0
  48. package/dist/scrapers/providers/animevietsub/parsers/episodes.js +76 -0
  49. package/dist/scrapers/providers/animevietsub/parsers/schedule.js +95 -0
  50. package/dist/scrapers/providers/animevietsub/streams/extractors.js +350 -0
  51. package/dist/scrapers/providers/animevietsub/streams/server-parser.js +222 -0
  52. package/dist/scrapers/providers/animevietsub.js +107 -1208
  53. package/dist/storage.js +136 -49
  54. package/package.json +26 -8
  55. package/player-main.js +434 -211
  56. package/player.html +123 -123
  57. package/webview-preload.js +465 -0
  58. package/dist/server/app.js +0 -53
  59. package/dist/server/config.js +0 -52
  60. package/dist/server/http.js +0 -78
  61. package/dist/server/index.js +0 -22
  62. package/dist/server/routes/admin.js +0 -39
  63. package/dist/server/routes/health.js +0 -14
  64. package/dist/server/routes/me.js +0 -50
  65. package/dist/server/routes/providers.js +0 -53
  66. package/dist/server/services/cache.js +0 -36
  67. package/dist/server/services/provider-service.js +0 -90
  68. package/dist/server/services/server-token.js +0 -51
  69. package/dist/server/services/session-store.js +0 -52
  70. package/dist/server/services/token-store.js +0 -58
package/README.md CHANGED
@@ -1,90 +1,169 @@
1
- # NekoStream CLI
2
-
3
- NekoStream CLI là ứng dụng dòng lệnh để tìm, duyệt và xem anime từ nhiều provider phổ biến tại Việt Nam. Ứng dụng tập trung vào trải nghiệm terminal gọn, nhanh, hỗ trợ lịch sử xem, tài khoản provider và trình phát video tích hợp.
4
-
5
- ## Cài đặt
6
-
7
- Yêu cầu:
8
-
9
- - Node.js 18 trở lên
10
- - Windows, macOS hoặc Linux có terminal hỗ trợ màu ANSI
11
-
12
- Cài đặt toàn cục từ npm:
13
-
14
- ```bash
15
- npm install -g nekostream
16
- ```
17
-
18
- Chạy ứng dụng:
19
-
20
- ```bash
21
- nekostream
22
- ```
23
-
24
- Kiểm tra phiên bản mới nhất trên npm:
25
-
26
- ```bash
27
- npm view nekostream version
28
- ```
29
-
30
- Cập nhật lên bản mới nhất:
31
-
32
- ```bash
33
- npm install -g nekostream@latest
34
- ```
35
-
36
- ## Tính năng chính
37
-
38
- - Tìm anime, xem danh sách thịnh hành và danh sách mới cập nhật.
39
- - Hỗ trợ nhiều provider: AnimeVietsub, Anime47, AnimeHay.
40
- - Hiển thị thông tin phim, thể loại, số tập, trạng thái và mô tả.
41
- - Lưu lịch sử xem local để tiếp tục xem nhanh.
42
- - Hỗ trợ đăng nhập provider cho các danh sách cá nhân khi provider hỗ trợ.
43
- - Tùy chỉnh domain provider khi domain mặc định bị chặn.
44
- - Trình phát video tích hợp bằng Electron/ArtPlayer.
45
- - Discord Rich Presence có thể bật/tắt trong cài đặt.
46
-
47
- ## Lệnh phát triển
48
-
49
- Cài dependencies:
50
-
51
- ```bash
52
- npm install
53
- ```
54
-
55
- Chạy bản development:
56
-
57
- ```bash
58
- npm start
59
- ```
60
-
61
- Build TypeScript:
62
-
63
- ```bash
64
- npm run build
65
- ```
66
-
67
- Trước khi publish, hãy đảm bảo `version` trong `package.json` và `package-lock.json` đã khớp nhau.
68
-
69
- ## Gỡ lỗi
70
-
71
- Mặc định CLI ẩn log scraper/debug để giao diện terminal sạch hơn.
72
-
73
- Bật debug log khi cần kiểm tra provider hoặc AniList:
74
-
75
- ```bash
76
- NEKOSTREAM_DEBUG=1 nekostream
77
- ```
78
-
79
- Trên Windows PowerShell:
80
-
81
- ```powershell
82
- $env:NEKOSTREAM_DEBUG="1"; nekostream
83
- ```
84
-
85
- ## Miễn trừ trách nhiệm
86
-
87
- - Dự án không lưu trữ, phát tán hoặc sở hữu nội dung video.
88
- - NekoStream CLI chỉ là client kết nối trực tiếp từ máy người dùng đến provider.
89
- - Dự án không thu thập dữ liệu cá nhân và không sử dụng máy chủ trung gian.
90
- - Vui lòng sử dụng có trách nhiệm và tuân thủ điều khoản của từng provider.
1
+ <div align="center">
2
+
3
+ # 🐱 NekoStream CLI
4
+
5
+ **Tìm, duyệt và xem anime Việt — ngay trong terminal.**
6
+
7
+ [![npm](https://img.shields.io/npm/v/nekostream?color=8b5cf6&label=npm&logo=npm)](https://www.npmjs.com/package/nekostream)
8
+ [![node](https://img.shields.io/badge/node-%E2%89%A522.12-3c873a?logo=nodedotjs&logoColor=white)](https://nodejs.org)
9
+ [![typescript](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](tsconfig.json)
10
+
11
+ ```
12
+ ╭──────────────────────────────────────────╮
13
+ │ ❯ nekostream │
14
+ │ │
15
+ │ ▸ Tìm anime │
16
+ │ Thịnh hành Mới cập nhật │
17
+ │ Lịch sử xem Tài khoản │
18
+ │ Cài đặt │
19
+ ╰──────────────────────────────────────────╯
20
+ ```
21
+
22
+ </div>
23
+
24
+ ---
25
+
26
+ ## Dự án này là gì
27
+
28
+ một CLI dùng được hằng ngày để xem anime từ các provider Việt Nam: không cần mở trình duyệt, không quảng cáo, không server trung gian.
29
+
30
+ ---
31
+
32
+ ## Cài đặt
33
+
34
+ ```bash
35
+ npm install -g nekostream
36
+ nekostream
37
+ ```
38
+
39
+ Yêu cầu: **Node.js ≥ 22.12**, terminal hỗ trợ màu ANSI (Windows / macOS / Linux).
40
+
41
+ <details>
42
+ <summary><b>Cập nhật &amp; kiểm tra phiên bản</b></summary>
43
+
44
+ ```bash
45
+ npm view nekostream version # phiên bản mới nhất trên npm
46
+ npm install -g nekostream@latest # cập nhật
47
+ ```
48
+
49
+ </details>
50
+
51
+ ---
52
+
53
+ ## Tính năng
54
+
55
+ | | |
56
+ | --- | --- |
57
+ | 🔍 **Tìm &amp; duyệt** | Tìm theo tên, xem danh sách thịnh hành và mới cập nhật |
58
+ | 🎬 **Trình phát tích hợp** | Electron + ArtPlayer, chặn quảng cáo và popup của provider |
59
+ | 🌐 **Đa provider** | AnimeVietsub, Anime47 — kèm AnimeHay ở chế độ *duy trì, không đầu tư thêm* |
60
+ | 📚 **Lịch sử xem** | Lưu local, tiếp tục xem nhanh, giữ 100 mục gần nhất |
61
+ | 👤 **Đăng nhập provider** | Truy cập danh sách cá nhân; session mã hoá AES-256-GCM tại chỗ |
62
+ | 🔗 **Metadata AniList** | Bổ sung mô tả, thể loại, điểm — không bắt buộc, lỗi không làm hỏng luồng |
63
+ | 🎮 **Discord Rich Presence** | Bật/tắt trong Cài đặt |
64
+ | ⚙️ **Domain tuỳ chỉnh** | Đổi domain provider khi domain mặc định bị chặn |
65
+
66
+ ---
67
+
68
+ ## Kiến trúc
69
+
70
+ ```text
71
+ nekostream
72
+ ├── cli/ Tầng giao diện — menu, flow, theme, feedback
73
+ │ ├── menus/ Module độc lập cho từng menu (home, settings, history)
74
+ │ └── flows/ Luồng nghiệp vụ: anime-flow, auth-flow, provider-lists
75
+ ├── scrapers/ Tầng dữ liệu & trích xuất
76
+ │ ├── providers/ animevietsub/ · anime47/ · animehay.ts
77
+ │ │ ├── <provider>/parsers/ Cheerio DOM parsers thuần túy (testable offline)
78
+ │ │ └── <provider>/streams/ Trích xuất luồng video & server parsers
79
+ │ ├── auth/ Tầng xác thực & quản lý phiên
80
+ │ │ ├── animevietsub/ Đăng nhập & danh sách theo dõi
81
+ │ │ ├── anime47/ Đăng nhập JWT & client REST API
82
+ │ │ └── auth-session-service.ts Quản lý trạng thái phiên
83
+ │ ├── auth-service.ts Facade tương thích ngược cho Auth
84
+ │ ├── http-client.ts Throttle theo domain, timeout controller, backoff
85
+ │ ├── adblock-policy.ts Bộ quy tắc chặn quảng cáo, popup và ad network
86
+ │ ├── html-utils.ts Giải mã HTML, chuẩn hóa URL, phân loại timeout
87
+ │ └── errors.ts Bảng mã lỗi chuẩn hóa (ProviderUnavailableError, ...)
88
+ ├── player-policy.ts Cô lập host trình phát, kiểm tra payload & ad rules
89
+ ├── player-main.js Electron main process phát video
90
+ ├── webview-preload.js Preload cho <webview> phát video (sandboxing)
91
+ ├── storage.ts Lưu trữ mã hóa AES-256-GCM (settings, history, session)
92
+ ├── provider-types.ts Discriminated union — nguồn sự thật về provider
93
+ └── checks/ 6 bộ test suites chạy hoàn toàn offline
94
+ ```
95
+
96
+ Chi tiết xem tại [ARCHITECTURE.md](ARCHITECTURE.md).
97
+
98
+ ---
99
+
100
+ ## Phát triển
101
+
102
+ ```bash
103
+ npm install
104
+ npm start # chạy bản dev
105
+ npm run check # lint → typecheck → test (6 suites) → build → pack:check
106
+ ```
107
+
108
+ `npm run check` là cổng kiểm thử duy nhất. Mọi thay đổi phải vượt qua:
109
+ 1. **ESLint**: 0 cảnh báo (`--max-warnings 0`).
110
+ 2. **TypeScript**: `tsc --noEmit` chế độ strict.
111
+ 3. **6 Offline Test Suites**: `search-check`, `parser-check` (HTML fixtures), `domain-check`, `storage-check`, `scraping-check`, `player-check`.
112
+ 4. **Clean Build**: Xóa và biên dịch sạch vào `dist/`.
113
+ 5. **Package Check**: Đảm bảo đủ 70 runtime assets trong npm tarball.
114
+
115
+ <details>
116
+ <summary><b>Gỡ lỗi</b></summary>
117
+
118
+ CLI ẩn log scraper để terminal sạch. Bật khi cần:
119
+
120
+ ```bash
121
+ NEKOSTREAM_DEBUG=1 nekostream # bash / zsh
122
+ $env:NEKOSTREAM_DEBUG="1"; nekostream # PowerShell
123
+ ```
124
+
125
+ Debug của **trình phát** (DOM dump, Eruda console) bật riêng qua `debugMode` trong menu Cài đặt — không đọc từ biến môi trường của shell, để một biến sót lại không thể tự mở công cụ debug bên trong trang phát.
126
+
127
+ </details>
128
+
129
+ <details>
130
+ <summary><b>Cập nhật fixture test</b></summary>
131
+
132
+ Fixture là snapshot HTML thật của provider. Khi provider đổi markup:
133
+
134
+ ```bash
135
+ npx tsx checks/capture-fixtures.mts
136
+ ```
137
+
138
+ Tool này **không** nằm trong `npm test`: nó cần mạng, còn test thì phải offline và deterministic.
139
+
140
+ </details>
141
+
142
+ ---
143
+
144
+ ## Đóng góp
145
+
146
+ Repo này hoan nghênh cả hai loại đóng góp:
147
+
148
+ - **Code** — đọc [CONTRIBUTING.md](CONTRIBUTING.md) và [CLEANUP_PLAN.md](CLEANUP_PLAN.md) trước; PR nhỏ, một scope, giữ `npm run check` xanh.
149
+ - **Phản biện đánh giá** — nếu bạn cho rằng [CODE_ASSESSMENT.md](CODE_ASSESSMENT.md) chấm sai hoặc bỏ sót một vấn đề, hãy mở issue. Một case study bị phản biện có giá trị hơn một case study được đồng thuận.
150
+
151
+ ---
152
+
153
+ ## Miễn trừ trách nhiệm
154
+
155
+ - Dự án **không** lưu trữ, phát tán hoặc sở hữu bất kỳ nội dung video nào.
156
+ - NekoStream CLI là client kết nối trực tiếp từ máy người dùng đến provider — **không có server trung gian**.
157
+ - Dự án không thu thập dữ liệu cá nhân. Toàn bộ lịch sử và session nằm trong `~/.nekostream-cli/` trên máy bạn.
158
+ - Vui lòng sử dụng có trách nhiệm và tuân thủ điều khoản của từng provider.
159
+
160
+ > [!CAUTION]
161
+ > ### TUYÊN BỐ MIỄN TRỪ TRÁCH NHIỆM PHÁP LÝ (DISCLAIMER)
162
+ >
163
+ > 1. **Sử dụng với rủi ro tự chịu (As-Is):** Mã nguồn này được cung cấp theo nguyên trạng "như hiện có" mà không có bất kỳ sự đảm bảo nào, dù là rõ ràng hay ngầm định, bao gồm nhưng không giới hạn ở các bảo đảm về khả năng thương mại hoặc tính phù hợp cho một mục đích cụ thể.
164
+ > 2. **Giới hạn trách nhiệm:** Trong mọi trường hợp, **NekoTech** cũng như các thành viên phát triển không chịu trách nhiệm pháp lý đối với bất kỳ khiếu nại, thiệt hại hoặc tổn thất nào (bao gồm tổn thất dữ liệu, lỗ hổng bảo mật, hoặc thiệt hại về tài chính) phát sinh từ hoặc có liên quan đến việc sử dụng, sửa đổi, hoặc phân phối mã nguồn này.
165
+ > 3. **Không hỗ trợ pháp lý:** Việc bạn sử dụng lại mã nguồn để vận hành các dịch vụ công cộng hoặc thương mại tự chịu hoàn toàn trách nhiệm trước pháp luật hiện hành. NekoTech không có nghĩa vụ bảo vệ, can thiệp hoặc chịu trách nhiệm thay cho bất kỳ hành vi vi phạm bản quyền hay pháp lý nào từ phía bên thứ ba.
166
+
167
+ <div align="center">
168
+
169
+ </div>
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runApplication = runApplication;
4
+ const discord_1 = require("../discord");
5
+ const logger_1 = require("../logger");
6
+ const storage_1 = require("../storage");
7
+ const domain_startup_1 = require("./domain-startup");
8
+ const home_menu_1 = require("./menus/home-menu");
9
+ const update_check_1 = require("./update-check");
10
+ /**
11
+ * Application entry point: bring up the runtime services, then hand control to
12
+ * the home menu loop, which owns the interactive session until exit.
13
+ *
14
+ * Domain resolution runs after the update check — that one already exits the
15
+ * process when a newer version exists, so there is no point probing first.
16
+ */
17
+ async function runApplication() {
18
+ (0, storage_1.ensureDataDir)();
19
+ await (0, update_check_1.checkUpdate)();
20
+ await (0, domain_startup_1.resolveProviderDomains)();
21
+ await (0, discord_1.initDiscord)();
22
+ (0, logger_1.debugLog)('NekoStream CLI started');
23
+ await (0, home_menu_1.showHomeMenu)();
24
+ }
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveProviderDomains = resolveProviderDomains;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const ora_1 = __importDefault(require("ora"));
9
+ const logger_1 = require("../logger");
10
+ const domain_resolver_1 = require("../scrapers/domain-resolver");
11
+ const feedback_1 = require("./feedback");
12
+ /**
13
+ * The fast path — probing the domain already in use — normally answers well
14
+ * inside this window, so no spinner is drawn at all and startup looks unchanged.
15
+ * The spinner only appears once resolution is slow enough to mean the current
16
+ * domain failed and the TLD fan-out is running.
17
+ */
18
+ const SPINNER_DELAY_MS = 900;
19
+ function summarize(outcomes) {
20
+ return {
21
+ changed: outcomes.filter((o) => o.status === 'ok' && o.changed),
22
+ failed: outcomes.filter((o) => o.status === 'failed')
23
+ };
24
+ }
25
+ /**
26
+ * A spinner that only materialises if the work outlives `SPINNER_DELAY_MS`.
27
+ *
28
+ * The state is kept behind functions rather than a local variable because the
29
+ * assignment happens inside a timer callback: TypeScript's control-flow analysis
30
+ * cannot see across that boundary and narrows a plain `let` to `null` forever.
31
+ */
32
+ function delayedSpinner(text) {
33
+ let spinner;
34
+ const timer = setTimeout(() => {
35
+ spinner = (0, ora_1.default)(text).start();
36
+ }, SPINNER_DELAY_MS);
37
+ return {
38
+ stop: () => {
39
+ clearTimeout(timer);
40
+ spinner?.stop();
41
+ },
42
+ wasShown: () => spinner !== undefined
43
+ };
44
+ }
45
+ /**
46
+ * Pick each provider's live domain before the menu opens.
47
+ *
48
+ * Startup resolution is what turns domain discovery from reactive into
49
+ * proactive: previously a moved provider surfaced as three failed retries and a
50
+ * Playwright escalation in the middle of a search, and only then fell back.
51
+ *
52
+ * This never rejects and never blocks entry to the app. Offline or slow hosts
53
+ * simply mean the cached or seeded domain is used as-is.
54
+ */
55
+ async function resolveProviderDomains() {
56
+ const spinner = delayedSpinner('Đang dò domain provider...');
57
+ let outcomes;
58
+ try {
59
+ outcomes = await (0, domain_resolver_1.resolveAllDomains)();
60
+ }
61
+ finally {
62
+ spinner.stop();
63
+ }
64
+ for (const outcome of outcomes) {
65
+ (0, logger_1.debugLog)(`[Domain] ${outcome.provider}: ${outcome.status} -> ${outcome.baseUrl}`);
66
+ }
67
+ const { changed, failed } = summarize(outcomes);
68
+ // Only speak up when something actually moved. A silent startup is the
69
+ // expected case and the whole point of the fast path.
70
+ if (changed.length > 0) {
71
+ for (const outcome of changed) {
72
+ console.log(chalk_1.default.cyan(` Domain ${outcome.provider} đã đổi -> ${outcome.baseUrl}`));
73
+ }
74
+ await (0, feedback_1.sleep)(feedback_1.CONFIRM_DELAY_MS);
75
+ return;
76
+ }
77
+ // Only mention a failure if the spinner had appeared. Without that guard an
78
+ // offline user gets a warning about a domain probe they never asked for and
79
+ // cannot act on; with it, the line only follows visible work.
80
+ if (failed.length > 0 && spinner.wasShown()) {
81
+ const names = failed.map((o) => o.provider).join(', ');
82
+ console.log(chalk_1.default.yellow(` Không dò được domain sống cho: ${names}. Dùng domain đã lưu.`));
83
+ await (0, feedback_1.sleep)(feedback_1.CONFIRM_DELAY_MS);
84
+ }
85
+ }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CONFIRM_DELAY_MS = exports.NOTICE_DELAY_MS = void 0;
7
+ exports.sleep = sleep;
8
+ exports.formatToggle = formatToggle;
9
+ exports.flushStdin = flushStdin;
10
+ exports.formatError = formatError;
11
+ exports.reportFailure = reportFailure;
12
+ exports.reportEmpty = reportEmpty;
13
+ exports.withSpinner = withSpinner;
14
+ exports.withSilentSpinner = withSilentSpinner;
15
+ const chalk_1 = __importDefault(require("chalk"));
16
+ const ora_1 = __importDefault(require("ora"));
17
+ const ui_1 = require("../ui");
18
+ /** How long a transient message stays on screen before the menu repaints. */
19
+ exports.NOTICE_DELAY_MS = 2000;
20
+ exports.CONFIRM_DELAY_MS = 1000;
21
+ async function sleep(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+ function formatToggle(value) {
25
+ return value ? chalk_1.default.green('Bật') : chalk_1.default.red('Tắt');
26
+ }
27
+ /**
28
+ * Drain buffered keypresses so a prompt does not immediately consume input
29
+ * typed while the previous screen was still rendering.
30
+ */
31
+ function flushStdin() {
32
+ if (!process.stdin.isTTY)
33
+ return;
34
+ process.stdin.resume();
35
+ while (process.stdin.read() !== null) {
36
+ // Discard whatever was buffered.
37
+ }
38
+ }
39
+ function formatError(error) {
40
+ if (error instanceof Error)
41
+ return error.message;
42
+ return String(error);
43
+ }
44
+ /** Print a failure and hold it on screen long enough to be read. */
45
+ async function reportFailure(label, error) {
46
+ const detail = error === undefined ? '' : `: ${formatError(error)}`;
47
+ (0, ui_1.printError)(`\n${label}${detail}`);
48
+ await sleep(exports.NOTICE_DELAY_MS);
49
+ }
50
+ /** Print an empty-result notice and hold it on screen. */
51
+ async function reportEmpty(message) {
52
+ (0, ui_1.printError)(`\n${message}`);
53
+ await sleep(exports.NOTICE_DELAY_MS);
54
+ }
55
+ /**
56
+ * Run an async task behind a spinner. Returns `undefined` when the task throws,
57
+ * after showing `failure` to the user — callers treat that as "abort this step".
58
+ */
59
+ async function withSpinner(message, failure, task) {
60
+ const spinner = (0, ora_1.default)(message).start();
61
+ try {
62
+ const value = await task();
63
+ spinner.stop();
64
+ return value;
65
+ }
66
+ catch (error) {
67
+ spinner.stop();
68
+ await reportFailure(failure, error);
69
+ return undefined;
70
+ }
71
+ }
72
+ /**
73
+ * Like {@link withSpinner} but keeps the spinner running on success so the
74
+ * caller can decide the final message.
75
+ */
76
+ async function withSilentSpinner(message, task) {
77
+ const spinner = (0, ora_1.default)(message).start();
78
+ try {
79
+ const value = await task();
80
+ spinner.stop();
81
+ return { ok: true, value };
82
+ }
83
+ catch (error) {
84
+ spinner.stop();
85
+ return { ok: false, error };
86
+ }
87
+ }