diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fb990aa51..f2d72b3f1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,8 +28,6 @@ updates: ignore: - dependency-name: "zod" update-types: ["version-update:semver-major"] - - dependency-name: "inquirer" - update-types: ["version-update:semver-major"] - dependency-name: "typescript" update-types: ["version-update:semver-major"] cooldown: diff --git a/AGENTS.md b/AGENTS.md index 71b4dcb53..1596b7d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v - **`durationFlag`** — `--duration` / `-D`. Use for long-running subscribe/stream commands that auto-exit after N seconds. - **`rewindFlag`** — `--rewind`. Use for subscribe commands that support message replay (default: 0). - **`timeRangeFlags`** — `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). -- **`forceFlag`** — `--force` / `-f`. Use for destructive commands (delete, revoke) that require user confirmation. When `--force` is provided, skip the interactive prompt. When `--json` is used without `--force`, fail with an error requiring `--force`. Use `promptForConfirmation()` from `src/utils/prompt-confirmation.js` for the interactive prompt — do NOT use `interactiveHelper.confirm()` (inquirer-based, inconsistent UX). +- **`forceFlag`** — `--force` / `-f`. Use for destructive commands (delete, revoke) that require user confirmation. When `--force` is provided, skip the interactive prompt. When `--json` is used without `--force`, fail with an error requiring `--force`. Use `promptForConfirmation()` from `src/utils/prompt-confirmation.js` for the interactive prompt. - **`endpointFlag`** — `--endpoint`. Hidden, only on `accounts login` and `accounts switch`. **Flags vs positional arguments (POSIX / docopt convention):** diff --git a/docs/Interactive-REPL.md b/docs/Interactive-REPL.md index 712c547fa..e8e7743e2 100644 --- a/docs/Interactive-REPL.md +++ b/docs/Interactive-REPL.md @@ -24,7 +24,7 @@ I would like to explore an alternative route where the Ably CLI supports an inte There are some relevant Node.js projects we can draw inspiration from: - [Vorpal interactive CLI](https://vorpal.js.org/) with source code at https://github.com/dthree/vorpal -- [Inquirer package](https://www.npmjs.com/package/inquirer) for common interactive command line user interface commands +- Node.js built-in `node:readline` module for interactive prompts (confirmation, selection) [oclif](https://oclif.io/) does not appear to have any plugins to support an interactive/embedded CLI mode. However, a [REPL plugin](https://github.com/sisou/oclif-plugin-repl) exists, although that's unlikely to share much with the goals of interactive CLI. diff --git a/package.json b/package.json index a7b0fea3e..ec1551d4c 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,6 @@ "dependencies": { "@ably/chat": "^1.3.1", "@ably/spaces": "^0.5.2", - "@inquirer/prompts": "^5.5.0", "@oclif/core": "^4.10.5", "@oclif/plugin-autocomplete": "^3.2.45", "@oclif/plugin-warn-if-update-available": "^3.1.60", @@ -125,7 +124,6 @@ "cli-table3": "^0.6.5", "color-json": "^3.0.5", "fast-levenshtein": "^3.0.0", - "inquirer": "^9.3.8", "jsonwebtoken": "^9.0.3", "node-fetch": "^3.3.2", "open": "^11.0.0", @@ -143,7 +141,6 @@ "@oclif/test": "^4.1.18", "@types/fast-levenshtein": "^0.0.4", "@types/fs-extra": "^11.0.4", - "@types/inquirer": "^9.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.6.0", "@types/node-fetch": "^2.6.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c522e165f..9605aeda9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,9 +39,6 @@ importers: '@ably/spaces': specifier: ^0.5.2 version: 0.5.2(ably@2.21.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@inquirer/prompts': - specifier: ^5.5.0 - version: 5.5.0 '@oclif/core': specifier: ^4.10.5 version: 4.10.5 @@ -75,9 +72,6 @@ importers: fast-levenshtein: specifier: ^3.0.0 version: 3.0.0 - inquirer: - specifier: ^9.3.8 - version: 9.3.8(@types/node@25.6.0) jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -124,9 +118,6 @@ importers: '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 - '@types/inquirer': - specifier: ^9.0.9 - version: 9.0.9 '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -1108,10 +1099,6 @@ packages: resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} - '@inquirer/checkbox@2.5.0': - resolution: {integrity: sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==} - engines: {node: '>=18'} - '@inquirer/checkbox@4.3.2': resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} engines: {node: '>=18'} @@ -1147,10 +1134,6 @@ packages: resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} engines: {node: '>=18'} - '@inquirer/editor@2.2.0': - resolution: {integrity: sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==} - engines: {node: '>=18'} - '@inquirer/editor@4.2.23': resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} engines: {node: '>=18'} @@ -1160,10 +1143,6 @@ packages: '@types/node': optional: true - '@inquirer/expand@2.3.0': - resolution: {integrity: sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==} - engines: {node: '>=18'} - '@inquirer/expand@4.0.23': resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} engines: {node: '>=18'} @@ -1203,10 +1182,6 @@ packages: '@types/node': optional: true - '@inquirer/number@1.1.0': - resolution: {integrity: sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==} - engines: {node: '>=18'} - '@inquirer/number@3.0.23': resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} engines: {node: '>=18'} @@ -1216,10 +1191,6 @@ packages: '@types/node': optional: true - '@inquirer/password@2.2.0': - resolution: {integrity: sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==} - engines: {node: '>=18'} - '@inquirer/password@4.0.23': resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} engines: {node: '>=18'} @@ -1229,10 +1200,6 @@ packages: '@types/node': optional: true - '@inquirer/prompts@5.5.0': - resolution: {integrity: sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==} - engines: {node: '>=18'} - '@inquirer/prompts@7.10.1': resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} engines: {node: '>=18'} @@ -1242,10 +1209,6 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@2.3.0': - resolution: {integrity: sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==} - engines: {node: '>=18'} - '@inquirer/rawlist@4.1.11': resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} engines: {node: '>=18'} @@ -1255,10 +1218,6 @@ packages: '@types/node': optional: true - '@inquirer/search@1.1.0': - resolution: {integrity: sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==} - engines: {node: '>=18'} - '@inquirer/search@3.2.2': resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} engines: {node: '>=18'} @@ -1578,56 +1537,67 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} @@ -1639,11 +1609,13 @@ packages: resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1955,24 +1927,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -2078,9 +2054,6 @@ packages: '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} - '@types/inquirer@9.0.9': - resolution: {integrity: sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2122,9 +2095,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -2490,9 +2460,6 @@ packages: resolution: {integrity: sha512-ZXBDPMt/v/8fsIqn+Z5VwrhdR6jVka0bYobHdGia0Nxi7BJ9i/Uvml3AocHIBtIIBhZjBw5MR0aR4ROs/8+SNg==} engines: {node: '>= 0.4'} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.16: resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==} engines: {node: '>=6.0.0'} @@ -2505,9 +2472,6 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bops@1.0.1: resolution: {integrity: sha512-qCMBuZKP36tELrrgXpAfM+gHzqa0nLsWZ+L37ncsb8txYlnAoxOPpVp+g7fK0sGkMXfA0wl8uQkESqw3v4HNag==} @@ -2540,9 +2504,6 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - builtin-modules@5.0.0: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} @@ -2636,9 +2597,6 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -2669,10 +2627,6 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2700,10 +2654,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2853,9 +2803,6 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -3165,10 +3112,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3532,10 +3475,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3544,9 +3483,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3567,16 +3503,9 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inquirer@9.3.8: - resolution: {integrity: sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==} - engines: {node: '>=18'} - internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3663,10 +3592,6 @@ packages: engines: {node: '>=14.16'} hasBin: true - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -3737,10 +3662,6 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -3999,10 +3920,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - log-symbols@7.0.1: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} @@ -4332,10 +4249,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - ora@9.3.0: resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} engines: {node: '>=20'} @@ -4591,10 +4504,6 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -4667,10 +4576,6 @@ packages: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -4700,16 +4605,9 @@ packages: resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} engines: {node: '>=18'} - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -4924,9 +4822,6 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5047,10 +4942,6 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tmp@0.2.4: - resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==} - engines: {node: '>=14.14'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5252,9 +5143,6 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -5362,9 +5250,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6436,14 +6321,6 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@2.5.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/figures': 1.0.11 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.2 - '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -6494,12 +6371,6 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 - '@inquirer/editor@2.2.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - external-editor: 3.1.0 - '@inquirer/editor@4.2.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -6508,12 +6379,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/expand@2.3.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - yoctocolors-cjs: 2.1.2 - '@inquirer/expand@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -6545,11 +6410,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/number@1.1.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - '@inquirer/number@3.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -6557,12 +6417,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/password@2.2.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - '@inquirer/password@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -6571,19 +6425,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/prompts@5.5.0': - dependencies: - '@inquirer/checkbox': 2.5.0 - '@inquirer/confirm': 3.2.0 - '@inquirer/editor': 2.2.0 - '@inquirer/expand': 2.3.0 - '@inquirer/input': 2.3.0 - '@inquirer/number': 1.1.0 - '@inquirer/password': 2.2.0 - '@inquirer/rawlist': 2.3.0 - '@inquirer/search': 1.1.0 - '@inquirer/select': 2.5.0 - '@inquirer/prompts@7.10.1(@types/node@25.6.0)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) @@ -6599,12 +6440,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/rawlist@2.3.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - yoctocolors-cjs: 2.1.2 - '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -6613,13 +6448,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/search@1.1.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/figures': 1.0.11 - '@inquirer/type': 1.5.5 - yoctocolors-cjs: 2.1.2 - '@inquirer/search@3.2.2(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -7514,11 +7342,6 @@ snapshots: '@types/http-cache-semantics@4.0.4': {} - '@types/inquirer@9.0.9': - dependencies: - '@types/through': 0.0.33 - rxjs: 7.8.2 - '@types/json-schema@7.0.15': {} '@types/jsonfile@6.1.4': @@ -7569,10 +7392,6 @@ snapshots: dependencies: '@types/node': 25.6.0 - '@types/through@0.0.33': - dependencies: - '@types/node': 25.6.0 - '@types/wrap-ansi@3.0.0': {} '@types/ws@8.18.1': @@ -8028,8 +7847,6 @@ snapshots: base64-js@1.0.2: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.16: {} basic-auth@2.0.1: @@ -8040,12 +7857,6 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - bops@1.0.1: dependencies: base64-js: 1.0.2 @@ -8091,11 +7902,6 @@ snapshots: buffer-equal-constant-time@1.0.1: {} - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - builtin-modules@5.0.0: {} bundle-name@4.1.0: @@ -8201,8 +8007,6 @@ snapshots: change-case@5.4.4: {} - chardet@0.7.0: {} - chardet@2.1.1: {} chokidar@4.0.3: @@ -8225,10 +8029,6 @@ snapshots: cli-boxes@3.0.0: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -8255,8 +8055,6 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - clsx@2.1.1: {} code-block-writer@10.1.1: {} @@ -8400,10 +8198,6 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.0 - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -8919,12 +8713,6 @@ snapshots: expect-type@1.3.0: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.2.4 - fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -9330,10 +9118,6 @@ snapshots: human-signals@8.0.1: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -9342,8 +9126,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -9354,27 +9136,8 @@ snapshots: indent-string@5.0.0: {} - inherits@2.0.4: {} - ini@1.3.8: {} - inquirer@9.3.8(@types/node@25.6.0): - dependencies: - '@inquirer/external-editor': 1.0.3(@types/node@25.6.0) - '@inquirer/figures': 1.0.15 - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 1.0.0 - ora: 5.4.1 - run-async: 3.0.0 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - transitivePeerDependencies: - - '@types/node' - internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -9458,8 +9221,6 @@ snapshots: dependencies: is-docker: 3.0.0 - is-interactive@1.0.0: {} - is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -9516,8 +9277,6 @@ snapshots: dependencies: which-typed-array: 1.1.19 - is-unicode-supported@0.1.0: {} - is-unicode-supported@2.1.0: {} is-weakmap@2.0.2: {} @@ -9772,11 +9531,6 @@ snapshots: lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - log-symbols@7.0.1: dependencies: is-unicode-supported: 2.1.0 @@ -10096,18 +9850,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - ora@9.3.0: dependencies: chalk: 5.6.2 @@ -10326,12 +10068,6 @@ snapshots: react@19.2.5: {} - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@4.1.2: {} rechoir@0.6.2: @@ -10412,11 +10148,6 @@ snapshots: dependencies: lowercase-keys: 3.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -10483,16 +10214,10 @@ snapshots: run-applescript@7.0.0: {} - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -10764,10 +10489,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -10875,8 +10596,6 @@ snapshots: tldts-core: 6.1.86 optional: true - tmp@0.2.4: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -11086,8 +10805,6 @@ snapshots: url-join@4.0.1: {} - util-deprecate@1.0.2: {} - v8-compile-cache-lib@3.0.1: {} validate-npm-package-license@3.0.4: @@ -11159,10 +10876,6 @@ snapshots: xml-name-validator: 5.0.0 optional: true - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/src/base-topic-command.ts b/src/base-topic-command.ts index 6a9e0808a..a9218b51b 100644 --- a/src/base-topic-command.ts +++ b/src/base-topic-command.ts @@ -1,10 +1,8 @@ import chalk from "chalk"; -import inquirer from "inquirer"; import pkg from "fast-levenshtein"; import { InteractiveBaseCommand } from "./interactive-base-command.js"; -import { runInquirerWithReadlineRestore } from "./utils/readline-helper.js"; +import { promptForConfirmation } from "./utils/prompt-confirmation.js"; import { formatWarning } from "./utils/output.js"; -import * as readline from "node:readline"; import { WEB_CLI_RESTRICTED_COMMANDS, WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS, @@ -121,25 +119,12 @@ export abstract class BaseTopicCommand extends InteractiveBaseCommand { if (skipConfirmation) { confirmed = true; } else { - // In interactive mode, we need to handle readline carefully - const interactiveReadline = isInteractiveMode - ? (globalThis as Record) - .__ablyInteractiveReadline - : null; - - const result = await runInquirerWithReadlineRestore( - async () => - inquirer.prompt<{ confirmed: boolean }>([ - { - name: "confirmed", - type: "confirm", - message: `Did you mean ${chalk.green(displaySuggestion)}?`, - default: true, - }, - ]), - interactiveReadline as readline.Interface | null, + // promptForConfirmation handles REPL readline state restoration + // internally when ABLY_INTERACTIVE_MODE is active. + confirmed = await promptForConfirmation( + `Did you mean ${chalk.green(displaySuggestion)}?`, + true, ); - confirmed = result.confirmed; } if (confirmed) { diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index f40dade4e..cdda6fc57 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -1,11 +1,14 @@ import { Args } from "@oclif/core"; import chalk from "chalk"; -import inquirer from "inquirer"; import { ControlBaseCommand } from "../../control-base-command.js"; import { endpointFlag } from "../../flags.js"; import { type AccountSummary } from "../../services/control-api.js"; import { formatResource } from "../../utils/output.js"; +import { + promptForSelection, + type SelectionChoice, +} from "../../utils/prompt-selection.js"; import { pickUniqueAlias, slugifyAccountName } from "../../utils/slugify.js"; export default class AccountsSwitch extends ControlBaseCommand { @@ -120,18 +123,15 @@ export default class AccountsSwitch extends ControlBaseCommand { // Remote accounts not already configured locally const remoteOnly = remoteAccounts.filter((r) => !localAccountIds.has(r.id)); - type Choice = { - name: string; - value: - | { type: "local"; alias: string } - | { type: "remote"; account: AccountSummary }; - }; + type SelectedAccount = + | { type: "local"; alias: string } + | { type: "remote"; account: AccountSummary }; - const choices: Array = []; + const choices: Array> = []; // Local accounts section if (localAccounts.length > 0) { - choices.push(new inquirer.Separator("── Local accounts ──")); + choices.push({ separator: "── Local accounts ──" }); for (const { account, alias } of localAccounts) { const isCurrent = alias === currentAlias; const name = account.accountName || account.accountId || "Unknown"; @@ -142,9 +142,9 @@ export default class AccountsSwitch extends ControlBaseCommand { // Remote-only accounts section if (remoteOnly.length > 0) { - choices.push( - new inquirer.Separator("── Other accounts (no login required) ──"), - ); + choices.push({ + separator: "── Other accounts (no login required) ──", + }); for (const account of remoteOnly) { const label = ` ${account.name} ${chalk.dim(`(${account.id})`)}`; choices.push({ name: label, value: { type: "remote", account } }); @@ -156,18 +156,11 @@ export default class AccountsSwitch extends ControlBaseCommand { return false; } - const { selected } = (await inquirer.prompt([ - { - choices, - message: "Select an account:", - name: "selected", - type: "list", - }, - ])) as { - selected: - | { type: "local"; alias: string } - | { type: "remote"; account: AccountSummary }; - }; + const selected = await promptForSelection("Select an account:", choices); + + if (!selected) { + return false; + } if (selected.type === "local") { await this.switchToLocalAccount(selected.alias, flags); diff --git a/src/hooks/command_not_found/did-you-mean.ts b/src/hooks/command_not_found/did-you-mean.ts index a75cc4101..c5abe7437 100644 --- a/src/hooks/command_not_found/did-you-mean.ts +++ b/src/hooks/command_not_found/did-you-mean.ts @@ -1,10 +1,8 @@ import { Hook } from "@oclif/core"; import chalk from "chalk"; -import inquirer from "inquirer"; import pkg from "fast-levenshtein"; -import { runInquirerWithReadlineRestore } from "../../utils/readline-helper.js"; +import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import { formatWarning } from "../../utils/output.js"; -import * as readline from "node:readline"; const { get: levenshteinDistance } = pkg; /** @@ -111,24 +109,12 @@ const hook: Hook<"command_not_found"> = async function (opts) { // Important: We still proceed to *try* running the command, but tests assert it *fails* correctly confirmed = true; } else { - // In interactive mode, we need to handle readline carefully - const interactiveReadline = isInteractiveMode - ? (globalThis as Record).__ablyInteractiveReadline - : null; - - const result = await runInquirerWithReadlineRestore( - async () => - inquirer.prompt<{ confirmed: boolean }>([ - { - name: "confirmed", - type: "confirm", - message: `Did you mean ${chalk.green(displaySuggestion)}?`, - default: true, - }, - ]), - interactiveReadline as readline.Interface | null, + // promptForConfirmation handles REPL readline state restoration internally + // when ABLY_INTERACTIVE_MODE is active. + confirmed = await promptForConfirmation( + `Did you mean ${chalk.green(displaySuggestion)}?`, + true, ); - confirmed = result.confirmed; } if (confirmed) { diff --git a/src/hooks/command_not_found/prompt-utils.ts b/src/hooks/command_not_found/prompt-utils.ts deleted file mode 100644 index af8962043..000000000 --- a/src/hooks/command_not_found/prompt-utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { confirm } from "@inquirer/prompts"; -import chalk from "chalk"; -import { setTimeout } from "node:timers/promises"; - -export class PromptHelper { - /** - * Prompts the user for confirmation with a timeout. - */ - async getConfirmation(suggestion: string): Promise { - const ac = new AbortController(); - const { signal } = ac; - - const confirmation = confirm({ - default: true, - message: `Did you mean ${chalk.blueBright(suggestion)}?`, - theme: { - prefix: "", - style: { - message: (text: string) => chalk.reset(text), - }, - }, - }); - - // Timeout the prompt after 10 seconds - void setTimeout(10_000, "timeout", { signal }) - .catch(() => false) // Ignore timeout errors, treat as 'No' - .then(() => confirmation.cancel()); - - try { - const value = await confirmation; - return value; - } catch { - // Handle cancellation (e.g., Ctrl+C) as 'No' - return false; - } finally { - ac.abort(); // Clean up the AbortController - } - } -} - -// Utility function to format a prompt message with chalk -export function formatPromptMessage( - message: string, - suggestion?: string, -): string { - if (suggestion) { - // Use chalk for styling - return `${message} ${chalk.blueBright(suggestion)}?`; - } - return message; -} - -// Note: The actual prompt logic (using inquirer) is now handled directly -// within the did-you-mean.ts hook for better context management. -// This file now only contains utility functions if needed, -// or can be removed if formatPromptMessage is moved/inlined. - -export function formatSuggestion(suggestion: string): string { - return chalk.blueBright(suggestion); -} diff --git a/src/services/interactive-helper.ts b/src/services/interactive-helper.ts index 2b5e36f09..f8b441f66 100644 --- a/src/services/interactive-helper.ts +++ b/src/services/interactive-helper.ts @@ -1,4 +1,4 @@ -import inquirer from "inquirer"; +import { promptForSelection } from "../utils/prompt-selection.js"; import type { ConfigManager, AccountConfig } from "./config-manager.js"; import type { AccountSummary, App, ControlApi, Key } from "./control-api.js"; @@ -21,22 +21,6 @@ export class InteractiveHelper { this.logErrors = options.logErrors !== false; // Default to true } - /** - * Confirm an action with the user - */ - async confirm(message: string): Promise { - const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ - { - default: false, - message, - name: "confirmed", - type: "confirm", - }, - ]); - - return confirmed; - } - /** * Interactively select an account from the list of configured accounts */ @@ -55,26 +39,17 @@ export class InteractiveHelper { return null; } - const { selectedAccount } = await inquirer.prompt<{ - selectedAccount: { account: AccountConfig; alias: string }; - }>([ - { - choices: accounts.map((account) => { - const isCurrent = account.alias === currentAlias; - const accountInfo = account.account.accountName; - const userInfo = account.account.userEmail; - return { - name: `${isCurrent ? "* " : " "}${account.alias} (${accountInfo}, ${userInfo})`, - value: account, - }; - }), - message: "Select an account:", - name: "selectedAccount", - type: "list", - }, - ]); - - return selectedAccount; + const choices = accounts.map((account) => { + const isCurrent = account.alias === currentAlias; + const accountInfo = account.account.accountName; + const userInfo = account.account.userEmail; + return { + name: `${isCurrent ? "* " : " "}${account.alias} (${accountInfo}, ${userInfo})`, + value: account, + }; + }); + + return await promptForSelection("Select an account:", choices); } catch (error) { if (this.logErrors) { this.log( @@ -96,19 +71,12 @@ export class InteractiveHelper { return null; } - const { selectedAccount } = (await inquirer.prompt([ - { - choices: accounts.map((account) => ({ - name: `${account.name} (${account.id})`, - value: account, - })), - message: "Select an account:", - name: "selectedAccount", - type: "list", - }, - ])) as { selectedAccount: AccountSummary }; - - return selectedAccount; + const choices = accounts.map((account) => ({ + name: `${account.name} (${account.id})`, + value: account, + })); + + return await promptForSelection("Select an account:", choices); } catch (error) { if (this.logErrors) { this.log( @@ -131,19 +99,12 @@ export class InteractiveHelper { return null; } - const { selectedApp } = await inquirer.prompt<{ selectedApp: App }>([ - { - choices: apps.map((app) => ({ - name: `${app.name} (${app.id})`, - value: app, - })), - message: "Select an app:", - name: "selectedApp", - type: "list", - }, - ]); - - return selectedApp; + const choices = apps.map((app) => ({ + name: `${app.name} (${app.id})`, + value: app, + })); + + return await promptForSelection("Select an app:", choices); } catch (error) { if (this.logErrors) { this.log( @@ -166,19 +127,12 @@ export class InteractiveHelper { return null; } - const { selectedKey } = await inquirer.prompt<{ selectedKey: Key }>([ - { - choices: keys.map((key) => ({ - name: `${key.name || "Unnamed key"} (${key.id})`, - value: key, - })), - message: "Select a key:", - name: "selectedKey", - type: "list", - }, - ]); - - return selectedKey; + const choices = keys.map((key) => ({ + name: `${key.name || "Unnamed key"} (${key.id})`, + value: key, + })); + + return await promptForSelection("Select a key:", choices); } catch (error) { if (this.logErrors) { this.log( diff --git a/src/utils/prompt-confirmation.ts b/src/utils/prompt-confirmation.ts index 15d80f6c0..93d006541 100644 --- a/src/utils/prompt-confirmation.ts +++ b/src/utils/prompt-confirmation.ts @@ -1,32 +1,83 @@ import * as readline from "node:readline"; +import { + getInteractiveReadline, + runWithReadlineRestore, +} from "./readline-helper.js"; + /** * Prompts the user for confirmation with a yes/no question. - * Automatically appends " [y/n]" to the message if not already present. + * Automatically appends a "[y/n]" or "[Y/n]" suffix based on the default value. * Accepts both "y" and "yes" as affirmative responses (case-insensitive). * + * When invoked from the interactive REPL (`ABLY_INTERACTIVE_MODE=true`), this + * function automatically pauses the REPL's readline, removes its line + * listeners, runs the prompt, and restores everything afterwards — so callers + * never need to wrap it in `runWithReadlineRestore` themselves. + * * @param message - The confirmation message to display to the user + * @param defaultValue - The value returned when the user presses Enter without typing (default: false). + * Note: SIGINT and close always resolve to false regardless of defaultValue, since cancellation should never confirm. * @returns Promise - true if user confirms (y/yes), false otherwise */ -export function promptForConfirmation(message: string): Promise { +export function promptForConfirmation( + message: string, + defaultValue: boolean = false, +): Promise { + return runWithReadlineRestore( + () => promptForConfirmationInternal(message, defaultValue), + getInteractiveReadline(), + ); +} + +function promptForConfirmationInternal( + message: string, + defaultValue: boolean, +): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); - // Add " [y/n]" suffix if not already present + // Determine suffix based on default + const suffix = defaultValue ? "[Y/n]" : "[y/n]"; + + // Add suffix if not already present (check all variants) const promptMessage = message.includes("[yes/no]") || message.includes("[y/n]") || - message.includes("[Y/N]") + message.includes("[Y/N]") || + message.includes("[Y/n]") || + message.includes("[y/N]") ? message - : `${message} [y/n]`; + : `${message} ${suffix}`; return new Promise((resolve) => { - rl.question(promptMessage, (answer) => { + let settled = false; + + const finish = (result: boolean) => { + if (settled) return; + settled = true; rl.close(); + resolve(result); + }; + + rl.on("SIGINT", () => { + finish(false); + }); + + rl.on("close", () => { + finish(false); + }); + + rl.question(promptMessage, (answer) => { const response = answer.toLowerCase().trim(); - resolve(response === "y" || response === "yes"); + // Empty input → use default value + if (response === "") { + finish(defaultValue); + return; + } + finish(response === "y" || response === "yes"); }); }); } diff --git a/src/utils/prompt-selection.ts b/src/utils/prompt-selection.ts new file mode 100644 index 000000000..09831e4be --- /dev/null +++ b/src/utils/prompt-selection.ts @@ -0,0 +1,134 @@ +import * as readline from "node:readline"; + +import { + getInteractiveReadline, + runWithReadlineRestore, +} from "./readline-helper.js"; + +/** + * A choice item is either selectable (has a value) or a separator (header line, not selectable). + */ +export type SelectionChoice = + | { name: string; value: T } + | { separator: string }; + +function isSeparator( + choice: SelectionChoice, +): choice is { separator: string } { + return "separator" in choice; +} + +/** + * Prompts the user to select an item from a numbered list. + * Displays choices as "[1] Choice one", "[2] Choice two", etc. + * Separator entries render as un-numbered header lines and cannot be selected. + * Re-prompts on invalid input (out-of-range, non-numeric). + * Returns null if the user enters empty input, stdin closes, the choices list + * has no selectable entries, or SIGINT is received. + * + * When invoked from the interactive REPL (`ABLY_INTERACTIVE_MODE=true`), this + * function automatically pauses the REPL's readline, removes its line + * listeners, runs the prompt, and restores everything afterwards — so callers + * never need to wrap it in `runWithReadlineRestore` themselves. + * + * @param message - The prompt message displayed above the list + * @param choices - Array of selectable items or separators + * @returns Promise - The selected item's value, or null if cancelled + */ +export function promptForSelection( + message: string, + choices: Array>, +): Promise { + const selectable = choices.filter( + (c): c is { name: string; value: T } => !isSeparator(c), + ); + + if (selectable.length === 0) { + return Promise.resolve(null); + } + + return runWithReadlineRestore( + () => promptForSelectionInternal(message, choices, selectable), + getInteractiveReadline(), + ); +} + +function promptForSelectionInternal( + message: string, + choices: Array>, + selectable: Array<{ name: string; value: T }>, +): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Display the list. Selectable items get sequential numbers; separators render as plain + // headers with a single leading space (matches inquirer's separator rendering in + // @inquirer/select, which prints ` ${separator}` so headers visually outdent from items). + rl.write(`${message}\n`); + let index = 0; + for (const choice of choices) { + if (isSeparator(choice)) { + rl.write(` ${choice.separator}\n`); + } else { + index += 1; + rl.write(` [${index}] ${choice.name}\n`); + } + } + + return new Promise((resolve) => { + let settled = false; + + const finish = (result: T | null) => { + if (settled) return; + settled = true; + rl.close(); + resolve(result); + }; + + rl.on("SIGINT", () => { + finish(null); + }); + + rl.on("close", () => { + finish(null); + }); + + const ask = () => { + rl.question("Enter selection: ", (answer) => { + const trimmed = answer.trim(); + + // Empty input → cancel + if (trimmed === "") { + finish(null); + return; + } + + // Require the entire input to be a base-10 integer string + if (!/^\d+$/.test(trimmed)) { + rl.write( + `Invalid selection. Enter a number between 1 and ${selectable.length}.\n`, + ); + ask(); + return; + } + + const num = Number.parseInt(trimmed, 10); + + // Out of range → re-prompt + if (num < 1 || num > selectable.length) { + rl.write( + `Invalid selection. Enter a number between 1 and ${selectable.length}.\n`, + ); + ask(); + return; + } + + finish(selectable[num - 1]!.value); + }); + }; + + ask(); + }); +} diff --git a/src/utils/readline-helper.ts b/src/utils/readline-helper.ts index ed176d87f..382b93bc9 100644 --- a/src/utils/readline-helper.ts +++ b/src/utils/readline-helper.ts @@ -1,14 +1,36 @@ import * as readline from "node:readline"; -// import inquirer from 'inquirer'; // Unused - kept for documentation /** - * Helper function to safely run inquirer prompts in interactive mode + * Returns the interactive REPL's readline interface if one is registered on + * `globalThis.__ablyInteractiveReadline`, otherwise `null`. The interactive + * command (`src/commands/interactive.ts`) sets this global when the REPL is + * active so that nested prompts can pause and restore its state. + */ +export function getInteractiveReadline(): readline.Interface | null { + const value = (globalThis as Record) + .__ablyInteractiveReadline; + return (value as readline.Interface | null | undefined) ?? null; +} + +/** + * Helper function to safely run prompt functions in interactive REPL mode * while preserving readline state and terminal settings. * - * This prevents issues with arrow keys showing escape sequences (^[[A) - * after inquirer prompts in interactive mode. + * When the interactive REPL is active, creating a new readline interface + * (e.g., via promptForConfirmation or promptForSelection) on the same stdin + * can interfere with the REPL's paused readline — causing issues like arrow + * keys showing escape sequences (^[[A) or lost line listeners. + * + * This wrapper: + * 1. Pauses the REPL's readline and removes its line listeners + * 2. Saves the terminal raw mode state + * 3. Runs the prompt function + * 4. Restores raw mode, line listeners, and resumes readline + * 5. Calls _refreshLine() to ensure proper terminal state + * + * In non-interactive mode (interactiveReadline is null), the prompt runs directly. */ -export async function runInquirerWithReadlineRestore( +export async function runWithReadlineRestore( promptFn: () => Promise, interactiveReadline: readline.Interface | null, ): Promise { @@ -27,12 +49,9 @@ export async function runInquirerWithReadlineRestore( const isRaw = stdin.isRaw; try { - // Run the inquirer prompt + // Run the prompt function const result = await promptFn(); - // Give inquirer time to clean up its terminal state - await new Promise((resolve) => setTimeout(resolve, 10)); - return result; } finally { // Restore terminal settings diff --git a/test/unit/commands/did-you-mean.test.ts b/test/unit/commands/did-you-mean.test.ts index c98d56750..1e9526d3d 100644 --- a/test/unit/commands/did-you-mean.test.ts +++ b/test/unit/commands/did-you-mean.test.ts @@ -106,7 +106,7 @@ describe("Did You Mean Functionality", () => { child.stdout!.on("data", (data) => { if ( - data.toString().includes("(Y/n)") || + data.toString().includes("[Y/n]") || data.toString().includes("Did you mean accounts current?") ) { foundPrompt = true; @@ -138,7 +138,7 @@ describe("Did You Mean Functionality", () => { const fullOutput = (result.stdout || "") + (result.stderr || ""); expect(fullOutput).toContain("Did you mean accounts current?"); - expect(fullOutput).toContain("(Y/n)"); + expect(fullOutput).toContain("[Y/n]"); }, timeout, ); @@ -180,7 +180,7 @@ describe("Did You Mean Functionality", () => { child.stdout!.on("data", (data) => { if ( data.toString().includes("Did you mean accounts current?") || - data.toString().includes("(Y/n)") + data.toString().includes("[Y/n]") ) { foundPrompt = true; setTimeout(() => child.stdin!.write("n\n"), 100); @@ -217,7 +217,7 @@ describe("Did You Mean Functionality", () => { child.stdout!.on("data", (data) => { if ( data.toString().includes("Did you mean accounts current?") || - data.toString().includes("(Y/n)") + data.toString().includes("[Y/n]") ) { foundPrompt = true; setTimeout(() => child.stdin!.write("y\n"), 100); @@ -276,7 +276,7 @@ describe("Did You Mean Functionality", () => { const fullOutput = (result.stdout || "") + (result.stderr || ""); expect(fullOutput).toContain("Did you mean accounts current?"); - expect(fullOutput).toContain("(Y/n)"); + expect(fullOutput).toContain("[Y/n]"); }, timeout, ); @@ -345,7 +345,7 @@ describe("Did You Mean Functionality", () => { expect(foundCommandsList).toBe(true); expect(fullOutput).toContain("Command accounts xyz not found"); expect(fullOutput).toContain("Ably accounts management commands:"); - expect(fullOutput).not.toContain("(Y/n)"); + expect(fullOutput).not.toContain("[Y/n]"); }); }, timeout, @@ -360,12 +360,12 @@ describe("Did You Mean Functionality", () => { const result1 = await execAsync(`node ${binPath} account current`, { timeout: 2000, }).catch((error) => error); - expect(result1.stdout + result1.stderr).toContain("(Y/n)"); + expect(result1.stdout + result1.stderr).toContain("[Y/n]"); const result2 = await execAsync(`node ${binPath} accounts curren`, { timeout: 2000, }).catch((error) => error); - expect(result2.stdout + result2.stderr).toContain("(Y/n)"); + expect(result2.stdout + result2.stderr).toContain("[Y/n]"); }, timeout, ); diff --git a/test/unit/hooks/interactive-did-you-mean.test.ts b/test/unit/hooks/interactive-did-you-mean.test.ts index d65887614..ad3b6593b 100644 --- a/test/unit/hooks/interactive-did-you-mean.test.ts +++ b/test/unit/hooks/interactive-did-you-mean.test.ts @@ -7,10 +7,13 @@ import { vi, MockInstance, } from "vitest"; -// import { Config } from '@oclif/core'; // Unused + +vi.mock("../../../src/utils/prompt-confirmation.js", () => ({ + promptForConfirmation: vi.fn().mockResolvedValue(true), +})); + import hook from "../../../src/hooks/command_not_found/did-you-mean.js"; -import inquirer from "inquirer"; -// import chalk from 'chalk'; // Unused +import { promptForConfirmation } from "../../../src/utils/prompt-confirmation.js"; describe("Did You Mean Hook - Interactive Mode", function () { let config: Record; @@ -19,7 +22,6 @@ describe("Did You Mean Hook - Interactive Mode", function () { let logStub: ReturnType; let consoleErrorStub: MockInstance; let consoleLogStub: MockInstance; - let inquirerStub: MockInstance; let runCommandStub: ReturnType; let originalEnv: NodeJS.ProcessEnv; @@ -55,10 +57,8 @@ describe("Did You Mean Hook - Interactive Mode", function () { }), }; - // Mock inquirer to auto-confirm - inquirerStub = vi - .spyOn(inquirer, "prompt") - .mockResolvedValue({ confirmed: true }); + // Reset promptForConfirmation mock to auto-confirm + vi.mocked(promptForConfirmation).mockResolvedValue(true); }); afterEach(function () { @@ -107,19 +107,6 @@ describe("Did You Mean Hook - Interactive Mode", function () { debug: vi.fn(), }; - // Mock the global readline instance - const mockReadline = { - pause: vi.fn(), - resume: vi.fn(), - prompt: vi.fn(), - listeners: vi.fn().mockReturnValue([]), - removeAllListeners: vi.fn(), - on: vi.fn(), - }; - ( - globalThis as unknown as Record - ).__ablyInteractiveReadline = mockReadline; - await hook.call(context, { id: "channels:pubish", argv: [], @@ -127,21 +114,12 @@ describe("Did You Mean Hook - Interactive Mode", function () { context, }); - // Should show confirmation prompt - expect(inquirerStub).toHaveBeenCalled(); - expect(inquirerStub.mock.calls[0][0][0].message).toContain( - "Did you mean", - ); - expect(inquirerStub.mock.calls[0][0][0].message).toContain( - "channels publish", - ); - - // Should pause readline (resume happens asynchronously) - expect(mockReadline.pause).toHaveBeenCalled(); - - // Clean up - delete (globalThis as unknown as Record) - .__ablyInteractiveReadline; + // Should show confirmation prompt via promptForConfirmation + expect(promptForConfirmation).toHaveBeenCalled(); + const callArgs = vi.mocked(promptForConfirmation).mock.calls[0]; + expect(callArgs[0]).toContain("Did you mean"); + expect(callArgs[0]).toContain("channels publish"); + expect(callArgs[1]).toBe(true); // defaultValue = true }); it("should throw error instead of calling this.error when command fails", async function () { @@ -154,19 +132,6 @@ describe("Did You Mean Hook - Interactive Mode", function () { debug: vi.fn(), }; - // Mock the global readline instance - const mockReadline = { - pause: vi.fn(), - resume: vi.fn(), - prompt: vi.fn(), - listeners: vi.fn().mockReturnValue([]), - removeAllListeners: vi.fn(), - on: vi.fn(), - }; - ( - globalThis as unknown as Record - ).__ablyInteractiveReadline = mockReadline; - // Make runCommand fail runCommandStub.mockImplementation(() => { throw new Error("Missing required arg: channel"); @@ -186,15 +151,11 @@ describe("Did You Mean Hook - Interactive Mode", function () { // Should throw error with oclif exit code expect(thrownError).toBeDefined(); - expect(thrownError.message).toContain("Missing required arg: channel"); + expect(thrownError!.message).toContain("Missing required arg: channel"); expect( (thrownError as unknown as { oclif?: { exit?: number } }).oclif?.exit, ).toBeDefined(); expect(errorStub).not.toHaveBeenCalled(); - - // Clean up - delete (globalThis as unknown as Record) - .__ablyInteractiveReadline; }); it("should use console.log for help output in interactive mode", async function () { @@ -207,19 +168,6 @@ describe("Did You Mean Hook - Interactive Mode", function () { debug: vi.fn(), }; - // Mock the global readline instance - const mockReadline = { - pause: vi.fn(), - resume: vi.fn(), - prompt: vi.fn(), - listeners: vi.fn().mockReturnValue([]), - removeAllListeners: vi.fn(), - on: vi.fn(), - }; - ( - globalThis as unknown as Record - ).__ablyInteractiveReadline = mockReadline; - // Make runCommand fail with missing args const error = new Error( "Missing required arg: channel\nSee more help with --help", @@ -253,10 +201,6 @@ describe("Did You Mean Hook - Interactive Mode", function () { expect(helpOutput).toContain("See more help with:"); expect(helpOutput).toContain("channels publish --help"); expect(helpOutput).not.toContain("ably channels publish --help"); - - // Clean up - delete (globalThis as unknown as Record) - .__ablyInteractiveReadline; }); it("should provide interactive-friendly error for unknown commands", async function () { @@ -291,76 +235,9 @@ describe("Did You Mean Hook - Interactive Mode", function () { }); }); - describe("readline restoration", function () { - it("should properly restore readline state after inquirer prompt", async function () { - const context = { - config, - warn: warnStub, - error: errorStub, - log: logStub, - exit: vi.fn(), - debug: vi.fn(), - }; - - // Mock the global readline instance with more detailed state tracking - const lineListeners = [vi.fn(), vi.fn()]; - const mockReadline = { - pause: vi.fn(), - resume: vi.fn(), - prompt: vi.fn(), - listeners: vi.fn().mockReturnValue(lineListeners), - removeAllListeners: vi.fn(), - on: vi.fn(), - _refreshLine: vi.fn(), - }; - ( - globalThis as unknown as Record - ).__ablyInteractiveReadline = mockReadline; - - // Mock process.stdin for terminal state - const originalIsRaw = process.stdin.isRaw; - const originalIsTTY = process.stdin.isTTY; - const originalSetRawMode = process.stdin.setRawMode; - - process.stdin.isRaw = false; - process.stdin.isTTY = true; - process.stdin.setRawMode = vi.fn().mockReturnValue(process.stdin); - - try { - await hook.call(context, { - id: "channels:pubish", - argv: [], - config, - context, - }); - - // Wait for async restoration — all state changes happen together in the hook's cleanup path - await vi.waitFor(() => { - expect(mockReadline.resume).toHaveBeenCalled(); - }); - - // Verify readline was paused during prompt - expect(mockReadline.pause).toHaveBeenCalled(); - - // Verify line listeners were temporarily removed and restored - expect(mockReadline.removeAllListeners).toHaveBeenCalledWith("line"); - expect(mockReadline.on.mock.calls.length).toBe(lineListeners.length); - lineListeners.forEach((listener, index) => { - expect(mockReadline.on.mock.calls[index]).toEqual(["line", listener]); - }); - - // Verify terminal state was restored - expect(process.stdin.setRawMode).toHaveBeenCalledWith(false); - } finally { - // Clean up - delete (globalThis as unknown as Record) - .__ablyInteractiveReadline; - process.stdin.isRaw = originalIsRaw; - process.stdin.isTTY = originalIsTTY; - process.stdin.setRawMode = originalSetRawMode; - } - }); - }); + // REPL readline restoration is now centralized inside promptForConfirmation + // (see src/utils/prompt-confirmation.ts), so it is exercised by + // test/unit/utils/prompt-confirmation.test.ts rather than at the hook level. describe("normal mode comparison", function () { it("should use normal error handling when not in interactive mode", async function () { diff --git a/test/unit/services/interactive-helper.test.ts b/test/unit/services/interactive-helper.test.ts index 2d0650325..ca9288256 100644 --- a/test/unit/services/interactive-helper.test.ts +++ b/test/unit/services/interactive-helper.test.ts @@ -7,7 +7,12 @@ import { vi, MockedFunction, } from "vitest"; -import inquirer from "inquirer"; + +vi.mock("../../../src/utils/prompt-selection.js", () => ({ + promptForSelection: vi.fn(), +})); + +import { promptForSelection } from "../../../src/utils/prompt-selection.js"; import { InteractiveHelper } from "../../../src/services/interactive-helper.js"; import { ConfigManager } from "../../../src/services/config-manager.js"; import { ControlApi, App, Key } from "../../../src/services/control-api.js"; @@ -20,15 +25,14 @@ describe("InteractiveHelper", function () { ConfigManager["getCurrentAccountAlias"] >; }; - let promptStub: ReturnType; let consoleLogSpy: ReturnType; + beforeEach(function () { // Create stubs and spies configManagerStub = { listAccounts: vi.fn(), getCurrentAccountAlias: vi.fn(), }; - promptStub = vi.spyOn(inquirer, "prompt"); consoleLogSpy = vi.spyOn(console, "log"); // Create fresh instance for each test @@ -44,29 +48,6 @@ describe("InteractiveHelper", function () { vi.restoreAllMocks(); }); - describe("#confirm", function () { - it("should return true when user confirms", async function () { - promptStub.mockResolvedValue({ confirmed: true }); - - const result = await interactiveHelper.confirm("Confirm this action?"); - - expect(result).toBe(true); - expect(promptStub).toHaveBeenCalledOnce(); - expect(promptStub.mock.calls[0][0][0].message).toBe( - "Confirm this action?", - ); - }); - - it("should return false when user denies", async function () { - promptStub.mockResolvedValue({ confirmed: false }); - - const result = await interactiveHelper.confirm("Confirm this action?"); - - expect(result).toBe(false); - expect(promptStub).toHaveBeenCalledOnce(); - }); - }); - describe("#selectAccount", function () { it("should return selected account", async function () { const accounts = [ @@ -92,12 +73,12 @@ describe("InteractiveHelper", function () { configManagerStub.getCurrentAccountAlias.mockReturnValue("default"); const selectedAccount = accounts[1]; - promptStub.mockResolvedValue({ selectedAccount }); + vi.mocked(promptForSelection).mockResolvedValue(selectedAccount); const result = await interactiveHelper.selectAccount(); expect(result).toBe(selectedAccount); - expect(promptStub).toHaveBeenCalledOnce(); + expect(promptForSelection).toHaveBeenCalledOnce(); expect(configManagerStub.listAccounts).toHaveBeenCalledOnce(); expect(configManagerStub.getCurrentAccountAlias).toHaveBeenCalledOnce(); }); @@ -108,7 +89,7 @@ describe("InteractiveHelper", function () { const result = await interactiveHelper.selectAccount(); expect(result).toBeNull(); - expect(promptStub).not.toHaveBeenCalled(); + expect(promptForSelection).not.toHaveBeenCalled(); expect( consoleLogSpy.mock.calls.some((call) => /No accounts configured/.test(call[0]), @@ -163,14 +144,14 @@ describe("InteractiveHelper", function () { controlApiStub.listApps.mockResolvedValue(apps); const selectedApp = apps[1]; - promptStub.mockResolvedValue({ selectedApp }); + vi.mocked(promptForSelection).mockResolvedValue(selectedApp); const result = await interactiveHelper.selectApp( controlApiStub as unknown as ControlApi, ); expect(result).toBe(selectedApp); - expect(promptStub).toHaveBeenCalledOnce(); + expect(promptForSelection).toHaveBeenCalledOnce(); expect(controlApiStub.listApps).toHaveBeenCalledOnce(); }); @@ -182,7 +163,7 @@ describe("InteractiveHelper", function () { ); expect(result).toBeNull(); - expect(promptStub).not.toHaveBeenCalled(); + expect(promptForSelection).not.toHaveBeenCalled(); expect( consoleLogSpy.mock.calls.some((call) => /No apps found/.test(call[0])), ).toBe(true); @@ -239,7 +220,7 @@ describe("InteractiveHelper", function () { controlApiStub.listKeys.mockResolvedValue(keys); const selectedKey = keys[1]; - promptStub.mockResolvedValue({ selectedKey }); + vi.mocked(promptForSelection).mockResolvedValue(selectedKey); const result = await interactiveHelper.selectKey( controlApiStub as unknown as ControlApi, @@ -247,11 +228,11 @@ describe("InteractiveHelper", function () { ); expect(result).toBe(selectedKey); - expect(promptStub).toHaveBeenCalledOnce(); + expect(promptForSelection).toHaveBeenCalledOnce(); expect(controlApiStub.listKeys).toHaveBeenCalledExactlyOnceWith("app1"); }); - it("should handle unnamed keys", async function () { + it("should pass correct choices including unnamed keys", async function () { const keys: Key[] = [ { id: "key1", @@ -278,15 +259,16 @@ describe("InteractiveHelper", function () { ]; controlApiStub.listKeys.mockResolvedValue(keys); - promptStub.mockResolvedValue({ selectedKey: keys[0] }); + vi.mocked(promptForSelection).mockResolvedValue(keys[0]); await interactiveHelper.selectKey( controlApiStub as unknown as ControlApi, "app1", ); - // Check that the prompt choices include "Unnamed key" for the first key - const choices = promptStub.mock.calls[0][0][0].choices; + // Check that the choices passed to promptForSelection include "Unnamed key" + const callArgs = vi.mocked(promptForSelection).mock.calls[0]; + const choices = callArgs[1] as Array<{ name: string }>; expect(choices[0].name).toContain("Unnamed key"); }); @@ -299,7 +281,7 @@ describe("InteractiveHelper", function () { ); expect(result).toBeNull(); - expect(promptStub).not.toHaveBeenCalled(); + expect(promptForSelection).not.toHaveBeenCalled(); expect( consoleLogSpy.mock.calls.some((call) => /No keys found/.test(call[0])), ).toBe(true); diff --git a/test/unit/utils/prompt-confirmation.test.ts b/test/unit/utils/prompt-confirmation.test.ts index 599685496..eae348f46 100644 --- a/test/unit/utils/prompt-confirmation.test.ts +++ b/test/unit/utils/prompt-confirmation.test.ts @@ -1,13 +1,21 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; let mockQuestion: (query: string, callback: (answer: string) => void) => void; +let mockOnHandlers: Record void)[]>; vi.mock("node:readline", () => ({ createInterface: () => ({ + closed: false, close: vi.fn(), question: (query: string, callback: (answer: string) => void) => { mockQuestion(query, callback); }, + on: (event: string, handler: () => void) => { + if (!mockOnHandlers[event]) { + mockOnHandlers[event] = []; + } + mockOnHandlers[event].push(handler); + }, }), })); @@ -16,6 +24,7 @@ import { promptForConfirmation } from "../../../src/utils/prompt-confirmation.js describe("promptForConfirmation", () => { beforeEach(() => { vi.restoreAllMocks(); + mockOnHandlers = {}; }); it.each(["y", "yes", "Y", "YES", " yes "])( @@ -45,4 +54,163 @@ describe("promptForConfirmation", () => { await promptForConfirmation("Are you sure?"); expect(capturedQuery).toBe("Are you sure? [y/n]"); }); + + describe("defaultValue parameter", () => { + it("returns true for empty input when defaultValue is true", async () => { + mockQuestion = (_query, callback) => callback(""); + const result = await promptForConfirmation("Did you mean X?", true); + expect(result).toBe(true); + }); + + it("returns false for empty input when defaultValue is false", async () => { + mockQuestion = (_query, callback) => callback(""); + const result = await promptForConfirmation("Delete this?", false); + expect(result).toBe(false); + }); + + it("explicit 'n' overrides defaultValue true", async () => { + mockQuestion = (_query, callback) => callback("n"); + const result = await promptForConfirmation("Did you mean X?", true); + expect(result).toBe(false); + }); + + it("explicit 'y' overrides defaultValue false", async () => { + mockQuestion = (_query, callback) => callback("y"); + const result = await promptForConfirmation("Delete this?", false); + expect(result).toBe(true); + }); + + it("appends [Y/n] suffix when defaultValue is true", async () => { + let capturedQuery = ""; + mockQuestion = (query, callback) => { + capturedQuery = query; + callback("y"); + }; + await promptForConfirmation("Did you mean X?", true); + expect(capturedQuery).toBe("Did you mean X? [Y/n]"); + }); + + it("appends [y/n] suffix when defaultValue is false", async () => { + let capturedQuery = ""; + mockQuestion = (query, callback) => { + capturedQuery = query; + callback("n"); + }; + await promptForConfirmation("Delete this?", false); + expect(capturedQuery).toBe("Delete this? [y/n]"); + }); + + it("does not double-append suffix when message already contains [Y/n]", async () => { + let capturedQuery = ""; + mockQuestion = (query, callback) => { + capturedQuery = query; + callback("y"); + }; + await promptForConfirmation("Continue? [Y/n]", true); + expect(capturedQuery).toBe("Continue? [Y/n]"); + }); + }); + + describe("interactive REPL state restoration", () => { + const originalIsRaw = process.stdin.isRaw; + const originalIsTTY = process.stdin.isTTY; + const originalSetRawMode = process.stdin.setRawMode; + + afterEach(() => { + delete (globalThis as Record).__ablyInteractiveReadline; + process.stdin.isRaw = originalIsRaw; + process.stdin.isTTY = originalIsTTY; + process.stdin.setRawMode = originalSetRawMode; + }); + + it("pauses, restores listeners, and resumes the REPL readline when active", async () => { + mockQuestion = (_query, callback) => callback("y"); + + const lineListeners = [vi.fn(), vi.fn()]; + const replReadline = { + pause: vi.fn(), + resume: vi.fn(), + listeners: vi.fn().mockReturnValue(lineListeners), + removeAllListeners: vi.fn(), + on: vi.fn(), + _refreshLine: vi.fn(), + }; + (globalThis as Record).__ablyInteractiveReadline = + replReadline; + + process.stdin.isRaw = false; + process.stdin.isTTY = true; + process.stdin.setRawMode = vi.fn().mockReturnValue(process.stdin); + + const result = await promptForConfirmation("Continue?", true); + expect(result).toBe(true); + + // Pause + listener removal happen before the prompt runs + expect(replReadline.pause).toHaveBeenCalled(); + expect(replReadline.removeAllListeners).toHaveBeenCalledWith("line"); + + // Resume is scheduled via setTimeout(20ms); wait for it to fire + await vi.waitFor(() => { + expect(replReadline.resume).toHaveBeenCalled(); + }); + + // Line listeners reattached + expect(replReadline.on.mock.calls.length).toBe(lineListeners.length); + lineListeners.forEach((listener, index) => { + expect(replReadline.on.mock.calls[index]).toEqual(["line", listener]); + }); + + // Terminal raw mode restored to its prior value + expect(process.stdin.setRawMode).toHaveBeenCalledWith(false); + }); + + it("runs without REPL interaction when no interactive readline is registered", async () => { + mockQuestion = (_query, callback) => callback("n"); + // No __ablyInteractiveReadline set → no pause/resume should happen + const result = await promptForConfirmation("Continue?"); + expect(result).toBe(false); + }); + }); + + describe("SIGINT and close handling", () => { + it("returns false on SIGINT even when defaultValue is true", async () => { + mockQuestion = () => { + for (const handler of mockOnHandlers["SIGINT"] ?? []) { + handler(); + } + }; + const result = await promptForConfirmation("Did you mean X?", true); + expect(result).toBe(false); + }); + + it("returns false on close even when defaultValue is true", async () => { + mockQuestion = () => { + for (const handler of mockOnHandlers["close"] ?? []) { + handler(); + } + }; + const result = await promptForConfirmation("Did you mean X?", true); + expect(result).toBe(false); + }); + + it("returns false on SIGINT with default defaultValue", async () => { + mockQuestion = () => { + for (const handler of mockOnHandlers["SIGINT"] ?? []) { + handler(); + } + }; + const result = await promptForConfirmation("Delete this?"); + expect(result).toBe(false); + }); + + it("returns false on close with default defaultValue", async () => { + mockQuestion = () => { + for (const handler of mockOnHandlers["close"] ?? []) { + handler(); + } + }; + const result = await promptForConfirmation("Delete this?"); + expect(result).toBe(false); + }); + }); }); diff --git a/test/unit/utils/prompt-selection.test.ts b/test/unit/utils/prompt-selection.test.ts new file mode 100644 index 000000000..b887ade6f --- /dev/null +++ b/test/unit/utils/prompt-selection.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +let mockQuestion: (query: string, callback: (answer: string) => void) => void; +let mockWrite: ReturnType; +let mockOnHandlers: Record void)[]>; +let mockClose: ReturnType; + +vi.mock("node:readline", () => ({ + createInterface: () => ({ + closed: false, + close: () => mockClose(), + write: (text: string) => mockWrite(text), + question: (query: string, callback: (answer: string) => void) => { + mockQuestion(query, callback); + }, + on: (event: string, handler: () => void) => { + if (!mockOnHandlers[event]) { + mockOnHandlers[event] = []; + } + mockOnHandlers[event].push(handler); + }, + }), +})); + +import { promptForSelection } from "../../../src/utils/prompt-selection.js"; + +const choices = [ + { name: "App One (app1)", value: { id: "app1", name: "App One" } }, + { name: "App Two (app2)", value: { id: "app2", name: "App Two" } }, + { name: "App Three (app3)", value: { id: "app3", name: "App Three" } }, +]; + +describe("promptForSelection", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockWrite = vi.fn(); + mockClose = vi.fn(); + mockOnHandlers = {}; + }); + + it("returns the selected item for valid input", async () => { + mockQuestion = (_query, callback) => callback("2"); + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app2", name: "App Two" }); + }); + + it("returns first item for input '1'", async () => { + mockQuestion = (_query, callback) => callback("1"); + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app1", name: "App One" }); + }); + + it("returns last item for input matching choices length", async () => { + mockQuestion = (_query, callback) => callback("3"); + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app3", name: "App Three" }); + }); + + it("returns null for empty input", async () => { + mockQuestion = (_query, callback) => callback(""); + const result = await promptForSelection("Select an app:", choices); + expect(result).toBeNull(); + }); + + it("returns null for empty choices array", async () => { + const result = await promptForSelection("Select:", []); + expect(result).toBeNull(); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("re-prompts on non-numeric input then accepts valid input", async () => { + let callCount = 0; + mockQuestion = (_query, callback) => { + callCount++; + if (callCount === 1) { + callback("abc"); + } else { + callback("1"); + } + }; + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app1", name: "App One" }); + expect(callCount).toBe(2); + }); + + it("re-prompts on partially numeric input like '2abc'", async () => { + let callCount = 0; + mockQuestion = (_query, callback) => { + callCount++; + if (callCount === 1) { + callback("2abc"); + } else { + callback("2"); + } + }; + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app2", name: "App Two" }); + expect(callCount).toBe(2); + }); + + it("re-prompts on decimal input like '2.5'", async () => { + let callCount = 0; + mockQuestion = (_query, callback) => { + callCount++; + if (callCount === 1) { + callback("2.5"); + } else { + callback("2"); + } + }; + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app2", name: "App Two" }); + expect(callCount).toBe(2); + }); + + it("re-prompts on out-of-range input then accepts valid input", async () => { + let callCount = 0; + mockQuestion = (_query, callback) => { + callCount++; + if (callCount === 1) { + callback("5"); + } else { + callback("2"); + } + }; + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app2", name: "App Two" }); + expect(callCount).toBe(2); + }); + + it("re-prompts on zero input", async () => { + let callCount = 0; + mockQuestion = (_query, callback) => { + callCount++; + if (callCount === 1) { + callback("0"); + } else { + callback("1"); + } + }; + const result = await promptForSelection("Select an app:", choices); + expect(result).toEqual({ id: "app1", name: "App One" }); + expect(callCount).toBe(2); + }); + + it("displays numbered list with message", async () => { + mockQuestion = (_query, callback) => callback("1"); + await promptForSelection("Select an app:", choices); + + expect(mockWrite).toHaveBeenCalledWith("Select an app:\n"); + expect(mockWrite).toHaveBeenCalledWith(" [1] App One (app1)\n"); + expect(mockWrite).toHaveBeenCalledWith(" [2] App Two (app2)\n"); + expect(mockWrite).toHaveBeenCalledWith(" [3] App Three (app3)\n"); + }); + + it("handles single choice", async () => { + mockQuestion = (_query, callback) => callback("1"); + const singleChoice = [{ name: "Only Option", value: "only" }]; + const result = await promptForSelection("Pick one:", singleChoice); + expect(result).toBe("only"); + }); + + it("trims whitespace from input", async () => { + mockQuestion = (_query, callback) => callback(" 2 "); + const result = await promptForSelection("Select:", choices); + expect(result).toEqual({ id: "app2", name: "App Two" }); + }); + + it("returns null on SIGINT", async () => { + mockQuestion = () => { + // Simulate SIGINT before answering + for (const handler of mockOnHandlers["SIGINT"] ?? []) { + handler(); + } + }; + const result = await promptForSelection("Select:", choices); + expect(result).toBeNull(); + }); + + it("returns null on close", async () => { + mockQuestion = () => { + // Simulate close before answering + for (const handler of mockOnHandlers["close"] ?? []) { + handler(); + } + }; + const result = await promptForSelection("Select:", choices); + expect(result).toBeNull(); + }); + + describe("interactive REPL state restoration", () => { + const originalIsRaw = process.stdin.isRaw; + const originalIsTTY = process.stdin.isTTY; + const originalSetRawMode = process.stdin.setRawMode; + + afterEach(() => { + delete (globalThis as Record).__ablyInteractiveReadline; + process.stdin.isRaw = originalIsRaw; + process.stdin.isTTY = originalIsTTY; + process.stdin.setRawMode = originalSetRawMode; + }); + + it("pauses, restores listeners, and resumes the REPL readline when active", async () => { + mockQuestion = (_query, callback) => callback("1"); + + const lineListeners = [vi.fn(), vi.fn()]; + const replReadline = { + pause: vi.fn(), + resume: vi.fn(), + listeners: vi.fn().mockReturnValue(lineListeners), + removeAllListeners: vi.fn(), + on: vi.fn(), + _refreshLine: vi.fn(), + }; + (globalThis as Record).__ablyInteractiveReadline = + replReadline; + + process.stdin.isRaw = false; + process.stdin.isTTY = true; + process.stdin.setRawMode = vi.fn().mockReturnValue(process.stdin); + + const result = await promptForSelection("Pick:", choices); + expect(result).toEqual({ id: "app1", name: "App One" }); + + expect(replReadline.pause).toHaveBeenCalled(); + expect(replReadline.removeAllListeners).toHaveBeenCalledWith("line"); + + // Resume scheduled via setTimeout(20ms) + await vi.waitFor(() => { + expect(replReadline.resume).toHaveBeenCalled(); + }); + + expect(replReadline.on.mock.calls.length).toBe(lineListeners.length); + lineListeners.forEach((listener, index) => { + expect(replReadline.on.mock.calls[index]).toEqual(["line", listener]); + }); + + expect(process.stdin.setRawMode).toHaveBeenCalledWith(false); + }); + + it("runs without REPL interaction when no interactive readline is registered", async () => { + mockQuestion = (_query, callback) => callback("2"); + // No __ablyInteractiveReadline set + const result = await promptForSelection("Pick:", choices); + expect(result).toEqual({ id: "app2", name: "App Two" }); + }); + }); + + describe("separators", () => { + it("renders separator entries un-numbered with single-space prefix", async () => { + mockQuestion = (_query, callback) => callback("1"); + await promptForSelection("Select an account:", [ + { separator: "── Local accounts ──" }, + { name: "prod", value: "prod" }, + { name: "staging", value: "staging" }, + { separator: "── Other accounts ──" }, + { name: "external", value: "external" }, + ]); + + // Separators: single leading space, no [N] marker (matches inquirer's render) + expect(mockWrite).toHaveBeenCalledWith(" ── Local accounts ──\n"); + expect(mockWrite).toHaveBeenCalledWith(" ── Other accounts ──\n"); + // Selectable items numbered sequentially, ignoring separators + expect(mockWrite).toHaveBeenCalledWith(" [1] prod\n"); + expect(mockWrite).toHaveBeenCalledWith(" [2] staging\n"); + expect(mockWrite).toHaveBeenCalledWith(" [3] external\n"); + }); + + it("numbers only selectable items so '1' picks the first selectable", async () => { + mockQuestion = (_query, callback) => callback("1"); + const result = await promptForSelection("Pick:", [ + { separator: "── Section ──" }, + { name: "first", value: "first" }, + { name: "second", value: "second" }, + ]); + expect(result).toBe("first"); + }); + + it("rejects out-of-range numbers based on selectable count, not total entries", async () => { + // 2 selectables + 2 separators = 4 entries. "3" must be rejected. + let callCount = 0; + mockQuestion = (_query, callback) => { + callCount++; + if (callCount === 1) { + callback("3"); + } else { + callback("2"); + } + }; + const result = await promptForSelection("Pick:", [ + { separator: "── A ──" }, + { name: "first", value: "first" }, + { separator: "── B ──" }, + { name: "second", value: "second" }, + ]); + expect(result).toBe("second"); + expect(callCount).toBe(2); + expect(mockWrite).toHaveBeenCalledWith( + "Invalid selection. Enter a number between 1 and 2.\n", + ); + }); + + it("returns null when choices contain only separators (no selectables)", async () => { + const result = await promptForSelection("Pick:", [ + { separator: "── A ──" }, + { separator: "── B ──" }, + ]); + expect(result).toBeNull(); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("supports separators interleaved at any position", async () => { + mockQuestion = (_query, callback) => callback("2"); + const result = await promptForSelection("Pick:", [ + { name: "alpha", value: "alpha" }, + { separator: "── divider ──" }, + { name: "beta", value: "beta" }, + ]); + expect(result).toBe("beta"); + }); + }); +});