diff --git a/.config/nextest.toml b/.config/nextest.toml index 96e3933d87..cec3120865 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -79,7 +79,7 @@ test-group = 'fleet-manager-lifecycle' [[profile.default.overrides]] # DSH preparation also launches the isolated host against the shared hermetic # home. Keep its ACL grants/retirement in this existing serial lane. -filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))' +filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::contract::tests::plugin_adapter_dsh_preview_runs_from_live_runtime$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))' test-group = 'extension-host' [[profile.default.overrides]] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2886f9020f..59f519c9de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,11 @@ jobs: # `tsc` ever running in CI. `npm test` compiles first (tsc -p ./), so # this is the type-check gate for the extension too. run: npm test + # @vscode/vsce 4 is a build-only packager that requires Node >=22. The + # extension itself still compiles and tests on Node 20 above. + - uses: actions/setup-node@v7 + with: + node-version: 22 - name: Package VS Code extension run: npm run package @@ -994,6 +999,19 @@ jobs: run: cargo build -p codewhale-cli --bin codewhale --all-features --locked env: RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }} + - name: Windows Terminal clipboard acceptance + if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest' + timeout-minutes: 5 + shell: pwsh + run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard" + - name: Retain Windows Terminal clipboard evidence + if: ${{ !cancelled() && matrix.os == 'windows-latest' && needs.changes.outputs.heavy == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-clipboard-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/windows-clipboard/ + if-no-files-found: error + retention-days: 7 - name: Run tests # Same test binaries as `cargo test`, run by cargo-nextest: one # process per test, all runner cores busy, slow tests named instead @@ -1281,9 +1299,9 @@ jobs: # leg was a ~28-minute hosted-Mac build on every main push while hosted # macOS is capped at 5 concurrent jobs; it stays in the manual # workflow_dispatch (full CI) matrix and in the release pipeline. - # A cold Windows build can take 29 minutes before the smoke even starts. - # Leave room for installation; bound the smoke itself independently below. - timeout-minutes: 45 + # A cold Windows compile can take 42 minutes; leave room for cache save + # after the separately bounded five-minute install/entrypoint smoke test. + timeout-minutes: 60 runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }} strategy: matrix: diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index d12b9358ab..8679d101b1 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -270,6 +270,19 @@ jobs: bin_dir="target/${{ matrix.target }}/dist" "${bin_dir}/${{ matrix.cli_binary }}" --version "${bin_dir}/${{ matrix.shim_binary }}" --version + - name: Windows Terminal clipboard acceptance + if: matrix.platform == 'windows-x64' + timeout-minutes: 5 + shell: pwsh + run: node scripts/release/windows-terminal-clipboard.mjs "target/${{ matrix.target }}/dist/codewhale.exe" "${{ runner.temp }}/windows-clipboard" + - name: Retain Windows Terminal clipboard evidence + if: ${{ !cancelled() && matrix.platform == 'windows-x64' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-windows-clipboard-${{ github.sha }} + path: ${{ runner.temp }}/windows-clipboard/ + if-no-files-found: error + retention-days: ${{ inputs.retention_days }} - name: Stage binaries shell: bash run: | diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 5a7c4f1206..57cef7de70 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -9,6 +9,11 @@ on: - '**/Cargo.toml' - '**/Cargo.lock' - '**/audit.toml' + - '**/package.json' + - '**/package-lock.json' + - 'web/scripts/patch-braces.mjs' + - 'web/scripts/audit-dependencies.mjs' + - 'web/scripts/dependency-security.test.mjs' push: branches: [main, master] schedule: @@ -23,6 +28,8 @@ jobs: name: cargo-audit runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + contents: read steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable @@ -30,3 +37,63 @@ jobs: run: cargo install cargo-audit - name: Run cargo audit run: cargo audit + + npm-audit: + name: npm-audit (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + # Every committed package-lock.json, including build-only tooling. + include: + - { name: root, directory: . } + - { name: web, directory: web } + - { name: telemetry-ingest, directory: telemetry-ingest } + - { name: extension-host, directory: crates/tui/extension-host } + - { name: embedded-computer-use, directory: crates/tui/plugins/computer-use } + - { name: vscode, directory: extensions/vscode } + - { name: feishu-bridge, directory: integrations/feishu-bridge } + - { name: telegram-bridge, directory: integrations/telegram-bridge } + - { name: wecom-bridge, directory: integrations/wecom-bridge } + - { name: pet, directory: pet } + defaults: + run: + working-directory: ${{ matrix.directory }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22' + # Audit the complete committed lock, including build dependencies, and + # keep the raw report. Runtime compatibility remains owned by surface CI. + - name: Audit locked dependencies + if: matrix.name != 'web' + run: | + set +e + npm audit --json > "${{ runner.temp }}/npm-audit.json" + status=$? + set -e + node -e ' + const r = require(process.argv[1]); + if (r.error || !r.metadata?.vulnerabilities) throw new Error("Incomplete dependency audit"); + console.log(JSON.stringify(r.metadata.vulnerabilities)); + ' "${{ runner.temp }}/npm-audit.json" + exit "$status" + # The website also verifies its installed, reviewed build dependency + # mitigation (docs/DEPENDENCY_SECURITY.md); the raw report keeps every + # upstream finding. + - name: Install and verify website build dependency mitigation + if: matrix.name == 'web' + run: npm ci --no-audit + - name: Audit website with verified build dependency mitigation + if: matrix.name == 'web' + run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: npm-audit-${{ matrix.name }} + path: ${{ runner.temp }}/npm-audit.json + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f410f468..783df2d084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.1] - 2026-10-01 +## [0.10.1] - 2026-10-07 ### Contributor integration and reliability +- OAuth retry diagnostics omit provider-controlled error fields; account replacement stops before retired Weixin receipts exceed their storage limit, preserving uncertain work for local review. +- Resuming a session through a symlink to the same workspace no longer shows a false workspace-change warning. - Runtime clients can read one tool call's actual workspace changes and reviewed skill details (thanks @gaord, #6817 and #6869). In-flight snapshot pairs remain pending; missing objects and corrupt repository metadata are distinguished. - Search accepts valid preferred locales, and image dimensions describe the same bytes sent to the model (thanks @asto18089, #6860 and #6858). Automation deletion keeps its definition until cleanup succeeds, and compaction preserves its original summary anchor (#6864 and #6857). - Config/status/permission commands share portable contracts while the host retains mutation authority; queue workers acknowledge a scheduled retry for temporary first-claim contention and fail honestly on corruption (thanks @aboimpinto, #6832). @@ -18,9 +20,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configured approval expiry belongs to the held Engine request; hiding or covering its card cannot restart the deadline, and a late queued answer cannot approve an expired call. - Tool discovery keeps the highest-ranked matches when a result batch exceeds the existing cache bounds, preserving search order and the 16 KiB limit (adapted from @AdityaVG13's #6393). - Model-switch receipts now translate their session-only saving note in every complete locale pack; the three save commands remain directly usable (thanks @Lstarsky0, #6875). +- Weixin bridge threads belong to the account and chat that created them: + resuming or listing another chat's thread is refused. An explicit `/new` after + an account replacement keeps the old account's private receipt and never + replays its prompts automatically. Replies never reuse another bot account's + Weixin context token, and `/threads` lists this chat's own older threads even + when other chats have newer ones. +- Windows messaging bridges retry a briefly locked record replacement + (EPERM/EACCES/EBUSY) with bounded delays and never delete the previous record + first. +- The website and README installation guides are generated from one shared + source, and the critical Simplified Chinese guides were re-reviewed against + current English. +- A Codewhale sign-in that expired, or an account service that is unreachable, + no longer blocks turns on your own provider key. The turn uses your local + settings and Codewhale says once how to restore account preferences. +- `codewhale login` keeps a DeepSeek route you chose, or one with a local key, + instead of switching it to the managed Codewhale provider. - The bundled `computer-use` plugin is 0.12.1, reconciled with canonical source - `a656f67455fc5639f28304fbf61075db3925058a` while retaining Core's embedding - manifest and version contract. + `724f9c422db1a51880dc81154dae70816c475ed8` while retaining Core's embedding + manifest and version contract. Its image renderer lock now carries Sharp 0.35.5. +- The bundled first-party catalog pins marketplace revision + `6512f1dfaa91ee287e9f81ebabaf4909e8a371a3` and lists all 19 reviewed plugins, + up from 6. Every catalog plugin still installs disabled and untrusted until + you review it. +- Every committed npm lockfile is audited in CI, including build tooling. The + VS Code extension packages with `@vscode/vsce` 4 on Node 22 while it still + compiles and tests on its Node 20 runtime. The website keeps one reviewed, + hash-verified depth guard for an unpatched `braces` advisory + (GHSA-vfj7-8cjw-p6xm); its raw audit findings are retained, not hidden. +- Pasting through Windows Terminal (Ctrl+Shift+V) no longer drops emoji and + other characters outside the Basic Multilingual Plane. Release binaries and source builds carry + a small patch to crossterm 0.29.0 in `patches/` (the change proposed upstream + as crossterm-rs/crossterm#1073); a real Windows Terminal paste of 24 Unicode + lines is now a release gate. +- Experimental extension host: reviewed Native plugins can register avatar + packs with `ctx.avatars.registerPack` (`/pet avatar [key]`, `/pet action|view + `). `/plugin import dsh ` reviews a DeepSeek Harness + bundle and now lists the Native host code it contains; `approve + ` installs it disabled and untrusted. Trusted host plugins stay + trusted across a restart, enabling a second host plugin activates it without + `/plugin reload`, and the review no longer crashes the TUI. Codewhale v0.10.1 focuses on reliability and first-run behavior. Turns that stall now say so, approvals keep what you approved, plugin suggestions are diff --git a/Cargo.lock b/Cargo.lock index b20be2878a..29f7242b8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,8 +1575,6 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.13.1", "crossterm_winapi", diff --git a/Cargo.toml b/Cargo.toml index b6a89fe11c..052edc8dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,3 +136,9 @@ codegen-units = 1 # them as 2 columns, causing cell-offset rendering glitches. (#4479) [patch.crates-io] unicode-width = { path = "patches/unicode-width-0.2.2" } +# Windows Terminal paste dropped every non-BMP character (emoji): upstream pairs a +# surrogate half with its own key-up record. See patches/crossterm-0.29.0 parse.rs. +# Same change as crossterm-rs/crossterm#1073 (open); drop this once a release carries it. +# Also drops one redundant pair of parentheses and allows upstream warnings, because a +# path crate is built under our `-D warnings`. +crossterm = { path = "patches/crossterm-0.29.0" } diff --git a/README.ar.md b/README.ar.md index b1d778529d..57f21c074c 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,4 +1,4 @@ - +
@@ -57,6 +57,12 @@ cargo install codewhale-cli --locked # build from crates.io ## البدء السريع +**مفاتيح API لمزوّديك، في مكان واحد.** [أنشئ حساب Codewhale](https://app.codewhale.net/register) +أو شغّل `codewhale login` لتسجيل الدخول. احفظ مفاتيح API وحدّثها في حسابك، +ثم استخدمها عبر مسار النماذج في Codewhale على الأجهزة التي سجّلت الدخول عليها. +[إعداد مفاتيح الحساب](docs/CONFIGURATION.md#account-provider-keys). +تسجيل الدخول لا يرفع المفاتيح المحلية الموجودة؛ ويمكنك مواصلة الاستخدام المحلي دون حساب. + 1. **افتح مشروعك.** شغّل `codewhale` في المجلد الذي تريد العمل عليه. 2. **اربط نموذجًا.** شغّل `/provider` (أو اضغط `F3`) لإضافة مفتاح مستضاف أو اختيار بيئة تشغيل محلية. إذا كان Ollama يعمل بالفعل مع نموذج محادثة، ينتقل Codewhale إليه diff --git a/README.ca.md b/README.ca.md index 7238caf22e..07884c34fd 100644 --- a/README.ca.md +++ b/README.ca.md @@ -1,4 +1,4 @@ - +
@@ -58,6 +58,12 @@ instal·lacions a la mateixa màquina acaben disputant-se el `PATH`. ## Inici ràpid +**Les claus API dels teus proveïdors, en un sol lloc.** [Crea un compte de Codewhale](https://app.codewhale.net/register) +o executa `codewhale login` per iniciar la sessió. Desa i actualitza les claus API al teu compte +i fes-les servir a través de la ruta de models de Codewhale als dispositius on hagis iniciat la sessió. +[Configura les claus del compte](docs/CONFIGURATION.md#account-provider-keys). +Iniciar la sessió no puja les claus locals existents; pots continuar fent-ne un ús local sense compte. + 1. **Obre el teu projecte.** Executa `codewhale` a la carpeta on vols treballar. 2. **Connecta un model.** Executa `/provider` (o prem `F3`) per afegir una clau allotjada o triar un entorn local. Si Ollama ja s’està executant amb un model diff --git a/README.de.md b/README.de.md index da511718d3..ad0501ff8e 100644 --- a/README.de.md +++ b/README.de.md @@ -1,4 +1,4 @@ - +
@@ -58,6 +58,12 @@ auf einem Rechner streiten sich am Ende um den `PATH`. ## Schnellstart +**Die API-Schlüssel deiner Anbieter an einem Ort verwalten.** [Erstelle ein Codewhale-Konto](https://app.codewhale.net/register) +oder führe `codewhale login` aus, um dich anzumelden. Speichere und aktualisiere deine API-Schlüssel im Konto +und nutze sie über die Codewhale-Modellroute auf deinen angemeldeten Geräten. +[Kontoschlüssel einrichten](docs/CONFIGURATION.md#account-provider-keys). +Beim Anmelden werden vorhandene lokale Schlüssel nicht hochgeladen; die lokale Nutzung ist weiterhin ohne Konto möglich. + 1. **Öffne dein Projekt.** Starte `codewhale` in dem Ordner, an dem du arbeiten willst. 2. **Verbinde ein Modell.** Starte `/provider` (oder drücke `F3`), um einen gehosteten Schlüssel hinzuzufügen oder eine lokale Runtime zu wählen. Läuft Ollama bereits mit einem Chat-Modell, diff --git a/README.es-419.md b/README.es-419.md index aa42112fc8..e8bc0a0bc4 100644 --- a/README.es-419.md +++ b/README.es-419.md @@ -1,4 +1,4 @@ - +
@@ -58,6 +58,12 @@ en la misma máquina terminan disputándose el `PATH`. ## Inicio rápido +**Las claves API de tus proveedores, en un solo lugar.** [Crea una cuenta de Codewhale](https://app.codewhale.net/register) +o ejecuta `codewhale login` para iniciar sesión. Guarda y actualiza tus claves API en tu cuenta +y úsalas a través de la ruta de modelos de Codewhale en los dispositivos donde hayas iniciado sesión. +[Configura las claves de tu cuenta](docs/CONFIGURATION.md#account-provider-keys). +Iniciar sesión no sube las claves locales existentes; puedes seguir usando Codewhale localmente sin una cuenta. + 1. **Abre tu proyecto.** Ejecuta `codewhale` en la carpeta en la que quieres trabajar. 2. **Conecta un modelo.** Ejecuta `/provider` (o presiona `F3`) para agregar una clave alojada o elegir un entorno local. Si Ollama ya se está ejecutando con diff --git a/README.fr.md b/README.fr.md index 79f0767e54..685af0d37a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,4 +1,4 @@ - +
@@ -58,6 +58,12 @@ sur une même machine finissent par se disputer le `PATH`. ## Démarrage rapide +**Les clés API de vos fournisseurs, au même endroit.** [Créez un compte Codewhale](https://app.codewhale.net/register) +ou lancez `codewhale login` pour vous connecter. Enregistrez et mettez à jour vos clés API dans votre compte, +puis utilisez-les via le routage des modèles Codewhale sur vos appareils connectés à ce compte. +[Configurer les clés du compte](docs/CONFIGURATION.md#account-provider-keys). +La connexion ne transfère pas les clés locales existantes ; l’utilisation locale reste possible sans compte. + 1. **Ouvrez votre projet.** Lancez `codewhale` dans le dossier sur lequel vous voulez travailler. 2. **Connectez un modèle.** Lancez `/provider` (ou appuyez sur `F3`) pour ajouter une clé hébergée ou choisir un runtime local. Si Ollama est déjà en cours d’exécution avec un modèle diff --git a/README.hi.md b/README.hi.md index f17a94c7e4..2af0dca66b 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,4 +1,4 @@ - +
@@ -57,6 +57,12 @@ Docker, Linux पर Nix और Homebrew, Android/Termux, चेकसम सत ## क्विकस्टार्ट +**अपने प्रदाताओं की API कुंजियाँ एक ही जगह सँभालें।** [Codewhale खाता बनाएँ](https://app.codewhale.net/register) +या साइन इन करने के लिए `codewhale login` चलाएँ। अपने खाते में API कुंजियाँ सहेजें और अपडेट करें, +फिर साइन इन किए हुए उपकरणों पर Codewhale के मॉडल रूट के ज़रिए उनका इस्तेमाल करें। +[खाते की कुंजियाँ सेट अप करें](docs/CONFIGURATION.md#account-provider-keys)। +साइन इन करने से मौजूदा स्थानीय कुंजियाँ अपलोड नहीं होतीं; बिना खाते के भी स्थानीय रूप से काम कर सकते हैं। + 1. **अपना प्रोजेक्ट खोलें।** जिस फ़ोल्डर में काम करना है, उसमें `codewhale` चलाएँ। 2. **कोई मॉडल जोड़ें।** होस्टेड कुंजी जोड़ने या कोई लोकल रनटाइम चुनने के लिए `/provider` चलाएँ (या `F3` दबाएँ)। अगर Ollama पहले से किसी चैट मॉडल के साथ चल रहा है, तो diff --git a/README.id.md b/README.id.md index 6e7a8971b1..443e761cd8 100644 --- a/README.id.md +++ b/README.id.md @@ -1,4 +1,4 @@ - +
@@ -60,6 +60,12 @@ satu mesin akan saling berebut `PATH`. ## Mulai cepat +**Kelola kunci API penyedia Anda di satu tempat.** [Buat akun Codewhale](https://app.codewhale.net/register) +atau jalankan `codewhale login` untuk masuk. Simpan dan perbarui kunci API di akun Anda, +lalu gunakan melalui rute model Codewhale pada perangkat yang sudah masuk. +[Siapkan kunci akun](docs/CONFIGURATION.md#account-provider-keys). +Masuk tidak mengunggah kunci lokal yang sudah ada; penggunaan lokal tetap tersedia tanpa akun. + 1. **Buka proyek Anda.** Jalankan `codewhale` di folder yang ingin Anda kerjakan. 2. **Hubungkan model.** Jalankan `/provider` (atau tekan `F3`) untuk menambahkan kunci model hosted atau memilih runtime lokal. Jika Ollama sudah berjalan diff --git a/README.it.md b/README.it.md index dd252a43d2..9ddfb69ce7 100644 --- a/README.it.md +++ b/README.it.md @@ -1,4 +1,4 @@ - +
@@ -58,6 +58,12 @@ sulla stessa macchina finiscono per contendersi il `PATH`. ## Avvio rapido +**Le chiavi API dei tuoi provider, in un unico posto.** [Crea un account Codewhale](https://app.codewhale.net/register) +oppure esegui `codewhale login` per accedere. Salva e aggiorna le chiavi API nel tuo account, +poi usale tramite l’instradamento dei modelli di Codewhale sui dispositivi su cui hai effettuato l’accesso. +[Configura le chiavi dell’account](docs/CONFIGURATION.md#account-provider-keys). +L’accesso non carica le chiavi locali esistenti; puoi continuare a usare Codewhale in locale senza un account. + 1. **Apri il tuo progetto.** Esegui `codewhale` nella cartella su cui vuoi lavorare. 2. **Collega un modello.** Esegui `/provider` (o premi `F3`) per aggiungere una chiave ospitata o scegliere un runtime locale. Se Ollama è già in esecuzione con un modello di diff --git a/README.ja-JP.md b/README.ja-JP.md index bc1cdd9f7c..6fe6029bdc 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,4 +1,4 @@ - +
@@ -57,6 +57,12 @@ Docker、Nix、Linux 上の Homebrew、Android/Termux、チェックサム検証 ## クイックスタート +**プロバイダーの API キーを一か所で管理。** [Codewhale アカウントを作成](https://app.codewhale.net/register)するか、 +`codewhale login` を実行してログインしてください。API キーをアカウントに保存・更新すると、 +ログイン済みのデバイスから Codewhale のモデルルート経由で利用できます。 +[アカウントのキーを設定する](docs/CONFIGURATION.md#account-provider-keys)。 +ログインしても既存のローカルキーはアップロードされません。アカウントなしでもローカルで利用できます。 + 1. **プロジェクトを開く。** 作業したいフォルダーで `codewhale` を実行します。 2. **モデルを接続する。** `/provider`(または `F3`)を実行して、ホスト型のキーを追加するか、ローカルランタイムを選びます。 チャットモデルを載せた Ollama がすでに起動していれば、Codewhale は自動的にそれへ切り替わります。モデルの変更は `/model` で行います。 diff --git a/README.ko-KR.md b/README.ko-KR.md index b547559e9f..522de42cf9 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,4 +1,4 @@ - +
@@ -60,6 +60,12 @@ Docker, Nix, Linux의 Homebrew, Android/Termux, 체크섬 검증을 포함한 ## 빠른 시작 +**공급자 API 키를 한곳에서 관리하세요.** [Codewhale 계정을 만들거나](https://app.codewhale.net/register) +`codewhale login`을 실행해 로그인하세요. 계정에서 API 키를 저장하고 업데이트한 뒤, +로그인한 기기에서 Codewhale 모델 경로를 통해 사용할 수 있습니다. +[계정 키 설정하기](docs/CONFIGURATION.md#account-provider-keys). +로그인해도 기존 로컬 키는 업로드되지 않으며, 계정 없이도 로컬에서 사용할 수 있습니다. + 1. **프로젝트를 엽니다.** 작업할 폴더에서 `codewhale`을 실행합니다. 2. **모델을 연결합니다.** `/provider`를 실행하거나 `F3`을 눌러 호스팅 모델 키를 추가하거나 로컬 런타임을 고릅니다. Ollama가 채팅 모델과 함께 이미 diff --git a/README.md b/README.md index 0d86399658..6a8f913512 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,13 @@ machine end up fighting over `PATH`. ## Quickstart +**Your provider keys, managed in one place.** [Create a Codewhale account](https://app.codewhale.net/register) +or run `codewhale login` to sign in. Save and update your API keys in your +account, then use them through the Codewhale model route on your signed-in +devices. [Set up account keys](docs/CONFIGURATION.md#account-provider-keys). +Signing in does not upload existing local keys; local use remains available +without an account. + 1. **Open your project.** Run `codewhale` in the folder you want to work on. 2. **Connect a model.** Run `/provider` (or press `F3`) to add a hosted key or pick a local runtime. If Ollama is already running with a chat model, diff --git a/README.pl.md b/README.pl.md index 2c79bf7c75..6eecf67a6a 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,4 +1,4 @@ - +
@@ -60,6 +60,12 @@ na jednej maszynie zaczyna się kłócić o `PATH`. ## Szybki start +**Klucze API dostawców w jednym miejscu.** [Utwórz konto Codewhale](https://app.codewhale.net/register) +lub uruchom `codewhale login`, aby się zalogować. Zapisuj i aktualizuj klucze API na swoim koncie, +a następnie korzystaj z nich przez trasę modeli Codewhale na urządzeniach, na których się zalogowano. +[Skonfiguruj klucze konta](docs/CONFIGURATION.md#account-provider-keys). +Logowanie nie przesyła istniejących kluczy lokalnych; korzystanie lokalne jest nadal możliwe bez konta. + 1. **Otwórz projekt.** Uruchom `codewhale` w folderze, z którym chcesz pracować. 2. **Połącz model.** Uruchom `/provider` (lub naciśnij `F3`), aby dodać klucz hostowanego dostawcy albo wybrać lokalne środowisko. Jeśli Ollama już działa diff --git a/README.pt-BR.md b/README.pt-BR.md index fa4ec52c60..831e1a23f8 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,4 +1,4 @@ - +
@@ -58,6 +58,12 @@ instalações na mesma máquina acabam disputando o `PATH`. ## Início rápido +**As chaves API dos seus provedores, em um só lugar.** [Crie uma conta Codewhale](https://app.codewhale.net/register) +ou execute `codewhale login` para entrar. Salve e atualize suas chaves API na conta +e use-as pela rota de modelos do Codewhale nos dispositivos em que você estiver conectado. +[Configure as chaves da conta](docs/CONFIGURATION.md#account-provider-keys). +Entrar na conta não envia as chaves locais existentes; o uso local continua disponível sem uma conta. + 1. **Abra seu projeto.** Execute `codewhale` na pasta em que quer trabalhar. 2. **Conecte um modelo.** Execute `/provider` (ou pressione `F3`) para adicionar uma chave hospedada ou escolher um ambiente local. Se o Ollama já estiver em diff --git a/README.ru.md b/README.ru.md index 831024f4d5..d9191ad28d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,4 @@ - +
@@ -60,6 +60,12 @@ Docker, Nix, Homebrew в Linux, Android/Termux, ручная загрузка с ## Быстрый старт +**Ключи API ваших провайдеров — в одном месте.** [Создайте аккаунт Codewhale](https://app.codewhale.net/register) +или выполните `codewhale login`, чтобы войти. Сохраняйте и обновляйте ключи API в аккаунте, +затем используйте их через маршрут моделей Codewhale на устройствах, где вы вошли в аккаунт. +[Настройка ключей аккаунта](docs/CONFIGURATION.md#account-provider-keys). +Вход не загружает существующие локальные ключи в аккаунт; локальная работа доступна и без аккаунта. + 1. **Откройте проект.** Запустите `codewhale` в папке, с которой хотите работать. 2. **Подключите модель.** Выполните `/provider` (или нажмите `F3`), чтобы diff --git a/README.tr.md b/README.tr.md index 39aec9edab..bc310a4e61 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,4 +1,4 @@ - +
@@ -57,6 +57,12 @@ Tek bir yol seçin: aynı makinedeki birden fazla kurulum `PATH` üzerinde birbi ## Hızlı başlangıç +**Sağlayıcı API anahtarlarınızı tek bir yerden yönetin.** [Codewhale hesabı oluşturun](https://app.codewhale.net/register) +veya oturum açmak için `codewhale login` komutunu çalıştırın. API anahtarlarınızı hesabınıza kaydedip güncelleyin, +ardından oturum açtığınız cihazlarda Codewhale model rotası üzerinden kullanın. +[Hesap anahtarlarını ayarlayın](docs/CONFIGURATION.md#account-provider-keys). +Oturum açmak mevcut yerel anahtarları yüklemez; hesap olmadan da yerel kullanım mümkündür. + 1. **Projenizi açın.** Üzerinde çalışmak istediğiniz klasörde `codewhale` çalıştırın. 2. **Bir model bağlayın.** Barındırılan bir anahtar eklemek veya yerel bir çalışma ortamı seçmek için `/provider` komutunu çalıştırın (veya `F3` tuşuna basın). Ollama zaten bir diff --git a/README.uk.md b/README.uk.md index b2c145ae60..0bd9ca4fb3 100644 --- a/README.uk.md +++ b/README.uk.md @@ -1,4 +1,4 @@ - +
@@ -59,6 +59,12 @@ Docker, Nix, Homebrew у Linux, Android/Termux, ручне завантажен ## Швидкий старт +**Ключі API ваших провайдерів — в одному місці.** [Створіть обліковий запис Codewhale](https://app.codewhale.net/register) +або виконайте `codewhale login`, щоб увійти. Зберігайте й оновлюйте ключі API в обліковому записі, +а потім використовуйте їх через маршрут моделей Codewhale на пристроях, де ви ввійшли. +[Налаштування ключів облікового запису](docs/CONFIGURATION.md#account-provider-keys). +Вхід не завантажує наявні локальні ключі до облікового запису; локальна робота доступна й без нього. + 1. **Відкрийте проєкт.** Запустіть `codewhale` у теці, з якою хочете працювати. 2. **Підключіть модель.** Виконайте `/provider` (або натисніть `F3`), щоб додати ключ хмарного провайдера чи вибрати локальний рантайм. Якщо Ollama вже diff --git a/README.vi.md b/README.vi.md index a3eb498e70..7ad90936dd 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,4 +1,4 @@ - +
@@ -60,6 +60,12 @@ một máy sẽ xung đột với nhau về `PATH`. ## Bắt đầu nhanh +**Quản lý khóa API của các nhà cung cấp ở một nơi.** [Tạo tài khoản Codewhale](https://app.codewhale.net/register) +hoặc chạy `codewhale login` để đăng nhập. Lưu và cập nhật khóa API trong tài khoản, +rồi sử dụng chúng qua tuyến mô hình Codewhale trên các thiết bị đã đăng nhập. +[Thiết lập khóa trong tài khoản](docs/CONFIGURATION.md#account-provider-keys). +Đăng nhập không tải các khóa cục bộ hiện có lên tài khoản; bạn vẫn có thể sử dụng cục bộ mà không cần tài khoản. + 1. **Mở dự án của bạn.** Chạy `codewhale` trong thư mục bạn muốn làm việc. 2. **Kết nối mô hình.** Chạy `/provider` (hoặc nhấn `F3`) để thêm khóa của mô hình lưu trữ trực tuyến hoặc chọn runtime cục bộ. Nếu Ollama đang chạy sẵn diff --git a/README.zh-CN.md b/README.zh-CN.md index 24a1f3e8f1..35b6a14bb3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,4 +1,4 @@ - +
@@ -57,6 +57,11 @@ Docker、Nix、Linux 上的 Homebrew、Android/Termux、带校验和验证的手 ## 快速开始 +**在一个地方管理你的模型 API 密钥。** [注册 Codewhale 账号](https://app.codewhale.net/register), +或运行 `codewhale login` 登录。将密钥保存到账号后,可以集中更新,并在已登录的设备上 +通过 Codewhale 模型通道使用。[设置账号密钥](docs/zh_hans/CONFIGURATION.md#账号提供商密钥)([英文版](docs/CONFIGURATION.md#account-provider-keys))。 +登录不会自动上传已有的本地密钥;不注册账号也可以继续在本地使用。 + 1. **打开你的项目。** 在要处理的文件夹中运行 `codewhale`。 2. **连接模型。** 运行 `/provider`(或按 `F3`)添加托管服务的密钥,或选择本地运行时。 如果 Ollama 已在运行并带有聊天模型,Codewhale 会自动切换到它。使用 `/model` 更换模型。 diff --git a/README.zh-TW.md b/README.zh-TW.md index daa9dccead..b2899a76bf 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,4 +1,4 @@ - +
@@ -57,6 +57,11 @@ Docker、Nix、Linux 上的 Homebrew、Android/Termux、附校驗碼驗證的手 ## 快速開始 +**在同一個地方管理你的模型 API 金鑰。** [註冊 Codewhale 帳號](https://app.codewhale.net/register), +或執行 `codewhale login` 登入。將 API 金鑰儲存至帳號後,即可集中更新,並在已登入的裝置上 +透過 Codewhale 模型路由使用。[設定帳號金鑰](docs/CONFIGURATION.md#account-provider-keys)。 +登入不會上傳既有的本機金鑰;不註冊帳號也可以繼續在本機使用。 + 1. **開啟你的專案。** 在要處理的資料夾中執行 `codewhale`。 2. **連接模型。** 執行 `/provider`(或按 `F3`)新增託管服務的金鑰,或選擇本機執行環境。 如果 Ollama 已在執行並載入聊天模型,Codewhale 會自動切換到它。使用 `/model` 更換模型。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b7fc0507b9..2b024ccc9d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -147,7 +147,7 @@ contains the following packages. The package metadata labels each as MIT; the MCP packages also carry the upstream licensing-transition statement and full Apache-2.0 text reproduced below. Their exact distributed notices are preserved. -### `@modelcontextprotocol/client` 2.0.0 +### `@modelcontextprotocol/client` 2.2.0 ```text The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. @@ -368,7 +368,7 @@ CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for the full license text. ``` -### `@modelcontextprotocol/core` 2.0.0 +### `@modelcontextprotocol/core` 2.2.0 ```text The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. diff --git a/crates/cli/src/cloud.rs b/crates/cli/src/cloud.rs index efa1184310..86cc3d6744 100644 --- a/crates/cli/src/cloud.rs +++ b/crates/cli/src/cloud.rs @@ -2350,9 +2350,21 @@ pub(crate) fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> { /// On account sign-in, point a never-configured local route at the managed /// Codewhale provider so chat works immediately. A provider the user chose -/// explicitly is left alone. -fn select_managed_route_on_login(config: &mut ConfigStore, out: &mut W) -> Result<()> { - if config.config.provider == ProviderKind::default() { +/// explicitly, or one that already has a local key (config, secret store or +/// environment), is left alone: the default provider is DeepSeek, so equality +/// with the default cannot tell "never configured" from "chose DeepSeek". +fn select_managed_route_on_login( + config: &mut ConfigStore, + provider_secrets: &Secrets, + out: &mut W, +) -> Result<()> { + let explicit_provider = config + .original_body() + .and_then(|body| body.parse::().ok()) + .is_some_and(|table| table.contains_key("provider")); + let has_local_key = + resolve_local_key(config, provider_secrets, config.config.provider)?.is_some(); + if config.config.provider == ProviderKind::default() && !explicit_provider && !has_local_key { config.config.provider = ProviderKind::Codewhale; config.config.model = Some("auto".to_string()); config.save()?; @@ -2416,7 +2428,7 @@ fn run_with( client.poll_device(&device, Duration::from_secs(login.timeout_seconds), sleeper)?; let user = client.me()?; write_account(out, "Signed in to Codewhale.", profile, api_base, &user)?; - select_managed_route_on_login(config, out)?; + select_managed_route_on_login(config, provider_secrets, out)?; Ok(()) } CloudCommand::Status => match client.load_auth()? { @@ -2565,10 +2577,11 @@ fn run_with( out, "dry-run: remote settings import is not available; local config unchanged" )?; - // Show the invariant: Bearer custody stays in the OS keyring, never in config.toml. + // Show the invariant: account session tokens stay in the private + // Codewhale secrets file, never in config.toml. writeln!( out, - "Secure custody: Bearer tokens remain in the OS keyring" + "Secure custody: account session tokens stay in the private Codewhale secrets file, never in config.toml" )?; Ok(()) } diff --git a/crates/cli/src/cloud/tests.rs b/crates/cli/src/cloud/tests.rs index 40462d2248..afcc044a39 100644 --- a/crates/cli/src/cloud/tests.rs +++ b/crates/cli/src/cloud/tests.rs @@ -635,6 +635,37 @@ fn login_keeps_explicitly_configured_route() { assert_eq!(saved.config.model, None); } +#[test] +fn login_keeps_an_explicit_deepseek_route() { + // DeepSeek is the default provider; choosing it explicitly must survive sign-in. + let (temp, _) = test_config(); + std::fs::write(temp.path().join("config.toml"), "provider = \"deepseek\"\n").unwrap(); + let mut config = ConfigStore::load(Some(temp.path().join("config.toml"))).unwrap(); + let (secrets, _) = test_secrets(); + let output = run_login(&mut config, &secrets); + assert!( + output.contains("Keeping your configured deepseek route."), + "{output}" + ); + let saved = ConfigStore::load(Some(temp.path().join("config.toml"))).unwrap(); + assert_eq!(saved.config.provider, ProviderKind::Deepseek); + assert_eq!(saved.config.model, None); +} + +#[test] +fn login_keeps_the_default_route_when_it_has_a_local_key() { + let (temp, mut config) = test_config(); + let (secrets, _) = test_secrets(); + secrets.set("deepseek", "sk-local-never-print").unwrap(); + let output = run_login(&mut config, &secrets); + assert!( + output.contains("Keeping your configured deepseek route."), + "{output}" + ); + let saved = ConfigStore::load(Some(temp.path().join("config.toml"))).unwrap(); + assert_eq!(saved.config.provider, ProviderKind::Deepseek); +} + #[test] fn cloud_sessions_are_isolated_by_profile_and_api_origin() { let (secrets, _) = test_secrets(); diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c2fd2e42e9..6df2d6867c 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -343,14 +343,17 @@ New integrations should prefer `codewhale app-server`.")] after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated." )] Web(WebArgs), - /// Sign in to your Codewhale account (browser device flow). + /// Sign in to your Codewhale account to manage provider API keys in one place. + #[command( + after_help = "Create an account at https://app.codewhale.net/register or sign in through the browser.\nSave a provider key with `codewhale account keys set deepseek`, then choose Codewhale in /provider to use it across your signed-in devices.\nSigning in does not upload existing local keys. Local use does not require an account." + )] Login(LoginArgs), /// Remove saved authentication state (every provider key, OAuth login, /// the account session and the Daytona token). Asks before deleting. Logout(LogoutArgs), /// Manage authentication credentials and provider mode. Auth(AuthArgs), - /// Sign in to your Codewhale account and manage account-scoped provider keys. + /// Manage your Codewhale account and centrally stored provider keys. #[command(visible_alias = "cloud")] Account(cloud::CloudArgs), /// Offload a coding agent to the Codewhale cloud. Never spends or pushes without --confirm. @@ -9640,8 +9643,12 @@ verbosity = "concise" "login help must describe account sign-in: {help}" ); assert!( - !help.to_lowercase().contains("api key"), - "login help must not advertise provider API keys: {help}" + help.contains("manage provider API keys"), + "login help must explain the account's immediate benefit: {help}" + ); + assert!( + help.contains("Signing in does not upload existing local keys"), + "login help must explain the local-key upload boundary: {help}" ); } diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index fd73a33be8..cafe02d78f 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -794,6 +794,8 @@ pub struct PluginDshPreview { pub remote_servers: Vec, pub local_servers: Vec, pub network_hosts: Vec, + /// Rows that become Native host code (JavaScript run by the extension host). + pub native_rows: Vec, pub requires_node: bool, /// One line per skipped row or patch operation (a manual port). pub manual_ports: Vec, diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index 27a8c5d724..b660ffe2ea 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Les instruccions de les extensions no estan disponibles; consulta /plugin show.", + "ProfileConstitutionUnavailableLocal": "No s'han pogut carregar les preferències del compte de Codewhale; aquest torn fa servir la configuració local. La clau del proveïdor continua funcionant; executa codewhale login per recuperar les preferències del compte.", "ComposerOversizedSubmitHeld": "No s’ha enviat: el missatge supera els {limit} caràcters i no s’ha pogut desar en un fitxer de text enganxat ({error}). El text complet continua al camp d’entrada: escurça’l o habilita l’escriptura a .codewhale/pastes i torna a prémer Enter.", "ComposerOversizedSubmitHeldShort": "No s’ha enviat: supera els {limit} caràcters; no s’ha desat el fitxer d’enganxat.", "CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}", diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index 5e008e60e4..66f912ff29 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Anweisungen von Erweiterungen sind nicht verfügbar; prüfe /plugin show.", + "ProfileConstitutionUnavailableLocal": "Die Codewhale-Kontoeinstellungen konnten nicht geladen werden; dieser Durchlauf nutzt deine lokalen Einstellungen. Dein Anbieter-Schlüssel funktioniert weiter; führe codewhale login aus, um die Kontoeinstellungen wiederherzustellen.", "ComposerOversizedSubmitHeld": "Nicht gesendet: Diese Nachricht enthält mehr als {limit} Zeichen und konnte nicht als Einfügedatei gespeichert werden ({error}). Der vollständige Text steht noch im Eingabefeld: Kürze ihn oder mache .codewhale/pastes beschreibbar und drücke dann erneut Enter.", "ComposerOversizedSubmitHeldShort": "Nicht gesendet: über {limit} Zeichen; Einfügedatei nicht gespeichert.", "CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}", diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index 9f210c6828..10ca0f7dde 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Extension instructions are unavailable; inspect /plugin show.", + "ProfileConstitutionUnavailableLocal": "Codewhale account preferences could not be loaded, so this turn uses your local settings. Your provider key still works; run codewhale login to restore account preferences.", "ComposerOversizedSubmitHeld": "Not sent: this message is over {limit} characters and could not be saved as a paste file ({error}). The full text is still in the composer: shorten it, or make .codewhale/pastes writable, then press Enter again.", "ComposerOversizedSubmitHeldShort": "Not sent: over {limit} characters; paste file not saved.", "CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}", diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index f63efb7c7e..1ed6c5bdec 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Las instrucciones de las extensiones no están disponibles; revisa /plugin show.", + "ProfileConstitutionUnavailableLocal": "No se pudieron cargar las preferencias de la cuenta de Codewhale; este turno usa tu configuración local. Tu clave del proveedor sigue funcionando; ejecuta codewhale login para restaurar las preferencias de la cuenta.", "ComposerOversizedSubmitHeld": "No se envió: este mensaje supera los {limit} caracteres y no se pudo guardar en un archivo de texto pegado ({error}). El texto completo sigue en el campo de entrada: acórtalo o habilita la escritura en .codewhale/pastes y vuelve a presionar Enter.", "ComposerOversizedSubmitHeldShort": "No se envió: supera los {limit} caracteres; no se guardó el archivo pegado.", "CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}", diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index cc25623049..d9f76357e2 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Les instructions des extensions sont indisponibles ; consultez /plugin show.", + "ProfileConstitutionUnavailableLocal": "Les préférences du compte Codewhale n'ont pas pu être chargées ; ce tour utilise vos réglages locaux. Votre clé de fournisseur fonctionne toujours ; lancez codewhale login pour rétablir les préférences du compte.", "ComposerOversizedSubmitHeld": "Non envoyé : ce message dépasse {limit} caractères et n’a pas pu être enregistré dans un fichier de texte collé ({error}). Le texte intégral reste dans la zone de saisie : raccourcissez-le ou autorisez l’écriture dans .codewhale/pastes, puis appuyez à nouveau sur Enter.", "ComposerOversizedSubmitHeldShort": "Non envoyé : plus de {limit} caractères ; fichier collé non enregistré.", "CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}", diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index 464e7ec930..77c08f4086 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "एक्सटेंशन के निर्देश उपलब्ध नहीं हैं; /plugin show से जाँचें।", + "ProfileConstitutionUnavailableLocal": "Codewhale खाते की प्राथमिकताएँ लोड नहीं हो सकीं, इसलिए यह टर्न आपकी स्थानीय सेटिंग्स का उपयोग करता है। आपकी प्रदाता कुंजी काम करती रहेगी; खाते की प्राथमिकताएँ वापस लाने के लिए codewhale login चलाएँ।", "ComposerOversizedSubmitHeld": "नहीं भेजा गया: इस संदेश में {limit} से अधिक वर्ण हैं और इसे पेस्ट फ़ाइल के रूप में सहेजा नहीं जा सका ({error})। पूरा पाठ अभी भी इनपुट बॉक्स में है: इसे छोटा करें या .codewhale/pastes में लिखने की अनुमति दें, फिर Enter दबाएँ।", "ComposerOversizedSubmitHeldShort": "नहीं भेजा गया: {limit} से अधिक वर्ण; पेस्ट फ़ाइल सहेजी नहीं गई।", "CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}", diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index f05622ba65..92aaa8f0b1 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Instruksi ekstensi tidak tersedia; periksa /plugin show.", + "ProfileConstitutionUnavailableLocal": "Preferensi akun Codewhale tidak dapat dimuat, jadi giliran ini memakai pengaturan lokal. Kunci penyedia tetap berfungsi; jalankan codewhale login untuk memulihkan preferensi akun.", "ComposerOversizedSubmitHeld": "Belum terkirim: pesan ini melebihi {limit} karakter dan tidak dapat disimpan sebagai berkas teks tempelan ({error}). Teks lengkap masih ada di kolom input: persingkat teks atau izinkan penulisan ke .codewhale/pastes, lalu tekan Enter lagi.", "ComposerOversizedSubmitHeldShort": "Belum terkirim: melebihi {limit} karakter; berkas tempelan tidak tersimpan.", "CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}", diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index 6b2d5cfb67..4dba289309 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "拡張機能の指示を利用できません。/plugin show で確認してください。", + "ProfileConstitutionUnavailableLocal": "Codewhale アカウントの設定を読み込めなかったため、このターンはローカル設定を使います。プロバイダーキーはそのまま使えます。アカウント設定を戻すには codewhale login を実行してください。", "ComposerOversizedSubmitHeld": "未送信:メッセージが {limit} 文字を超えており、貼り付けファイルに保存できませんでした({error})。全文は入力欄に残っています。短くするか .codewhale/pastes を書き込み可能にしてから、もう一度 Enter を押してください。", "ComposerOversizedSubmitHeldShort": "未送信:{limit} 文字超。貼り付けファイルは未保存です。", "CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}", diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index 18d3818cc7..55fb94373a 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "확장 기능의 지침을 사용할 수 없습니다. /plugin show에서 확인하세요.", + "ProfileConstitutionUnavailableLocal": "Codewhale 계정 설정을 불러오지 못해 이번 턴은 로컬 설정을 사용합니다. 공급자 키는 계속 사용할 수 있으며, 계정 설정을 복원하려면 codewhale login을 실행하세요.", "ComposerOversizedSubmitHeld": "전송하지 못했습니다. 메시지가 {limit}자를 초과하며 붙여넣기 파일로 저장할 수 없습니다({error}). 전체 텍스트는 입력란에 그대로 있습니다. 내용을 줄이거나 .codewhale/pastes에 쓰기 권한을 부여한 뒤 Enter를 다시 누르세요.", "ComposerOversizedSubmitHeldShort": "전송 안 됨: {limit}자 초과, 붙여넣기 파일 저장 실패.", "CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}", diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index 67c7a74b07..1cc0e8a9f2 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "As instruções das extensões não estão disponíveis; confira /plugin show.", + "ProfileConstitutionUnavailableLocal": "Não foi possível carregar as preferências da conta Codewhale; este turno usa suas configurações locais. Sua chave do provedor continua funcionando; execute codewhale login para restaurar as preferências da conta.", "ComposerOversizedSubmitHeld": "Não enviado: esta mensagem excede {limit} caracteres e não pôde ser salva em um arquivo de texto colado ({error}). O texto completo continua no campo de entrada: reduza-o ou permita a gravação em .codewhale/pastes e pressione Enter novamente.", "ComposerOversizedSubmitHeldShort": "Não enviado: acima de {limit} caracteres; arquivo colado não salvo.", "CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}", diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index 9a621ab5db..02e704265e 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Инструкции расширений недоступны; проверьте /plugin show.", + "ProfileConstitutionUnavailableLocal": "Не удалось загрузить настройки аккаунта Codewhale, поэтому этот ход использует локальные настройки. Ключ провайдера продолжает работать; выполните codewhale login, чтобы вернуть настройки аккаунта.", "ComposerOversizedSubmitHeld": "Не отправлено: сообщение длиннее {limit} символов, и его не удалось сохранить в файл вставленного текста ({error}). Полный текст остался в поле ввода: сократите его или разрешите запись в .codewhale/pastes, затем снова нажмите Enter.", "ComposerOversizedSubmitHeldShort": "Не отправлено: больше {limit} символов; файл вставки не сохранён.", "CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}", diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index 30a5ac2135..c4e02eb578 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Інструкції розширень недоступні; перевірте /plugin show.", + "ProfileConstitutionUnavailableLocal": "Не вдалося завантажити налаштування облікового запису Codewhale, тому цей хід використовує локальні налаштування. Ключ провайдера й далі працює; виконайте codewhale login, щоб повернути налаштування облікового запису.", "ComposerOversizedSubmitHeld": "Не надіслано: повідомлення перевищує {limit} символів, і його не вдалося зберегти у файл вставленого тексту ({error}). Повний текст залишається в полі введення: скоротіть його або дозвольте запис у .codewhale/pastes, а потім знову натисніть Enter.", "ComposerOversizedSubmitHeldShort": "Не надіслано: понад {limit} символів; файл вставки не збережено.", "CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}", diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index f0f3ed6a0f..dc703926b6 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "Không thể tải hướng dẫn của tiện ích mở rộng; kiểm tra /plugin show.", + "ProfileConstitutionUnavailableLocal": "Không thể tải tùy chọn tài khoản Codewhale nên lượt này dùng cài đặt cục bộ. Khóa nhà cung cấp của bạn vẫn hoạt động; chạy codewhale login để khôi phục tùy chọn tài khoản.", "ComposerOversizedSubmitHeld": "Chưa gửi: tin nhắn vượt quá {limit} ký tự và không thể lưu thành tệp văn bản đã dán ({error}). Toàn bộ nội dung vẫn còn trong ô nhập: hãy rút ngắn hoặc cho phép ghi vào .codewhale/pastes, rồi nhấn Enter lần nữa.", "ComposerOversizedSubmitHeldShort": "Chưa gửi: vượt quá {limit} ký tự; chưa lưu tệp dán.", "CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}", diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 1e26f3dd1c..8f595fc297 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "扩展指令不可用;请通过 /plugin show 查看。", + "ProfileConstitutionUnavailableLocal": "无法加载 Codewhale 账号偏好,本轮改用本地设置。你的服务商密钥仍可正常使用;运行 codewhale login 可恢复账号偏好。", "ComposerOversizedSubmitHeld": "未发送:消息超过 {limit} 个字符,无法保存为粘贴文本文件({error})。完整文本仍保留在输入框中:请缩短内容,或允许写入 .codewhale/pastes,然后再次按 Enter。", "ComposerOversizedSubmitHeldShort": "未发送:超过 {limit} 个字符;粘贴文件未保存。", "CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}", diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index 008f079500..aed250c74c 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -1,5 +1,6 @@ { "ExtensionPromptUnavailable": "擴充功能指令無法使用;請透過 /plugin show 查看。", + "ProfileConstitutionUnavailableLocal": "無法載入 Codewhale 帳號偏好,本輪改用本機設定。你的服務商金鑰仍可正常使用;執行 codewhale login 可恢復帳號偏好。", "ComposerOversizedSubmitHeld": "未傳送:訊息超過 {limit} 個字元,無法儲存為貼上文字檔案({error})。完整文字仍保留在輸入框中:請縮短內容,或允許寫入 .codewhale/pastes,然後再次按 Enter。", "ComposerOversizedSubmitHeldShort": "未傳送:超過 {limit} 個字元;貼上檔案未儲存。", "CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}", diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index c34da98dac..0c25f591bd 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -604,6 +604,7 @@ pub enum MessageId { ExtensionsInventoryNone, ExtensionsInventorySkills, ExtensionPromptUnavailable, + ProfileConstitutionUnavailableLocal, ExtensionsMarketplaceDetail, ExtensionsMarketplaceUnavailable, ExtensionsMcpEmpty, @@ -3173,6 +3174,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ExtensionsInventoryNone, MessageId::ExtensionsInventorySkills, MessageId::ExtensionPromptUnavailable, + MessageId::ProfileConstitutionUnavailableLocal, MessageId::ExtensionsMarketplaceDetail, MessageId::ExtensionsMarketplaceUnavailable, MessageId::ExtensionsMcpEmpty, diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6a02275c47..d360de6ea8 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.1] - 2026-10-01 +## [0.10.1] - 2026-10-07 ### Contributor integration and reliability +- OAuth retry diagnostics omit provider-controlled error fields; account replacement stops before retired Weixin receipts exceed their storage limit, preserving uncertain work for local review. +- Resuming a session through a symlink to the same workspace no longer shows a false workspace-change warning. - Runtime clients can read one tool call's actual workspace changes and reviewed skill details (thanks @gaord, #6817 and #6869). In-flight snapshot pairs remain pending; missing objects and corrupt repository metadata are distinguished. - Search accepts valid preferred locales, and image dimensions describe the same bytes sent to the model (thanks @asto18089, #6860 and #6858). Automation deletion keeps its definition until cleanup succeeds, and compaction preserves its original summary anchor (#6864 and #6857). - Config/status/permission commands share portable contracts while the host retains mutation authority; queue workers acknowledge a scheduled retry for temporary first-claim contention and fail honestly on corruption (thanks @aboimpinto, #6832). @@ -18,9 +20,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configured approval expiry belongs to the held Engine request; hiding or covering its card cannot restart the deadline, and a late queued answer cannot approve an expired call. - Tool discovery keeps the highest-ranked matches when a result batch exceeds the existing cache bounds, preserving search order and the 16 KiB limit (adapted from @AdityaVG13's #6393). - Model-switch receipts now translate their session-only saving note in every complete locale pack; the three save commands remain directly usable (thanks @Lstarsky0, #6875). +- Weixin bridge threads belong to the account and chat that created them: + resuming or listing another chat's thread is refused. An explicit `/new` after + an account replacement keeps the old account's private receipt and never + replays its prompts automatically. Replies never reuse another bot account's + Weixin context token, and `/threads` lists this chat's own older threads even + when other chats have newer ones. +- Windows messaging bridges retry a briefly locked record replacement + (EPERM/EACCES/EBUSY) with bounded delays and never delete the previous record + first. +- The website and README installation guides are generated from one shared + source, and the critical Simplified Chinese guides were re-reviewed against + current English. +- A Codewhale sign-in that expired, or an account service that is unreachable, + no longer blocks turns on your own provider key. The turn uses your local + settings and Codewhale says once how to restore account preferences. +- `codewhale login` keeps a DeepSeek route you chose, or one with a local key, + instead of switching it to the managed Codewhale provider. - The bundled `computer-use` plugin is 0.12.1, reconciled with canonical source - `a656f67455fc5639f28304fbf61075db3925058a` while retaining Core's embedding - manifest and version contract. + `724f9c422db1a51880dc81154dae70816c475ed8` while retaining Core's embedding + manifest and version contract. Its image renderer lock now carries Sharp 0.35.5. +- The bundled first-party catalog pins marketplace revision + `6512f1dfaa91ee287e9f81ebabaf4909e8a371a3` and lists all 19 reviewed plugins, + up from 6. Every catalog plugin still installs disabled and untrusted until + you review it. +- Every committed npm lockfile is audited in CI, including build tooling. The + VS Code extension packages with `@vscode/vsce` 4 on Node 22 while it still + compiles and tests on its Node 20 runtime. The website keeps one reviewed, + hash-verified depth guard for an unpatched `braces` advisory + (GHSA-vfj7-8cjw-p6xm); its raw audit findings are retained, not hidden. +- Pasting through Windows Terminal (Ctrl+Shift+V) no longer drops emoji and + other characters outside the Basic Multilingual Plane. Release binaries and source builds carry + a small patch to crossterm 0.29.0 in `patches/` (the change proposed upstream + as crossterm-rs/crossterm#1073); a real Windows Terminal paste of 24 Unicode + lines is now a release gate. +- Experimental extension host: reviewed Native plugins can register avatar + packs with `ctx.avatars.registerPack` (`/pet avatar [key]`, `/pet action|view + `). `/plugin import dsh ` reviews a DeepSeek Harness + bundle and now lists the Native host code it contains; `approve + ` installs it disabled and untrusted. Trusted host plugins stay + trusted across a restart, enabling a second host plugin activates it without + `/plugin reload`, and the review no longer crashes the TUI. Codewhale v0.10.1 focuses on reliability and first-run behavior. Turns that stall now say so, approvals keep what you approved, plugin suggestions are diff --git a/crates/tui/assets/first-party-marketplace.json b/crates/tui/assets/first-party-marketplace.json index 2f823054d0..48906d0186 100644 --- a/crates/tui/assets/first-party-marketplace.json +++ b/crates/tui/assets/first-party-marketplace.json @@ -1,15 +1,51 @@ { "repository": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", - "revision": "ae3dd2255a9a266365c6125a084f511eb26bc04d", + "revision": "6512f1dfaa91ee287e9f81ebabaf4909e8a371a3", "catalog": { "name": "codewhale", "description": "First-party Codewhale extensions: the plugins and skills Codewhale ships, maintained in the open so anyone can propose a change.", "version": "2", "plugins": [ + { + "name": "chromewhale", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/chromewhale", + "version": "0.4.0", + "description": "Let Codewhale read and act on selected Chrome tabs and frames, one granted site at a time. Needs the bundled Chrome extension loaded.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace/tree/main/plugins/chromewhale", + "display_name": "Codewhale for Chrome", + "author": "Codewhale" + }, + { + "name": "cloudflare", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/cloudflare", + "version": "0.1.0", + "description": "Cloudflare Workers workflows for Codewhale: build and test Workers, deploy with cf only after you approve, use KV, R2, D1 and Durable Objects, and move Pages projects to Workers.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Cloudflare", + "author": "Codewhale" + }, + { + "name": "cloudflare-docs", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/cloudflare-docs", + "version": "0.1.0", + "description": "Search current Cloudflare documentation through its official remote MCP. No account or credential required.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Cloudflare Docs", + "author": "Codewhale" + }, + { + "name": "codewhale-skills", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=skills", + "version": "1.1.0", + "description": "47 workflows for coding, research, documents, email, calendar, travel, shopping and local audio. Account and tool setup is separate; see the skill directory.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Codewhale Skills", + "author": "Codewhale" + }, { "name": "computer-use", - "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/ae3dd2255a9a266365c6125a084f511eb26bc04d#path=plugins/computer-use", - "version": "0.12.0", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/computer-use", + "version": "0.12.1", "description": "Control apps with Codewhale. macOS beta; unsigned Windows preview and experimental Linux/Docker support.", "homepage": "https://codewhale.net/computer-use", "display_name": "Computer Use", @@ -20,48 +56,137 @@ ] }, { - "name": "whalesong", - "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/ae3dd2255a9a266365c6125a084f511eb26bc04d#path=plugins/whalesong", - "version": "0.2.0", - "description": "Review agent traces and failures, compare runs, and turn session timing into audio. Requires the local Whalesong platform.", - "homepage": "https://github.com/Hmbown/codewhale-plugin-marketplace", - "display_name": "Whalesong", + "name": "design-review", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/design-review", + "version": "0.1.0", + "description": "Review a user interface three ways: a severity-ranked design critique, a WCAG-based accessibility audit with a static scanner and contrast calculator, and a UX copy review.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Design Review", "author": "Codewhale" }, { - "name": "whalewiki", - "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/ae3dd2255a9a266365c6125a084f511eb26bc04d#path=plugins/whalewiki", - "version": "0.2.0", - "description": "Understand a repo, find where to change it, and see which docs depend on a file. Source citations, freshness checks and a searchable offline reader.", - "homepage": "https://github.com/Hmbown/codewhale-plugin-marketplace", - "display_name": "WhaleWiki", + "name": "github-workflow", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/github-workflow", + "version": "0.1.0", + "description": "Review pull requests, triage issues and file well-formed issues through GitHub's official remote MCP server. Needs your own GitHub token; never posts without your approval.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "GitHub Workflow", "author": "Codewhale" }, { - "name": "cloudflare-docs", - "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/ae3dd2255a9a266365c6125a084f511eb26bc04d#path=plugins/cloudflare-docs", + "name": "hello-extension", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/samples/hello-extension", "version": "0.1.0", - "description": "Search current Cloudflare documentation through its official remote MCP. No account or credential required.", - "homepage": "https://github.com/Hmbown/codewhale-plugin-marketplace", - "display_name": "Cloudflare Docs", + "description": "A runnable sample of a native TypeScript extension: one greeting tool and /hello-greet. Install it to see the review flow; it only runs with the experimental extension host turned on.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace/tree/main/plugins/samples/hello-extension", + "display_name": "Hello Extension (sample)", + "author": "Codewhale", + "platforms": [ + "macos", + "linux" + ] + }, + { + "name": "linear-workflow", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/linear-workflow", + "version": "0.1.0", + "description": "Triage Linear issues, claim work with evidence and post status updates through Linear's official remote MCP server. Needs your own Linear API key.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Linear Workflow", "author": "Codewhale" }, { - "name": "codewhale-skills", - "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/ae3dd2255a9a266365c6125a084f511eb26bc04d#path=skills", - "version": "1.1.0", - "description": "47 workflows for coding, research, documents, email, calendar, travel, shopping and local audio. Account and tool setup is separate; see the skill directory.", - "homepage": "https://github.com/Hmbown/codewhale-plugin-marketplace", - "display_name": "Codewhale Skills", + "name": "loop", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/loop", + "version": "0.1.0", + "description": "Run a task again and again until it is done: /loop [--max N] [--until ] re-prompts the session after every reply, with a hard iteration cap, a completion phrase and /cancel-loop.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Loop", + "author": "Codewhale", + "platforms": [ + "macos", + "linux" + ] + }, + { + "name": "review-toolkit", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/review-toolkit", + "version": "0.1.0", + "description": "Six read-only specialist reviewers (silent failures, type design, test gaps, comment accuracy, simplification, general correctness) and a /review-pr command that fans them out over your diff and merges one ranked report.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Review Toolkit", "author": "Codewhale" }, { - "name": "chromewhale", - "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/ae3dd2255a9a266365c6125a084f511eb26bc04d#path=plugins/chromewhale", - "version": "0.3.0", - "description": "Let Codewhale read and act on the Chrome tab you are looking at, one granted site at a time. Needs the bundled Chrome extension loaded.", - "homepage": "https://github.com/Hmbown/codewhale-plugin-marketplace/tree/main/plugins/chromewhale", - "display_name": "Codewhale for Chrome", + "name": "skills-dev-process", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/skills-dev-process", + "version": "0.1.0", + "description": "Software process workflows: plan, implement, debug, test, review, security-review, simplify, verify, hand off, and interview for missing requirements.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Dev Process Skills", + "author": "Codewhale" + }, + { + "name": "skills-docs", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/skills-docs", + "version": "0.1.0", + "description": "Workflows for Word, PDF, PowerPoint and Excel files, plus writing repository documentation. No account or connection needed.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Docs Skills", + "author": "Codewhale" + }, + { + "name": "skills-google", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/skills-google", + "version": "0.1.0", + "description": "Workflows for Gmail and Google Calendar through a connected tool or your own OAuth client. Does not connect an account by itself.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Google Skills", + "author": "Codewhale" + }, + { + "name": "skills-personal", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/skills-personal", + "version": "0.1.0", + "description": "Everyday workflows for bank balances, shopping, flights, local Photos, Spotify, speech, podcasts and image search. Each skill states its own setup.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Personal Skills", + "author": "Codewhale" + }, + { + "name": "vercel-workflow", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/vercel-workflow", + "version": "0.1.0", + "description": "Vercel workflows for Codewhale: preview and production deploys only after you approve, environment variables, runtime and build logs, and Next.js configuration.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Vercel Workflow", + "author": "Codewhale" + }, + { + "name": "web-artifacts-builder", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/web-artifacts-builder", + "version": "0.1.0", + "description": "Build a self-contained single-file or small React and Tailwind page locally, check that it is really self-contained, and preview it on loopback.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Web Artifacts Builder", + "author": "Codewhale" + }, + { + "name": "whalesong", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/whalesong", + "version": "0.2.0", + "description": "Review agent traces and failures, compare runs, and turn session timing into audio. Requires the local Whalesong platform.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "Whalesong", + "author": "Codewhale" + }, + { + "name": "whalewiki", + "source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/6512f1dfaa91ee287e9f81ebabaf4909e8a371a3#path=plugins/whalewiki", + "version": "0.2.0", + "description": "Understand a repo, find where to change it, and see which docs depend on a file. Source citations, freshness checks and a searchable offline reader.", + "homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace", + "display_name": "WhaleWiki", "author": "Codewhale" } ] diff --git a/crates/tui/extension-host/build.mjs b/crates/tui/extension-host/build.mjs index c6dce5081d..cc027a2a88 100644 --- a/crates/tui/extension-host/build.mjs +++ b/crates/tui/extension-host/build.mjs @@ -7,6 +7,7 @@ import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { redactMcpOAuthLogs } from './scripts/redact-mcp-oauth-logs.mjs' const here = dirname(fileURLToPath(import.meta.url)) const outdir = join(here, 'dist') @@ -51,6 +52,9 @@ for (const id of builtinIds) { banner: { js: `// codewhale built-in host module ${id} — generated by crates/tui/extension-host/build.mjs; do not edit.` }, }) builtinMetafiles.push(built.metafile) + if (id === 'mcp') { + writeFileSync(outfile, redactMcpOAuthLogs(readFileSync(outfile, 'utf8'))) + } builtinDigests[id] = createHash('sha256').update(readFileSync(outfile)).digest('hex') } writeFileSync(join(outdir, 'builtin-modules.json'), JSON.stringify({ modules: builtinDigests }, null, 2) + '\n') @@ -174,6 +178,23 @@ await build({ banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/shims/skills.ts; do not edit.' }, }) +// Test-only avatar-pack shim, bundled so tests remain dependency-free before npm ci. +await build({ + entryPoints: [join(here, 'src/shims/avatars.ts')], + outfile: join(outdir, 'avatars.mjs'), + absWorkingDir: here, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22.19', + sourcemap: false, + minify: false, + legalComments: 'none', + charset: 'utf8', + logLevel: 'warning', + banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/shims/avatars.ts; do not edit.' }, +}) + // Source-focused raw roster tests use the same discovery/receipt adapter. await build({entryPoints:[join(here,'src/dsh/agent-presets.ts')],outfile:join(outdir,'agent-presets.mjs'),alias:compositionAlias,bundle:true,platform:'node',format:'esm',target:'node22.19',sourcemap:false,minify:false,legalComments:'none',charset:'utf8',logLevel:'warning'}) diff --git a/crates/tui/extension-host/dist/LICENSES.txt b/crates/tui/extension-host/dist/LICENSES.txt index 73dec05b8b..abd9803cf8 100644 --- a/crates/tui/extension-host/dist/LICENSES.txt +++ b/crates/tui/extension-host/dist/LICENSES.txt @@ -16,8 +16,8 @@ Packages: @deepseek-ai/dsh-tools@0.1.7-alpha.2 (MIT) @deepseek-ai/dsh-util-values@0.1.7-alpha.2 (MIT) @deepseek-ai/schemastery@3.18.4 (MIT) - @modelcontextprotocol/client@2.0.0 (MIT) - @modelcontextprotocol/core@2.0.0 (MIT) + @modelcontextprotocol/client@2.2.0 (MIT) + @modelcontextprotocol/core@2.2.0 (MIT) acorn@8.18.0 (MIT) eventsource@3.0.7 (MIT) eventsource-parser@3.1.1 (MIT) @@ -411,7 +411,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ======================================================================== -@modelcontextprotocol/client@2.0.0 (MIT) +@modelcontextprotocol/client@2.2.0 (MIT) ======================================================================== The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. @@ -632,7 +632,7 @@ CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for the full license text. ======================================================================== -@modelcontextprotocol/core@2.0.0 (MIT) +@modelcontextprotocol/core@2.2.0 (MIT) ======================================================================== The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. diff --git a/crates/tui/extension-host/dist/agent-presets.mjs b/crates/tui/extension-host/dist/agent-presets.mjs index a24510198c..7d7dc088e8 100644 --- a/crates/tui/extension-host/dist/agent-presets.mjs +++ b/crates/tui/extension-host/dist/agent-presets.mjs @@ -12540,6 +12540,14 @@ var init_storage = __esm({ } }); +// src/shims/avatars.ts +var init_avatars = __esm({ + "src/shims/avatars.ts"() { + "use strict"; + init_owned(); + } +}); + // src/tier.ts var init_tier = __esm({ "src/tier.ts"() { @@ -12574,6 +12582,7 @@ var init_root = __esm({ init_prompt(); init_storage(); init_mcp(); + init_avatars(); init_skills(); init_tier(); init_commands(); diff --git a/crates/tui/extension-host/dist/avatars.mjs b/crates/tui/extension-host/dist/avatars.mjs new file mode 100644 index 0000000000..0d2b4b2add --- /dev/null +++ b/crates/tui/extension-host/dist/avatars.mjs @@ -0,0 +1,2017 @@ +// generated by crates/tui/extension-host/build.mjs from src/shims/avatars.ts; do not edit. + +// node_modules/@deepseek-ai/cosmokit/lib/index.js +function isNullable(value) { + return value === null || value === void 0; +} +function defineProperty(object, key, value) { + return Object.defineProperty(object, key, { + writable: true, + value, + enumerable: false + }); +} +function is(type, value) { + if (arguments.length === 1) return (value2) => is(type, value2); + return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type; +} +function isArrayBufferLike(value) { + return is("ArrayBuffer", value) || is("SharedArrayBuffer", value); +} +function isArrayBufferSource(value) { + return isArrayBufferLike(value) || ArrayBuffer.isView(value); +} +var Binary; +(function(Binary2) { + Binary2.is = isArrayBufferLike; + Binary2.isSource = isArrayBufferSource; + function fromSource(source) { + if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength); + else return source; + } + Binary2.fromSource = fromSource; + function toBase64(source) { + source = fromSource(source); + if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64"); + let binary = ""; + const bytes = new Uint8Array(source); + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + } + Binary2.toBase64 = toBase64; + function fromBase64(source) { + if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64")); + return Uint8Array.from(atob(source), (c) => c.charCodeAt(0)); + } + Binary2.fromBase64 = fromBase64; + function toHex(source) { + source = fromSource(source); + if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex"); + return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + Binary2.toHex = toHex; + function fromHex(source) { + if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex")); + const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1); + const buffer = []; + for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16)); + return Uint8Array.from(buffer).buffer; + } + Binary2.fromHex = fromHex; +})(Binary || (Binary = {})); +var base64ToArrayBuffer = Binary.fromBase64; +var arrayBufferToBase64 = Binary.toBase64; +var hexToArrayBuffer = Binary.fromHex; +var arrayBufferToHex = Binary.toHex; +function tokenize(source, delimiters, delimiter) { + const output = []; + let state = 0; + for (let i = 0; i < source.length; i++) { + const code = source.charCodeAt(i); + if (code >= 65 && code <= 90) { + if (state === 1) { + const next = source.charCodeAt(i + 1); + if (next >= 97 && next <= 122) output.push(delimiter); + output.push(code + 32); + } else { + if (state !== 0) output.push(delimiter); + output.push(code + 32); + } + state = 1; + } else if (code >= 97 && code <= 122) { + output.push(code); + state = 2; + } else if (delimiters.includes(code)) { + if (state !== 0) output.push(delimiter); + state = 0; + } else output.push(code); + } + return String.fromCharCode(...output); +} +function paramCase(source) { + return tokenize(source, [45, 95], 45); +} +var hyphenate = paramCase; +var Time; +(function(Time2) { + Time2.millisecond = 1; + Time2.second = 1e3; + Time2.minute = Time2.second * 60; + Time2.hour = Time2.minute * 60; + Time2.day = Time2.hour * 24; + Time2.week = Time2.day * 7; + let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); + function setTimezoneOffset(offset) { + timezoneOffset = offset; + } + Time2.setTimezoneOffset = setTimezoneOffset; + function getTimezoneOffset() { + return timezoneOffset; + } + Time2.getTimezoneOffset = getTimezoneOffset; + function getDateNumber(date = /* @__PURE__ */ new Date(), offset) { + if (typeof date === "number") date = new Date(date); + if (offset === void 0) offset = timezoneOffset; + return Math.floor((date.valueOf() / Time2.minute - offset) / 1440); + } + Time2.getDateNumber = getDateNumber; + function fromDateNumber(value, offset) { + const date = new Date(value * Time2.day); + if (offset === void 0) offset = timezoneOffset; + return new Date(+date + offset * Time2.minute); + } + Time2.fromDateNumber = fromDateNumber; + const numeric = /\d+(?:\.\d+)?/.source; + const timeRegExp = new RegExp(`^${[ + "w(?:eek(?:s)?)?", + "d(?:ay(?:s)?)?", + "h(?:our(?:s)?)?", + "m(?:in(?:ute)?(?:s)?)?", + "s(?:ec(?:ond)?(?:s)?)?" + ].map((unit) => `(${numeric}${unit})?`).join("")}$`); + function parseTime(source) { + const capture = timeRegExp.exec(source); + if (!capture) return 0; + return (parseFloat(capture[1]) * Time2.week || 0) + (parseFloat(capture[2]) * Time2.day || 0) + (parseFloat(capture[3]) * Time2.hour || 0) + (parseFloat(capture[4]) * Time2.minute || 0) + (parseFloat(capture[5]) * Time2.second || 0); + } + Time2.parseTime = parseTime; + function parseDate(date) { + const parsed = parseTime(date); + if (parsed) date = Date.now() + parsed; + else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`; + else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`; + return date ? new Date(date) : /* @__PURE__ */ new Date(); + } + Time2.parseDate = parseDate; + function format(ms) { + const abs = Math.abs(ms); + if (abs >= Time2.day - Time2.hour / 2) return Math.round(ms / Time2.day) + "d"; + else if (abs >= Time2.hour - Time2.minute / 2) return Math.round(ms / Time2.hour) + "h"; + else if (abs >= Time2.minute - Time2.second / 2) return Math.round(ms / Time2.minute) + "m"; + else if (abs >= Time2.second) return Math.round(ms / Time2.second) + "s"; + return ms + "ms"; + } + Time2.format = format; + function toDigits(source, length = 2) { + return source.toString().padStart(length, "0"); + } + Time2.toDigits = toDigits; + function template(template2, time = /* @__PURE__ */ new Date()) { + return template2.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3)); + } + Time2.template = template; +})(Time || (Time = {})); + +// node_modules/@deepseek-ai/cordis/lib/index.js +var DisposableList = class { + sn = 0; + map = /* @__PURE__ */ new Map(); + weak = /* @__PURE__ */ new WeakMap(); + get length() { + return this.map.size; + } + push(value) { + const sn = ++this.sn; + this.map.set(sn, value); + this.weak.set(value, sn); + return () => this.map.delete(sn); + } + delete(value) { + const sn = this.weak.get(value); + if (!sn) return false; + return this.map.delete(sn); + } + clear() { + const values = [...this.map.values()]; + this.map.clear(); + return values.reverse(); + } + [Symbol.iterator]() { + return this.map.values(); + } + [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() { + return [...this]; + } +}; +var symbols = { + shadow: /* @__PURE__ */ Symbol.for("cordis.shadow"), + receiver: /* @__PURE__ */ Symbol.for("cordis.receiver"), + original: /* @__PURE__ */ Symbol.for("cordis.original"), + metadata: /* @__PURE__ */ Symbol.for("cordis.metadata"), + initHooks: /* @__PURE__ */ Symbol.for("cordis.initHooks"), + checkProto: /* @__PURE__ */ Symbol.for("cordis.checkProto"), + effect: /* @__PURE__ */ Symbol.for("cordis.effect"), + filter: /* @__PURE__ */ Symbol.for("cordis.filter"), + isolate: /* @__PURE__ */ Symbol.for("cordis.isolate"), + intercept: /* @__PURE__ */ Symbol.for("cordis.intercept"), + init: /* @__PURE__ */ Symbol.for("cordis.init"), + check: /* @__PURE__ */ Symbol.for("cordis.check"), + config: /* @__PURE__ */ Symbol.for("cordis.config"), + invoke: /* @__PURE__ */ Symbol.for("cordis.invoke"), + extend: /* @__PURE__ */ Symbol.for("cordis.extend"), + tracker: /* @__PURE__ */ Symbol.for("cordis.tracker"), + resolveConfig: /* @__PURE__ */ Symbol.for("cordis.resolveConfig") +}; +var GeneratorFunction = function* () { +}.constructor; +var AsyncGeneratorFunction = async function* () { +}.constructor; +function isConstructor(func) { + if (!func.prototype) return false; + if (func instanceof GeneratorFunction) return false; + if (AsyncGeneratorFunction !== Function && func instanceof AsyncGeneratorFunction) return false; + return true; +} +function joinPrototype(proto1, proto2) { + if (proto1 === Object.prototype) return proto2; + const result = Object.create(joinPrototype(Object.getPrototypeOf(proto1), proto2)); + for (const key of Reflect.ownKeys(proto1)) Object.defineProperty(result, key, Object.getOwnPropertyDescriptor(proto1, key)); + return result; +} +function isObject(value) { + return value && (typeof value === "object" || typeof value === "function"); +} +function getPropertyDescriptor(target, prop) { + let proto = target; + while (proto) { + const desc = Reflect.getOwnPropertyDescriptor(proto, prop); + if (desc) return desc; + proto = Object.getPrototypeOf(proto); + } +} +function getTraceable(ctx, value) { + if (!isObject(value)) return value; + if (Object.hasOwn(value, symbols.shadow)) return Object.getPrototypeOf(value); + const tracker = value[symbols.tracker]; + if (!tracker) return value; + return createTraceable(ctx, value, tracker); +} +function withProps(target, props) { + if (!props) return target; + return new Proxy(target, { + get: (target2, prop, receiver) => { + if (prop in props && prop !== "constructor") return Reflect.get(props, prop, receiver); + return Reflect.get(target2, prop, receiver); + }, + set: (target2, prop, value, receiver) => { + if (prop in props && prop !== "constructor") return Reflect.set(props, prop, value, receiver); + return Reflect.set(target2, prop, value, receiver); + } + }); +} +function withProp(target, prop, value) { + return withProps(target, Object.defineProperty(/* @__PURE__ */ Object.create(null), prop, { + value, + writable: false + })); +} +function createShadow(ctx, target, property, receiver) { + if (!property) return receiver; + const origin = Reflect.getOwnPropertyDescriptor(target, property)?.value; + if (!origin) return receiver; + return withProp(receiver, property, ctx.extend({ [symbols.shadow]: origin })); +} +function createShadowMethod(ctx, value, outer, shadow) { + return new Proxy(value, { apply: (target, thisArg, args) => { + if (thisArg === outer) thisArg = shadow; + return getTraceable(ctx, Reflect.apply(target, thisArg, args)); + } }); +} +function createTraceable(ctx, value, tracker) { + if (ctx[symbols.shadow] && !tracker.noShadow) ctx = Object.getPrototypeOf(ctx); + const proxy = new Proxy(value, { + get: (target, prop, receiver) => { + if (prop === symbols.original) return target; + if (prop === tracker.property) return ctx; + if (typeof prop === "symbol") return Reflect.get(target, prop, receiver); + if (tracker.associate && ctx.reflect.props[`${tracker.associate}.${prop}`]) return Reflect.get(ctx, `${tracker.associate}.${prop}`, withProp(ctx, symbols.receiver, receiver)); + let shadow, innerValue; + const desc = getPropertyDescriptor(target, prop); + if (desc && "value" in desc) innerValue = desc.value; + else { + shadow = createShadow(ctx, target, tracker.property, receiver); + innerValue = Reflect.get(target, prop, shadow); + } + const innerTracker = innerValue?.[symbols.tracker]; + if (innerTracker) return createTraceable(ctx, innerValue, innerTracker); + else if (!tracker.noShadow && typeof innerValue === "function") { + shadow ??= createShadow(ctx, target, tracker.property, receiver); + return createShadowMethod(ctx, innerValue, receiver, shadow); + } else return innerValue; + }, + set: (target, prop, value2, receiver) => { + if (prop === symbols.original) return false; + if (prop === tracker.property) return false; + if (typeof prop === "symbol") return Reflect.set(target, prop, value2, receiver); + if (tracker.associate && ctx.reflect.props[`${tracker.associate}.${prop}`]) return Reflect.set(ctx, `${tracker.associate}.${prop}`, value2, withProp(ctx, symbols.receiver, receiver)); + const shadow = createShadow(ctx, target, tracker.property, receiver); + return Reflect.set(target, prop, value2, shadow); + }, + apply: (target, thisArg, args) => { + return applyTraceable(proxy, target, thisArg, args); + } + }); + return proxy; +} +function applyTraceable(proxy, value, thisArg, args) { + if (!value[symbols.invoke]) return Reflect.apply(value, thisArg, args); + return value[symbols.invoke].apply(proxy, args); +} +function createCallable(name, proto, tracker) { + const self = function(...args) { + return applyTraceable(createTraceable(self["ctx"], self, tracker), self, this, args); + }; + defineProperty(self, "name", name); + return Object.setPrototypeOf(self, proto); +} +function handleError(info, reason, getOuterStack) { + const innerLines = info.error.stack.split("\n"); + if (typeof reason?.stack !== "string") { + const outerError = new Error(reason); + const lines2 = outerError.stack.split("\n"); + lines2.splice(1, Infinity, ...getOuterStack()); + outerError.stack = lines2.join("\n"); + throw outerError; + } + const lines = reason.stack.split("\n"); + let index = lines.indexOf(innerLines[2]); + if (index === -1) throw reason; + index -= info.offset; + while (index > 0) { + if (!lines[index - 1].endsWith(" ()")) break; + index -= 1; + } + lines.splice(index, Infinity, ...getOuterStack()); + reason.stack = lines.join("\n"); + throw reason; +} +function composeError(callback, getOuterStack = buildOuterStack()) { + const info = { + offset: 1, + error: /* @__PURE__ */ new Error() + }; + try { + const result = callback(info); + if (isObject(result) && "then" in result) return result.then(void 0, (reason) => handleError(info, reason, getOuterStack)); + else return result; + } catch (reason) { + handleError(info, reason, getOuterStack); + } +} +function buildOuterStack(offset = 0) { + const outerError = /* @__PURE__ */ new Error(); + return () => outerError.stack.split("\n").slice(3 + offset); +} +function isBailed(value) { + return value !== null && value !== false && value !== void 0; +} +var EventsService = class { + ctx; + _hooks = {}; + constructor(ctx) { + this.ctx = ctx; + defineProperty(this, symbols.tracker, { + property: "ctx", + noShadow: true + }); + this.on("internal/listener", function(name, listener, options) { + if (name === "internal/update" && !options.global) return (this.fiber._hooks["internal/update"] ??= new DisposableList())[options.prepend ? "unshift" : "push"](listener); + }); + this.on("internal/update", function(config, noSave, next) { + const cbs = [...this._hooks["internal/update"] || []]; + const _next = () => { + return (cbs.shift() ?? next).call(this, config, noSave, _next); + }; + return _next(); + }, { + global: true, + prepend: true + }); + } + /** + * Resolve listeners for one dispatch and apply context filtering. + * + * @param type — the dispatch mode, reported on `internal/dispatch`. + * @param args — the raw dispatch arguments; consumed up to the event name. + * @returns the matching listener callbacks, bound to the dispatch `this`. + */ + dispatch(type, args) { + const thisArg = typeof args[0] === "object" || typeof args[0] === "function" ? args.shift() : null; + const name = args.shift(); + if (!name.startsWith("internal/")) this.emit("internal/dispatch", type, name, args, thisArg); + const filter = thisArg?.[Context.filter]; + return (this._hooks[name] || []).filter((hook) => hook.global || !filter || filter.call(thisArg, hook.ctx)).map((hook) => hook.callback.bind(thisArg)); + } + /** + * Run listeners concurrently and wait for all of them. + * + * @param args — optional `this`, the event name, then listener arguments. + * @returns a promise resolving once every listener has settled. + */ + async parallel(...args) { + const errors = (await Promise.allSettled(this.dispatch("emit", args).map(async (cb) => cb(...args)))).filter((result) => result.status === "rejected"); + if (errors.length) throw new AggregateError(errors.map((error) => error.reason)); + } + /** + * Run listeners synchronously without waiting for returned promises. + * + * @param args — optional `this`, the event name, then listener arguments. + */ + emit(...args) { + this.dispatch("emit", args).map((cb) => cb(...args)); + } + /** + * Run listeners in order, awaiting each, until one returns a bail value. + * + * @param args — optional `this`, the event name, then listener arguments. + * @returns the first bail value (see {@link isBailed}), if any. + */ + async serial(...args) { + for (const cb of this.dispatch("serial", args)) { + const result = await cb(...args); + if (isBailed(result)) return result; + } + } + /** + * Run listeners synchronously until one returns a bail value. + * + * @param args — optional `this`, the event name, then listener arguments. + * @returns the first bail value (see {@link isBailed}), if any. + */ + bail(...args) { + for (const cb of this.dispatch("bail", args)) { + const result = cb(...args); + if (isBailed(result)) return result; + } + } + /** + * Compose listeners around the final `next` callback. + * + * The last dispatch argument is treated as the innermost `next`. Listeners + * run outermost-first; a listener that does not call `next()` vetoes the + * rest of the chain, including the built-in behavior. + * + * @param args — optional `this`, the event name, listener arguments, then `next`. + * @returns the outermost listener's return value. + */ + waterfall(...args) { + const cbs = this.dispatch("waterfall", args); + const inner = args.pop(); + const next = () => { + return (cbs.shift() ?? inner)(...args); + }; + args.push(next); + return next(); + } + /** + * Store a listener record as an effect on the current fiber. + * + * @param label — effect label shown in fiber diagnostics. + * @param hooks — the listener list for one event. + * @param callback — the listener to store. + * @param options — placement and filtering options. + * @returns a disposer that unregisters the listener. + */ + register(label, hooks, callback, options) { + const method = options.prepend ? "unshift" : "push"; + return this.ctx.fiber.effect(() => { + hooks[method]({ + ctx: this.ctx, + callback, + ...options + }); + return () => this.unregister(hooks, callback); + }, label); + } + /** + * Remove a stored listener record. + * + * @param hooks — the listener list for one event. + * @param callback — the listener to remove. + * @returns `true` if the listener was found and removed. + */ + unregister(hooks, callback) { + const index = hooks.findIndex((hook) => hook.callback === callback); + if (index >= 0) { + hooks.splice(index, 1); + return true; + } + } + /** + * Register an event listener owned by the current fiber. + * + * The listener is removed automatically when the fiber unloads. Throws + * `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed. + * + * @param name — the event name to listen for. + * @param listener — called with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ + on(name, listener, options) { + if (typeof options !== "object") options = { prepend: options }; + this.ctx.fiber.assertActive(); + listener = this.ctx.reflect.bind(listener); + const result = this.bail(this.ctx, "internal/listener", name, listener, options); + if (result) return result; + const hooks = this._hooks[name] ||= []; + const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`; + return this.register(label, hooks, listener, options); + } + /** + * Register an event listener that disposes itself after the first call. + * + * @param name — the event name to listen for. + * @param listener — called at most once with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ + once(name, listener, options) { + const dispose = this.on(name, function(...args) { + dispose(); + return listener.apply(this, args); + }, options); + return dispose; + } +}; +var defaultFormatters = { + s: (value) => String(value), + d: (value) => Math.trunc(Number(value)), + i: (value) => Math.trunc(Number(value)), + f: (value) => Number(value), + o: (value) => JSON.stringify(value), + O: (value) => JSON.stringify(value), + c: () => "", + C: (value, exporter, message) => { + return Logger.color(exporter, Logger.code(message.name, exporter.colors), value); + } +}; +function isAggregateError(error) { + return error instanceof Error && Array.isArray(error["errors"]); +} +var Logger = class { + service; + static color(exporter, code, value, decoration = "") { + if (!exporter.colors) return "" + value; + return `\x1B[3${code < 8 ? code : "8;5;" + code}${exporter.colors >= 2 ? decoration : ""}m${value}\x1B[0m`; + } + static code(name, level) { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash << 3) - hash + name.charCodeAt(i) + 13; + hash |= 0; + } + const colors = !level ? [] : level >= 2 ? c256 : c16; + return colors[Math.abs(hash) % colors.length]; + } + static format(exporter, message) { + const args = message.args.slice(); + if (args[0] instanceof Error) { + args[0] = args[0].stack || args[0].message; + args.unshift("%s"); + } else if (typeof args[0] !== "string") args.unshift("%o"); + let format = args.shift(); + format = format.replace(/%([a-zA-Z%])/g, (match, char) => { + if (match === "%%") return "%"; + const formatter = exporter.formatters?.[char] ?? defaultFormatters[char]; + if (typeof formatter === "function") return formatter(args.shift(), exporter, message); + return match; + }); + const oFormatter = exporter.formatters?.o ?? defaultFormatters.o; + for (let arg of args) { + if (typeof arg === "object" && arg) arg = oFormatter(arg, exporter, message); + format += " " + arg; + } + const { maxLength = 10240 } = exporter; + return format.split(/\r?\n/g).map((line) => { + return line.slice(0, maxLength) + (line.length > maxLength ? "..." : ""); + }).join("\n"); + } + constructor(options, service) { + this.service = service; + Object.assign(this, options); + this.error = this._method("error", 0); + this.info = this._method("info", 1); + this.warn = this._method("warn", 2); + this.debug = this._method("debug", 3); + } + _method(type, level) { + return (...args) => { + if (args.length === 1 && args[0] instanceof Error) { + if (args[0].cause) this[type](args[0].cause); + else if (isAggregateError(args[0])) { + args[0].errors.forEach((error) => this[type](error)); + return; + } + } + const sn = ++this.service._snMessage; + const ts = Date.now(); + for (const exporter of this.service.exporters.values()) { + if ((exporter.levels?.[this.name] ?? exporter.levels?.default ?? this.level ?? 1) < level) continue; + const message = { + sn, + ts, + type, + level, + name: this.name, + ...this.meta, + args + }; + exporter.export(message); + } + }; + } +}; +var c16 = [ + 6, + 2, + 3, + 4, + 5, + 1 +]; +var c256 = [ + 20, + 21, + 26, + 27, + 32, + 33, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 56, + 57, + 62, + 63, + 68, + 69, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 92, + 93, + 98, + 99, + 112, + 113, + 129, + 134, + 135, + 148, + 149, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 178, + 179, + 184, + 185, + 196, + 197, + 198, + 199, + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 207, + 208, + 209, + 214, + 215, + 220, + 221 +]; +var LoggerService = class LoggerService2 { + bufferSize = 1e3; + buffer = []; + ctx; + _snMessage = 0; + _snExporter = 0; + exporters = /* @__PURE__ */ new Map(); + constructor(ctx) { + const tracker = { + property: "ctx", + noShadow: true + }; + const self = createCallable("logger", joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker); + Object.assign(self, this); + self.ctx = ctx; + defineProperty(self, symbols.tracker, tracker); + self.exporter({ + colors: 3, + export: (message) => { + self.buffer.push(message); + if (self.buffer.length > self.bufferSize) self.buffer = self.buffer.slice(-self.bufferSize); + } + }); + return self; + } + /** + * Register an exporter and dispose it with the current fiber. + * + * @param exporter — the sink that receives structured log messages. + * @returns a disposer that removes the exporter. + */ + exporter(exporter) { + return this.ctx.effect(() => { + const id = ++this._snExporter; + this.exporters.set(id, exporter); + return () => this.exporters.delete(id); + }, "ctx.logger.exporter()"); + } + _resolveConfig() { + let intercept = this.ctx[symbols.intercept]; + const configs = []; + while ("logger" in intercept) { + if (Object.hasOwn(intercept, "logger")) configs.unshift(intercept["logger"]); + intercept = Object.getPrototypeOf(intercept); + } + return Object.assign({}, ...configs); + } + [symbols.invoke](name) { + const config = this._resolveConfig(); + const fiber = (this.ctx[symbols.shadow] ?? this.ctx).fiber; + name ??= config.name; + name ??= hyphenate(fiber.name); + return new Logger({ + name, + level: config.level, + meta: { fiber: new WeakRef(fiber) } + }, this); + } + static { + for (const type of [ + "error", + "info", + "warn", + "debug" + ]) LoggerService2.prototype[type] = function(...args) { + return this()[type](...args); + }; + } +}; +function enhanceError(error) { + const lines = error.stack.split("\n"); + lines.splice(0, 2, `Error: ${error.message}`); + error.stack = lines.join("\n"); + return error; +} +var RESERVED_WORDS = ["prototype", "then"]; +function isSpecialProperty(prop) { + return typeof prop === "symbol" || RESERVED_WORDS.includes(prop) || parseInt(prop).toString() === prop || prop.startsWith("_"); +} +var ReflectService = class { + ctx; + /** Proxy traps implementing service resolution for every context object. */ + static handler = { + get: (target, prop, ctx) => { + if (isSpecialProperty(prop)) return Reflect.get(target, prop, ctx); + if (Reflect.has(target, prop)) return getTraceable(ctx, Reflect.get(target, prop, ctx)); + const error = /* @__PURE__ */ new Error(`cannot get property "${prop}" without inject`); + try { + const def = target.reflect.props[prop]; + if (def?.type === "accessor") return def.get.call(ctx, ctx[symbols.receiver], error); + if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false); + return ctx.events.waterfall("internal/get", ctx, prop, error, () => { + const key = target[symbols.isolate][prop]; + let fiber = (ctx[symbols.shadow] ?? ctx).fiber; + while (true) { + const impl = fiber.store?.[prop]; + if (impl) return getTraceable(ctx, impl.value); + if (prop in fiber.inject) { + error.message = `cannot get required service "${prop}" in inactive context`; + throw error; + } + if (!fiber.runtime) throw error; + if (fiber.parent[symbols.isolate][prop] !== key) throw error; + fiber = fiber.parent.fiber; + } + }); + } catch (e) { + throw e === error ? enhanceError(e) : e; + } + }, + set: (target, prop, value, ctx) => { + if (isSpecialProperty(prop)) return Reflect.set(target, prop, value, ctx); + const error = /* @__PURE__ */ new Error(`cannot set property "${prop}" without provide`); + const def = target.reflect.props[prop]; + if (!def) { + if (!ctx.fiber.runtime) return Reflect.set(target, prop, value, ctx); + throw enhanceError(error); + } + try { + if (def.type === "accessor") { + if (!def.set) return false; + return def.set.call(ctx, value, ctx[symbols.receiver], error); + } + return ctx.events.waterfall("internal/set", ctx, prop, value, error, () => { + return ctx.reflect.set(prop, value, error); + }); + } catch (e) { + throw e === error ? enhanceError(e) : e; + } + }, + has: (target, prop) => { + if (isSpecialProperty(prop)) return Reflect.has(target, prop); + if (Reflect.has(target, prop)) return true; + return !!target.reflect.props[prop]; + } + }; + /** Service implementations, keyed by isolation label. */ + store = /* @__PURE__ */ Object.create(null); + /** Declared context properties (services and accessors), by name. */ + props = /* @__PURE__ */ Object.create(null); + constructor(ctx) { + this.ctx = ctx; + defineProperty(this, symbols.tracker, { + property: "ctx", + noShadow: true + }); + this.mixin("reflect", [ + "get", + "set", + "provide", + "accessor", + "mixin" + ]); + this.mixin("fiber", ["runtime", "effect"]); + this.mixin("registry", ["inject", "plugin"]); + this.mixin("events", [ + "on", + "once", + "parallel", + "emit", + "serial", + "bail", + "waterfall" + ]); + } + /** + * Read a service from the store without the inject requirement. + * + * @param name — the service name. + * @param strict — when `true`, only return implementations whose providing + * fiber is currently active. + * @returns the service value, or `undefined` when not (yet) provided. + */ + get(name, strict = true) { + return getTraceable(this.ctx, this._getImpl(name, strict)?.value); + } + _getImpl(name, strict = true) { + const key = this.ctx[symbols.isolate][name]; + const impl = key && this.store[key]; + if (!impl) return; + if (strict && impl.fiber.state !== 2) return; + return impl; + } + /** + * Overwrite a provided service's value. + * + * @param name — the service name. + * @param value — the new service value. + * @param error — carrier for the caller stack in diagnostics. + * @returns `true` on success. + * @throws when `name` was never provided, or was provided by another fiber. + */ + set(name, value, error) { + const key = this.ctx[symbols.isolate][name]; + const impl = this.store[key]; + if (!impl) throw new Error(`cannot set property "${name}" without provide`); + if (impl.fiber !== this.ctx.fiber) throw new Error(`cannot set property "${name}" in multiple fibers`); + impl.value = value; + return true; + } + /** + * Register a service implementation owned by the current fiber. + * + * See the `ctx.provide()` overload above for the full contract. + * + * @param name — the service name. + * @param value — the service value. + * @param check — optional availability predicate for dependents. + * @returns a disposer that unregisters the service. + */ + provide(name, value, check) { + return this.ctx.fiber.effect(() => { + if (!this.props[name]) this.props[name] ??= { type: "service" }; + else if (this.props[name].type !== "service") throw new Error(`property "${name}" is already declared as ${this.props[name].type}`); + this.props[name] = { type: "service" }; + this.ctx.root[symbols.isolate][name] ??= Symbol(name); + const key = this.ctx[symbols.isolate][name]; + const impl = { + name, + value, + fiber: this.ctx.fiber, + check + }; + if (this.store[key]) throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`); + this.store[key] = impl; + this.ctx.fiber.store[name] = impl; + if (this.ctx.fiber.state === 2) this.notify([name]); + return async () => { + delete this.store[key]; + const fibers = this.notify([name]); + await Promise.allSettled(fibers.map((fiber) => fiber.await())); + delete this.ctx.fiber.store[name]; + }; + }, `ctx.provide(${JSON.stringify(name)})`); + } + /** + * Re-evaluate every fiber that requires one of the given services. + * + * @param names — the service names that changed. + * @param filter — restricts notification to matching isolation scopes. + * @returns the fibers whose dependency state was refreshed. + */ + notify(names, filter = (ctx, name) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) { + const fibers = []; + for (const runtime of this.ctx.registry.values()) for (const fiber of runtime.fibers) { + let hasUpdate = false; + for (const name of names) { + if (!(name in fiber.inject)) continue; + if (!filter(fiber.ctx, name)) continue; + hasUpdate = true; + fiber._checkImpl(name); + } + if (!hasUpdate) continue; + fiber._refresh(); + fibers.push(fiber); + } + for (const name of names) { + const self = Object.create(this.ctx); + self[symbols.filter] = (target) => filter(target, name); + this.ctx.events.emit(self, "internal/service", name, this._getImpl(name, false)?.value); + } + return fibers; + } + /** + * Define a computed context property backed by get/set hooks. + * + * @param name — the context property name. + * @param options — the `get` hook and optional `set` hook. + * @returns a disposer that removes the accessor. + */ + accessor(name, options) { + return this.ctx.fiber.effect(() => { + if (name in this.props) throw new Error(`property "${name}" is already declared as ${this.props[name].type}`); + this.props[name] = { + type: "accessor", + ...options + }; + return () => delete this.props[name]; + }, `ctx.accessor(${JSON.stringify(name)})`); + } + /** + * Expose selected members of a service directly on `ctx`. + * + * See the `ctx.mixin()` overload above for the full contract. + * + * @param source — a context property name or a source object. + * @param mixins — keys to forward, or a source-key → ctx-key map. + * @returns a disposer that removes all created accessors. + */ + mixin(source, mixins) { + const self = this; + return this.ctx.fiber.effect(function* () { + const entries = Array.isArray(mixins) ? mixins.map((key) => [key, key]) : Object.entries(mixins); + const getTarget = (ctx, error) => { + return ctx[source]; + }; + for (const [key, value] of entries) yield self.accessor(value, { + get(receiver, error) { + const service = getTarget(this, error); + if (isNullable(service)) return service; + const mixin = receiver ? withProps(receiver, service) : service; + const value2 = Reflect.get(service, key, mixin); + if (typeof value2 !== "function") return value2; + return value2.bind(mixin ?? service); + }, + set(value2, receiver, error) { + const service = getTarget(this, error); + const mixin = receiver ? withProps(receiver, service) : service; + return Reflect.set(service, key, value2, mixin); + } + }); + }, `ctx.mixin(${JSON.stringify(source)})`); + } + /** + * Attach this context's tracing wrapper to a value. + * + * @param value — the value to wrap. + * @returns the traceable wrapper (or the value itself when not applicable). + */ + trace(value) { + return getTraceable(this.ctx, value); + } + /** + * Wrap a callback so calls trace `this` and arguments to this context. + * + * @param callback — the function to wrap. + * @returns a proxy delegating to `callback` with traced values. + */ + bind(callback) { + return new Proxy(callback, { + apply: (target, thisArg, args) => { + return Reflect.apply(target, this.trace(thisArg), args.map((arg) => this.trace(arg))); + }, + construct: (target, args, newTarget) => { + return Reflect.construct(target, args.map((arg) => this.trace(arg)), newTarget); + } + }); + } +}; +var kValidationError = /* @__PURE__ */ Symbol.for("ValidationError"); +var ValidationError = class extends TypeError { + name = "ValidationError"; + /** + * Build the aggregated message from schema issues. + * + * @param issues — the standard-schema issues, one message line each. + */ + constructor(issues) { + super(`invalid config: +` + issues.map((issue) => { + if (issue.path) return ` - ${issue.message} (at ${issue.path.join(".")})`; + else return ` - ${issue.message}`; + }).join("\n")); + } +}; +Object.defineProperty(ValidationError.prototype, kValidationError, { value: true }); +function resolveConfig(runtime, config) { + if (!runtime.Config) return config; + const result = runtime.Config["~standard"].validate(config); + if ("then" in result) throw new TypeError("Async config validation is not supported"); + if (result.issues) throw new ValidationError(result.issues); + else return result.value; +} +var effectInertia = /* @__PURE__ */ new WeakMap(); +function runDisposable(dispose) { + const result = dispose(); + return effectInertia.get(dispose)?.() ?? result; +} +function emitPluginDisposed(context, fiber) { + const args = ["internal/plugin", fiber]; + let callbacks; + try { + callbacks = context.events.dispatch("emit", args); + } catch (error) { + context.logger.error(error); + return; + } + for (const callback of callbacks) try { + const returned = callback(...args); + Promise.resolve(returned).catch((error) => context.logger.error(error)); + } catch (error) { + context.logger.error(error); + } +} +var CordisError = class CordisError2 extends Error { + code; + /** + * @param code — the stable error code; also the default message. + * @param message — optional human-readable override. + */ + constructor(code, message) { + super(message ?? CordisError2.Code[code]); + this.code = code; + } +}; +(function(CordisError3) { + CordisError3.Code = { INACTIVE_EFFECT: "cannot create effect on inactive context" }; +})(CordisError || (CordisError = {})); +var INACTIVE = "__INACTIVE__"; +var Fiber = class { + parent; + inject; + runtime; + /** Unique id within the registry; 0 for the root fiber, `null` once disposed. */ + uid; + /** The context this fiber's plugin runs in (extends the parent context). */ + ctx; + /** The validated plugin config (updated by `update()`). */ + config; + /** The raw plugin config, re-resolved before each activation. */ + _config; + /** Current lifecycle state; transitions emit `internal/status`. */ + state = 0; + /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ + dispose; + /** Snapshot of required service implementations while loaded; `undefined` otherwise. */ + store; + /** The in-flight load/unload transition, if one is currently running. */ + inertia; + _hooks = /* @__PURE__ */ Object.create(null); + _disposables = new DisposableList(); + context; + _error; + _runner; + _store = /* @__PURE__ */ Object.create(null); + /** + * Create a fiber. Plugin authors normally obtain fibers from `ctx.plugin()` + * rather than constructing them directly. + * + * @param parent — the context the plugin was loaded from. + * @param config — raw config, validated against the runtime's schema. + * @param inject — resolved dependency map (service name → intercept config). + * @param runtime — the shared plugin runtime, or `null` for the root fiber. + * @param getOuterStack — captures the caller stack for effect diagnostics. + */ + constructor(parent, config, inject, runtime, getOuterStack) { + this.parent = parent; + this.inject = inject; + this.runtime = runtime; + this._config = config; + const collect = (dispose) => { + this._disposables.push(dispose); + }; + if (runtime) { + this.uid = parent.registry.counter; + this.ctx = this.context = parent.extend({ fiber: this }); + const injectEntries = Object.entries(this.inject); + if (injectEntries.length) { + this.ctx[Context.intercept] = Object.create(parent[Context.intercept]); + for (const [name, config2] of injectEntries) { + if (isNullable(config2)) continue; + this.ctx[Context.intercept][name] = config2; + } + } + this._runner = { + epoch: INACTIVE, + getOuterStack, + execute: function() { + if (isConstructor(runtime.callback)) { + const instance = new runtime.callback(this.ctx, this.config); + for (const hook of instance?.[symbols.initHooks] ?? []) hook(); + return instance?.[symbols.init]?.(); + } else return runtime.callback(this.ctx, this.config); + }, + collect + }; + this.dispose = parent.fiber.effect(() => { + const remove = runtime.fibers.push(this); + return async () => { + this.uid = null; + emitPluginDisposed(this.context, this); + if (this.ctx.registry.has(runtime.callback)) { + remove(); + if (!runtime.fibers.length) this.ctx.registry.delete(runtime.callback); + } + this._setEpoch(INACTIVE); + if (!this.inertia) this._updateState(() => { + this.inertia = this._unload(); + return 5; + }); + while (this.inertia) await this.inertia; + }; + }, "ctx.plugin()"); + try { + this.context.emit("internal/plugin", this); + } catch (error) { + Promise.resolve(this.dispose()).catch((reason) => this.ctx.logger.error(reason)); + throw error; + } + if (this.uid !== null && parent.fiber.state !== 5) { + for (const name of Object.keys(this.inject)) this._checkImpl(name); + this._refresh(); + } + } else { + this.uid = 0; + this.ctx = this.context = parent; + this.state = 2; + this.store = /* @__PURE__ */ Object.create(null); + this._runner = { + epoch: "", + getOuterStack, + execute: () => { + }, + collect + }; + this.dispose = () => this.restart(); + } + } + /** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */ + get name() { + let fiber = this; + do { + if (fiber.runtime?.name) return fiber.runtime.name; + fiber = fiber.parent.fiber; + } while (fiber !== fiber.parent.fiber); + return "root"; + } + /** + * Throw if the fiber has already been disposed. + * + * @returns nothing when the fiber is still active. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared. + */ + assertActive() { + if (this.uid !== null) return; + throw new CordisError("INACTIVE_EFFECT"); + } + _execute(runner) { + const oldEpoch = runner.epoch; + return composeError((info) => { + const safeCollect = (dispose) => { + if (typeof dispose === "function") runner.collect(dispose); + else if (!isNullable(dispose)) throw new TypeError("Invalid effect"); + }; + const effect = runner.execute.call(this); + if (typeof effect === "function") return runner.collect(effect); + else if (isNullable(effect)) { + } else if (!isObject(effect)) throw new TypeError("Invalid effect"); + else if ("then" in effect) return effect.then(safeCollect); + else if (Symbol.iterator in effect) { + info.error = /* @__PURE__ */ new Error(); + const iter = effect[Symbol.iterator](); + while (true) { + const result = iter.next(); + safeCollect(result.value); + if (result.done) return; + } + } else if (Symbol.asyncIterator in effect) { + const iter = effect[Symbol.asyncIterator](); + return (async () => { + await Promise.resolve(); + info.error = /* @__PURE__ */ new Error(); + while (true) { + if (runner.epoch !== oldEpoch) return; + const result = await iter.next(); + safeCollect(result.value); + if (result.done) return; + } + })(); + } else throw new TypeError("Invalid effect"); + }, runner.getOuterStack); + } + effect(execute, label = "anonymous") { + this.assertActive(); + if (this.state === 5) throw new CordisError("INACTIVE_EFFECT"); + const disposables = []; + let disposing = false; + let disposalTask; + const dispose = () => { + if (disposing) return disposalTask; + disposing = true; + let task2; + for (const disposable of disposables.splice(0).reverse()) if (task2) task2 = task2.then(() => runDisposable(disposable)); + else { + const result = runDisposable(disposable); + if (isObject(result) && "then" in result) task2 = result; + } + return disposalTask = task2; + }; + const meta = { + label, + children: [] + }; + const runner = { + execute, + epoch: true, + collect: (dispose2) => { + disposables.push(dispose2); + this._disposables.delete(dispose2); + if (dispose2[symbols.effect]) meta.children.push(dispose2[symbols.effect]); + }, + getOuterStack: buildOuterStack() + }; + let task; + let executing = true; + let resolveSetup; + let rejectSetup; + let setupBarrier; + let setupFailed = false; + let inFlight; + let removeWrapper = () => false; + const waitForSetup = () => { + setupBarrier ??= new Promise((resolve, reject) => { + resolveSetup = resolve; + rejectSetup = reject; + }); + return setupBarrier; + }; + const disposeAfter = (setup) => { + return Promise.resolve(setup).then(() => dispose(), async (reason) => { + await dispose(); + throw reason; + }); + }; + const finalizeDisposal = (callback) => { + let result; + try { + result = callback(); + } catch (error) { + removeWrapper(); + throw error; + } + if (isObject(result) && "then" in result) { + const pending = Promise.resolve(result).finally(() => { + removeWrapper(); + if (inFlight === pending) inFlight = void 0; + }); + return inFlight = pending; + } + removeWrapper(); + return result; + }; + const wrapper = defineProperty(() => { + if (!runner.epoch) return setupFailed ? inFlight : void 0; + runner.epoch = false; + return finalizeDisposal(() => { + if (executing) return disposeAfter(waitForSetup()); + return task ? disposeAfter(task) : dispose(); + }); + }, symbols.effect, meta); + effectInertia.set(wrapper, () => inFlight); + removeWrapper = this._disposables.push(wrapper); + try { + task = this._execute(runner); + } catch (reason) { + executing = false; + setupFailed = true; + runner.epoch = false; + let cleanup; + try { + cleanup = finalizeDisposal(dispose); + } finally { + rejectSetup?.(reason); + } + if (isObject(cleanup) && "then" in cleanup) cleanup.catch((error) => this.ctx.logger.error(error)); + throw reason; + } + executing = false; + if (setupBarrier) Promise.resolve(task).then(resolveSetup, rejectSetup); + task?.catch(() => { + if (!runner.epoch) return dispose(); + return finalizeDisposal(dispose); + }).catch((error) => this.ctx.logger.error(error)); + const disposeAsync = () => { + if (!runner.epoch) return; + runner.epoch = false; + return finalizeDisposal(dispose); + }; + wrapper.then = async (onFulfilled, onRejected) => { + return Promise.resolve(task).then(() => disposeAsync).then(onFulfilled, onRejected); + }; + return wrapper; + } + /** + * Return metadata for currently registered effects. + * + * @returns one {@link EffectMeta} tree per labeled live effect. + */ + getEffects() { + return [...this._disposables].map((dispose) => dispose[symbols.effect]).filter(Boolean); + } + _getState() { + if (this.uid === null) return 4; + if (this._error) return 3; + if (this._runner.epoch !== INACTIVE) return 2; + return 0; + } + _updateState(callback) { + const oldState = this.state; + this.state = callback() ?? this._getState(); + if (oldState === this.state) return; + this.context.emit("internal/status", this, oldState); + if (oldState !== 2 && this.state !== 2) return; + for (const key of Reflect.ownKeys(this.ctx.reflect.store)) { + const impl = this.ctx.reflect.store[key]; + if (impl.fiber !== this) continue; + this.ctx.reflect.notify([impl.name]); + } + } + _checkImpl(name) { + const impl = this.ctx.reflect._getImpl(name, true); + if (!impl) return delete this._store[name]; + try { + if (impl.check && !impl.check.call(getTraceable(this.ctx, impl.value))) return delete this._store[name]; + } catch (error) { + impl.fiber.ctx.logger.error(error); + return delete this._store[name]; + } + this._store[name] = impl; + } + _refresh() { + let epoch = false; + epoch = ""; + for (const name of Object.keys(this.inject)) { + const impl = this._store[name]; + if (!impl) { + epoch = INACTIVE; + break; + } + epoch += ":" + impl.fiber.uid; + } + this._setEpoch(epoch); + } + _setEpoch(epoch) { + const oldEpoch = this._runner.epoch; + if (epoch === oldEpoch) return; + this._runner.epoch = epoch; + if (this.inertia) return; + this._updateState(() => { + if (epoch !== INACTIVE && oldEpoch === INACTIVE) { + this.inertia = this._reload(); + return 1; + } else { + this.inertia = this._unload(); + return 5; + } + }); + } + _resolveConfig(config) { + config = this.context.waterfall(this, "internal/config", config, () => config); + return this.runtime ? resolveConfig(this.runtime, config) : config; + } + async _reload() { + this.store = { ...this._store }; + const oldEpoch = this._runner.epoch; + try { + await Promise.resolve(); + if (this._runner.epoch === oldEpoch) { + this.config = this._resolveConfig(this._config); + await this._execute(this._runner); + this._error = void 0; + } + } catch (reason) { + this.ctx.logger.error(reason); + this._error = reason; + this._runner.epoch = INACTIVE; + } + this._updateState(() => { + if (this._runner.epoch === oldEpoch) this.inertia = void 0; + else { + this.inertia = this._unload(); + return 5; + } + }); + } + async _unload() { + await Promise.all(this._disposables.clear().map(async (dispose) => { + try { + await composeError(async (info) => { + await Promise.resolve(); + info.error = /* @__PURE__ */ new Error(); + await runDisposable(dispose); + }, this._runner.getOuterStack); + } catch (reason) { + this.ctx.logger.error(reason); + } + })); + this.store = void 0; + this._updateState(() => { + if (this._runner.epoch === INACTIVE) this.inertia = void 0; + else { + this.inertia = this._reload(); + return 1; + } + }); + } + /** + * Wait for current lifecycle work and rethrow startup errors. + * + * @returns this fiber, once it has settled into a stable state. + * @throws the config-validation or plugin-startup error, if any. + */ + async await() { + while (this.inertia) await this.inertia; + if (this._error) throw this._error; + return this; + } + /** + * Dispose and immediately reload this plugin with its current config. + * + * @returns a promise resolving once the reload settled. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. + */ + async restart() { + this.assertActive(); + this._setEpoch(INACTIVE); + this._refresh(); + await this.await(); + } + /** + * Validate and apply new config, then restart the plugin. + * + * Runs the `internal/update` waterfall first, so update hooks (and HMR) + * can veto or replace the restart. + * + * @param config — the new raw config; validated before anything restarts. + * @param noSave — hint for persistence hooks not to write the change back. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. + */ + update(config, noSave = false) { + this.assertActive(); + this._config = config; + if (this.state !== 2) { + this._error = void 0; + this._setEpoch(INACTIVE); + this._refresh(); + return; + } + config = this._resolveConfig(config); + this.context.waterfall(this, "internal/update", config, noSave, () => { + this.config = config; + this._error = void 0; + return this.restart(); + }); + } +}; +function isApplicable(object) { + return object && typeof object === "object" && typeof object.apply === "function"; +} +function Inject(name, config) { + return function(value, decorator) { + if (decorator.kind === "class") { + if (!Object.hasOwn(value, "inject")) { + defineProperty(value, "inject", Object.create(Object.getPrototypeOf(value).inject ?? null)); + defineProperty(value.inject, symbols.checkProto, true); + } + value.inject[name] = config; + } else if (decorator.kind === "method") { + const inject = (value[symbols.metadata] ??= {}).inject ??= /* @__PURE__ */ Object.create(null); + inject[name] = config; + decorator.addInitializer(function() { + const property = this[symbols.tracker]?.property; + (this[symbols.initHooks] ??= []).push(() => { + this.ctx.inject(inject, (ctx) => { + return value.call(property ? withProps(this, { [property]: ctx }) : this); + }); + }); + }); + } else throw new Error("@Inject() can only be used on class or class methods"); + }; +} +(function(Inject2) { + function resolve(inject, result = /* @__PURE__ */ Object.create(null)) { + if (!inject) return result; + if (Array.isArray(inject)) for (const name of inject) result[name] = null; + else if (Reflect.has(inject, symbols.checkProto)) { + Object.assign(result, resolve(Object.getPrototypeOf(inject))); + for (const name of Object.keys(inject)) result[name] = inject[name] ?? null; + } else for (const name of Object.keys(inject)) result[name] = inject[name] ?? null; + return result; + } + Inject2.resolve = resolve; +})(Inject || (Inject = {})); +var RegistryService = class { + ctx; + _counter = 0; + _internal = /* @__PURE__ */ new Map(); + constructor(ctx) { + this.ctx = ctx; + defineProperty(this, symbols.tracker, { + property: "ctx", + noShadow: true + }); + } + /** Allocate the next fiber uid (increments on every read). */ + get counter() { + return ++this._counter; + } + /** Number of registered plugin runtimes. */ + get size() { + return this._internal.size; + } + /** + * Resolve a supported plugin shape to its executable callback. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @returns the callback identifying the plugin, or `undefined` if invalid. + */ + resolve(plugin) { + try { + if (typeof plugin === "function") return plugin; + if (isApplicable(plugin)) return plugin.apply; + } catch { + } + } + /** + * Look up the runtime record for a plugin. + * + * @param plugin — any supported plugin shape. + * @returns the runtime, or `undefined` when the plugin is not registered. + */ + get(plugin) { + const key = this.resolve(plugin); + return key && this._internal.get(key); + } + /** + * Check whether a plugin has a registered runtime. + * + * @param plugin — any supported plugin shape. + * @returns `true` when at least one fiber of the plugin exists. + */ + has(plugin) { + const key = this.resolve(plugin); + return !!key && this._internal.has(key); + } + /** + * Dispose every running fiber for a plugin and remove its runtime record. + * + * @param plugin — any supported plugin shape. + * @returns the removed runtime, or `undefined` when none was registered. + */ + delete(plugin) { + const key = this.resolve(plugin); + const runtime = key && this._internal.get(key); + if (!runtime) return; + this._internal.delete(key); + for (const fiber of runtime.fibers) fiber.dispose(); + return runtime; + } + /** Iterate the registered plugin callbacks. */ + keys() { + return this._internal.keys(); + } + /** Iterate the registered plugin runtimes. */ + values() { + return this._internal.values(); + } + /** Iterate `[callback, runtime]` pairs. */ + entries() { + return this._internal.entries(); + } + /** + * Visit every registered runtime. + * + * @param callback — receives each runtime and its identifying callback. + */ + forEach(callback) { + return this._internal.forEach(callback); + } + /** + * Start a callback once the requested dependencies are available. + * + * @param inject — required services, as an array or a name → config map. + * @param callback — plugin body called with `(ctx, config)`. + * @returns the fiber; awaiting it settles once loading finished. + */ + inject(inject, callback) { + return this.plugin({ + inject, + apply: callback, + name: callback.name + }); + } + /** + * Start a plugin in the current context and return its fiber. + * + * Creates (or reuses) the plugin's runtime record, then starts a new fiber + * under the current context. Throws if `plugin` is not a supported shape or + * if the current fiber is already disposed. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @param config — the plugin config, validated against its `Config` schema. + * @param getOuterStack — captures the caller stack for effect diagnostics. + * @returns the fiber; awaiting it settles once loading finished. + */ + plugin(plugin, config, getOuterStack = buildOuterStack()) { + const callback = this.resolve(plugin); + if (!callback) throw new Error('invalid plugin, expect function or object with an "apply" method, received ' + typeof plugin); + this.ctx.fiber.assertActive(); + let runtime = this._internal.get(callback); + if (!runtime) { + let name = plugin.name; + if (name === "apply") name = void 0; + runtime = { + name, + callback, + fibers: new DisposableList(), + Config: plugin.Config + }; + this._internal.set(callback, runtime); + } + const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack); + const wrapped = Object.create(fiber); + wrapped.then = (onFulfilled, onRejected) => { + return fiber.await().then(onFulfilled, onRejected); + }; + return wrapped; + } +}; +var Context = class Context2 { + /** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */ + static effect = symbols.effect; + /** Symbol key for a context's listener filter, consulted on every event dispatch. */ + static filter = symbols.filter; + /** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */ + static isolate = symbols.isolate; + /** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */ + static intercept = symbols.intercept; + /** + * Returns true for Cordis context proxies and context prototypes. + * + * Works across realms and across multiple copies of cordis, because the + * brand is keyed by a global symbol rather than by `instanceof`. + * + * @param value — the value to test. + * @returns `true` if `value` is a Cordis context, narrowing its type. + */ + static is(value) { + return !!value?.[Context2.is]; + } + static { + Context2.is[Symbol.toPrimitive] = () => /* @__PURE__ */ Symbol.for("cordis.is"); + Context2.prototype[Context2.is] = true; + } + /** Create the root context and install the built-in services. */ + constructor() { + this[symbols.isolate] = /* @__PURE__ */ Object.create(null); + this[symbols.intercept] = /* @__PURE__ */ Object.create(null); + const self = new Proxy(this, ReflectService.handler); + this.root = self; + this.baseUrl = void 0; + this.fiber = new Fiber(self, {}, /* @__PURE__ */ Object.create(null), null, () => []); + this.reflect = new ReflectService(self); + this.registry = new RegistryService(self); + this.events = new EventsService(self); + this.logger = new LoggerService(self); + this.fiber._disposables.clear(); + return self; + } + [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() { + return `Context <${this.fiber.name}>`; + } + /** + * Create a child context with extra metadata on top of the current scope. + * + * The child prototypally inherits every property of this context; own + * properties of `meta` shadow the inherited ones. The parent is not mutated. + * + * @param meta — own properties (including symbol keys) to define on the child. + * @returns a child context inheriting from this one. + */ + extend(meta = {}) { + const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value; + const self = Object.create(getTraceable(this, this)); + for (const prop of Reflect.ownKeys(meta)) Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)); + if (!shadow) return self; + return Object.assign(Object.create(self), { [symbols.shadow]: shadow }); + } + /** + * Create a child context with an independent service scope for `name`. + * + * Below the returned context, reads and writes of the service `name` + * resolve against the new label instead of the parent's, so a different + * implementation can be provided without affecting the parent scope. + * Passing the same `label` to two `isolate()` calls joins their scopes. + * + * @param name — the service name to isolate. + * @param label — scope label to join; defaults to a fresh unique symbol. + * @returns a child context whose `name` service resolves in the new scope. + */ + isolate(name, label) { + const shadow = Object.create(this[symbols.isolate]); + shadow[name] = label ?? Symbol(name); + return this.extend({ [symbols.isolate]: shadow }); + } + intercept(name, config) { + const intercept = Object.create(this[symbols.intercept]); + intercept[name] = config; + return this.extend({ [symbols.intercept]: intercept }); + } +}; +var Service = class Service2 { + ctx; + /** Symbol key of an instance method run after construction (class plugins). */ + static init = symbols.init; + /** Symbol key of the availability predicate passed to `ctx.provide()`. */ + static check = symbols.check; + /** Symbol key of the phantom intercept-config type parameter. */ + static config = symbols.config; + /** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */ + static invoke = symbols.invoke; + /** Symbol key of the helper deriving an extended service instance. */ + static extend = symbols.extend; + /** Symbol key of the tracker metadata used for context tracing. */ + static tracker = symbols.tracker; + /** Symbol key of the intercept-config resolution helper below. */ + static resolveConfig = symbols.resolveConfig; + /** The service name this instance is registered under. */ + name; + /** + * Register this instance as `name` in the current context. + * + * Calls `ctx.reflect.provide(name, this, this[Service.check])`, so the + * service is unregistered automatically when the owning fiber unloads. + * Services with a `[Service.invoke]` body return a callable instance. + * + * @param ctx — the context to register in (stored as `this.ctx`). + * @param name — the service name; defaults to the static `provide` field. + */ + constructor(ctx, name) { + this.ctx = ctx; + name ??= this.constructor["provide"]; + let self = this; + const tracker = { + associate: name, + property: "ctx" + }; + if (self[symbols.invoke]) self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker); + self.ctx = ctx; + self.name = name; + defineProperty(self, symbols.tracker, tracker); + self.ctx.reflect.provide(name, self, this[symbols.check]); + return self; + } + [symbols.filter](ctx) { + return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]; + } + [symbols.extend](props) { + let self; + if (this[Service2.invoke]) self = createCallable(this.name, this, this[symbols.tracker]); + else self = Object.create(this); + return Object.assign(self, props); + } + /** + * Merge intercept config from ancestors with optional base and head values. + * + * Entries added closer to the root apply first; `base` is prepended and + * `head` appended. Uses `Config.merge` when the service declares one, + * otherwise a shallow `Object.assign`. + * + * @param base — lowest-precedence config merged before all intercepts. + * @param head — highest-precedence config merged after all intercepts. + * @returns the merged config. + */ + [symbols.resolveConfig](base, head) { + let intercept = this.ctx[Context.intercept]; + const configs = []; + while (this.name in intercept) { + if (Object.hasOwn(intercept, this.name)) configs.unshift(intercept[this.name]); + intercept = Object.getPrototypeOf(intercept); + } + if (base) configs.unshift(base); + if (head) configs.push(head); + if (this["Config"]?.merge) return this["Config"].merge(...configs); + else return Object.assign({}, ...configs); + } + static [Symbol.hasInstance](instance) { + if (!instance) return false; + let constructor = instance.constructor; + while (constructor) { + constructor = constructor.prototype?.constructor; + if (constructor === this) return true; + constructor &&= Object.getPrototypeOf(constructor); + } + return false; + } +}; + +// src/shims/owned.ts +function describeError(error) { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} +var OwnedRegistrations = class { + /** Admitted entries by core handle. */ + byHandle = /* @__PURE__ */ new Map(); + rpc; + kind; + /** The owner's own index of this kind, for the leak report at deactivation. */ + ownedBy; + warn; + constructor(rpc, kind, ownedBy, warn) { + this.rpc = rpc; + this.kind = kind; + this.ownedBy = ownedBy; + this.warn = warn; + } + /** Called inside the owner's effect; returns the effect's cleanup. */ + add(entry, spec) { + const owner = entry.owner; + if (owner.state !== "activating" && owner.state !== "active") throw new Error("registration owner was disposed"); + const registration = this.rpc.request("registry/register", { owner: owner.ref, ...owner.scope ? { scope: owner.scope } : {}, kind: this.kind, spec }).then( + (result) => { + if (typeof result?.handle === "number") { + entry.handle = result.handle; + if (entry.disposed || owner.state !== "activating" && owner.state !== "active") { + return this.unregister(entry); + } + this.ownedBy(owner).set(result.handle, entry); + this.byHandle.set(result.handle, entry); + } else { + const reason = typeof result?.refused === "string" ? result.refused : "refused without a reason"; + owner.refusals.push(`${this.kind} \`${entry.name}\` refused: ${reason}`); + if (owner.state === "active") this.warn(`${this.kind} \`${entry.name}\` refused: ${reason}`, owner); + } + }, + (error) => { + owner.refusals.push(`${this.kind} \`${entry.name}\` registration failed: ${describeError(error)}`); + } + ).finally(() => owner.pendingRegistrations.delete(registration)); + owner.pendingRegistrations.add(registration); + return () => { + if (entry.disposed) return; + entry.disposed = true; + if (entry.handle !== void 0) void this.unregister(entry); + }; + } + /** Undo exactly this entry. The core revokes first, so a failure here is expected after revocation. */ + unregister(entry) { + const handle = entry.handle; + const owner = entry.owner; + this.ownedBy(owner).delete(handle); + this.byHandle.delete(handle); + const cleanup = this.rpc.request("registry/unregister", { owner: owner.ref, handle }).then(() => void 0, () => void 0).finally(() => owner.pendingRegistrations.delete(cleanup)); + owner.pendingRegistrations.add(cleanup); + return cleanup; + } + /** Drop the host's index of everything `owner` registered (after its teardown). */ + forget(owner) { + for (const entry of this.ownedBy(owner).values()) this.byHandle.delete(entry.handle); + } +}; + +// src/shims/avatars.ts +var MAX_AVATAR_PACKS_PER_OWNER = 4; +var MAX_AVATAR_PACKS_PER_HOST = 16; +function normalizeAvatarPack(value) { + if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).some((key) => key !== "path")) throw new TypeError("avatar pack supports only path"); + const { path } = value; + if (typeof path !== "string" || !path || Buffer.byteLength(path, "utf8") > 256 || !path.endsWith(".json") || /[\\:\u0000-\u001f\u007f-\u009f]/u.test(path) || path.split("/").some((part) => !part || part === "." || part === "..")) { + throw new TypeError("avatar pack path must be a bounded bundle-relative path with normal slash-separated components"); + } + return Object.freeze({ path }); +} +var AvatarPacks = class { + registrations; + owners = /* @__PURE__ */ new Map(); + count = 0; + constructor(rpc, ownedBy, warn) { + this.registrations = new OwnedRegistrations(rpc, "avatar_pack", ownedBy, warn); + } + register(owner, definition) { + if (owner.state !== "activating" && owner.state !== "active") throw new Error("avatar owner is not live"); + const { path } = normalizeAvatarPack(definition); + const roots = this.owners.get(owner) ?? /* @__PURE__ */ new Map(); + if (roots.has(path)) throw new Error("avatar pack is already registered; dispose it before registering it again"); + if (roots.size >= MAX_AVATAR_PACKS_PER_OWNER || this.count >= MAX_AVATAR_PACKS_PER_HOST) throw new RangeError("avatar pack owner or host registration limit reached"); + const undo = this.registrations.add({ owner, name: path, disposed: false }, { name: path, description: "" }); + const dispose = () => { + if (roots.get(path) !== dispose) return; + roots.delete(path); + this.count--; + if (!roots.size) this.owners.delete(owner); + undo(); + }; + roots.set(path, dispose); + this.owners.set(owner, roots); + this.count++; + return dispose; + } + forget(owner) { + for (const dispose of [...this.owners.get(owner)?.values() ?? []]) dispose(); + this.registrations.forget(owner); + } +}; +function defineAvatarsService(host) { + class AvatarsShim extends Service { + constructor(ctx) { + super(ctx, "avatars"); + } + registerPack(definition) { + const ctx = this.ctx; + const owner = host.ownerOf(ctx); + if (!owner) throw new Error("avatars.registerPack called outside an extension owner"); + const root = normalizeAvatarPack(definition); + return ctx.effect(() => host.avatarPacks.register(owner, root), `avatars.registerPack(${JSON.stringify(root.path)})`); + } + } + Object.freeze(AvatarsShim.prototype); + return AvatarsShim; +} +export { + AvatarPacks, + MAX_AVATAR_PACKS_PER_HOST, + MAX_AVATAR_PACKS_PER_OWNER, + defineAvatarsService, + normalizeAvatarPack +}; diff --git a/crates/tui/extension-host/dist/builtin-modules.json b/crates/tui/extension-host/dist/builtin-modules.json index 70943c7601..5edce80079 100644 --- a/crates/tui/extension-host/dist/builtin-modules.json +++ b/crates/tui/extension-host/dist/builtin-modules.json @@ -1,6 +1,6 @@ { "modules": { "harness": "114addde4e6e70ade28a38fe2c1fa0b521729ab9aaa58273c77f33b2a1b608ae", - "mcp": "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55" + "mcp": "5bc04b62832310724fdfed3999362b1667bf9eddf4936e1535b5767019596839" } } diff --git a/crates/tui/extension-host/dist/builtin/mcp.mjs b/crates/tui/extension-host/dist/builtin/mcp.mjs index 726ce1f1f3..f928f7818b 100644 --- a/crates/tui/extension-host/dist/builtin/mcp.mjs +++ b/crates/tui/extension-host/dist/builtin/mcp.mjs @@ -6864,7 +6864,7 @@ function date3(params) { return _coercedDate(ZodDate, params); } -// node_modules/@modelcontextprotocol/core/dist/auth-CUe6YdwF.mjs +// node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs var LATEST_PROTOCOL_VERSION = "2025-11-25"; var SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, @@ -7701,7 +7701,8 @@ var OAuthMetadataSchema = looseObject({ introspection_endpoint_auth_signing_alg_values_supported: array(string2()).optional(), code_challenge_methods_supported: array(string2()).optional(), client_id_metadata_document_supported: boolean2().optional(), - authorization_response_iss_parameter_supported: boolean2().optional().catch(void 0) + authorization_response_iss_parameter_supported: boolean2().optional().catch(void 0), + dpop_signing_alg_values_supported: array(string2()).optional() }); var OpenIdProviderMetadataSchema = looseObject({ issuer: string2(), @@ -7752,7 +7753,8 @@ var OAuthTokensSchema = object({ token_type: string2(), expires_in: coerce_exports.number().optional(), scope: string2().optional(), - refresh_token: string2().optional() + refresh_token: string2().optional(), + issuer: string2().optional().catch(void 0) }).strip(); var IdJagTokenExchangeResponseSchema = object({ issued_token_type: literal("urn:ietf:params:oauth:token-type:id-jag"), @@ -7790,7 +7792,8 @@ var OAuthClientInformationSchema = object({ client_id: string2(), client_secret: string2().optional(), client_id_issued_at: number2().optional(), - client_secret_expires_at: number2().optional() + client_secret_expires_at: number2().optional(), + issuer: string2().optional().catch(void 0) }).strip(); var OAuthClientInformationFullSchema = OAuthClientMetadataSchema.merge(OAuthClientInformationSchema); var OAuthClientRegistrationErrorSchema = object({ @@ -7802,7 +7805,7 @@ var OAuthTokenRevocationRequestSchema = object({ token_type_hint: string2().optional() }).strip(); -// node_modules/@modelcontextprotocol/client/dist/src-D_zzAWoS.mjs +// node_modules/@modelcontextprotocol/client/dist/src-xKF0PJN-.mjs var BRANDS = /* @__PURE__ */ Symbol.for("mcp.sdk.errorBrands"); function stampErrorBrands(instance, ctor) { const brands = /* @__PURE__ */ new Set(); @@ -7848,6 +7851,8 @@ var OAuthErrorCode = /* @__PURE__ */ (function(OAuthErrorCode$1) { OAuthErrorCode$1["InvalidRedirectUri"] = "invalid_redirect_uri"; OAuthErrorCode$1["InsufficientScope"] = "insufficient_scope"; OAuthErrorCode$1["InvalidTarget"] = "invalid_target"; + OAuthErrorCode$1["InvalidDpopProof"] = "invalid_dpop_proof"; + OAuthErrorCode$1["UseDpopNonce"] = "use_dpop_nonce"; return OAuthErrorCode$1; })({}); var OAuthError = class OAuthError2 extends Error { @@ -7937,8 +7942,18 @@ var SdkError = class extends Error { if (typeof this !== "function") throw new TypeError("isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`"); return brandedHasInstance(this, value); } - constructor(code, message, data) { - super(message); + /** + * @param code - Stable string code identifying the failure ({@linkcode SdkErrorCode}). + * @param message - Human-readable description. + * @param data - Optional structured payload (for example the HTTP status carried by + * {@linkcode SdkHttpError}). Opaque to the SDK: a `cause` key inside `data` is not + * promoted to `Error.cause`. + * @param options - Standard `ErrorOptions`, forwarded to `Error`. Pass the underlying + * failure as `{ cause }` so it is reachable through the `Error.cause` chain that + * loggers and error trackers walk. + */ + constructor(code, message, data, options) { + super(message, options); this.code = code; this.data = data; this.name = "SdkError"; @@ -7949,8 +7964,11 @@ var SdkHttpError = class extends SdkError { static { Object.defineProperty(this, "mcpBrand", { value: "mcp.SdkHttpError" }); } - constructor(code, message, data) { - super(code, message, data); + /** + * @param options - Standard `ErrorOptions`, forwarded to `Error` (see {@linkcode SdkError}). + */ + constructor(code, message, data, options) { + super(code, message, data, options); this.name = "SdkHttpError"; } get status() { @@ -7965,6 +7983,12 @@ function resourceUrlFromServerUrl(url2) { resourceURL.hash = ""; return resourceURL; } +function withoutIssuer(body) { + if (typeof body !== "object" || body === null || Array.isArray(body)) return body; + const copy = { ...body }; + delete copy.issuer; + return copy; +} function checkResourceAllowed({ requestedResource, configuredResource }) { const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href); const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href); @@ -9074,7 +9098,7 @@ function getNotificationSchema(method) { } var rev2025RequestMethods = Object.keys(requestMethodKeys$1); var rev2025NotificationMethods = Object.keys(notificationMethodKeys$1); -function isPlainObject$4(value) { +function isPlainObject$5(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function triState$1(schema, raw) { @@ -9097,7 +9121,7 @@ var NOT_IN_ERA$1 = { reason: "not-in-era" }; function toolNeedsLegacyWrap(t) { - return isPlainObject$4(t) && isPlainObject$4(t["outputSchema"]) && isNonObjectJsonSchemaRoot(t["outputSchema"]); + return isPlainObject$5(t) && isPlainObject$5(t["outputSchema"]) && isNonObjectJsonSchemaRoot(t["outputSchema"]); } function toNeutralResult(value) { return value; @@ -9131,7 +9155,7 @@ var rev2025Codec = { }; }, decodeResult(_method, raw) { - if (isPlainObject$4(raw) && "resultType" in raw) { + if (isPlainObject$5(raw) && "resultType" in raw) { const stripped = { ...raw }; delete stripped["resultType"]; return { @@ -10210,7 +10234,7 @@ function fillCacheFields(method, result) { delete filled[RESULT_CACHE_HINT_FALLBACK]; return filled; } -function isPlainObject$3(value) { +function isPlainObject$4(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function stampServerInfoMeta(result, serverInfo) { @@ -10220,7 +10244,7 @@ function stampServerInfoMeta(result, serverInfo) { ...result, _meta: { [SERVER_INFO_META_KEY]: serverInfo } }; - if (!isPlainObject$3(meta2)) return result; + if (!isPlainObject$4(meta2)) return result; if (meta2[SERVER_INFO_META_KEY] !== void 0) return result; return { ...result, @@ -10324,7 +10348,7 @@ function getNotificationSchema2026(method) { } var rev2026RequestMethods = Object.keys(requestMethodKeys); var rev2026NotificationMethods = Object.keys(notificationMethodKeys); -function isPlainObject$2(value) { +function isPlainObject$3(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function triState(schema, raw) { @@ -10358,14 +10382,14 @@ function enforceDeletedFields(method, result) { return next; }; const tools = result.tools; - if (method === "tools/list" && Array.isArray(tools) && tools.some((tool) => isPlainObject$2(tool) && "execution" in tool)) copy().tools = tools.map((tool) => { - if (!isPlainObject$2(tool) || !("execution" in tool)) return tool; + if (method === "tools/list" && Array.isArray(tools) && tools.some((tool) => isPlainObject$3(tool) && "execution" in tool)) copy().tools = tools.map((tool) => { + if (!isPlainObject$3(tool) || !("execution" in tool)) return tool; const rest = { ...tool }; delete rest["execution"]; return rest; }); const capabilities = result.capabilities; - if (isPlainObject$2(capabilities) && "tasks" in capabilities) { + if (isPlainObject$3(capabilities) && "tasks" in capabilities) { const rest = { ...capabilities }; delete rest["tasks"]; copy().capabilities = rest; @@ -10412,7 +10436,7 @@ var rev2026Codec = { projectCallToolResult: (result) => appendTextFallbackForNonObject(result), inputRequestSchema: getInputRequestSchema2026, decodeResult(method, raw) { - if (!isPlainObject$2(raw)) return { + if (!isPlainObject$3(raw)) return { kind: "invalid", error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: not an object`, { method }) }; @@ -10433,8 +10457,10 @@ var rev2026Codec = { }; if (rawResultType === "input_required") { const rawInputRequests = raw["inputRequests"]; - const inputRequests = isPlainObject$2(rawInputRequests) ? rawInputRequests : {}; + const inputRequests = isPlainObject$3(rawInputRequests) ? rawInputRequests : {}; const requestState = raw["requestState"]; + const metaParse = raw["_meta"] === void 0 ? void 0 : buildSchemas2026().ResultMetaSchema.safeParse(raw["_meta"]); + const meta2 = metaParse?.success ? metaParse.data : void 0; if (Object.keys(inputRequests).length === 0 && typeof requestState !== "string") return { kind: "invalid", error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: input_required carries neither inputRequests nor requestState (every input_required result must include at least one of the two)`, { @@ -10445,7 +10471,8 @@ var rev2026Codec = { return { kind: "input_required", inputRequests, - ...typeof requestState === "string" && { requestState } + ...typeof requestState === "string" && { requestState }, + ...meta2 !== void 0 && { _meta: meta2 } }; } if (rawResultType !== "complete") return { @@ -10886,7 +10913,7 @@ var INBOUND_VALIDATION_LADDER = [ evaluatedAt: "pre-dispatch", codes: [HEADER_MISMATCH_ERROR_CODE], conformance: ["http-header-validation"], - rationale: "SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check — are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge `era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted." + rationale: "SEP-2243 standard `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check — are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before dispatch. The spec requires `MCP-Protocol-Version` and `Mcp-Method` on every modern *request* POST (`Mcp-Name` only for the methods that mirror `params.name` / `params.uri` / `params.taskId`, see `MCP_NAME_HEADER_SOURCE`) and names them in that order, so a request missing several is answered by the earliest. Notification POSTs are exempt: the presence half runs on requests only, so a modern-enveloped notification is dispatched even with no standard headers at all. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge `era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half — including the missing `MCP-Protocol-Version` cell, which cannot live on the edge rung without breaking body-primary classification. Evaluated before the capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted." }, { rung: "client-capabilities", @@ -10913,6 +10940,26 @@ var LADDER_ERROR_HTTP_STATUS = { [ProtocolErrorCode.MissingRequiredClientCapability]: 400, [HEADER_MISMATCH_ERROR_CODE]: 400 }; +var MCP_NAME_HEADER_SOURCE = { + "tools/call": "name", + "prompts/get": "name", + "resources/read": "uri", + "tasks/get": "taskId", + "tasks/update": "taskId", + "tasks/cancel": "taskId" +}; +function mcpNameSource(method, params) { + const field = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : void 0; + if (field === void 0) return; + const raw = isPlainObject$2(params) ? params[field] : void 0; + return { + field, + value: typeof raw === "string" ? raw : void 0 + }; +} +function isPlainObject$2(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} function parseSchema(schema, data) { return safeParse(schema, data); } @@ -11669,7 +11716,7 @@ var Protocol = class { return this._requestHandlers.get(method); } async _oncancel(notification) { - if (!notification.params.requestId) return; + if (notification.params.requestId === void 0) return; this._requestHandlerAbortControllers.get(notification.params.requestId)?.abort(notification.params.reason); } _setupTimeout(messageId, timeout, maxTotalTimeout, onTimeout, resetTimeoutOnProgress = false) { @@ -12068,19 +12115,20 @@ var Protocol = class { const cancel = (reason) => { if (responseReceived) return; this._progressHandlers.delete(messageId); - if (requestAbort === void 0) this._transport?.send(this._envelopeOutbound({ - jsonrpc: "2.0", - method: "notifications/cancelled", - params: { - requestId: messageId, - reason: String(reason) - } - }), { - relatedRequestId, - resumptionToken, - onresumptiontoken - }).catch((error2) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error2}`))); - else requestAbort.abort(); + if (requestAbort === void 0) { + if (request.method !== "initialize") this._transport?.send(this._envelopeOutbound({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { + requestId: messageId, + reason: String(reason) + } + }), { + relatedRequestId, + resumptionToken, + onresumptiontoken + }).catch((error2) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error2}`))); + } else requestAbort.abort(); reject(reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason))); }; this._responseHandlers.set(messageId, (response) => { @@ -12142,7 +12190,7 @@ var Protocol = class { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification, options) { - return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); + return await this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); } /** * The notification funnel proper, keyed by the resolved era codec — @@ -12160,7 +12208,7 @@ var Protocol = class { jsonrpc: "2.0", ...notification }); - if ((this._options?.debouncedNotificationMethods ?? []).includes(notification.method) && !notification.params && !options?.relatedRequestId) { + if ((this._options?.debouncedNotificationMethods ?? []).includes(notification.method) && !notification.params && options?.relatedRequestId === void 0) { if (this._pendingDebouncedNotifications.has(notification.method)) return; this._pendingDebouncedNotifications.add(notification.method); Promise.resolve().then(() => { @@ -12372,7 +12420,8 @@ function manualInputRequiredValue(decoded) { return { resultType: "input_required", inputRequests: decoded.inputRequests, - ...decoded.requestState !== void 0 && { requestState: decoded.requestState } + ...decoded.requestState !== void 0 && { requestState: decoded.requestState }, + ...decoded._meta !== void 0 && { _meta: decoded._meta } }; } var require_content_type = /* @__PURE__ */ __commonJSMin(((exports) => { @@ -20074,7 +20123,7 @@ var InsecureTokenEndpointError = class extends OAuthClientFlowError { /** The token endpoint URL that was rejected. */ tokenEndpoint; constructor(tokenEndpoint) { - super(`Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).`); + super(`Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. OAuth token requests MUST use TLS (localhost / *.localhost / 127.0.0.1 / ::1 are exempt).`); this.tokenEndpoint = tokenEndpoint; } }; @@ -20083,7 +20132,7 @@ var AuthorizationServerMismatchError = class extends OAuthClientFlowError { Object.defineProperty(this, "mcpBrand", { value: "mcp.AuthorizationServerMismatchError" }); } constructor(recordedIssuer, currentIssuer) { - super(`Authorization server changed between redirect and callback (redirected to ${JSON.stringify(recordedIssuer)}, callback resolved ${JSON.stringify(currentIssuer)}); refusing to send authorization_code/code_verifier to a different token endpoint`); + super(`Authorization server mismatch: credentials are bound to ${JSON.stringify(recordedIssuer)} but this call resolved ${JSON.stringify(currentIssuer)}; refusing to present them to a different authorization server`); this.recordedIssuer = recordedIssuer; this.currentIssuer = currentIssuer; } @@ -20106,10 +20155,13 @@ var InsufficientScopeError = class extends OAuthClientFlowError { } }; function discardIfIssuerMismatch(stored, issuer, opts) { - if (stored === void 0) return void 0; - if (stored.issuer === void 0) { + if (!stored) return void 0; + if (typeof stored.issuer !== "string") { if (opts?.canPersistStamp !== false) console.warn("[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or provider not round-tripping the value). SEP-2352 isolation is inactive for this read; ensure your provider round-trips the issuer field."); - return stored; + return stored.issuer === void 0 ? stored : { + ...stored, + issuer: void 0 + }; } return issuersMatch(stored.issuer, issuer) ? stored : void 0; } @@ -20264,7 +20316,7 @@ function applyPublicAuth(clientId, params) { params.set("client_id", clientId); } function isLoopbackHost(hostname) { - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; + return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; } function assertSecureTokenEndpoint(tokenEndpoint) { const url2 = new URL(String(tokenEndpoint)); @@ -20299,20 +20351,26 @@ async function parseErrorResponse(input) { const result = OAuthErrorResponseSchema.parse(JSON.parse(body)); return OAuthError.fromResponse(result); } catch (error2) { - const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ""}Invalid OAuth error response: ${error2}. Raw body: ${body}`; + const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ""}OAuth error response details omitted`; return new OAuthError(OAuthErrorCode.ServerError, errorMessage); } } +function warnCredentialInvalidation(provider, error2, invalidated) { + const action = provider.invalidateCredentials === void 0 ? `retrying authorization without discarding the stored ${invalidated} (provider implements no invalidateCredentials())` : `invalidating the stored ${invalidated} and retrying authorization`; + console.warn(`[mcp-sdk] OAuth authorization failed — ${action}; details omitted.`); +} async function auth(provider, options) { try { return await authInternal(provider, options); } catch (error2) { if (error2 instanceof OAuthError) { if (error2.code === OAuthErrorCode.InvalidClient || error2.code === OAuthErrorCode.UnauthorizedClient) { + warnCredentialInvalidation(provider, error2, "client credentials and tokens"); await provider.invalidateCredentials?.("client"); await provider.invalidateCredentials?.("tokens"); return await authInternal(provider, options); - } else if (error2.code === OAuthErrorCode.InvalidGrant) { + } else if (error2.code === OAuthErrorCode.InvalidGrant || error2.code === OAuthErrorCode.InvalidDpopProof) { + warnCredentialInvalidation(provider, error2, "tokens"); await provider.invalidateCredentials?.("tokens"); return await authInternal(provider, options); } @@ -20380,8 +20438,9 @@ async function authInternal(provider, { serverUrl, authorizationCode, iss, scope } else if (!issuersMatch(recordedIssuer, issuer)) throw new AuthorizationServerMismatchError(recordedIssuer, issuer); } if (freshDiscoveryState) await provider.saveDiscoveryState?.(freshDiscoveryState); - const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); - if (resource) await provider.saveResourceUrl?.(String(resource)); + const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource = selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource; + if (resource) await provider.saveResourceUrl?.(resourceIndicatorToString(resource)); const resolvedScope = determineScope({ requestedScope: scope, resourceMetadata, @@ -20452,24 +20511,30 @@ async function authInternal(provider, { serverUrl, authorizationCode, iss, scope }; await provider.saveTokens(tokens, infoCtx); } - if (tokens?.refresh_token && !forceReauthorization) try { - const newTokens = await refreshAuthorization(authorizationServerUrl, { - metadata, - clientInformation, - refreshToken: tokens.refresh_token, - resource, - addClientAuthentication: provider.addClientAuthentication, - fetchFn - }); - await provider.saveTokens({ - ...newTokens, - issuer - }, infoCtx); - return "AUTHORIZED"; - } catch (error2) { - if (error2 instanceof InsecureTokenEndpointError) throw error2; - if (!(error2 instanceof OAuthError) || error2.code === OAuthErrorCode.ServerError) { - } else throw error2; + if (tokens?.refresh_token && !forceReauthorization) { + let newTokens; + try { + newTokens = await refreshAuthorization(authorizationServerUrl, { + metadata, + clientInformation, + refreshToken: tokens.refresh_token, + resource, + addClientAuthentication: provider.addClientAuthentication, + dpop: await provider.dpop?.(), + fetchFn + }); + } catch (error2) { + if (error2 instanceof InsecureTokenEndpointError) throw error2; + if (!(error2 instanceof OAuthError) || error2.code === OAuthErrorCode.ServerError) console.warn("[mcp-sdk] Could not refresh OAuth tokens; falling back to a new authorization request."); + else throw error2; + } + if (newTokens) { + await provider.saveTokens({ + ...newTokens, + issuer + }, infoCtx); + return "AUTHORIZED"; + } } const state = provider.state ? await provider.state() : void 0; const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { @@ -20503,11 +20568,12 @@ async function selectResourceURL(serverUrl, provider, resourceMetadata) { })) throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); return new URL(resourceMetadata.resource); } +var RECOGNIZED_CHALLENGE_SCHEMES = /* @__PURE__ */ new Set(["bearer", "dpop"]); function extractWWWAuthenticateParams(res) { const authenticateHeader = res.headers.get("WWW-Authenticate"); if (!authenticateHeader) return {}; const [type, scheme] = authenticateHeader.split(" "); - if (type?.toLowerCase() !== "bearer" || !scheme) return {}; + if (!type || !RECOGNIZED_CHALLENGE_SCHEMES.has(type.toLowerCase()) || !scheme) return {}; const resourceMetadataMatch = extractFieldFromWwwAuth(res, "resource_metadata") || void 0; let resourceMetadataUrl; if (resourceMetadataMatch) try { @@ -20664,6 +20730,9 @@ async function discoverOAuthServerInfo(serverUrl, opts) { resourceMetadata }; } +function resourceIndicatorToString(resource) { + return typeof resource === "string" ? resource : resource.href; +} async function startAuthorization(authorizationServerUrl, { metadata, clientInformation, redirectUrl, scope, state, resource }) { let authorizationUrl; if (metadata) { @@ -20682,7 +20751,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo if (state) authorizationUrl.searchParams.set("state", state); if (scope) authorizationUrl.searchParams.set("scope", scope); if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent"); - if (resource) authorizationUrl.searchParams.set("resource", resource.href); + if (resource) authorizationUrl.searchParams.set("resource", resourceIndicatorToString(resource)); return { authorizationUrl, codeVerifier @@ -20696,30 +20765,46 @@ function prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redire redirect_uri: String(redirectUri) }); } -async function executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, clientInformation, addClientAuthentication, resource, fetchFn }) { +async function executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, clientInformation, addClientAuthentication, resource, dpop, fetchFn }) { const tokenUrl = assertSecureTokenEndpoint(metadata?.token_endpoint ?? new URL("/token", authorizationServerUrl)); const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }); - if (resource) tokenRequestParams.set("resource", resource.href); - if (addClientAuthentication) await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata); - else if (clientInformation) applyClientAuthentication(selectClientAuthMethod(clientInformation, metadata?.token_endpoint_auth_methods_supported ?? []), clientInformation, headers, tokenRequestParams); - const response = await (fetchFn ?? fetch)(tokenUrl, { - method: "POST", - headers, - body: tokenRequestParams - }); + if (resource) tokenRequestParams.set("resource", resourceIndicatorToString(resource)); + if (!addClientAuthentication && clientInformation) applyClientAuthentication(selectClientAuthMethod(clientInformation, metadata?.token_endpoint_auth_methods_supported ?? []), clientInformation, headers, tokenRequestParams); + const requestOnce = async () => { + const requestHeaders = new Headers(headers); + if (addClientAuthentication) await addClientAuthentication(requestHeaders, tokenRequestParams, tokenUrl, metadata); + if (dpop) requestHeaders.set("DPoP", await dpop.buildProof({ + htm: "POST", + htu: tokenUrl + })); + return (fetchFn ?? fetch)(tokenUrl, { + method: "POST", + headers: requestHeaders, + body: tokenRequestParams + }); + }; + let response = await requestOnce(); + if (dpop && response.status === 400) { + if ((await response.clone().json().catch(() => { + }))?.error === OAuthErrorCode.UseDpopNonce) { + dpop.observeNonce(response, tokenUrl); + response = await requestOnce(); + } + } + dpop?.observeNonce(response, tokenUrl); if (!response.ok) throw await parseErrorResponse(response); const json = await response.json(); try { - return OAuthTokensSchema.parse(json); + return OAuthTokensSchema.parse(withoutIssuer(json)); } catch (parseError) { if (typeof json === "object" && json !== null && "error" in json) throw await parseErrorResponse(JSON.stringify(json)); throw parseError; } } -async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, fetchFn }) { +async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, dpop, fetchFn }) { return { refresh_token: refreshToken, ...await executeTokenRequest(authorizationServerUrl, { @@ -20731,6 +20816,7 @@ async function refreshAuthorization(authorizationServerUrl, { metadata, clientIn clientInformation, addClientAuthentication, resource, + dpop, fetchFn }) }; @@ -20741,6 +20827,14 @@ async function fetchToken(provider, authorizationServerUrl, { metadata, resource expectedIssuer: metadata?.issuer, issParameterSupported: isIssParameterSupported(metadata) }); + const issuer = metadata?.issuer ?? String(authorizationServerUrl); + const readClientInformation = async () => { + const rawClientInfo = await provider.clientInformation({ issuer }); + const checked = discardIfIssuerMismatch(rawClientInfo, issuer, { canPersistStamp: false }); + if (rawClientInfo && checked === void 0) throw new AuthorizationServerMismatchError(String(rawClientInfo.issuer), issuer); + return checked; + }; + let clientInformation = await readClientInformation(); const effectiveScope = scope ?? provider.clientMetadata.scope; let tokenRequestParams; if (provider.prepareTokenRequest) tokenRequestParams = await provider.prepareTokenRequest(effectiveScope); @@ -20749,13 +20843,14 @@ async function fetchToken(provider, authorizationServerUrl, { metadata, resource if (!provider.redirectUrl) throw new Error("redirectUrl is required for authorization_code flow"); tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, await provider.codeVerifier(), provider.redirectUrl); } - const clientInformation = await provider.clientInformation({ issuer: metadata?.issuer ?? String(authorizationServerUrl) }); + clientInformation ??= await readClientInformation(); return executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, - clientInformation: clientInformation ?? void 0, + clientInformation, addClientAuthentication: provider.addClientAuthentication, resource, + dpop: await provider.dpop?.(), fetchFn }); } @@ -20779,7 +20874,7 @@ async function registerClient(authorizationServerUrl, { metadata, clientMetadata body: await response.text(), submittedMetadata }); - return OAuthClientInformationFullSchema.parse(await response.json()); + return OAuthClientInformationFullSchema.parse(withoutIssuer(await response.json())); } var CAP_EXEMPT_METHODS = /* @__PURE__ */ new Set([ "tools/list", @@ -21365,7 +21460,7 @@ function classifyNetworkError(error2, context) { if (context.environment === "browser" && isOpaqueFetchTypeError(error2)) return { kind: "legacy" }; return { kind: "error", - error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error2)}`, { cause: error2 }) + error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error2)}`, { cause: error2 }, { cause: error2 }) }; } function isOpaqueFetchTypeError(error2) { @@ -22399,7 +22494,7 @@ var Client = class extends Protocol { }, options); const hit = await this._serveFromCache("prompts/list", void 0, options); if (hit !== void 0) return hit; - return this._listAllPages("prompts/list", params, options, (acc, page) => acc.prompts.push(...page.prompts)); + return this._listAllPages("prompts/list", params, options, (r) => r.prompts); } /** * Lists available resources. @@ -22438,7 +22533,7 @@ var Client = class extends Protocol { }, options); const hit = await this._serveFromCache("resources/list", void 0, options); if (hit !== void 0) return hit; - return this._listAllPages("resources/list", params, options, (acc, page) => acc.resources.push(...page.resources)); + return this._listAllPages("resources/list", params, options, (r) => r.resources); } /** * Lists available resource URI templates for dynamic resources. @@ -22463,7 +22558,7 @@ var Client = class extends Protocol { }, options); const hit = await this._serveFromCache("resources/templates/list", void 0, options); if (hit !== void 0) return hit; - return this._listAllPages("resources/templates/list", params, options, (acc, page) => acc.resourceTemplates.push(...page.resourceTemplates)); + return this._listAllPages("resources/templates/list", params, options, (r) => r.resourceTemplates); } /** * Walk every page of a paginated list verb, aggregate, and write ONE @@ -22471,8 +22566,7 @@ var Client = class extends Protocol { * methods' no-`cursor` auto-aggregate path. Page 1's result object is * mutated in place (its items array is extended; `nextCursor` is * cleared); page-1 metadata (`ttlMs`, `cacheScope`, `_meta`) is preserved. - * A `nextCursor` that repeats stops the walk (defence against a - * non-converging server, mcp.d's `drainList` guard); + * A page with the same items and `nextCursor` as the previous page ends the walk; * {@linkcode ClientOptions.listMaxPages} is a hard cap — hitting it * throws, so a partial aggregate is never cached. The * captured-generation guard skips the write when a `list_changed` landed @@ -22487,7 +22581,7 @@ var Client = class extends Protocol { * trace context) supplied to the public `list*()` reaches every wire * request the walk issues. */ - async _listAllPages(method, baseParams, options, append, finalize2) { + async _listAllPages(method, baseParams, options, items, finalize2) { const bypass = options?.cacheMode === "bypass"; const generation = this._cache.captureGeneration(method); const acc = await this.request({ @@ -22495,14 +22589,13 @@ var Client = class extends Protocol { ...baseParams && { params: { ...baseParams } } }, options); let cursor = acc.nextCursor; - const seen = /* @__PURE__ */ new Set(); + let previous = acc; let pages = 1; - while (cursor !== void 0 && !seen.has(cursor)) { + while (cursor !== void 0) { if (this._listMaxPages !== 0 && pages >= this._listMaxPages) throw new SdkError(SdkErrorCode.ListPaginationExceeded, `${method}: exceeded listMaxPages (${this._listMaxPages}); server pagination did not terminate`, { method, listMaxPages: this._listMaxPages }); - seen.add(cursor); const page = await this.request({ method, params: { @@ -22510,7 +22603,9 @@ var Client = class extends Protocol { cursor } }, options); - append(acc, page); + if (page.nextCursor === cursor && JSON.stringify(items(page)) === JSON.stringify(items(previous))) break; + items(acc).push(...items(page)); + previous = page; cursor = page.nextCursor; pages++; } @@ -22774,19 +22869,22 @@ var Client = class extends Protocol { notifications: filter } }; + const routeSendFailure = (error2) => { + settle2({ + cause: "remote", + error: error2 instanceof Error ? error2 : new Error(String(error2)) + }); + }; try { - await this.transport.send(jsonrpcRequest, { + this.transport.send(jsonrpcRequest, { requestSignal: requestAbort.signal, onRequestStreamEnd: () => settle2({ cause: "remote", error: /* @__PURE__ */ new Error("subscriptions/listen: stream ended") }) - }); + }).catch(routeSendFailure); } catch (error2) { - settle2({ - cause: "remote", - error: error2 instanceof Error ? error2 : new Error(String(error2)) - }); + routeSendFailure(error2); } return { honoredFilter: await opening, @@ -23038,7 +23136,7 @@ var Client = class extends Protocol { } const hit = await this._serveFromCache("tools/list", void 0, options); if (hit !== void 0) return hit; - return this._listAllPages("tools/list", params, options, (acc, page) => acc.tools.push(...page.tools), (acc) => this._excludeInvalidXMcpHeaderTools(acc)); + return this._listAllPages("tools/list", params, options, (r) => r.tools, (acc) => this._excludeInvalidXMcpHeaderTools(acc)); } /** * SEP-2243 (protocol revision 2026-07-28): a Streamable HTTP client MUST @@ -23109,6 +23207,63 @@ var Client = class extends Protocol { return this.notification({ method: "notifications/roots/list_changed" }); } }; +function hasDpopChallenge(wwwAuthenticate) { + return /(?:^|,)\s*dpop(?:\s|$|,)/i.test(wwwAuthenticate); +} +function isDpopNonceChallenge(response) { + if (response.status !== 401) return false; + const wwwAuthenticate = response.headers.get("www-authenticate") ?? ""; + return hasDpopChallenge(wwwAuthenticate) && /use_dpop_nonce/i.test(wwwAuthenticate); +} +var withDpop = (session, getToken) => (next) => { + const resolveSession = typeof session === "function" ? session : () => session; + return async (input, init) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url2 = new URL(input.toString()); + const activeSession = await resolveSession(); + if (!activeSession) return next(input, init); + const makeRequest = async () => { + const accessToken = await getToken(); + if (!accessToken) return next(input, init); + const headers = new Headers(init?.headers); + const proof = await activeSession.buildProof({ + htm: method, + htu: url2, + accessToken + }); + headers.set("Authorization", `DPoP ${accessToken}`); + headers.set("DPoP", proof); + return next(input, { + ...init, + headers + }); + }; + let response = await makeRequest(); + if (isDpopNonceChallenge(response) && response.headers.has("dpop-nonce")) { + activeSession.observeNonce(response, url2); + await response.text?.().catch(() => { + }); + response = await makeRequest(); + } + activeSession.observeNonce(response, url2); + return response; + }; +}; +var withDpopFromProvider = (provider) => withDpop(async () => { + try { + return await provider.dpop?.(); + } catch (error2) { + throw markAuthSeamEscape(error2); + } +}, async () => { + let tokens; + try { + tokens = await provider.tokens(); + } catch (error2) { + throw markAuthSeamEscape(error2); + } + return tokens?.token_type?.toLowerCase() === "dpop" ? tokens.access_token : void 0; +}); var SseError = class extends Error { static { Object.defineProperty(this, "mcpBrand", { value: "mcp.SseError" }); @@ -23150,6 +23305,7 @@ var SSEClientTransport = class { _skipIssuerMetadataValidation; _fetch; _fetchWithInit; + _dpop; _protocolVersion; onclose; onerror; @@ -23161,32 +23317,33 @@ var SSEClientTransport = class { this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation; + this._fetch = opts?.fetch; if (isOAuthClientProvider(opts?.authProvider)) { this._oauthProvider = opts.authProvider; this._authProvider = adaptOAuthProvider(opts.authProvider, { skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation }); + if (opts.authProvider.dpop) { + this._dpop = withDpopFromProvider(opts.authProvider); + this._fetch = this._dpop(opts.fetch ?? fetch); + } } else this._authProvider = opts?.authProvider; - this._fetch = opts?.fetch; this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); } _last401Response; async _commonHeaders() { - const headers = {}; + const headers = new Headers(this._requestInit?.headers || void 0); let token; try { token = await this._authProvider?.token(); } catch (error2) { throw markAuthSeamEscape(error2); } - if (token) headers["Authorization"] = `Bearer ${token}`; - if (this._protocolVersion) headers["mcp-protocol-version"] = this._protocolVersion; - const extraHeaders = normalizeHeaders(this._requestInit?.headers); - return new Headers({ - ...headers, - ...extraHeaders - }); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (this._protocolVersion) headers.set("mcp-protocol-version", this._protocolVersion); + return headers; } _startOrAuth() { - const fetchImpl = this?._eventSourceInit?.fetch ?? this._fetch ?? fetch; + const eventSourceFetch = this._eventSourceInit?.fetch; + const fetchImpl = eventSourceFetch ? this._dpop?.(eventSourceFetch) ?? eventSourceFetch : this._fetch ?? fetch; return new Promise((resolve, reject) => { this._eventSource = new EventSource(this._url.href, { ...this._eventSourceInit, @@ -23356,6 +23513,7 @@ var DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS = { }; var RESERVED_REQUEST_HEADER_NAMES = /* @__PURE__ */ new Set([ "authorization", + "dpop", "content-type", "mcp-protocol-version", "mcp-method", @@ -23418,11 +23576,12 @@ var StreamableHTTPClientTransport = class { this._scope = void 0; this._requestInit = opts?.requestInit; this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation; + this._fetch = opts?.fetch; if (isOAuthClientProvider(opts?.authProvider)) { this._oauthProvider = opts.authProvider; this._authProvider = adaptOAuthProvider(opts.authProvider, { skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation }); + if (opts.authProvider.dpop) this._fetch = withDpopFromProvider(opts.authProvider)(opts.fetch ?? fetch); } else this._authProvider = opts?.authProvider; - this._fetch = opts?.fetch; this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); this._sessionId = opts?.sessionId; this._protocolVersion = opts?.protocolVersion; @@ -23476,21 +23635,17 @@ var StreamableHTTPClientTransport = class { }); } async _commonHeaders() { - const headers = {}; + const headers = new Headers(this._requestInit?.headers || void 0); let token; try { token = await this._authProvider?.token(); } catch (error2) { throw markAuthSeamEscape(error2); } - if (token) headers["Authorization"] = `Bearer ${token}`; - if (this._sessionId) headers["mcp-session-id"] = this._sessionId; - if (this._protocolVersion) headers["mcp-protocol-version"] = this._protocolVersion; - const extraHeaders = normalizeHeaders(this._requestInit?.headers); - return new Headers({ - ...headers, - ...extraHeaders - }); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (this._sessionId) headers.set("mcp-session-id", this._sessionId); + if (this._protocolVersion) headers.set("mcp-protocol-version", this._protocolVersion); + return headers; } /** * Body-derived per-request headers: when an outgoing request carries a @@ -23506,9 +23661,8 @@ var StreamableHTTPClientTransport = class { if (typeof envelopeVersion !== "string") return; headers.set("mcp-protocol-version", envelopeVersion); headers.set("mcp-method", message.method); - const params = message.params; - const nameHeader = message.method === "resources/read" ? typeof params?.uri === "string" ? params.uri : void 0 : typeof params?.name === "string" ? params.name : void 0; - if (nameHeader !== void 0) headers.set("mcp-name", encodeMcpParamValue(nameHeader)); + const source = mcpNameSource(message.method, message.params); + if (source?.value !== void 0) headers.set("mcp-name", encodeMcpParamValue(source.value)); } /** * `true` when the outbound message is a single request carrying a diff --git a/crates/tui/extension-host/dist/codewhale-extension-host.mjs b/crates/tui/extension-host/dist/codewhale-extension-host.mjs index 4e5c1415bd..52c6c71642 100644 --- a/crates/tui/extension-host/dist/codewhale-extension-host.mjs +++ b/crates/tui/extension-host/dist/codewhale-extension-host.mjs @@ -19,7 +19,7 @@ var __export = (target, all) => { var define_BUILTIN_MODULE_DIGESTS_default; var init_define_BUILTIN_MODULE_DIGESTS = __esm({ ""() { - define_BUILTIN_MODULE_DIGESTS_default = { harness: "114addde4e6e70ade28a38fe2c1fa0b521729ab9aaa58273c77f33b2a1b608ae", mcp: "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55" }; + define_BUILTIN_MODULE_DIGESTS_default = { harness: "114addde4e6e70ade28a38fe2c1fa0b521729ab9aaa58273c77f33b2a1b608ae", mcp: "5bc04b62832310724fdfed3999362b1667bf9eddf4936e1535b5767019596839" }; } }); @@ -13262,7 +13262,7 @@ var init_protocol_generated = __esm({ }, RegisterParams: { strict: true, - required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } }, + required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "avatar_pack", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } }, optional: { scope: { ref: "EntryRef" } } }, RegisterSpecWire: { @@ -13377,7 +13377,7 @@ function validateMessage(value, direction, tier, methods = METHODS) { } if (method === "registry/register") { const { kind, spec: spec2 } = params; - const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0; + const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "avatar_pack" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0; if (reason !== void 0) throw new ProtocolError(`${method}: ${reason}`); } return value; @@ -13953,6 +13953,74 @@ var init_storage = __esm({ } }); +// src/shims/avatars.ts +function normalizeAvatarPack(value) { + if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).some((key) => key !== "path")) throw new TypeError("avatar pack supports only path"); + const { path } = value; + if (typeof path !== "string" || !path || Buffer.byteLength(path, "utf8") > 256 || !path.endsWith(".json") || /[\\:\u0000-\u001f\u007f-\u009f]/u.test(path) || path.split("/").some((part) => !part || part === "." || part === "..")) { + throw new TypeError("avatar pack path must be a bounded bundle-relative path with normal slash-separated components"); + } + return Object.freeze({ path }); +} +function defineAvatarsService(host2) { + class AvatarsShim extends Service { + constructor(ctx) { + super(ctx, "avatars"); + } + registerPack(definition) { + const ctx = this.ctx; + const owner = host2.ownerOf(ctx); + if (!owner) throw new Error("avatars.registerPack called outside an extension owner"); + const root = normalizeAvatarPack(definition); + return ctx.effect(() => host2.avatarPacks.register(owner, root), `avatars.registerPack(${JSON.stringify(root.path)})`); + } + } + Object.freeze(AvatarsShim.prototype); + return AvatarsShim; +} +var MAX_AVATAR_PACKS_PER_OWNER, MAX_AVATAR_PACKS_PER_HOST, AvatarPacks; +var init_avatars = __esm({ + "src/shims/avatars.ts"() { + "use strict"; + init_define_BUILTIN_MODULE_DIGESTS(); + init_lib2(); + init_owned(); + MAX_AVATAR_PACKS_PER_OWNER = 4; + MAX_AVATAR_PACKS_PER_HOST = 16; + AvatarPacks = class { + registrations; + owners = /* @__PURE__ */ new Map(); + count = 0; + constructor(rpc2, ownedBy, warn) { + this.registrations = new OwnedRegistrations(rpc2, "avatar_pack", ownedBy, warn); + } + register(owner, definition) { + if (owner.state !== "activating" && owner.state !== "active") throw new Error("avatar owner is not live"); + const { path } = normalizeAvatarPack(definition); + const roots = this.owners.get(owner) ?? /* @__PURE__ */ new Map(); + if (roots.has(path)) throw new Error("avatar pack is already registered; dispose it before registering it again"); + if (roots.size >= MAX_AVATAR_PACKS_PER_OWNER || this.count >= MAX_AVATAR_PACKS_PER_HOST) throw new RangeError("avatar pack owner or host registration limit reached"); + const undo = this.registrations.add({ owner, name: path, disposed: false }, { name: path, description: "" }); + const dispose = () => { + if (roots.get(path) !== dispose) return; + roots.delete(path); + this.count--; + if (!roots.size) this.owners.delete(owner); + undo(); + }; + roots.set(path, dispose); + this.owners.set(owner, roots); + this.count++; + return dispose; + } + forget(owner) { + for (const dispose of [...this.owners.get(owner)?.values() ?? []]) dispose(); + this.registrations.forget(owner); + } + }; + } +}); + // src/tier.ts function parseTier(argv) { let found; @@ -14198,6 +14266,7 @@ var init_root = __esm({ init_prompt(); init_storage(); init_mcp(); + init_avatars(); init_skills(); init_tier(); init_commands(); @@ -14222,10 +14291,11 @@ var init_root = __esm({ "prompt", "storage", "skills", + "avatars", "mcp", "logger" ]); - PROVIDED_SERVICES = /* @__PURE__ */ new Set(["tools", "commands", "prompt", "storage", "skills", "mcp", "logger", "events", "reflect", "registry"]); + PROVIDED_SERVICES = /* @__PURE__ */ new Set(["tools", "commands", "prompt", "storage", "skills", "avatars", "mcp", "logger", "events", "reflect", "registry"]); ACTIVATE_DEADLINE_MS = 5e3; DISPOSE_DEADLINE_MS = 2e3; ownerStorage = new AsyncLocalStorage(); @@ -14261,6 +14331,7 @@ var init_root = __esm({ (message, owner) => this.log("warn", message, owner) ); this.mcpDefinitions = new McpDefinitions(rpc2, (owner) => owner.mcpDefinitions, (message, owner) => this.log("warn", message, owner)); + this.avatarPacks = new AvatarPacks(rpc2, (owner) => owner.avatarPacks, (message, owner) => this.log("warn", message, owner)); this.skillRoots = new SkillRoots( rpc2, (owner) => owner.skillRoots, @@ -14333,6 +14404,7 @@ var init_root = __esm({ }); const ShellHooksShim = defineShellHooksService({ ownerOf: (ctx) => ctx[OWNER], registrations: this.shellRegistrations }); const McpShim = defineMcpService({ ownerOf: (ctx) => ctx[OWNER], definitions: this.mcpDefinitions }); + const AvatarsShim = defineAvatarsService({ ownerOf: (ctx) => ctx[OWNER], avatarPacks: this.avatarPacks }); const SkillsShim = defineSkillsService({ ownerOf: (ctx) => ctx[OWNER], skillRoots: this.skillRoots }); class StorageShim extends Service { constructor(ctx) { @@ -14363,6 +14435,7 @@ var init_root = __esm({ shimClasses.set("prompt", PromptShim); shimClasses.set("storage", StorageShim); shimClasses.set("skills", SkillsShim); + shimClasses.set("avatars", AvatarsShim); shimClasses.set("mcp", McpShim); shimClasses.set("shellHooks", ShellHooksShim); root.plugin(ToolsShim); @@ -14370,6 +14443,7 @@ var init_root = __esm({ root.plugin(PromptShim); root.plugin(StorageShim); root.plugin(SkillsShim); + root.plugin(AvatarsShim); root.plugin(McpShim); root.plugin(ShellHooksShim); shimClasses.set("loader", ReviewedLoader); @@ -14385,6 +14459,7 @@ var init_root = __esm({ hookRegistrations; promptSections; skillRoots; + avatarPacks; mcpDefinitions; log(level, msg, owner) { const params = { level, msg: msg.slice(0, 8192) }; @@ -14426,7 +14501,7 @@ var init_root = __esm({ const scopeKey = params.scope === void 0 ? void 0 : `${params.scope.path}\0${params.scope.sha256}`; if (scopeKey !== void 0) { if (params.scope?.path !== params.entry.path || params.scope?.sha256 !== params.entry.sha256) return { status: "failed", diagnostic: "scope does not match the core-selected entry" }; - parent ??= { ref: params.owner, pluginName: params.plugin_name, fibers: [], pendingRegistrations: /* @__PURE__ */ new Set(), refusals: [], tools: /* @__PURE__ */ new Map(), commands: /* @__PURE__ */ new Map(), hooks: /* @__PURE__ */ new Map(), shellHooks: /* @__PURE__ */ new Map(), promptSections: /* @__PURE__ */ new Map(), skillRoots: /* @__PURE__ */ new Map(), mcpDefinitions: /* @__PURE__ */ new Map(), entries: /* @__PURE__ */ new Set(), views: /* @__PURE__ */ new Map(), state: "active", ...params.data_dir === void 0 ? {} : { dataDir: params.data_dir } }; + parent ??= { ref: params.owner, pluginName: params.plugin_name, fibers: [], pendingRegistrations: /* @__PURE__ */ new Set(), refusals: [], tools: /* @__PURE__ */ new Map(), commands: /* @__PURE__ */ new Map(), hooks: /* @__PURE__ */ new Map(), shellHooks: /* @__PURE__ */ new Map(), promptSections: /* @__PURE__ */ new Map(), skillRoots: /* @__PURE__ */ new Map(), avatarPacks: /* @__PURE__ */ new Map(), mcpDefinitions: /* @__PURE__ */ new Map(), entries: /* @__PURE__ */ new Set(), views: /* @__PURE__ */ new Map(), state: "active", ...params.data_dir === void 0 ? {} : { dataDir: params.data_dir } }; if (parent.state !== "active" || parent.ref.plugin_id !== params.owner.plugin_id || parent.ref.generation !== params.owner.generation || parent.pluginName !== params.plugin_name) return { status: "failed", diagnostic: "scope owner was withdrawn" }; parent.views ??= /* @__PURE__ */ new Map(); this.owners.set(key, parent); @@ -14455,6 +14530,7 @@ var init_root = __esm({ shellHooks: /* @__PURE__ */ new Map(), promptSections: /* @__PURE__ */ new Map(), skillRoots: /* @__PURE__ */ new Map(), + avatarPacks: /* @__PURE__ */ new Map(), mcpDefinitions: /* @__PURE__ */ new Map(), entries: /* @__PURE__ */ new Set(), ...params.data_dir === void 0 ? {} : { dataDir: params.data_dir }, @@ -14520,6 +14596,7 @@ var init_root = __esm({ this.shellRegistrations.forget(owner); this.promptSections.forget(owner); this.skillRoots.forget(owner); + this.avatarPacks.forget(owner); this.mcpDefinitions.forget(owner); if (scopeKey === void 0) { if (this.owners.get(key) === owner) this.owners.delete(key); @@ -14593,6 +14670,7 @@ var init_root = __esm({ ...[...owner.hooks.values()].map((hook) => `hook:${hook.name}`), ...[...owner.promptSections.values()].map((section) => `prompt_section:${section.name}`), ...[...owner.mcpDefinitions.values()].map((server) => `mcp_server:${server.name}`), + ...[...owner.avatarPacks.values()].map((pack) => `avatar_pack:${pack.name}`), ...[...owner.skillRoots.values()].map((root) => `skill_root:${root.name}`) ]; for (const fiber of owner.fibers) { @@ -14604,6 +14682,7 @@ var init_root = __esm({ this.shellRegistrations.forget(owner); this.promptSections.forget(owner); this.skillRoots.forget(owner); + this.avatarPacks.forget(owner); this.mcpDefinitions.forget(owner); if (scopeKey === void 0) this.owners.delete(ref2.owner_token); else parent?.views?.delete(scopeKey); diff --git a/crates/tui/extension-host/dist/dsh-composition-review.mjs b/crates/tui/extension-host/dist/dsh-composition-review.mjs index dae1ff860d..5c03c24556 100644 --- a/crates/tui/extension-host/dist/dsh-composition-review.mjs +++ b/crates/tui/extension-host/dist/dsh-composition-review.mjs @@ -12190,6 +12190,14 @@ var init_storage = __esm({ } }); +// src/shims/avatars.ts +var init_avatars = __esm({ + "src/shims/avatars.ts"() { + "use strict"; + init_owned(); + } +}); + // src/tier.ts var init_tier = __esm({ "src/tier.ts"() { @@ -12224,6 +12232,7 @@ var init_root = __esm({ init_prompt(); init_storage(); init_mcp(); + init_avatars(); init_skills(); init_tier(); init_commands(); diff --git a/crates/tui/extension-host/dist/protocol.mjs b/crates/tui/extension-host/dist/protocol.mjs index d42e820518..990ab9b578 100644 --- a/crates/tui/extension-host/dist/protocol.mjs +++ b/crates/tui/extension-host/dist/protocol.mjs @@ -209,7 +209,7 @@ var SHAPES = { }, RegisterParams: { strict: true, - required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } }, + required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "avatar_pack", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } }, optional: { scope: { ref: "EntryRef" } } }, RegisterSpecWire: { @@ -360,7 +360,7 @@ function validateMessage(value, direction, tier, methods = METHODS) { } if (method === "registry/register") { const { kind, spec: spec2 } = params; - const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0; + const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "avatar_pack" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0; if (reason !== void 0) throw new ProtocolError(`${method}: ${reason}`); } return value; diff --git a/crates/tui/extension-host/dist/rpc.mjs b/crates/tui/extension-host/dist/rpc.mjs index d0f7c3f146..b6e8d2ef69 100644 --- a/crates/tui/extension-host/dist/rpc.mjs +++ b/crates/tui/extension-host/dist/rpc.mjs @@ -206,7 +206,7 @@ var SHAPES = { }, RegisterParams: { strict: true, - required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } }, + required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "avatar_pack", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } }, optional: { scope: { ref: "EntryRef" } } }, RegisterSpecWire: { @@ -316,7 +316,7 @@ function validateMessage(value, direction, tier, methods = METHODS) { } if (method === "registry/register") { const { kind, spec: spec2 } = params; - const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0; + const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "avatar_pack" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0; if (reason !== void 0) throw new ProtocolError(`${method}: ${reason}`); } return value; diff --git a/crates/tui/extension-host/package-lock.json b/crates/tui/extension-host/package-lock.json index 54d3d097c1..888f285f37 100644 --- a/crates/tui/extension-host/package-lock.json +++ b/crates/tui/extension-host/package-lock.json @@ -13,7 +13,7 @@ "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-util-values": "0.1.7-alpha.2", "@deepseek-ai/schemastery": "3.18.4", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.2.0", "@types/js-yaml": "4.0.9", "@types/node": "22.20.4", "acorn": "8.18.0", @@ -523,13 +523,13 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.2.0.tgz", + "integrity": "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.2.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -542,9 +542,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.2.0.tgz", + "integrity": "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/crates/tui/extension-host/package.json b/crates/tui/extension-host/package.json index 3684258343..fb0e2d7d16 100644 --- a/crates/tui/extension-host/package.json +++ b/crates/tui/extension-host/package.json @@ -21,7 +21,7 @@ "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-util-values": "0.1.7-alpha.2", "@deepseek-ai/schemastery": "3.18.4", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.2.0", "@types/js-yaml": "4.0.9", "@types/node": "22.20.4", "acorn": "8.18.0", diff --git a/crates/tui/extension-host/scripts/redact-mcp-oauth-logs.mjs b/crates/tui/extension-host/scripts/redact-mcp-oauth-logs.mjs new file mode 100644 index 0000000000..fe04ca1369 --- /dev/null +++ b/crates/tui/extension-host/scripts/redact-mcp-oauth-logs.mjs @@ -0,0 +1,28 @@ +function replaceExactlyOnce(source, pattern, replacement, label) { + const matches = [...source.matchAll(pattern)] + if (matches.length !== 1) { + throw new Error(`Expected one ${label} in the MCP SDK bundle, found ${matches.length}`) + } + return source.replace(pattern, replacement) +} + +export function redactMcpOAuthLogs(bundle) { + let output = replaceExactlyOnce( + bundle, + /const errorMessage = `\$\{statusCode \? `HTTP \$\{statusCode\}: ` : ""\}Invalid OAuth error response: \$\{error2\}\. Raw body: \$\{body\}`;/g, + 'const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ""}OAuth error response details omitted`;', + 'raw OAuth response body interpolation', + ) + output = replaceExactlyOnce( + output, + /console\.warn\(`\[mcp-sdk\] OAuth \$\{JSON\.stringify\(error2\.code\)\} — \$\{action\}\. Cause: \$\{JSON\.stringify\(error2\.message\)\}`\);/g, + 'console.warn(`[mcp-sdk] OAuth authorization failed — ${action}; details omitted.`);', + 'OAuth error detail log', + ) + return replaceExactlyOnce( + output, + /console\.warn\(`\[mcp-sdk\] Could not refresh OAuth tokens; falling back to a new authorization request\. Cause: \$\{JSON\.stringify\(error2 instanceof Error \? error2\.message : String\(error2\)\)\}`\);/g, + 'console.warn("[mcp-sdk] Could not refresh OAuth tokens; falling back to a new authorization request.");', + 'refresh-token error detail log', + ) +} diff --git a/crates/tui/extension-host/src/protocol.generated.ts b/crates/tui/extension-host/src/protocol.generated.ts index 26f04da484..3c90c262c4 100644 --- a/crates/tui/extension-host/src/protocol.generated.ts +++ b/crates/tui/extension-host/src/protocol.generated.ts @@ -238,7 +238,7 @@ export const SHAPES: { readonly [name: string]: Shape } = { }, RegisterParams: { strict: true, - required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool', 'command', 'hook', 'prompt_section', 'prompt_template', 'skill_root', 'shell_hook', 'mcp_server'] }, spec: { ref: 'RegisterSpecWire' } }, + required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool', 'command', 'hook', 'prompt_section', 'prompt_template', 'skill_root', 'avatar_pack', 'shell_hook', 'mcp_server'] }, spec: { ref: 'RegisterSpecWire' } }, optional: { scope: { ref: 'EntryRef' } }, }, RegisterSpecWire: { @@ -490,7 +490,7 @@ export interface ProtocolRange { max: number } -export type RegisterKind = 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'shell_hook' | 'mcp_server' +export type RegisterKind = 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'avatar_pack' | 'shell_hook' | 'mcp_server' export interface RegisterParams { owner: OwnerRef diff --git a/crates/tui/extension-host/src/protocol.ts b/crates/tui/extension-host/src/protocol.ts index 7f4f00004f..5c3ab403f8 100644 --- a/crates/tui/extension-host/src/protocol.ts +++ b/crates/tui/extension-host/src/protocol.ts @@ -200,7 +200,7 @@ export function validateMessage( ? 'a tool registration has no `spec.argument_hint`' : kind === 'command' && spec.input_schema != null ? 'a command registration has no `spec.input_schema`' - : (kind === 'hook' || kind === 'prompt_section' || kind === 'prompt_template' || kind === 'skill_root' || kind === 'shell_hook' || kind === 'mcp_server') && (spec.input_schema != null || spec.argument_hint != null) + : (kind === 'hook' || kind === 'prompt_section' || kind === 'prompt_template' || kind === 'skill_root' || kind === 'avatar_pack' || kind === 'shell_hook' || kind === 'mcp_server') && (spec.input_schema != null || spec.argument_hint != null) ? 'a hook, prompt or skill root registration has no input schema or argument hint' : undefined if (reason !== undefined) throw new ProtocolError(`${method}: ${reason}`) diff --git a/crates/tui/extension-host/src/root.ts b/crates/tui/extension-host/src/root.ts index 309166a5fa..884f915c3a 100644 --- a/crates/tui/extension-host/src/root.ts +++ b/crates/tui/extension-host/src/root.ts @@ -40,6 +40,7 @@ import { hookExecution, hookVerdict, type LocalHook } from './shims/hooks.ts' import { PromptSections, definePromptService, type LocalPromptSection } from './shims/prompt.ts' import { createStorage, type PluginStorage } from './shims/storage.ts' import {McpDefinitions,defineMcpService,type LocalMcp} from './shims/mcp.ts' +import { AvatarPacks, defineAvatarsService, type LocalAvatarPack } from './shims/avatars.ts' import { SkillRoots, defineSkillsService, type LocalSkillRoot } from './shims/skills.ts' import { ownerTier, type HostTier } from './tier.ts' import { @@ -77,12 +78,13 @@ export const REFUSED_SERVICES = new Set(['shellHooks', 'prompt', 'storage', 'skills', + 'avatars', 'mcp', 'logger', ]) /** Services the root provides; `inject` of anything else fails activation. */ -const PROVIDED_SERVICES = new Set(['tools', 'commands', 'prompt', 'storage', 'skills', 'mcp', 'logger', 'events', 'reflect', 'registry']) +const PROVIDED_SERVICES = new Set(['tools', 'commands', 'prompt', 'storage', 'skills', 'avatars', 'mcp', 'logger', 'events', 'reflect', 'registry']) const ACTIVATE_DEADLINE_MS = 5_000 const DISPOSE_DEADLINE_MS = 2_000 @@ -113,6 +115,7 @@ export interface OwnerRecord { hooks: Map> promptSections: Map> skillRoots: Map> + avatarPacks: Map> mcpDefinitions:Map> storage?: PluginStorage warnedAllow?: boolean @@ -157,6 +160,7 @@ export class HostRoot { private readonly hookRegistrations: OwnedRegistrations> private readonly promptSections: PromptSections private readonly skillRoots: SkillRoots + private readonly avatarPacks: AvatarPacks private readonly mcpDefinitions:McpDefinitions constructor( @@ -185,6 +189,7 @@ export class HostRoot { this.promptSections = new PromptSections(rpc, (owner) => owner.promptSections, (message, owner) => this.log('warn', message, owner)) this.mcpDefinitions=new McpDefinitions(rpc,(owner)=>owner.mcpDefinitions,(message,owner)=>this.log('warn',message,owner)) + this.avatarPacks = new AvatarPacks(rpc, owner => owner.avatarPacks, (message, owner) => this.log('warn', message, owner)) this.skillRoots = new SkillRoots(rpc, (owner) => owner.skillRoots, (message, owner) => this.log('warn', message, owner)) @@ -268,6 +273,7 @@ export class HostRoot { }) const ShellHooksShim=defineShellHooksService({ownerOf:(ctx)=>ctx[OWNER],registrations:this.shellRegistrations}) const McpShim=defineMcpService({ownerOf:(ctx)=>(ctx as Context & { [OWNER]?: OwnerRecord })[OWNER],definitions:this.mcpDefinitions}) + const AvatarsShim = defineAvatarsService({ ownerOf: (ctx) => ctx[OWNER], avatarPacks: this.avatarPacks }) const SkillsShim = defineSkillsService({ ownerOf: (ctx) => ctx[OWNER], skillRoots: this.skillRoots }) class StorageShim extends Service { constructor(ctx: any) { super(ctx, 'storage') } @@ -290,6 +296,7 @@ export class HostRoot { shimClasses.set('prompt', PromptShim) shimClasses.set('storage', StorageShim) shimClasses.set('skills', SkillsShim) + shimClasses.set('avatars', AvatarsShim) shimClasses.set('mcp',McpShim) shimClasses.set('shellHooks',ShellHooksShim) root.plugin(ToolsShim) @@ -297,6 +304,7 @@ export class HostRoot { root.plugin(PromptShim) root.plugin(StorageShim) root.plugin(SkillsShim) + root.plugin(AvatarsShim) root.plugin(McpShim) root.plugin(ShellHooksShim) shimClasses.set('loader', ReviewedLoader) @@ -348,7 +356,7 @@ export class HostRoot { const scopeKey=params.scope===undefined ? undefined : `${params.scope.path}\0${params.scope.sha256}` if (scopeKey!==undefined) { if (params.scope?.path!==params.entry.path || params.scope?.sha256!==params.entry.sha256) return {status:'failed',diagnostic:'scope does not match the core-selected entry'} - parent ??= {ref:params.owner,pluginName:params.plugin_name,fibers:[],pendingRegistrations:new Set(),refusals:[],tools:new Map(),commands:new Map(),hooks:new Map(),shellHooks:new Map(),promptSections:new Map(),skillRoots:new Map(),mcpDefinitions:new Map(),entries:new Set(),views:new Map(),state:'active',...(params.data_dir===undefined?{}:{dataDir:params.data_dir})} + parent ??= {ref:params.owner,pluginName:params.plugin_name,fibers:[],pendingRegistrations:new Set(),refusals:[],tools:new Map(),commands:new Map(),hooks:new Map(),shellHooks:new Map(),promptSections:new Map(),skillRoots:new Map(),avatarPacks:new Map(),mcpDefinitions:new Map(),entries:new Set(),views:new Map(),state:'active',...(params.data_dir===undefined?{}:{dataDir:params.data_dir})} if (parent.state!=='active' || parent.ref.plugin_id!==params.owner.plugin_id || parent.ref.generation!==params.owner.generation || parent.pluginName!==params.plugin_name) return {status:'failed',diagnostic:'scope owner was withdrawn'} parent.views ??=new Map() this.owners.set(key,parent) @@ -377,6 +385,7 @@ export class HostRoot { shellHooks:new Map(), promptSections: new Map(), skillRoots: new Map(), + avatarPacks: new Map(), mcpDefinitions:new Map(), entries: new Set(), ...(params.data_dir === undefined ? {} : { dataDir: params.data_dir }), @@ -440,7 +449,7 @@ export class HostRoot { this.commandRegistrations.forget(owner) this.hookRegistrations.forget(owner);this.shellRegistrations.forget(owner) this.promptSections.forget(owner) - this.skillRoots.forget(owner);this.mcpDefinitions.forget(owner) + this.skillRoots.forget(owner);this.avatarPacks.forget(owner);this.mcpDefinitions.forget(owner) if (scopeKey === undefined) { if (this.owners.get(key) === owner) this.owners.delete(key) } else if (parent?.views?.get(scopeKey) === owner) parent.views.delete(scopeKey) @@ -513,6 +522,7 @@ export class HostRoot { ...[...owner.hooks.values()].map((hook) => `hook:${hook.name}`), ...[...owner.promptSections.values()].map((section) => `prompt_section:${section.name}`), ...[...owner.mcpDefinitions.values()].map(server=>`mcp_server:${server.name}`), + ...[...owner.avatarPacks.values()].map((pack) => `avatar_pack:${pack.name}`), ...[...owner.skillRoots.values()].map((root) => `skill_root:${root.name}`), ] for (const fiber of owner.fibers) { @@ -522,7 +532,7 @@ export class HostRoot { this.commandRegistrations.forget(owner) this.hookRegistrations.forget(owner);this.shellRegistrations.forget(owner) this.promptSections.forget(owner) - this.skillRoots.forget(owner);this.mcpDefinitions.forget(owner) + this.skillRoots.forget(owner);this.avatarPacks.forget(owner);this.mcpDefinitions.forget(owner) if(scopeKey===undefined)this.owners.delete(ref.owner_token);else parent?.views?.delete(scopeKey) return { disposed, leaked } } diff --git a/crates/tui/extension-host/src/shims/avatars.ts b/crates/tui/extension-host/src/shims/avatars.ts new file mode 100644 index 0000000000..7cf0a4afa1 --- /dev/null +++ b/crates/tui/extension-host/src/shims/avatars.ts @@ -0,0 +1,68 @@ +/** Reviewed bundle roots contribute to Rust's existing avatar catalog. */ +import { Service } from '@deepseek-ai/cordis' +import type { RpcPeer } from '../rpc.ts' +import { OwnedRegistrations, type OwnedEntry, type OwnerBase } from './owned.ts' + +export const MAX_AVATAR_PACKS_PER_OWNER = 4 +export const MAX_AVATAR_PACKS_PER_HOST = 16 +export interface AvatarPackDefinition { readonly path: string } +export interface LocalAvatarPack extends OwnedEntry {} + +export function normalizeAvatarPack(value: unknown): AvatarPackDefinition { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || Object.keys(value).some(key => key !== 'path')) throw new TypeError('avatar pack supports only path') + const { path } = value as { path?: unknown } + if (typeof path !== 'string' || !path || Buffer.byteLength(path, 'utf8') > 256 || !path.endsWith('.json') + || /[\\:\u0000-\u001f\u007f-\u009f]/u.test(path) + || path.split('/').some(part => !part || part === '.' || part === '..')) { + throw new TypeError('avatar pack path must be a bounded bundle-relative path with normal slash-separated components') + } + return Object.freeze({ path }) +} + +export class AvatarPacks { + private readonly registrations: OwnedRegistrations> + private readonly owners = new Map void>>() + private count = 0 + constructor(rpc: RpcPeer, ownedBy: (owner: O) => Map>, warn: (message: string, owner: O) => void) { + this.registrations = new OwnedRegistrations(rpc, 'avatar_pack', ownedBy, warn) + } + register(owner: O, definition: AvatarPackDefinition): () => void { + if (owner.state !== 'activating' && owner.state !== 'active') throw new Error('avatar owner is not live') + const { path } = normalizeAvatarPack(definition) + const roots = this.owners.get(owner) ?? new Map void>() + if (roots.has(path)) throw new Error('avatar pack is already registered; dispose it before registering it again') + if (roots.size >= MAX_AVATAR_PACKS_PER_OWNER || this.count >= MAX_AVATAR_PACKS_PER_HOST) throw new RangeError('avatar pack owner or host registration limit reached') + const undo = this.registrations.add({ owner, name: path, disposed: false }, { name: path, description: '' }) + const dispose = () => { + if (roots.get(path) !== dispose) return + roots.delete(path) + this.count-- + if (!roots.size) this.owners.delete(owner) + undo() + } + roots.set(path, dispose) + this.owners.set(owner, roots) + this.count++ + return dispose + } + forget(owner: O) { + for (const dispose of [...(this.owners.get(owner)?.values() ?? [])]) dispose() + this.registrations.forget(owner) + } +} + +export function defineAvatarsService(host: { ownerOf(ctx: any): O | undefined, avatarPacks: AvatarPacks }) { + class AvatarsShim extends Service { + constructor(ctx: any) { super(ctx, 'avatars') } + registerPack(definition: AvatarPackDefinition): () => void { + const ctx: any = this.ctx + const owner = host.ownerOf(ctx) + if (!owner) throw new Error('avatars.registerPack called outside an extension owner') + const root = normalizeAvatarPack(definition) + return ctx.effect(() => host.avatarPacks.register(owner, root), `avatars.registerPack(${JSON.stringify(root.path)})`) + } + } + Object.freeze(AvatarsShim.prototype) + return AvatarsShim +} diff --git a/crates/tui/extension-host/src/shims/owned.ts b/crates/tui/extension-host/src/shims/owned.ts index 9c73415f2f..92be931014 100644 --- a/crates/tui/extension-host/src/shims/owned.ts +++ b/crates/tui/extension-host/src/shims/owned.ts @@ -42,14 +42,14 @@ export class OwnedRegistrations> { readonly byHandle = new Map() private readonly rpc: RpcPeer - private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'shell_hook' | 'mcp_server' + private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'avatar_pack' | 'shell_hook' | 'mcp_server' /** The owner's own index of this kind, for the leak report at deactivation. */ private readonly ownedBy: (owner: O) => Map private readonly warn: (message: string, owner: O) => void constructor( rpc: RpcPeer, - kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'shell_hook' | 'mcp_server', + kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'avatar_pack' | 'shell_hook' | 'mcp_server', ownedBy: (owner: O) => Map, warn: (message: string, owner: O) => void, ) { diff --git a/crates/tui/extension-host/test/avatars-host.test.mjs b/crates/tui/extension-host/test/avatars-host.test.mjs new file mode 100644 index 0000000000..d6a8a4efda --- /dev/null +++ b/crates/tui/extension-host/test/avatars-host.test.mjs @@ -0,0 +1,56 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { activate, startHost } from './harness.mjs' + +function plugin(t, body) { + const directory = mkdtempSync(join(tmpdir(), 'cw-avatar-pack-')) + const entry = join(directory, 'index.mjs') + writeFileSync(entry, `export const inject = ['avatars']\nexport function apply(ctx) {\n${body}\n}\n`) + t.after(() => rmSync(directory, { recursive: true, force: true })) + return entry +} + +test('real host admits detached avatar pack proposals and disposes only their owner', async (t) => { + const host = await startHost() + t.after(() => host.stop()) + const entry = plugin(t, `const root = { path: 'avatars/avatar.json' }; ctx.avatars.registerPack(root); root.path = '../changed'`) + const a = await activate(host, 'avatar-a', entry), b = await activate(host, 'avatar-b', entry) + assert.equal(a.result.status, 'ok'); assert.equal(b.result.status, 'ok') + const registrations = host.registry.filter(item => item.op === 'register' && item.kind === 'avatar_pack') + assert.equal(registrations.length, 2) + assert.deepEqual(registrations[0].spec, { name: 'avatars/avatar.json', description: '' }) + assert.deepEqual(await host.call('ext/deactivate', { owner: a.ref }), { disposed: true, leaked: [] }) + assert.ok(host.registry.some(item => item.op === 'unregister' && item.handle === registrations[0].handle)) + assert.ok(!host.registry.some(item => item.op === 'unregister' && item.handle === registrations[1].handle)) +}) + +test('invalid and duplicate avatar packs fail activation and roll back proposals', async (t) => { + const host = await startHost() + t.after(() => host.stop()) + const cases = [ + [`ctx.avatars.registerPack({ path: '../outside' })`, /bundle-relative/], + [`ctx.avatars.registerPack({ path: 'avatars/avatar.json', watch: true })`, /only path/], + [`ctx.avatars.registerPack({ path: 'avatars/avatar.json' }); ctx.avatars.registerPack({ path: 'avatars/avatar.json' })`, /already registered/], + ] + for (const [index, [body, reason]] of cases.entries()) { + const before = host.registry.length + const { result } = await activate(host, `bad-root-${index}`, plugin(t, body)) + assert.equal(result.status, 'failed'); assert.match(result.diagnostic, reason) + for (const item of host.registry.slice(before).filter(item => item.op === 'register')) { + if (!host.registry.some(later => later.op === 'unregister' && later.handle === item.handle)) { + await host.waitFor(message => message.method === 'registry/unregister' && message.params.handle === item.handle) + } + } + } +}) + +test('real host preserves Rust root admission refusal in activation result', async (t) => { + const host = await startHost({ admit: () => ({ refused: 'root is outside the reviewed inventory' }) }) + t.after(() => host.stop()) + const { result } = await activate(host, 'refused-root', plugin(t, `ctx.avatars.registerPack({ path: 'avatars/avatar.json' })`)) + assert.equal(result.status, 'failed') + assert.match(result.diagnostic, /outside the reviewed inventory/) +}) diff --git a/crates/tui/extension-host/test/avatars.test.mjs b/crates/tui/extension-host/test/avatars.test.mjs new file mode 100644 index 0000000000..3d8b0daa1c --- /dev/null +++ b/crates/tui/extension-host/test/avatars.test.mjs @@ -0,0 +1,107 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { AvatarPacks, normalizeAvatarPack } from '../dist/avatars.mjs' + +function fixture({ deferred = false, refused } = {}) { + const calls = [], pending = [] + let next = 0 + const rpc = { request(method, params) { + calls.push({ method, params }) + if (method === 'registry/unregister') return Promise.resolve({}) + const result = refused ? { refused } : { handle: ++next } + if (!deferred) return Promise.resolve(result) + return new Promise(resolve => pending.push(() => resolve(result))) + } } + const roots = new AvatarPacks(rpc, owner => owner.roots, () => {}) + const owner = (id) => ({ ref: { plugin_id: id, generation: 1, owner_token: id }, state: 'active', pendingRegistrations: new Set(), refusals: [], roots: new Map() }) + return { roots, calls, pending, owner } +} +const settled = async owner => Promise.all([...owner.pendingRegistrations]) + +test('avatar packs snapshot only bounded clean bundle-relative paths', () => { + const input = { path: 'avatars/review/avatar.json' } + const normalized = normalizeAvatarPack(input) + input.path = '../outside' + assert.deepEqual(normalized, { path: 'avatars/review/avatar.json' }) + assert.ok(Object.isFrozen(normalized)) + for (const path of ['', '/absolute', '../outside', './same', 'a/../b', 'a//b', 'a/', 'C:/disk', 'a\\b', 'a\u001bb', '界'.repeat(171)]) { + assert.throws(() => normalizeAvatarPack({ path }), /bounded bundle-relative/) + } + for (const value of [null, [], 'path', { path: 'good.json', priority: 1 }]) assert.throws(() => normalizeAvatarPack(value)) +}) + +test('owned avatar packs dispose exact handles without crossing equal-path owners', async () => { + const { roots, calls, owner } = fixture() + const a = owner('a'), b = owner('b') + const undo = roots.register(a, { path: 'avatars/avatar.json' }) + roots.register(b, { path: 'avatars/avatar.json' }) + await Promise.all([settled(a), settled(b)]) + assert.deepEqual(calls.filter(call => call.method === 'registry/register').map(call => call.params.spec), [ + { name: 'avatars/avatar.json', description: '' }, { name: 'avatars/avatar.json', description: '' }, + ]) + undo(); undo() + await Promise.resolve() + assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1]) + assert.equal(b.roots.size, 1) + roots.forget(b) + await Promise.resolve() + assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1, 2]) +}) + +test('pending roots count toward limits and disposal before admission cannot remove replacement', async () => { + const { roots, calls, pending, owner } = fixture({ deferred: true }) + const a = owner('a') + const old = roots.register(a, { path: 'avatar.json' }) + assert.throws(() => roots.register(a, { path: 'avatar.json' }), /already registered/) + old() + roots.register(a, { path: 'avatar.json' }) + for (let i = 0; i < 3; i++) roots.register(a, { path: `avatar-${i}.json` }) + assert.throws(() => roots.register(a, { path: 'ninth.json' }), /limit/) + pending.forEach(resolve => resolve()) + await settled(a) + assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1)) + assert.ok(!calls.some(call => call.method === 'registry/unregister' && call.params.handle === 2)) + roots.forget(a) + roots.register(a, { path: 'after-disposal.json' }) + pending.at(-1)() + await settled(a) + assert.equal(a.roots.size, 1) +}) + +test('host root cap includes multiple owners and recovers after owner teardown', async () => { + const { roots, owner } = fixture() + const owners = Array.from({ length: 4 }, (_, i) => owner(String(i))) + for (const current of owners) for (let i = 0; i < 4; i++) roots.register(current, { path: `avatar-${i}.json` }) + assert.throws(() => roots.register(owner('overflow'), { path: 's.json' }), /limit/) + await Promise.all(owners.map(settled)) + roots.forget(owners[0]) + const replacement = owner('replacement') + roots.register(replacement, { path: 's.json' }) + await settled(replacement) + assert.equal(replacement.roots.size, 1) +}) + +test('revoked owner cannot register and a late admitted root is withdrawn', async () => { + const { roots, calls, pending, owner } = fixture({ deferred: true }) + const a = owner('a') + roots.register(a, { path: 'avatar.json' }) + a.state = 'disposed' + roots.forget(a) + assert.throws(() => roots.register(a, { path: 'late.json' }), /not live/) + pending.forEach(resolve => resolve()) + await settled(a) + assert.equal(a.roots.size, 0) + assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1)) +}) + +test('core refusal is preserved and local reservations retire with owner', async () => { + const { roots, owner } = fixture({ refused: 'reviewed bundle was changed' }) + const a = owner('a') + roots.register(a, { path: 'avatar.json' }) + await settled(a) + assert.match(a.refusals[0], /avatar_pack.*reviewed bundle was changed/) + assert.equal(a.roots.size, 0) + roots.forget(a) + roots.register(a, { path: 'avatar.json' }) + await settled(a) +}) diff --git a/crates/tui/extension-host/test/harness.mjs b/crates/tui/extension-host/test/harness.mjs index 21c937021a..2ce2bb1113 100644 --- a/crates/tui/extension-host/test/harness.mjs +++ b/crates/tui/extension-host/test/harness.mjs @@ -78,8 +78,11 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', nextHandle: 1, exit: new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal }))), send(message) { + // Shutdown abandons queued fake-core replies; never write them to stdin + // after `stop()` has ended the stream (Windows reports this as an error). + if (stopping || child.exitCode !== null || child.signalCode !== null || child.stdin.destroyed || child.stdin.writableEnded) return false validateMessage(message, 'core_to_host', tier ?? 'plugin') - child.stdin.write(encodeFrame(message)) + return child.stdin.write(encodeFrame(message)) }, request(method, params) { const id = host.nextId++ diff --git a/crates/tui/extension-host/test/mcp-bundle-redaction.test.mjs b/crates/tui/extension-host/test/mcp-bundle-redaction.test.mjs new file mode 100644 index 0000000000..f33da17c31 --- /dev/null +++ b/crates/tui/extension-host/test/mcp-bundle-redaction.test.mjs @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +const here = dirname(fileURLToPath(import.meta.url)) +const bundle = readFileSync(join(here, '..', 'dist', 'builtin', 'mcp.mjs'), 'utf8') + +test('bundled MCP OAuth errors omit response bodies and dynamic error details', () => { + assert.doesNotMatch(bundle, /Raw body: \$\{body\}/) + assert.doesNotMatch(bundle, /Cause: \$\{JSON\.stringify\(error2\.message\)\}/) + assert.doesNotMatch(bundle, /Cause: \$\{JSON\.stringify\(error2 instanceof Error/) + assert.match(bundle, /OAuth error response details omitted/) + assert.ok(bundle.includes('falling back to a new authorization request.')) +}) + +test('credential retry logging never reads provider-controlled OAuth error fields', () => { + const logger = bundle.match(/function warnCredentialInvalidation\(provider, error2, invalidated\) \{[\s\S]*?\n\}/)?.[0] + assert.ok(logger, 'the shipped SDK credential retry logger must be exercised') + for (const provider of [{}, { invalidateCredentials() {} }]) { + const warnings = [] + runInNewContext(`${logger}\nwarnCredentialInvalidation(provider, error, "tokens");`, { + provider, + error: new Proxy({}, { get() { throw new Error('read of provider-controlled OAuth error') } }), + console: { warn: message => warnings.push(message) }, + }) + assert.equal(warnings.length, 1) + assert.match(warnings[0], /tokens.*retrying authorization|retrying authorization.*tokens/) + assert.match(warnings[0], /details omitted/) + } +}) diff --git a/crates/tui/extension-host/test/prompt.test.mjs b/crates/tui/extension-host/test/prompt.test.mjs index c4a83b6021..c1ba9ec4e0 100644 --- a/crates/tui/extension-host/test/prompt.test.mjs +++ b/crates/tui/extension-host/test/prompt.test.mjs @@ -14,6 +14,13 @@ function plugin(t, body) { return entry } +test('fake core drops late replies after host shutdown closes stdin', async () => { + const host = await startHost() + const stopping = host.stop() + assert.equal(host.send({ jsonrpc: '2.0', method: '$/cancel', params: { id: 1 } }), false) + await stopping +}) + test('prompt sections detach text, allow owner-local ids and dispose exact registrations', async (t) => { const host = await startHost() t.after(() => host.stop()) diff --git a/crates/tui/plugins/computer-use.upstream-sha b/crates/tui/plugins/computer-use.upstream-sha index 63411d7412..ccc37ca5b3 100644 --- a/crates/tui/plugins/computer-use.upstream-sha +++ b/crates/tui/plugins/computer-use.upstream-sha @@ -1 +1 @@ -a656f67455fc5639f28304fbf61075db3925058a +724f9c422db1a51880dc81154dae70816c475ed8 diff --git a/crates/tui/plugins/computer-use/README.md b/crates/tui/plugins/computer-use/README.md index 6daa95e063..c195b23e1a 100644 --- a/crates/tui/plugins/computer-use/README.md +++ b/crates/tui/plugins/computer-use/README.md @@ -97,7 +97,8 @@ Arbitrary background dragging remains unavailable. Rebuild Core to include the updated native helper; updating a separate marketplace checkout alone does not update an already-installed Core binary. -This embedded source matches canonical 9a261c4. Windows controlled-desktop +The canonical runtime revision is recorded in `../computer-use.upstream-sha`; +this guide and package identity are specific to the Engine bundle. Windows controlled-desktop acceptance passed in upstream CI; signed Windows distribution, mixed-DPI/raw input and continuous keyboard coexistence remain unqualified. Core discovery and materialization tests do not constitute an installed model-driven trial. diff --git a/crates/tui/plugins/computer-use/package-lock.json b/crates/tui/plugins/computer-use/package-lock.json index 6ce6cb6145..c521308b4c 100644 --- a/crates/tui/plugins/computer-use/package-lock.json +++ b/crates/tui/plugins/computer-use/package-lock.json @@ -10,10 +10,11 @@ "license": "MIT", "bin": { "codewhale-cu": "mcp/server.mjs", - "codewhale-cu-app": "app/daemon.mjs" + "codewhale-cu-app": "app/daemon.mjs", + "codewhale-cu-turn-hold": "mcp/turn-hold.mjs" }, "devDependencies": { - "sharp": "0.35.4" + "sharp": "0.35.5" }, "engines": { "node": ">=20" @@ -41,9 +42,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -60,13 +61,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -83,13 +84,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -97,7 +98,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -107,9 +108,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -124,9 +125,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -141,9 +142,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -158,9 +159,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -175,9 +176,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -192,9 +193,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -209,9 +210,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -226,9 +227,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -243,9 +244,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -260,9 +261,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -277,9 +278,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -296,13 +297,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -319,13 +320,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -342,13 +343,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -365,13 +366,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -388,13 +389,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -411,13 +412,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -434,13 +435,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -457,13 +458,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -478,9 +479,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -488,7 +489,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -498,9 +499,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -518,9 +519,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -538,9 +539,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -581,9 +582,9 @@ } }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -598,31 +599,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { diff --git a/crates/tui/plugins/computer-use/package.json b/crates/tui/plugins/computer-use/package.json index 10abf8a869..b8ba5881da 100644 --- a/crates/tui/plugins/computer-use/package.json +++ b/crates/tui/plugins/computer-use/package.json @@ -17,6 +17,6 @@ "node": ">=20" }, "devDependencies": { - "sharp": "0.35.4" + "sharp": "0.35.5" } } diff --git a/crates/tui/plugins/computer-use/src/backends/win32.mjs b/crates/tui/plugins/computer-use/src/backends/win32.mjs index 0baaf651d4..484bbd644c 100644 --- a/crates/tui/plugins/computer-use/src/backends/win32.mjs +++ b/crates/tui/plugins/computer-use/src/backends/win32.mjs @@ -380,18 +380,28 @@ foreach ($t in $targets) { $entry = $stack.Pop(); $cur = $entry[0]; $path = $entry[1]; if ($els.Count -ge $max) { $truncated = $true; break } $rect = $cur.Current.BoundingRectangle; + # UIA returns Rect.Empty for controls without a visible rectangle. Keep + # their semantic identity, but never turn absent/nonfinite bounds into a click. + $position = $null; $size = $null; + if (-not $rect.IsEmpty -and $rect.X -ge [int]::MinValue -and $rect.X -le [int]::MaxValue -and + $rect.Y -ge [int]::MinValue -and $rect.Y -le [int]::MaxValue -and + $rect.Width -gt 0 -and $rect.Width -le [int]::MaxValue -and + $rect.Height -gt 0 -and $rect.Height -le [int]::MaxValue) { + $position = [pscustomobject]@{ x = [int]$rect.X; y = [int]$rect.Y }; + $size = [pscustomobject]@{ w = [int]$rect.Width; h = [int]$rect.Height }; + } $acts = @(); try { $acts = @($cur.GetSupportedPatterns() | ForEach-Object { $_.ProgrammaticName -replace 'PatternIdentifiers\\.Pattern$','' -replace 'Pattern$','' }) } catch {} $value = ''; $vp = $null; if (-not $cur.Current.IsPassword -and $cur.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) { $value = [string]$vp.Current.Value } [void]$els.Add([pscustomobject]@{ index = $els.Count; path = @($path); runtime_id = @($cur.GetRuntimeId()); window_runtime_id = $windowId; role = [string]$cur.Current.ControlType.ProgrammaticName; label = [string]$cur.Current.Name; value = $value.Substring(0, [Math]::Min(120, $value.Length)); enabled = $cur.Current.IsEnabled; - x = [int]$rect.X; y = [int]$rect.Y; w = [int]$rect.Width; h = [int]$rect.Height; actions = $acts }); + position = $position; size = $size; actions = $acts }); $kids = $cur.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition); for ($i = $kids.Count - 1; $i -ge 0; $i--) { $stack.Push(@($kids[$i], ($path + $i))) } } break; } -$result = [pscustomobject]@{ found = $found; name = $appName; truncated = $truncated; elements = @($els | ForEach-Object { [pscustomobject]@{ index = $_.index; path = @($_.path); runtime_id = $_.runtime_id; window_runtime_id = $_.window_runtime_id; role = ($_.role -replace 'ControlType.',''); label = $_.label; value = $_.value; enabled = $_.enabled; position = [pscustomobject]@{ x = $_.x; y = $_.y }; size = [pscustomobject]@{ w = $_.w; h = $_.h }; actions = $_.actions } }) }; +$result = [pscustomobject]@{ found = $found; name = $appName; truncated = $truncated; elements = @($els | ForEach-Object { [pscustomobject]@{ index = $_.index; path = @($_.path); runtime_id = $_.runtime_id; window_runtime_id = $_.window_runtime_id; role = ($_.role -replace 'ControlType.',''); label = $_.label; value = $_.value; enabled = $_.enabled; position = $_.position; size = $_.size; actions = $_.actions } }) }; Write-Output ($result | ConvertTo-Json -Depth 6 -Compress);`, { timeoutMs: 60_000 }); if (!j.found) throw new ExecError("application window not found in UIA tree — pass app_ref.name as the exact window title from list_windows or list_apps.title"); return j; diff --git a/crates/tui/plugins/computer-use/tests/fixtures/windows-desktop.ps1 b/crates/tui/plugins/computer-use/tests/fixtures/windows-desktop.ps1 index 8bb043cf52..2868bb94ca 100644 --- a/crates/tui/plugins/computer-use/tests/fixtures/windows-desktop.ps1 +++ b/crates/tui/plugins/computer-use/tests/fixtures/windows-desktop.ps1 @@ -22,9 +22,19 @@ $form.Controls.Add($panel) $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 100 $timer.Add_Tick({ + try { $json = @{ title = $form.Text; visible = $form.Visible; text = $edit.Text; clicks = $script:clicks; hwnd = $form.Handle.ToInt64() } | ConvertTo-Json -Compress [IO.File]::WriteAllText($Receipt + '.tmp', $json, (New-Object Text.UTF8Encoding($false))) - Move-Item -Force ($Receipt + '.tmp') $Receipt + if ([IO.File]::Exists($Receipt)) { + [IO.File]::Replace($Receipt + '.tmp', $Receipt, [NullString]::Value) + } else { + [IO.File]::Move($Receipt + '.tmp', $Receipt) + } + } catch { + [Console]::Error.WriteLine($_.Exception.ToString()) + $timer.Stop() + $form.Close() + } }) $timer.Start() try { [System.Windows.Forms.Application]::Run($form) } finally { $timer.Dispose(); $form.Dispose() } diff --git a/crates/tui/plugins/computer-use/tests/wait-for.test.mjs b/crates/tui/plugins/computer-use/tests/wait-for.test.mjs index 4aee1cb3fd..e0c3f0b68e 100644 --- a/crates/tui/plugins/computer-use/tests/wait-for.test.mjs +++ b/crates/tui/plugins/computer-use/tests/wait-for.test.mjs @@ -118,7 +118,7 @@ test("wait_for times out honestly when the predicate never holds", async () => { assert.equal(r.ok, true, JSON.stringify(r)); assert.equal(r.matched, false); assert.equal(r.timed_out, true); - assert.ok(r.polls >= 2, `expected several polls, got ${r.polls}`); + assert.ok(r.polls >= 1, `expected an observation before the deadline, got ${r.polls}`); assert.equal(r.state_id, undefined, "a timed-out wait binds nothing"); assert.ok(calls("get_app_state").length - before >= r.polls - 1, "ephemeral polls reached the backend"); }); @@ -126,11 +126,18 @@ test("wait_for times out honestly when the predicate never holds", async () => { test("ephemeral wait_for polls do not evict earlier states", async () => { const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } }); assert.ok(st.state_id); - // ~30 polls: more than the 24-state cache cap. If polls were cached, st - // would be evicted; ephemeral polling keeps it targetable. - const w = await tool("wait_for", { query: "never-present-label", timeout: 3, interval: 100 }); - assert.equal(w.timed_out, true); - assert.ok(w.polls > 24, `expected >24 polls to prove non-caching, got ${w.polls}`); + // Count actual observations beyond the 24-state cache cap. A loaded runner + // may complete fewer polls per deadline; at most 25 bounded waits suffice. + const before = calls("get_app_state").length; + let polls = 0; + while (polls <= 24) { + const w = await tool("wait_for", { query: "never-present-label", timeout: 0.5, interval: 100 }); + assert.equal(w.ok, true, JSON.stringify(w)); + assert.equal(w.timed_out, true); + assert.ok(Number.isSafeInteger(w.polls) && w.polls > 0, `expected completed polls, got ${w.polls}`); + polls += w.polls; + } + assert.ok(calls("get_app_state").length - before > 24, "more than a cache capacity of observations reached the backend"); const focus = await tool("focus", { target: { type: "element", state_id: st.state_id, index: 1 } }); assert.equal(focus.ok, true, JSON.stringify(focus.error)); }); diff --git a/crates/tui/plugins/computer-use/tests/win32-targeting.test.mjs b/crates/tui/plugins/computer-use/tests/win32-targeting.test.mjs index 08becddaba..ed9e5b6b2f 100644 --- a/crates/tui/plugins/computer-use/tests/win32-targeting.test.mjs +++ b/crates/tui/plugins/computer-use/tests/win32-targeting.test.mjs @@ -202,3 +202,30 @@ test("Windows PowerShell refuses substring-only and ambiguous exact names", WIND const ambiguous = nativeFixture((script) => stateFixture(script, ["Fixture", "FIXTURE"])); await assert.rejects(ambiguous.get_app_state({ app_ref: { name: "Fixture" } }), /More than one application window/u); }); + +test("Windows UIA geometry retains negative screen coordinates and omits absent or nonfinite rectangles", WINDOWS, async () => { + const cases = [ + ["[System.Windows.Rect]::Empty", null, null], + ["[System.Windows.Rect]::new(-40, 20, 300, 40)", { x: -40, y: 20 }, { w: 300, h: 40 }], + ["[System.Windows.Rect]::new(0, 0, 0, 40)", null, null], + ...["[double]::NaN", "[double]::PositiveInfinity", "2147483648"].map(x => [ + `[pscustomobject]@{ IsEmpty = $false; X = ${x}; Y = 0; Width = 20; Height = 20 }`, null, null, + ]), + ]; + for (const [rect, position, size] of cases) { + const backend = nativeFixture(original => { + const start = " $position = $null; $size = $null;"; + const end = " $acts = @();"; + assert.equal(original.split(start).length, 2); + assert.equal(original.split(end).length, 2); + const geometry = original.slice(original.indexOf(start), original.indexOf(end)); + return `Add-Type -AssemblyName WindowsBase; +$rect = ${rect}; +${geometry} +Write-Output (@{ found = $true; name = 'Fixture'; elements = @(@{ position = $position; size = $size }) } | ConvertTo-Json -Depth 6 -Compress);`; + }); + const state = await backend.get_app_state({ app_ref: { name: "Fixture" } }); + assert.deepEqual(state.elements[0].position, position, rect); + assert.deepEqual(state.elements[0].size, size, rect); + } +}); diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 79cd4c3c8f..c3ca88944c 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -3970,6 +3970,11 @@ impl CommandPluginContext for PluginAdapter<'_> { } let mut app = self.host.app.borrow_mut(); std::sync::Arc::make_mut(&mut app.plugin_registry).enable(selector)?; + // Rediscover, as install and reload do: an in-place enable keeps the + // pinned Native selection, so a host plugin enabled after a preset + // catalog was already active would never be activated. + let workspace = app.workspace.clone(); + app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace); app.refresh_skill_cache(); Ok(()) } @@ -4195,37 +4200,58 @@ impl CommandPluginContext for PluginAdapter<'_> { &self, package: &Path, ) -> Result { - let canonical = package - .canonicalize() - .map_err(|_| format!("DSH package not found at {}", package.display()))?; - let (conversion, content_hash) = crate::plugins::install::preview_dsh(&canonical) - .map_err(|error| format!("{error:#}"))?; - Ok(codewhale_command_contract::facets::PluginDshPreview { - package_path: canonical, - plugin_name: conversion.plugin_name, - source_package: conversion.source_package, - source_version: conversion.source_version, - content_hash, - skills: conversion.skills, - remote_servers: conversion.remote_servers, - local_servers: conversion.local_servers, - network_hosts: conversion.network_hosts, - requires_node: conversion.requires_node, - manual_ports: conversion - .outcomes - .iter() - .filter(|outcome| outcome.needs_manual_port()) - .map(|outcome| { - format!( - "{} ({}) {}: {}", - outcome.row.as_deref().unwrap_or("unlabeled"), - outcome.package.as_deref().unwrap_or("unlabeled"), - outcome.kind, - outcome.reason - ) - }) - .collect(), - diagnostics: conversion.diagnostics, + let package = package.to_path_buf(); + let preview = move || { + let canonical = package + .canonicalize() + .map_err(|_| format!("DSH package not found at {}", package.display()))?; + let (conversion, content_hash) = crate::plugins::install::preview_dsh(&canonical) + .map_err(|error| format!("{error:#}"))?; + Ok(codewhale_command_contract::facets::PluginDshPreview { + package_path: canonical, + plugin_name: conversion.plugin_name, + source_package: conversion.source_package, + source_version: conversion.source_version, + content_hash, + skills: conversion.skills, + remote_servers: conversion.remote_servers, + local_servers: conversion.local_servers, + network_hosts: conversion.network_hosts, + native_rows: conversion.native_rows, + requires_node: conversion.requires_node, + manual_ports: conversion + .outcomes + .iter() + .filter(|outcome| outcome.needs_manual_port()) + .map(|outcome| { + format!( + "{} ({}) {}: {}", + outcome.row.as_deref().unwrap_or("unlabeled"), + outcome.package.as_deref().unwrap_or("unlabeled"), + outcome.kind, + outcome.reason + ) + }) + .collect(), + diagnostics: conversion.diagnostics, + }) + }; + // Native composition review owns a bounded async process runner. Like + // the Runtime API and installer, keep the complete filesystem/reviewer + // operation off the TUI worker before entering that runner. + if tokio::runtime::Handle::try_current().is_err() { + return preview(); + } + #[cfg(test)] + let ticket = crate::test_support::env_scope_ticket(); + run_async(async move { + tokio::task::spawn_blocking(move || { + #[cfg(test)] + let _membership = crate::test_support::join_env_scope(ticket); + preview() + }) + .await + .map_err(|error| format!("DSH preview task failed: {error}"))? }) } @@ -6499,6 +6525,37 @@ mod tests { assert!(plugin.validation_is_clean()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn plugin_adapter_dsh_preview_runs_from_live_runtime() { + let _home = crate::test_support::SealedHome::new(); + if crate::extension_host::tests::node_for_tests( + "plugin_adapter_dsh_preview_runs_from_live_runtime", + ) + .is_none() + { + return; + } + let tmp = TempDir::new().unwrap(); + let mut app = plugin_test_app(&tmp); + let before = app.plugin_registry.len(); + let package = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/extension_host/raw-agent-presets/source"); + let mut bundle = app.command_contexts(); + let mut parts = bundle.contexts(CommandCapabilities::PLUGIN).into_parts(); + let plugin = parts.plugin.as_deref_mut().unwrap(); + let review = plugin + .dsh_preview(&package) + .expect("live TUI Native review"); + assert_eq!(review.plugin_name, "raw-agent-presets"); + assert_eq!( + review.source_package.as_deref(), + Some("@demo/raw-agent-presets") + ); + assert_eq!(review.content_hash.len(), 64); + assert!(review.manual_ports.is_empty(), "{:?}", review.manual_ports); + assert_eq!(plugin.len(), before, "preview must not install a bundle"); + } + #[test] fn plugin_adapter_registry_mutations_and_suggest_are_behavior_faithful() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index b3baa7c777..3f8f59c3a8 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -714,7 +714,7 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult { /// appearance studio, the native window, source selection, replay export and /// the single audio lease. The pet has no workbar panel. pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult { - const USAGE: &str = "Usage: /pet [on|off|status|appearance|window|source|export|sound on|off]"; + const USAGE: &str = "Usage: /pet [on|off|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]"; use crate::tui::pet_watch::{self, Control}; let words = arg .map(str::trim) @@ -765,6 +765,21 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult { ), app.pet_watch.status() )), + ["avatar"] => CommandResult::message(app.pet_watch.avatar_choices()), + ["avatar", key] => { + if !app.pet_watch.select_avatar(key) { + return CommandResult::error(USAGE); + } + app.needs_redraw = true; + CommandResult::message(format!("/pet avatar {key}")) + } + [kind @ ("action" | "view"), name] => { + if !app.pet_watch.preview_avatar(name, *kind == "view") { + return CommandResult::error(USAGE); + } + app.needs_redraw = true; + CommandResult::message(format!("/pet {kind} {name}")) + } ["appearance"] => queued(app, Control::Browser), ["window"] => queued(app, Control::Window), ["source"] => queued(app, Control::Select), diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index cb401dd519..c76b49129b 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -74,7 +74,7 @@ static RAIL_INFO: CommandInfo = CommandInfo { static PET_INFO: CommandInfo = CommandInfo { name: "pet", aliases: &[], - usage: "/pet [on|off|status|appearance|window|source|export|sound on|off]", + usage: "/pet [on|off|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]", description_id: MessageId::CmdPetDescription, }; static SETTINGS_INFO: CommandInfo = CommandInfo { diff --git a/crates/tui/src/commands/groups/plugins/dsh_import.rs b/crates/tui/src/commands/groups/plugins/dsh_import.rs index 7624a62b7c..d9f8524a38 100644 --- a/crates/tui/src/commands/groups/plugins/dsh_import.rs +++ b/crates/tui/src/commands/groups/plugins/dsh_import.rs @@ -73,6 +73,18 @@ fn review(plugin: &dyn CommandPluginContext, path: &str) -> CommandResult { "Network hosts it will request", &preview.network_hosts, ); + if !preview.native_rows.is_empty() { + list_line( + &mut output, + "Native host code (JavaScript; runs in the experimental extension host)", + &preview.native_rows, + ); + if !crate::plugins::activation::extension_host_policy_enabled() { + output.push_str( + " Requires: [features] extension_host = true (off now, so this bundle cannot be enabled)\n", + ); + } + } if preview.requires_node { output.push_str(" Requires: node on PATH\n"); } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 9fb2320afd..f279cf821f 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -959,6 +959,9 @@ pub struct Engine { /// compaction uses these same bytes, never a mid-turn host re-sampling. extension_prompt_block: Option, constitution_block: Option, + /// The local-fallback notice for an unavailable account profile is shown + /// once per engine; later turns only log it. + profile_constitution_fallback_noticed: bool, api_provider: ProviderKind, /// One captured admitted route. Presentation snapshots derive strings from /// it; a changed table cannot be blessed by reinterpreting those strings. @@ -2237,6 +2240,7 @@ impl Engine { extension_host, extension_prompt_block: None, constitution_block: None, + profile_constitution_fallback_noticed: false, api_provider, api_provider_identity, active_route_limits, @@ -5911,6 +5915,7 @@ impl Engine { { self.constitution_block.clone() } else { + let host_supplied = profile_constitution.is_some(); match crate::profile_constitution::capture( self.api_config.account_profile.as_deref(), profile_constitution, @@ -5918,6 +5923,25 @@ impl Engine { .await { Ok(block) => block, + // Local use never depends on the Codewhale account: an expired + // sign-in or unreachable account service falls back to the + // signed-out local constitution, never another account's. + Err(error) if !host_supplied => { + tracing::warn!(%error, "account profile constitution unavailable; using local constitution"); + if !self.profile_constitution_fallback_noticed { + self.profile_constitution_fallback_noticed = true; + let _ = self + .send_event(Event::status( + codewhale_localization::tr( + codewhale_localization::resolve_locale(&self.config.locale_tag), + codewhale_localization::MessageId::ProfileConstitutionUnavailableLocal, + ) + .to_string(), + )) + .await; + } + crate::prompts::load_user_constitution_block() + } Err(error) => { crate::cost_status::report_runtime_usage_batch( crate::cost_status::scope_token(), diff --git a/crates/tui/src/exec_agent.rs b/crates/tui/src/exec_agent.rs index 2c93760a0f..4d55ae09c9 100644 --- a/crates/tui/src/exec_agent.rs +++ b/crates/tui/src/exec_agent.rs @@ -737,7 +737,15 @@ pub(crate) async fn run_exec_agent( let mut loaded_session_id = None; if let Some(saved) = resume_session { let saved_id = saved.metadata.id.clone(); - if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text { + let saved_workspace = tokio::fs::canonicalize(&saved.metadata.workspace) + .await + .unwrap_or_else(|_| saved.metadata.workspace.clone()); + let launch_workspace = tokio::fs::canonicalize(&workspace) + .await + .unwrap_or_else(|_| workspace.clone()); + if !paths_equal_for_config(&saved_workspace, &launch_workspace) + && output_format == ExecOutputFormat::Text + { eprintln!( "Warning: session {} was created in a different workspace ({}). Resuming anyway.", truncate_id(&saved_id), diff --git a/crates/tui/src/extension_host/avatars.rs b/crates/tui/src/extension_host/avatars.rs new file mode 100644 index 0000000000..f98cb4544f --- /dev/null +++ b/crates/tui/src/extension_host/avatars.rs @@ -0,0 +1,377 @@ +//! Declarative art registered through the existing reviewed Native owner. +//! No script, lifecycle event, input handler, URL, or filesystem authority is +//! delivered to clients. Named actions are clips on the caller's existing clock. +use super::{ + ManagerShared, + protocol::{EntryRef, OwnerRef, RegisterParams, RegisterResult}, + supervisor::HostRequestContext, + tier::HostTier, +}; +use crate::plugins::{ + activation::PluginActivationCapability, + manifest::{PluginManifest, ValidatedManifest}, + types::PluginAuthority, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use codewhale_ratatui::avatar::{MAX_PACK_BYTES, MAX_PNG_BYTES, Pack, RegisteredPack}; +use sha2::{Digest, Sha256}; +use std::{ + io::Read, + path::Path, + sync::{Arc, atomic::Ordering}, +}; + +#[derive(Clone, Debug)] +pub(crate) struct AvatarRegistration { + pub handle: u64, + pub owner: OwnerRef, + pub scope: Option, + pub host_generation: u64, + pub content_hash: String, + pub pack: Pack, + pub png: Arc>>, +} + +fn reviewed_bytes( + review: &ValidatedManifest, + relative: &Path, + limit: usize, +) -> Result, String> { + let expected = review + .file_hashes + .get(relative) + .ok_or("avatar file is outside reviewed inventory")?; + let path = review.canonical_root.join(relative); + crate::fleet::files::reject_linked_path(&review.canonical_root, &path) + .map_err(|e| e.to_string())?; + let file = + crate::fs_confined::open_read(&review.canonical_root, &path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("avatar asset is not a regular file".into()); + } + let mut bytes = Vec::new(); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > limit { + return Err("avatar file exceeds its byte budget".into()); + } + let mut digest = Sha256::new(); + digest.update(b"codewhale-plugin-file-bytes-v1\0"); + digest.update(&bytes); + if digest + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + != *expected + { + return Err("avatar asset changed since review".into()); + } + Ok(bytes) +} + +fn snapshot(authority: &PluginAuthority, relative: &Path) -> Result<(Pack, Vec>), String> { + crate::plugins::registry::verify_plugin_component_authority( + authority, + PluginActivationCapability::Native, + )?; + let review = PluginManifest::validate_from_path(&authority.staged_manifest)?; + if review.content_hash != authority.content_hash + || review.capability_hash != authority.capability_hash + { + return Err("avatar bundle no longer matches its review".into()); + } + let pack = Pack::parse(&reviewed_bytes(&review, relative, MAX_PACK_BYTES)?)?; + let mut images = Vec::new(); + let mut total = 0; + for atlas in &pack.atlases { + let path = relative.parent().unwrap_or(Path::new("")).join(atlas); + let png = reviewed_bytes(&review, &path, MAX_PNG_BYTES)?; + total += png.len(); + if total > 16 * 1024 * 1024 { + return Err("avatar pages exceed the pack byte budget".into()); + } + pack.validate_png(&png)?; + image::load_from_memory_with_format(&png, image::ImageFormat::Png) + .map_err(|_| "avatar PNG cannot be decoded")?; + images.push(png); + } + crate::plugins::registry::verify_plugin_component_authority( + authority, + PluginActivationCapability::Native, + )?; + Ok((pack, images)) +} + +pub(super) async fn admit( + shared: &Arc, + tier: HostTier, + host_generation: u64, + params: RegisterParams, + cx: &HostRequestContext, +) -> RegisterResult { + let result = async { + params.check_spec()?; + if tier != HostTier::Plugin || !params.spec.description.is_empty() { + return Err("avatar packs require a reviewed Native bundle and a manifest path".into()); + } + if !codewhale_ratatui::avatar::relative_asset(¶ms.spec.name) + || !params.spec.name.ends_with(".json") + { + return Err("avatar manifest must be a bounded bundle-relative JSON path".into()); + } + let authority = shared + .live_owner_authority(tier, |registry| { + registry + .authority_for(¶ms.owner) + .ok_or("stale avatar owner")?; + Ok(params.owner.clone()) + })? + .ok_or("avatar owner has no reviewed authority")?; + let relative = std::path::PathBuf::from(¶ms.spec.name); + let (pack, png) = super::skills::bounded_review_check( + Arc::clone(&shared.skill_admission), + &cx.cancel, + move || snapshot(&authority, &relative), + ) + .await?; + shared.ready_host(tier).map_err(|s| s.to_string())?; + let runtime = shared.tier_runtime(tier); + let _slot = runtime.host.lock().expect("host lock"); + if runtime.host_generation.load(Ordering::SeqCst) != host_generation + || cx.cancel.is_cancelled() + { + return Err("avatar admission cancelled or host replaced".into()); + } + shared + .registry + .lock() + .expect("registry lock") + .register_avatar(¶ms, pack, png, host_generation) + } + .await; + match result { + Ok(handle) => RegisterResult::Admitted { handle }, + Err(refused) => { + shared.plugin_diagnostic( + ¶ms.owner.plugin_id, + format!("avatar pack refused: {refused}"), + ); + RegisterResult::Refused { refused } + } + } +} + +/// Workspace-wide decorative catalog: every entry still needs a live owner, +/// live composition scope, current host, and current persisted Native receipt. +/// Each request is bounded by the same disk-admission semaphore as Native Skills. +async fn live(workspace: std::path::PathBuf) -> Vec { + if !super::activation::extension_host_policy_enabled() { + return Vec::new(); + } + let manager = super::manager(); + let shared = Arc::clone(&manager.shared); + let admission = Arc::clone(&shared.skill_admission); + super::skills::bounded_review_check( + admission, + &tokio_util::sync::CancellationToken::new(), + move || { + let entries = shared + .registry + .lock() + .expect("registry lock") + .live_avatars(); + let mut catalog = Vec::new(); + for entry in entries { + let authority = shared + .registry + .lock() + .expect("registry lock") + .authority_for(&entry.owner); + let Some(authority) = authority else { continue }; + if authority.workspace != workspace + || authority.content_hash != entry.content_hash + || crate::plugins::registry::verify_plugin_component_authority( + &authority, + PluginActivationCapability::Native, + ) + .is_err() + { + continue; + } + let Ok(host) = shared.ready_host(HostTier::Plugin) else { + continue; + }; + if host.generation != entry.host_generation { + continue; + } + if !shared + .registry + .lock() + .expect("registry lock") + .live_avatars() + .iter() + .any(|r| r.handle == entry.handle) + { + continue; + } + catalog.push(entry); + } + Ok(catalog) + }, + ) + .await + .unwrap_or_default() +} + +pub(crate) async fn catalog(workspace: std::path::PathBuf) -> Vec { + let mut packs: Vec<_> = live(workspace) + .await + .into_iter() + .map(|entry| RegisteredPack { + key: format!("plugin:{}:{}", entry.owner.plugin_id, entry.pack.id), + handle: entry.handle, + content_hash: entry.content_hash, + pack: entry.pack, + }) + .collect(); + packs.sort_by(|a, b| a.key.cmp(&b.key).then(a.handle.cmp(&b.handle))); + packs.dedup_by(|a, b| a.key == b.key); + packs +} +pub(crate) async fn atlas( + workspace: std::path::PathBuf, + handle: u64, + page: usize, + content_hash: &str, +) -> Option { + live(workspace) + .await + .into_iter() + .find(|r| r.handle == handle && r.content_hash == content_hash) + .and_then(|r| r.png.get(page).map(|png| STANDARD.encode(png))) +} + +#[cfg(test)] +mod tests { + use super::super::{ + protocol::{RegisterKind, RegisterSpecWire}, + registry::OwnerRegistry, + tests::fake_authority, + }; + use super::*; + fn params(owner: OwnerRef) -> RegisterParams { + RegisterParams { + owner, + scope: None, + kind: RegisterKind::AvatarPack, + spec: RegisterSpecWire { + name: "avatars/avatar.json".into(), + description: String::new(), + input_schema: None, + argument_hint: None, + }, + } + } + #[test] + fn avatar_registration_is_native_owned_and_revocation_removes_it() { + let mut registry = OwnerRegistry::new(); + let a = registry + .begin_owner( + HostTier::Plugin, + "a", + "a", + Some(fake_authority("a")), + "hash-a", + ) + .unwrap(); + let b = registry + .begin_owner( + HostTier::Plugin, + "b", + "b", + Some(fake_authority("b")), + "hash-b", + ) + .unwrap(); + let pack = codewhale_ratatui::whale_girl::pack().clone(); + // Ownership does not depend on native artwork. Generate a valid page + // so the test also works from a package without repository-only PNGs. + let mut page = std::io::Cursor::new(Vec::new()); + image::RgbaImage::new( + u32::from(pack.columns) * u32::from(pack.tile_width), + u32::from(pack.rows) * u32::from(pack.tile_height), + ) + .write_to(&mut page, image::ImageFormat::Png) + .unwrap(); + let png = vec![page.into_inner(); pack.atlases.len()]; + assert!( + registry.register(¶ms(a.clone())).is_err(), + "synchronous admission cannot skip review" + ); + let handle = registry + .register_avatar(¶ms(a.clone()), pack.clone(), png.clone(), 7) + .unwrap(); + assert!( + registry.live_avatars().is_empty(), + "activation must settle first" + ); + registry.mark_active(&a); + assert_eq!(registry.live_avatars().len(), 1); + registry.unregister(&b, handle); + assert_eq!(registry.live_avatars().len(), 1); + assert!( + registry + .register_avatar(¶ms(a.clone()), pack.clone(), png.clone(), 7) + .is_err(), + "duplicate" + ); + registry.unregister(&a, handle); + assert!(registry.live_avatars().is_empty()); + let new_handle = registry + .register_avatar(¶ms(a.clone()), pack.clone(), png.clone(), 7) + .unwrap(); + assert_ne!(handle, new_handle); + registry.revoke_owner("a"); + assert!(registry.live_avatars().is_empty()); + let replacement = registry + .begin_owner( + HostTier::Plugin, + "a", + "a", + Some(fake_authority("a")), + "hash-a", + ) + .unwrap(); + assert_ne!(a.generation, replacement.generation); + assert!( + registry.register_avatar(¶ms(a), pack, png, 7).is_err(), + "stale owner" + ); + } + #[test] + fn avatar_snapshot_requires_inventory_hash_and_bounded_file() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + // Reuse a real validated Native bundle; admission also checks its receipt. + std::fs::write(root.join("plugin.json"),r#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"avatar-fixture","version":"1.0.0","extensions":{"net.codewhale":{"native":{"path":"index.mjs"}}}}"#).unwrap(); + std::fs::write(root.join("index.mjs"), "export default () => {};").unwrap(); + std::fs::write(root.join("avatar.json"), "{}").unwrap(); + let review = PluginManifest::validate_from_path(&root.join("plugin.json")).unwrap(); + assert_eq!( + reviewed_bytes(&review, Path::new("avatar.json"), 2).unwrap(), + b"{}" + ); + assert!(reviewed_bytes(&review, Path::new("avatar.json"), 1).is_err()); + assert!(reviewed_bytes(&review, Path::new("missing.json"), 100).is_err()); + std::fs::OpenOptions::new() + .append(true) + .open(root.join("avatar.json")) + .unwrap() + .write_all(b" ") + .unwrap(); + assert!(reviewed_bytes(&review, Path::new("avatar.json"), 100).is_err()); + } +} diff --git a/crates/tui/src/extension_host/mod.rs b/crates/tui/src/extension_host/mod.rs index 1d80665325..9eb469693f 100644 --- a/crates/tui/src/extension_host/mod.rs +++ b/crates/tui/src/extension_host/mod.rs @@ -125,6 +125,7 @@ pub mod composition_scope; pub(crate) mod core_call; pub(crate) mod execution; pub(crate) use execution::StockOperation; +pub(crate) mod avatars; mod hooks; pub(crate) mod mcp; pub(crate) mod native_mcp; @@ -962,6 +963,13 @@ impl HostEvents for Events { native_mcp::admit(&shared, self.tier, self.generation, params, &cx).await; Ok(serde_json::to_value(result).expect("registration result is JSON")) } + protocol::HostRequest::Register(params) if params.kind == RegisterKind::AvatarPack => { + let Some(shared) = self.shared.upgrade() else { + return Err(refuse("extension host manager is gone")); + }; + let result = avatars::admit(&shared, self.tier, self.generation, params, &cx).await; + Ok(serde_json::to_value(result).expect("registration result is JSON")) + } protocol::HostRequest::Register(params) if params.kind == RegisterKind::SkillRoot => { let Some(shared) = self.shared.upgrade() else { return Err(refuse("extension host manager is gone")); @@ -1038,6 +1046,7 @@ impl HostEvents for Events { RegisterKind::PromptSection => "prompt section", RegisterKind::PromptTemplate => "prompt template", RegisterKind::SkillRoot => "skill root", + RegisterKind::AvatarPack => "avatar pack", RegisterKind::ShellHook => "shell hook", RegisterKind::McpServer => "MCP server", }; diff --git a/crates/tui/src/extension_host/protocol.rs b/crates/tui/src/extension_host/protocol.rs index 8e78c52cb8..73e5fd4419 100644 --- a/crates/tui/src/extension_host/protocol.rs +++ b/crates/tui/src/extension_host/protocol.rs @@ -444,6 +444,7 @@ pub enum RegisterKind { PromptSection, PromptTemplate, SkillRoot, + AvatarPack, ShellHook, McpServer, } @@ -499,7 +500,7 @@ impl RegisterParams { RegisterKind::Command if spec.input_schema.is_some() => { Err("a command registration has no `spec.input_schema`".to_string()) } - RegisterKind::Hook | RegisterKind::PromptSection | RegisterKind::PromptTemplate | RegisterKind::SkillRoot | RegisterKind::ShellHook | RegisterKind::McpServer + RegisterKind::Hook | RegisterKind::PromptSection | RegisterKind::PromptTemplate | RegisterKind::SkillRoot | RegisterKind::AvatarPack | RegisterKind::ShellHook | RegisterKind::McpServer if spec.input_schema.is_some() || spec.argument_hint.is_some() => { Err( diff --git a/crates/tui/src/extension_host/registry.rs b/crates/tui/src/extension_host/registry.rs index 67f5df4cfd..6e5a5eb07f 100644 --- a/crates/tui/src/extension_host/registry.rs +++ b/crates/tui/src/extension_host/registry.rs @@ -252,6 +252,7 @@ pub struct OwnerRegistry { shell_hooks: BTreeMap, prompt_sections: BTreeMap, skill_roots: BTreeMap, + avatar_packs: BTreeMap, mcp_servers: BTreeMap, /// Lower-cased names of every native tool any engine's turn build has /// reported, plus the static set. Only ever grows: engines in one @@ -570,6 +571,12 @@ impl OwnerRegistry { .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope)) .map(|r| r.handle), ) + .chain( + self.avatar_packs + .values() + .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope)) + .map(|r| r.handle), + ) .chain( self.mcp_servers .values() @@ -597,6 +604,9 @@ impl OwnerRegistry { RegisterKind::McpServer => { Err("MCP definitions require reviewed snapshot admission".into()) } + RegisterKind::AvatarPack => { + Err("avatar packs require reviewed snapshot admission".into()) + } RegisterKind::SkillRoot => { Err("skill roots require reviewed snapshot admission".to_string()) } @@ -679,6 +689,79 @@ impl OwnerRegistry { }) } + pub(crate) fn register_avatar( + &mut self, + params: &RegisterParams, + pack: codewhale_ratatui::avatar::Pack, + png: Vec>, + host_generation: u64, + ) -> Result { + self.check_scope(¶ms.owner, params.scope.as_ref(), false)?; + params.check_spec()?; + if params.kind != RegisterKind::AvatarPack || !params.spec.description.is_empty() { + return Err("invalid avatar pack registration".into()); + } + pack.validate()?; + if png.len() != pack.atlases.len() { + return Err("avatar pages do not match manifest".into()); + } + for page in &png { + pack.validate_png(page)?; + } + let entry = self.current(¶ms.owner).ok_or("stale avatar owner")?; + if entry.tier != HostTier::Plugin || entry.authority.is_none() { + return Err("avatar pack owner has no reviewed bundle".into()); + } + let content_hash = entry.content_hash.clone(); + let owned: Vec<_> = self + .avatar_packs + .values() + .filter(|r| r.owner == params.owner) + .collect(); + if owned.len() >= 4 + || self.avatar_packs.len() >= 16 + || self + .avatar_packs + .values() + .map(|r| r.png.iter().map(Vec::len).sum::()) + .sum::() + + png.iter().map(Vec::len).sum::() + > 32 * 1024 * 1024 + || owned + .iter() + .any(|r| r.pack.id == pack.id && r.scope == params.scope) + { + return Err("avatar pack duplicate or registration budget exceeded".into()); + } + self.next_handle += 1; + let handle = self.next_handle; + self.avatar_packs.insert( + handle, + super::avatars::AvatarRegistration { + handle, + owner: params.owner.clone(), + scope: params.scope.clone(), + host_generation, + content_hash, + pack, + png: Arc::new(png), + }, + ); + Ok(handle) + } + + pub(crate) fn live_avatars(&self) -> Vec { + self.avatar_packs + .values() + .filter(|r| { + self.current(&r.owner) + .is_some_and(|entry| entry.state == OwnerState::Active) + && self.check_scope(&r.owner, r.scope.as_ref(), true).is_ok() + }) + .cloned() + .collect() + } + pub(crate) fn register_skill_root( &mut self, params: &RegisterParams, @@ -1307,6 +1390,14 @@ impl OwnerRegistry { self.shell_hooks.remove(&handle); return; } + if self + .avatar_packs + .get(&handle) + .is_some_and(|r| r.owner == *owner) + { + self.avatar_packs.remove(&handle); + return; + } if self .skill_roots .get(&handle) @@ -1425,6 +1516,8 @@ impl OwnerRegistry { .retain(|_, h| h.owner.plugin_id != plugin_id); self.skill_roots .retain(|_, root| root.owner.plugin_id != plugin_id); + self.avatar_packs + .retain(|_, r| r.owner.plugin_id != plugin_id); self.prompt_sections .retain(|_, section| section.owner.plugin_id != plugin_id); self.hooks @@ -1491,6 +1584,7 @@ impl OwnerRegistry { if tier == HostTier::Plugin { self.skill_roots.clear(); + self.avatar_packs.clear(); } self.prompt_sections .retain(|_, section| section.tier != tier); diff --git a/crates/tui/src/extension_host/tier.rs b/crates/tui/src/extension_host/tier.rs index ec8e3bc3d3..bcc9fc91ec 100644 --- a/crates/tui/src/extension_host/tier.rs +++ b/crates/tui/src/extension_host/tier.rs @@ -152,7 +152,7 @@ pub(crate) const BUILTIN_MODULES: &[BuiltinModule] = &[ }, BuiltinModule { id: "mcp", - source_sha256: "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55", + source_sha256: "5bc04b62832310724fdfed3999362b1667bf9eddf4936e1535b5767019596839", tools: &[], }, ]; diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 01c898ee0e..64eb9a00b1 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -2542,6 +2542,7 @@ async fn run_async_main_dispatch( } Commands::Exec(args) => { let config = load_config_from_cli(&cli)?; + let plugin_registry = policy_current_registry(plugin_registry); let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); @@ -2894,6 +2895,7 @@ async fn run_async_main_dispatch( io::stdout().is_terminal(), )?; let config = load_config_from_cli(&cli)?; + let plugin_registry = policy_current_registry(plugin_registry); let prepared = prepare_interactive_config(&cli, &config, true)?; let source_id = resolve_session_id(session_id, last, &prepared.workspace)?; let (prepared, new_session_id) = prepare_mounted_session( @@ -13060,6 +13062,26 @@ fn normalize_windows_config_path_str(path: &str) -> String { normalized.to_ascii_lowercase() } +/// Startup discovery runs before config can select the extension-host policy, +/// so it judged every reviewed Native plugin `CapabilitiesChanged`. Call this +/// after the config load to re-judge under the installed policy, as +/// `/plugin reload` does, so trusted host plugins survive a restart. With the +/// flag off it returns the startup snapshot unchanged. +/// +/// Known limits: plugin-declared providers keep the startup snapshot, and the +/// `mcp`, `doctor`, `setup`, `pr`, `review` and workflow-tool subcommands do +/// not call this yet. +fn policy_current_registry( + registry: Arc, +) -> Arc { + if crate::plugins::activation::extension_host_policy_enabled() { + let workspace = registry.workspace().to_path_buf(); + registry.rediscover_for_workspace(&workspace) + } else { + registry + } +} + fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool { yolo || config.interactive_allow_shell() } @@ -13097,6 +13119,7 @@ async fn run_interactive_with_notice( plugin_registry: std::sync::Arc, ) -> Result<()> { tui::ui::require_interactive_terminal(io::stdin().is_terminal(), io::stdout().is_terminal())?; + let plugin_registry = policy_current_registry(plugin_registry); let prepared = prepare_interactive_config(cli, config, resume_session_id.is_some())?; let (prepared, resume_session_id) = if let Some(selector) = resume_session_id { let (prepared, id) = prepare_mounted_session( diff --git a/crates/tui/src/plugins/marketplace/store.rs b/crates/tui/src/plugins/marketplace/store.rs index 0259965719..44f5521f2b 100644 --- a/crates/tui/src/plugins/marketplace/store.rs +++ b/crates/tui/src/plugins/marketplace/store.rs @@ -333,7 +333,7 @@ mod tests { let store = MarketplaceStore::open(Some(&state_path)).unwrap(); let initial = store.load().unwrap(); let catalog = initial.get("codewhale").expect("first-party catalog"); - assert_eq!(catalog.catalog.total_candidates(), 6); + assert_eq!(catalog.catalog.total_candidates(), 19); assert_eq!(catalog.catalog.error_count(), 0); assert_eq!(catalog.catalog.warning_count(), 0); assert!(!catalog.catalog.provenance.grants_trust()); diff --git a/crates/tui/src/profile_constitution.rs b/crates/tui/src/profile_constitution.rs index 243b9c9ee7..d06f41bbed 100644 --- a/crates/tui/src/profile_constitution.rs +++ b/crates/tui/src/profile_constitution.rs @@ -3,9 +3,11 @@ use anyhow::{Context, Result, ensure}; use codewhale_config::user_constitution::ProfileConstitutionSnapshot; use codewhale_secrets::account::{AccountSessionStore, secure_account_session_secrets}; -/// No cached cross-account fallback: unavailable or malformed signed-in profiles -/// must be resolved before starting another turn. Signed-out/local use retains -/// the existing local constitution. +/// No cached cross-account fallback. An unavailable or malformed signed-in +/// profile is an error here; interactive turn admission (`Engine`) then uses the +/// signed-out local constitution and tells the person once, because local use +/// with their own provider key never requires a Codewhale sign-in. A +/// host-supplied snapshot (Runtime API) still fails its turn closed. pub(crate) async fn load(profile: Option<&str>) -> Result> { let api_base = crate::runtime_api::runtime_account_api_base(); let store = AccountSessionStore::new(secure_account_session_secrets()?, profile, &api_base); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 884752910b..12f7a57a56 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -99,6 +99,7 @@ use codewhale_protocol::fleet::{ }; mod auth; +mod avatars; mod computer_display; mod context; mod diagnostics; @@ -960,6 +961,9 @@ struct ThreadSummaryQuery { limit: Option, search: Option, include_archived: Option, + /// Comma-separated exact IDs let callers filter before applying the result + /// limit. One parameter, because axum's `Query` cannot collect repeated keys. + thread_ids: Option, /// When `true`, returns archived threads only (overrides `include_archived`). /// Whalescale#260 / #563. archived_only: Option, @@ -2553,6 +2557,11 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/skills/{name}/audit", get(audit_skill_api)) .route("/v1/apps/mcp/tools", get(list_mcp_tools)) .route("/v1/apps/plugins", get(plugins::list_plugins)) + .route("/v1/apps/avatars", get(avatars::list_avatars)) + .route( + "/v1/apps/avatars/{handle}/atlases/{page}", + get(avatars::get_atlas), + ) .route( "/v1/apps/plugins/install", post(plugins::install_plugin_api), @@ -3072,16 +3081,41 @@ async fn list_threads_summary( ) -> Result>, ApiError> { let limit = query.limit.unwrap_or(50).clamp(1, 500); let search = query.search.as_deref().map(str::to_ascii_lowercase); + let thread_ids = query + .thread_ids + .map(|ids| { + let ids = ids.split(',').map(str::to_owned).collect::>(); + if ids.is_empty() + || ids.len() > 200 + || ids.iter().any(|id| { + id.is_empty() + || id.trim() != id + || id.len() > 128 + || id.chars().any(char::is_control) + }) + { + return Err(ApiError::bad_request( + "thread_ids must be at most 200 comma-separated nonempty IDs of at most 128 bytes", + )); + } + Ok(ids.into_iter().collect::>()) + }) + .transpose()?; let filter = resolve_thread_filter(query.include_archived, query.archived_only); // `limit` bounds the rows this route returns, not how far a search looks. // Passing it to the store read as well matched only inside the newest // `limit` threads, so any older match — the row the caller typed the query // to find — was invisible. Unsearched listings keep the cheap bounded read; - // a search scans in newest-first order and stops at `limit` matches. + // a search or exact-ID filter scans in newest-first order and stops at + // `limit` matches. // // Match on the thread record *before* harvesting row facts. Preview is // filled only for rows that are returned; it is not a search key. - let scan_limit = if search.is_some() { None } else { Some(limit) }; + let scan_limit = if search.is_some() || thread_ids.is_some() { + None + } else { + Some(limit) + }; let threads = state .runtime_threads .list_threads(filter, scan_limit) @@ -3093,6 +3127,11 @@ async fn list_threads_summary( if rows.len() >= limit { break; } + if let Some(thread_ids) = &thread_ids + && !thread_ids.contains(&thread.id) + { + continue; + } if let Some(search) = &search && !state .runtime_threads diff --git a/crates/tui/src/runtime_api/avatars.rs b/crates/tui/src/runtime_api/avatars.rs new file mode 100644 index 0000000000..fa5e839927 --- /dev/null +++ b/crates/tui/src/runtime_api/avatars.rs @@ -0,0 +1,31 @@ +//! Authenticated projection of the existing Native avatar registrations. +use super::RuntimeApiState; +use axum::{Json, extract::State}; + +pub(super) async fn list_avatars( + State(state): State, +) -> Json> { + Json(crate::extension_host::avatars::catalog(state.workspace.clone()).await) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AtlasQuery { + content_hash: String, +} + +pub(super) async fn get_atlas( + State(state): State, + axum::extract::Path((handle, page)): axum::extract::Path<(u64, usize)>, + axum::extract::Query(query): axum::extract::Query, +) -> Result, axum::http::StatusCode> { + crate::extension_host::avatars::atlas( + state.workspace.clone(), + handle, + page, + &query.content_hash, + ) + .await + .map(Json) + .ok_or(axum::http::StatusCode::NOT_FOUND) +} diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index e22fc766fb..aa395f66c9 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -9314,6 +9314,107 @@ async fn list_threads_archived_only_filter_matches_only_archived() -> Result<()> Ok(()) } +/// The exact-ID filter must run before the result limit so an owned older +/// thread remains visible even when newer threads belong to other callers. +#[tokio::test] +async fn list_thread_summary_filters_ids_before_limit() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + let mut owned_ids = Vec::new(); + + for _ in 0..2 { + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({})) + .send() + .await? + .error_for_status()? + .json() + .await?; + owned_ids.push( + created["id"] + .as_str() + .context("missing thread id")? + .to_string(), + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + for _ in 0..8 { + client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({})) + .send() + .await? + .error_for_status()?; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + let recent: serde_json::Value = client + .get(format!( + "http://{addr}/v1/threads/summary?limit=8&include_archived=true" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + let recent_ids: Vec<&str> = recent + .as_array() + .context("summary must be an array")? + .iter() + .filter_map(|thread| thread["id"].as_str()) + .collect(); + assert!( + owned_ids + .iter() + .all(|id| !recent_ids.contains(&id.as_str())) + ); + + let mut filtered_url = reqwest::Url::parse(&format!( + "http://{addr}/v1/threads/summary?limit=8&include_archived=true" + ))?; + filtered_url + .query_pairs_mut() + .append_pair("thread_ids", &owned_ids.join(",")); + let filtered: serde_json::Value = client + .get(filtered_url) + .send() + .await? + .error_for_status()? + .json() + .await?; + let filtered_ids: Vec<&str> = filtered + .as_array() + .context("filtered summary must be an array")? + .iter() + .filter_map(|thread| thread["id"].as_str()) + .collect(); + assert_eq!(filtered_ids.len(), owned_ids.len()); + assert!( + owned_ids + .iter() + .all(|id| filtered_ids.contains(&id.as_str())) + ); + + let mut oversized_url = + reqwest::Url::parse(&format!("http://{addr}/v1/threads/summary?limit=8"))?; + oversized_url + .query_pairs_mut() + .append_pair("thread_ids", &vec!["fixture-id"; 201].join(",")); + let rejected = client.get(oversized_url).send().await?; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + let at_limit = format!( + "http://{addr}/v1/threads/summary?limit=8&thread_ids={}", + vec!["fixture-id"; 200].join(",") + ); + assert_eq!(client.get(at_limit).send().await?.status(), StatusCode::OK); + + handle.abort(); + Ok(()) +} + /// #564 / whalescale#261 — `GET /v1/usage` aggregates per-turn token + /// cost data. With no threads the response is well-formed and totals are /// zero with empty buckets (never a 404). diff --git a/crates/tui/src/tui/pet_watch/avatars.rs b/crates/tui/src/tui/pet_watch/avatars.rs new file mode 100644 index 0000000000..c7c22120b9 --- /dev/null +++ b/crates/tui/src/tui/pet_watch/avatars.rs @@ -0,0 +1,328 @@ +//! Terminal avatar selection projects the existing owner frame and clock. +use super::*; +use base64::Engine as _; +use codewhale_ratatui::{ + Paint, + avatar::{Pack, RegisteredPack}, + avatar_sprite::Sprite, + whale_girl, +}; +use std::{ + path::{Path, PathBuf}, + sync::mpsc::{self, Receiver}, +}; + +#[derive(Default)] +pub(super) struct Avatars { + pub key: String, + pub view: Option, + pub action: Option, + catalog: Vec, + workspace: PathBuf, + next: Option, + updated: Option, + pending: Option>>, + decoded: Option<(String, Vec, usize, usize)>, + images: std::collections::BTreeMap>, + wanted: Option<(RegisteredPack, usize)>, + image_pending: Option)>>, + next_image: Option, +} +impl Avatars { + pub fn refresh(&mut self, workspace: &Path, now: Instant) -> bool { + let mut changed = false; + + let received = self.image_pending.as_ref().map(|rx| rx.try_recv()); + if matches!(received, Some(Err(mpsc::TryRecvError::Disconnected))) { + self.image_pending = None; + } + if let Some(Ok((key, encoded))) = received { + self.image_pending = None; + self.next_image = Some(now + Duration::from_secs(2)); + if let Some(encoded) = encoded + && let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&encoded) + { + if self.images.len() >= 4 { + self.images.clear(); + } + self.images.insert(key, bytes); + changed = true; + } + } + if self.image_pending.is_none() + && self.next_image.is_none_or(|at| now >= at) + && let Some((entry, page)) = self.wanted.take() + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + let (tx, rx) = mpsc::channel(); + self.image_pending = Some(rx); + let workspace = workspace.to_path_buf(); + let key = entry.image_key(page); + runtime.spawn(async move { + let png = crate::extension_host::avatars::atlas( + workspace, + entry.handle, + page, + &entry.content_hash, + ) + .await; + let _ = tx.send((key, png)); + }); + } + + if self.workspace != workspace { + self.workspace = workspace.into(); + self.images.clear(); + self.wanted = None; + self.image_pending = None; + self.next_image = None; + self.catalog.clear(); + self.decoded = None; + self.pending = None; + self.next = None; + self.updated = None; + self.view = None; + self.action = None; + changed = true; + } + let received = self.pending.as_ref().map(|rx| rx.try_recv()); + if matches!(received, Some(Err(mpsc::TryRecvError::Disconnected))) { + self.pending = None; + } + if let Some(Ok(value)) = received { + self.pending = None; + self.catalog = value; + self.updated = Some(now); + changed = true; + } + if self + .updated + .is_some_and(|at| now.saturating_duration_since(at) > Duration::from_secs(12)) + { + self.catalog.clear(); + self.decoded = None; + self.updated = None; + changed = true; + } + if self.pending.is_none() && self.next.is_none_or(|at| now >= at) { + self.next = Some(now + Duration::from_secs(5)); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + let (tx, rx) = mpsc::channel(); + let workspace = workspace.to_path_buf(); + self.pending = Some(rx); + runtime.spawn(async move { + let _ = tx.send(crate::extension_host::avatars::catalog(workspace).await); + }); + } + } + changed + } + pub fn choices(&self) -> String { + std::iter::once("whale".to_string()) + .chain(std::iter::once("whale-girl".to_string())) + .chain(self.catalog.iter().map(|p| p.key.clone())) + .collect::>() + .join("\n") + } + pub fn select(&mut self, key: &str) -> bool { + if !matches!(key, "whale" | "whale-girl") && !self.catalog.iter().any(|p| p.key == key) { + return false; + } + self.key = key.into(); + self.view = None; + self.action = None; + self.decoded = None; + true + } + fn pack(&self) -> Option<&Pack> { + if self.key == "whale-girl" { + Some(whale_girl::pack()) + } else { + self.catalog + .iter() + .find(|p| p.key == self.key) + .map(|p| &p.pack) + } + } + pub fn preview(&mut self, value: &str, view: bool) -> bool { + if value == "live" { + self.action = None; + self.view = None; + return true; + } + let Some(pack) = self.pack() else { + return false; + }; + if view && pack.views.contains_key(value) { + self.view = Some(value.into()); + self.action = None; + true + } else if !view && pack.actions.contains_key(value) { + self.action = Some(value.into()); + self.view = None; + true + } else { + false + } + } + pub fn paint( + &mut self, + area: Rect, + buf: &mut ratatui::buffer::Buffer, + act: &str, + clock: f64, + reduced: bool, + ) -> bool { + let Some(pack) = self.pack() else { + return false; + }; + let sample = pack.sample( + act, + clock, + reduced, + self.view.as_deref(), + self.action.as_deref(), + ); + let theme = codewhale_ratatui::Theme::new(codewhale_ratatui::Caps { + depth: codewhale_ratatui::color::ColorDepth::detect(), + ascii: std::env::var_os("CODEWHALE_ASCII_SAFE").is_some(), + appearance: codewhale_ratatui::detect::Appearance::Dark, + }); + if self.key == "whale-girl" { + if let Ok(sprite) = Sprite::new(pack, whale_girl::TERMINAL, 96, 96, sample.index) { + sprite.paint(area, buf, &theme); + return true; + } + return false; + } + let Some(entry) = self.catalog.iter().find(|p| p.key == self.key) else { + return false; + }; + let page = entry.pack.page(sample.index); + let cache_key = entry.image_key(page); + let Some(png) = self.images.get(&cache_key) else { + self.wanted = Some((entry.clone(), page)); + return false; + }; + if self.decoded.as_ref().is_none_or(|d| d.0 != cache_key) { + self.decoded = entry.pack.validate_png(png).ok().and_then(|()| { + let image = image::load_from_memory_with_format(png, image::ImageFormat::Png) + .ok()? + .to_rgba8(); + let (w, h) = ( + u32::from(entry.pack.tile_width), + u32::from(entry.pack.tile_height), + ); + let mut pixels = Vec::new(); + for i in 0..u32::from(entry.pack.columns) * u32::from(entry.pack.rows) { + pixels.extend( + image::imageops::crop_imm( + &image, + i % u32::from(entry.pack.columns) * w, + i / u32::from(entry.pack.columns) * h, + w, + h, + ) + .to_image() + .into_raw(), + ); + } + Some((cache_key, pixels, w as usize, h as usize)) + }); + } + let Some((_, pixels, w, h)) = &self.decoded else { + return false; + }; + if let Ok(sprite) = Sprite::page(&entry.pack, pixels, *w, *h, sample.index) { + sprite.paint(area, buf, &theme); + return true; + } + false + } +} + +/// Every specific act comes from typed owner activity. Unknown/stale work is +/// plain busy. This is the same acting mapper as the native character; no tool +/// names, labels or captions are classified by an avatar plugin. +pub(super) fn act(raster: Option<&Presentation>, loading: bool) -> (&'static str, f64) { + use codewhale_protocol::engine_owner::{OwnerFreshness, OwnerPresence}; + use codewhale_ratatui::whale_motion::{Activity, Context, Presence, acting_for}; + let Some(r) = raster else { + return (if loading { "busy" } else { "rest" }, 0.); + }; + let fresh = + r.frame_changed.elapsed() < Duration::from_millis(800) && r.scene.producer_connected; + let owner = r + .scene + .activity + .as_ref() + .filter(|a| fresh && a.observed && a.freshness == OwnerFreshness::Fresh); + let presence = match owner.map(|a| a.authoritative_presence) { + Some(OwnerPresence::NeedsYou) => Presence::NeedsYou, + Some(OwnerPresence::Done) => Presence::Done, + Some(OwnerPresence::Working) => Presence::Working, + _ if loading => Presence::Working, + _ => Presence::Idle, + }; + let activity = owner.map(|a| Activity { + kind: a.activity_kind.map(|k| k.as_str().into()), + observed: true, + parallel: Some(f64::from(a.parallel_agent_count)), + active: Vec::new(), + }); + ( + acting_for( + presence, + activity.as_ref(), + &Context { + live: fresh, + ..Context::default() + }, + ) + .id(), + r.scene.tick as f64, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn avatar_previews_validate_names_and_selection_clears_them() { + let mut avatars = Avatars::default(); + assert!(!avatars.select("plugin:missing:pack")); + assert!(avatars.select("whale-girl")); + assert!(avatars.preview("back", true)); + assert_eq!(avatars.view.as_deref(), Some("back")); + assert!(!avatars.preview("not-an-action", false)); + assert!(avatars.preview("read", false)); + assert_eq!(avatars.action.as_deref(), Some("read")); + assert!(avatars.view.is_none()); + assert!(avatars.preview("live", false)); + assert!(avatars.action.is_none()); + assert!(avatars.select("whale")); + assert!(avatars.pack().is_none()); + } + #[test] + fn avatar_workspace_change_and_stale_discovery_drop_plugin_art() { + let mut avatars = Avatars::default(); + let now = Instant::now(); + let entry = RegisteredPack { + key: "plugin:test:girl".into(), + handle: 1, + content_hash: "reviewed".into(), + pack: whale_girl::pack().clone(), + }; + avatars.workspace = PathBuf::from("old"); + avatars.catalog.push(entry.clone()); + avatars.images.insert(entry.image_key(0), vec![1]); + assert!(avatars.select(&entry.key)); + avatars.refresh(Path::new("new"), now); + assert!(avatars.pack().is_none()); + assert!(avatars.images.is_empty()); + avatars.catalog.push(entry); + avatars.updated = Some(now - Duration::from_secs(13)); + avatars.refresh(Path::new("new"), now); + assert!(avatars.pack().is_none()); + } +} diff --git a/crates/tui/src/tui/pet_watch/mod.rs b/crates/tui/src/tui/pet_watch/mod.rs index fb2805566b..bd9de9e5a7 100644 --- a/crates/tui/src/tui/pet_watch/mod.rs +++ b/crates/tui/src/tui/pet_watch/mod.rs @@ -23,6 +23,7 @@ use std::{ mod appearance; mod audio; mod audio_cursor; +mod avatars; mod graphics; mod habitat; mod live; @@ -41,6 +42,7 @@ pub enum Control { } #[derive(Default)] pub struct PetWatch { + avatars: avatars::Avatars, worker: Option, session: Option, active_turn_id: Option, @@ -69,6 +71,15 @@ pub struct PetWatch { result_scroll: u16, } impl PetWatch { + pub fn avatar_choices(&self) -> String { + self.avatars.choices() + } + pub fn select_avatar(&mut self, key: &str) -> bool { + self.avatars.select(key) + } + pub fn preview_avatar(&mut self, name: &str, view: bool) -> bool { + self.avatars.preview(name, view) + } pub fn set_sound(&mut self, enabled: bool) { self.sound_requested = enabled; } @@ -94,6 +105,8 @@ impl PetWatch { self.work_enter_pending = false; self.work_complete = false; self.result_scroll = 0; + self.avatars.view = None; + self.avatars.action = None; } fn ensure(&mut self, session: Option) { if self.session != session { @@ -385,6 +398,9 @@ pub fn tick(app: &mut App, now: Instant) { && app.onboarding == crate::tui::app::OnboardingState::None && is_open(app); let motion = visible && crate::tui::underwater::decorative_shell_motion_enabled(app); + if visible && app.pet_watch.avatars.refresh(&app.workspace, now) { + app.needs_redraw = true; + } let waiting = matches!( ShellPhase::from_app(app), ShellPhase::Waiting | ShellPhase::Approval @@ -452,7 +468,8 @@ pub fn tick(app: &mut App, now: Instant) { cell_width: cell.0, cell_height: cell.1, motion, - pixels: crate::tui::mark::kitty_graphics_supported() + pixels: matches!(state.avatars.key.as_str(), "" | "whale") + && crate::tui::mark::kitty_graphics_supported() && app.synchronized_output_enabled && std::env::var("CODEWHALE_PET_GRAPHICS").as_deref() != Ok("braille"), visible, @@ -574,6 +591,33 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) { Block::default().style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2]))), area, ); + let (act, clock) = avatars::act(raster, app.is_loading); + let reduced = !crate::tui::underwater::decorative_shell_motion_enabled(app); + let tank = Rect { + height: area.height.saturating_sub(1), + ..area + }; + if app + .pet_watch + .avatars + .paint(tank, frame.buffer_mut(), act, clock, reduced) + { + let preview = app.pet_watch.avatars.action.as_deref().or(app + .pet_watch + .avatars + .view + .as_deref()); + let label = preview.map_or(label.clone(), |p| format!("/pet action {p} · {label}")); + frame.render_widget( + Paragraph::new(label).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)), + Rect { + y: area.bottom().saturating_sub(1), + height: 1, + ..area + }, + ); + return; + } if image { let tank = Rect { height: area.height.saturating_sub(1), diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 8baab6278c..fd158b1954 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -8249,7 +8249,10 @@ pub(super) fn route_key_to_view_stack( /// Keep only the Engine's retry receipts in the existing transcript. Ordinary /// status/footer behavior and internal/model-only status projection stay intact. pub(super) fn apply_engine_status(app: &mut App, message: String) -> bool { - let retain = crate::core::events::is_retry_status_receipt(&message); + // The account-profile fallback notice is sent once per engine; keep it in + // the transcript so the next turn's status line cannot erase it. + let retain = crate::core::events::is_retry_status_receipt(&message) + || app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str(); if retain { app.add_message(HistoryCell::System { content: message.clone(), diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 97242ab332..3c5fb74d5c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -33884,6 +33884,26 @@ fn extension_prompt_origin_keeps_denials_and_other_policy_holds() { ); } +#[test] +fn account_profile_fallback_notice_stays_in_the_transcript() { + let _home = SettingsHomeGuard::new(); + let mut app = App::new(create_test_options(), &Config::default()); + let notice = app + .tr(MessageId::ProfileConstitutionUnavailableLocal) + .to_string(); + let before = app.history.len(); + assert!(super::event_loop::apply_engine_status( + &mut app, + notice.clone() + )); + assert!(!super::event_loop::apply_engine_status( + &mut app, + "Executing tools sequentially".into() + )); + assert_eq!(app.history.len(), before + 1); + assert!(matches!(&app.history[before], HistoryCell::System { content } if *content == notice)); +} + #[test] fn engine_retry_status_receipts_survive_footer_overwrite_in_the_existing_transcript() { let _home = SettingsHomeGuard::new(); diff --git a/crates/tui/src/tui/views/extensions.rs b/crates/tui/src/tui/views/extensions.rs index 0ee0113082..9789a73787 100644 --- a/crates/tui/src/tui/views/extensions.rs +++ b/crates/tui/src/tui/views/extensions.rs @@ -2944,7 +2944,14 @@ mod tests { ); assert!(!builtin.trusted()); assert!(!builtin.enabled); - assert_eq!(group.items.iter().filter(|row| matches!(&row.action, Some(ExtensionAction::Command { command, .. }) if command.starts_with("/plugin marketplace install "))).count(), 5); + assert!( + group.items.iter().any(|row| matches!( + &row.action, + Some(ExtensionAction::Command { command, .. }) + if command == "/plugin marketplace install codewhale whalewiki" + )), + "an absent catalog bundle should still offer installation" + ); } #[test] diff --git a/docs/CNB_MIRROR.md b/docs/CNB_MIRROR.md index 3de28984fd..4db4c724a9 100644 --- a/docs/CNB_MIRROR.md +++ b/docs/CNB_MIRROR.md @@ -8,6 +8,22 @@ GitHub repository for users on networks where GitHub is slow or blocked `fix/*`, `rebrand/*`, and `work/v*` branch used for first-party release work, and each `v*` release tag after its complete GitHub Release is published. +## Source and downloads are separate checks (2026-10-06 audit) + +CNB `main` matched canonical GitHub `main` during this audit. GitHub, npm and +npmmirror's latest published version was **0.10.0**; the two registry wrapper +tarballs had identical bytes and verified integrity. CNB's latest binary +release remained **0.9.9**. Its `v0.10.0` source tag exists, but that tag build +failed the release-note check before compilation and upload. The CNB 0.10.0 +checksum manifest returned 404; do not recommend forcing the CNB-only installer +for that version. 0.10.1 remains a Main source candidate until publication. + +Check source identity, then the actual versioned release, assets and manifest. +A successful GitHub mirror workflow or a source tag is not binary availability. +Re-pushing an existing tag does not create a fresh tag-push build. Maintainers +must recover the build/publication separately and verify the result, preserving +published tags. Refresh this dated status after publication. + ## Provenance **GitHub is the sole canonical source.** All releases, tags, and source code @@ -42,9 +58,9 @@ GitHub Actions workflow: push exactly that tag. Branch pushes mirror `main`, first-party `fix/*`/`rebrand/*` branches, or explicitly matched release branches. Other feature branches and dependabot refs are intentionally *not* mirrored. -- **Concurrency:** runs are serialized via a `cnb-sync` concurrency - group so the back-to-back `main` push and tag push from - `auto-tag.yml` cannot race each other. +- **Concurrency:** `cnb-sync-${{ github.ref }}` serializes each ref separately. + Distinct release tags cannot replace one another in a global pending slot; + only a newer push of the same branch supersedes its pending branch sync. - **Retry:** each push is retried up to three times with linear backoff (5s, 10s) before the workflow gives up. @@ -251,7 +267,10 @@ https://cnb.cool/codewhale.net/codewhale.git The mirror receives `main`, release tags, and matched release branches. GitHub is the fallback when the CNB workflow or credentials are unhealthy. -CNB deploy-button examples live in `deploy/tencent-lighthouse/cnb/`. They are +The bilingual self-hosting guide is +[`scripts/tencent-lighthouse/README.md`](../scripts/tencent-lighthouse/README.md). +CNB deploy-button examples live in +[`deploy/tencent-lighthouse/cnb/`](../deploy/tencent-lighthouse/cnb/README.md). They are not active until copied into `.cnb.yml` and `.cnb/tag_deploy.yml`, because live deploy jobs require a Lighthouse deploy key, target host, and explicit CNB quota/billing policy. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f1cfd80add..08ce511ae4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -329,29 +329,50 @@ If a repo-local config declares `api_key`, `base_url`, `providers`, `provider`, `mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`, Codewhale ignores that key and keeps the user's global setting. -The consolidated `codewhale` runtime uses one config file for DeepSeek auth -and model defaults. `codewhale auth set --provider deepseek` saves -the key to `~/.codewhale/config.toml` (migrating legacy `~/.deepseek/config.toml` -on first launch when needed), and `codewhale --model deepseek-v4-flash` is -forwarded to the TUI as `CODEWHALE_MODEL`. The dispatcher no longer writes the -`DEEPSEEK_*` twins of these variables; a `DEEPSEEK_*` value you set yourself is -still read as a legacy alias when the `CODEWHALE_*` one is unset. - -`codewhale login` signs in to the Codewhale account — it is the same browser -device flow as `codewhale account login`, not a provider-key command. Provider -credentials are configured exclusively through `codewhale auth set ---provider `. - -That provider credential is distinct from the optional managed-product -account. `codewhale account login` starts the Codewhale browser device flow; -`codewhale account status` and `codewhale account logout` inspect or remove the -session for the selected `--profile`. Account sessions prefer the OS -credential manager and fall back automatically to the private `0600` -Codewhale secrets file when no credential manager is available (headless -hosts, SSH, containers). -`codewhale account keys list|set|remove` manages the -signed-in account's BYOK vault without displaying secret values. The older -`codewhale cloud ...` spelling remains a command alias. +The consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for +provider and model settings. `codewhale auth set --provider deepseek` saves +the key in the local secret store (a private file under `~/.codewhale/secrets/` +by default; the OS keychain is an explicit option), rather than writing it +into that config file. Legacy `~/.deepseek/config.toml` is migrated on first +launch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the +TUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins; +a value you set yourself remains a legacy alias when its `CODEWHALE_*` +equivalent is unset. + +### Account provider keys + +A Codewhale account lets you **manage your provider API keys in one place**. +[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login), +then add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers) +or from the terminal: + +```bash +codewhale login # browser sign-in; also: account login +codewhale account keys set deepseek # hidden prompt; add or replace a key +codewhale account keys list # configured status, never secret values +codewhale --provider codewhale # use the account's model route +``` + +Account keys stay on the service and are used for account-routed requests. +Sign in on another device and choose Codewhale in `/provider` to use them +without pasting keys again. `/models` lists models available to that account. +Replacing a saved account key affects subsequent requests through this route; +it does not change independent local copies or revoke keys at the provider. +`codewhale account keys remove deepseek` removes the account's saved key. + +Login does **not** upload existing local keys. If you want to copy one, choose +`codewhale account keys set deepseek --from-local` explicitly. Normal local +provider setup remains `codewhale auth set --provider `; local keys +and local models work without a Codewhale account. Signing in preserves an +explicit or already configured local provider. Choose `--provider codewhale` +when you want the account route instead. + +The account session is separate from provider keys. `codewhale account status` +inspects it; `codewhale account logout` removes it for the selected `--profile` +without removing your provider keys. Account sessions use the private `0600` +Codewhale secrets file, scoped to the profile and account API origin; they do +not use the OS keychain. The older `codewhale cloud ...` spelling remains an +alias for account commands. ### Portable config bundles @@ -468,7 +489,7 @@ Anthropic providers, set `provider = ""` or pass For the provider-by-provider registry, including wire protocol, auth variables, default base URLs, model IDs, and capability metadata, see [PROVIDERS.md](PROVIDERS.md). -The facade saves provider credentials to the shared user config and forwards +The facade saves provider credentials in the local secret store and forwards the resolved key, base URL, provider, and model to the TUI process. Use `codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or `codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` or diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index af68305992..ce227b62b1 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -166,8 +166,10 @@ require another review. See [bundle rules](PLUGIN_BUNDLES.md). Export a Cordis plugin function or an object with `apply`. The host supplies one shared Cordis and the supported DSH compatibility services. The example's `inject = ['tools', 'commands']` asks for the tool and command registries. The -supplied service names are `tools`, `commands`, `prompt`, `storage`, `skills`, `shellHooks`, `mcp`, `logger`, -`events`, `reflect` and `registry`. A tool can ask the core to run a core tool through +supplied service names are `tools`, `commands`, `prompt`, `storage`, `skills`, `shellHooks`, `avatars`, `mcp`, `logger`, +`events`, `reflect` and `registry`. `ctx.avatars.registerPack({path})` registers a +reviewed bundle-relative avatar pack; see +[the whale-avatar example](examples/plugins/whale-avatar/README.md). A tool can ask the core to run a core tool through `exec.core` (see [Asking the core to run a tool](#asking-the-core-to-run-a-tool)); Reviewed Native entries can propose MCP definitions through `ctx.mcp` as described below. Programmable pre-execute listeners use `ctx.on` as described below. A required diff --git a/docs/INSTALL.md b/docs/INSTALL.md index bf82bbbf0d..288e421cb0 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,6 +1,6 @@ # Installing Codewhale -> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md) (not yet updated for this revision) +> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md) Codewhale is an open-source coding agent that runs in your terminal. You give it a task ("fix the failing test", "add a CLI flag"). It reads your repository, @@ -11,9 +11,9 @@ works with many model providers. **DeepSeek** is the default. The command is `codewhale`. `codew` is a shorter alias for the same program. -This guide was written by installing **v0.10.0** (released 2026-09-22) on a -fresh **Ubuntu 24.04 x86_64** machine, on every path described here. Every -command shown was run and its output checked (see the [install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md)). Steps that +The installation paths were first checked with **v0.10.0** (released 2026-09-22) on a +fresh **Ubuntu 24.04 x86_64** machine. The dated +[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that could not be run on that machine are marked **(untested on this VM: reason)**. macOS, Windows and Android are out of scope, apart from a few notes. A second pass re-ran the installer, manual-download, archive and npm paths, the no-key @@ -24,8 +24,52 @@ there. Install commands that use `latest` resolve to the latest **published** GitHub Release or package. Between releases, `main` may already describe the next version (for example a v0.10.1 source candidate before its tag). A -candidate isn't installable until its tag, checksums and release assets -exist. +prebuilt candidate is available through the official installer only after its +tag, checksums and release assets exist. Contributors can build `main` now. + +## One account for your provider keys + +[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login) +to keep your model providers' API keys in one place. Add, replace or remove +keys in your account's [Providers settings](https://app.codewhale.net/providers), +or use the terminal commands below. On another device, sign in and select the +Codewhale model route to use the same account keys without pasting them again. + +```bash +codewhale login +codewhale account keys set deepseek # asks for the key without echoing it +codewhale account keys list # shows status, never the key itself +codewhale --provider codewhale # use your account's model route +``` + +The account setup here describes the 0.10.1 source candidate. Signing in does +not upload existing local keys. To copy one deliberately, use +`codewhale account keys set deepseek --from-local`. Account keys stay on the +service; they are used for account-routed requests, not downloaded into each +device's local key store. You can keep using local keys or a local model +without an account. See [account key settings](CONFIGURATION.md#account-provider-keys) +for updating keys and switching between these options. + +## Test and contribute to current source + +`latest` selects the latest published release. To test unreleased repairs, +build canonical GitHub `main`, or CNB `main` after verifying its commit matches. +The source version number alone does not establish a published package. + +```bash +git clone --branch main https://github.com/codewhale-hq/CodeWhale.git +cd CodeWhale +cargo build --release --locked -p codewhale-cli +target/release/codewhale --version +``` + +See the Cargo section for build prerequisites; use the repository's stable Rust +selection (declared minimum 1.89). Windows uses `target/release/codewhale.exe` +and needs MSVC. A development build is separate from a signed release package. +For mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md). +The [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is +bilingual. An npm registry mirror serves the wrapper; native binary downloads +still need an available release asset source. --- diff --git a/docs/MEDIA_MODELS.md b/docs/MEDIA_MODELS.md new file mode 100644 index 0000000000..1dc0d920ee --- /dev/null +++ b/docs/MEDIA_MODELS.md @@ -0,0 +1,63 @@ +# Speech, translation and video models + +> 简体中文:[zh_hans/MEDIA_MODELS.md](zh_hans/MEDIA_MODELS.md)。 +> Source and upstream documentation reviewed: 2026-10-06. Candidates below are +> not additional working Codewhale backends. + +## Current source support + +| Task | Codewhale path | Limits | +| --- | --- | --- | +| Text to speech (TTS) | `speech` tool; `codewhale --provider xiaomi-mimo speech "Hello from Codewhale"`; CLI alias `tts` | The synthesis client requires Xiaomi MiMo. Models: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, legacy `mimo-v2-tts`. Design/cloning need their corresponding input. | +| Speech to text (STT/ASR) | Terminal voice input, Runtime `/v1/voice/*`; GPUI uses that Runtime path | Explicit `CODEWHALE_ASR_MODEL`, otherwise detected local Whisper → Groq → configured provider. Local `tiny`; Groq `whisper-large-v3-turbo`; provider `mimo-v2.5-asr`. A chat endpoint does not prove ASR support. | +| Video understanding | Media attachment/read paths and applicable provider capabilities | Reading video, translating subtitles and generating video are separate operations. | +| Video generation | No first-class generation client/tool in the audited source | A remote catalogue row does not create an executable generation route. | + +`CODEWHALE_ASR_MODEL=local-whisper` explicitly selects offline recognition; +its binary/model must already be installed. `groq` needs `GROQ_API_KEY`; +`mimo` selects the configured provider endpoint. There is no config-file ASR +selector or arbitrary model-ID override. A failed selected local/Groq backend +does not silently upload to another provider. Setting a Qwen/Index model name +in this variable does not install a new backend. + +Owners: [speech tool](../crates/tui/src/tools/speech.rs), +[synthesis client](../crates/tui/src/client.rs), [voice resolver](../crates/tui/src/voice.rs), +[Runtime voice](../crates/tui/src/runtime_api/voice.rs). Real microphone capture, +synthesis and a complete customer voice journey require separate acceptance. + +## Index-Translate evaluation + +[Bilibili's repository](https://github.com/bilibili/Index-Translate) publishes +Qwen3.5-based text translation for 150 languages under Apache-2.0, Homura +syllable constraints and NativeLong document translation. NativeLong's actual +IDs are `IndexTeam/Index-Nailong-2B` / `IndexTeam/Index-Nailong-9B`. +The [official collection](https://huggingface.co/collections/IndexTeam/index-translate) +includes quantized releases; Echo GGUF is the text backbone only. + +The advertised text API is `https://index-translate.bilibili.com/v1`, model +`Index-Translate-35B-A3B`. A bounded public-text probe on 2026-10-06 returned +**HTTP 412**, an explicit access-policy rejection. No credentials/private +content were sent. This endpoint is not qualified as a working service. + +| Separate package | Published scope | Integration status | +| --- | --- | --- | +| [Echo S2TT](https://github.com/bilibili/Index-Translate/blob/main/inference/echo-s2tt/README.md) | Chinese audio/video → English/Japanese/Spanish timestamped subtitles; 2B guide estimates about 10 GB VRAM | Local pipeline candidate, not the public text API or generic 150-language STT. | +| [Echo S2ST](https://github.com/bilibili/Index-Translate/blob/main/inference/echo-s2st/README.md) | Chinese → English/Spanish/Japanese; English → Chinese/Spanish/Japanese, preserving source voice. CUDA GPU ≥12 GB (2B) / ≥24 GB (9B) VRAM. | Reviewed dubbing-job candidate; long-video orchestration is separate and does not generate a new video. | + +## Other candidates + +This proposed sequence follows existing execution paths; no paid test has run. + +| Priority / task | Official source | Fit and remaining work | +| --- | --- | --- | +| 1: Local multilingual STT | [Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR), 0.6B / 1.7B, Apache-2.0 | Evaluate Chinese/dialect accuracy through a reviewed local adapter beside the voice resolver. Prove hardware, latency and failed-backend privacy before changing defaults. | +| 2: Local TTS | [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS), 0.6B / 1.7B, Apache-2.0; [CosyVoice](https://github.com/QwenAudio/CosyVoice), `Fun-CosyVoice3-0.5B-2512` | Evaluate synthesis/design/permitted voice references through existing Engine permissions, independently of the chat provider. | +| 2: Duration-controlled dubbing | [IndexTTS](https://github.com/index-tts/index-tts) | Its official repository uses the bilibili Model Use License Agreement. Do not infer Apache-2.0 from Index-Translate or a fork; review selected weights/terms separately. | +| 3: Video generation | [xAI video API](https://docs.x.ai/developers/model-capabilities/video/generation), `grok-imagine-video-1.5` | Separate asynchronous `/v1/videos/generations` and result polling. Needs typed cost/task authority, cancellation, durable job identity and verified output artifacts. A chat adapter is insufficient. | + +Reuse the Engine, permission gate and artifact receipts. Pin reviewed runtime/ +weights, expose the backend/upload destination before execution and preserve an +explicit offline choice. Downloads, hosted calls and voice-reference uploads +require ordinary authorization. Record actual failures, language directions, +licence scope and hardware proof. Related contracts: [providers](PROVIDERS.md), +[tools](TOOL_SURFACE.md), [plugins](PLUGIN_AUTHORING.md). diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 928bfc07f1..f93300b654 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -207,9 +207,10 @@ paths, parser rules and count/byte limits are documented in [the skill-root contract](EXTENSIONS.md#skill-roots). This API adds instructions; tool permissions remain with the shared engine. -Custom Ratatui/GPUI widgets, DSH browser UI slots, -native `dsh.bundle.patch` execution and DSH's agent runtime are not provided. -The static importer described below still converts only its portable subset. +The DSH importer described below can prepare a reviewed Native composition +from a closed package, as well as convert portable declarative components. +Custom Ratatui/GPUI widgets, DSH browser UI slots and DSH's agent runtime +are not provided. Compatible Claude bundles still use the existing declarative component adapters; this Native API does not load Claude's agent loop or automatically adapt Pi's extension API. Port executable mod behavior against the documented host contract. @@ -320,7 +321,7 @@ the same review as `POST /v1/apps/plugins/import/dsh/preview`, whose `install_source` and `content_hash` go to `POST /v1/apps/plugins/install`. Those two are the only import surfaces today: the TUI slash command (`/plugin import dsh ` and `approve`) and the Runtime API. There is no -`codewhale plugin` CLI subcommand for DSH import. This static import is also +`codewhale plugin` CLI subcommand for DSH import. This bundle import is also not the external-launcher integration `codewhale integrations dsh`, which runs the user's installed `dsh` (see [INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)). @@ -332,7 +333,24 @@ whole field: `config` is **not** deep-merged. This is not a complete profile resolver: other bundles, user overlays and deployment configuration are absent. Plain YAML scalars follow the YAML 1.2 core schema, as DSH's own parser does. -Disabled groups propagate their state to descendants. Disabled MCP declarations +Packages containing supported local modules, contained ESM package exports or +the supported DSH service bridges can produce a Native composition. The importer +seals the contained source files and module hashes, then generates the Native +entry or entries. A Native graph with an unresolved or unsupported row is +refused as a whole. It does not silently install the parts that happened to +resolve. Reviewed agent-preset packages can produce separate Native entries; +the selected default or child preset scopes their contributions. + +Import and review never execute plugin code. A generated Native composition +remains inactive until `extension_host` is explicitly enabled and the installed +bundle is reviewed, trusted and enabled. The host verifies its source closure +before loading it, and Rust retains tool approval and session authority. The +supported services, runtime limits and teardown behavior are documented in +[EXTENSIONS.md](EXTENSIONS.md). DSH UI code and the upstream agent loop are +outside this contract. + +For portable declarative conversion, disabled groups propagate their state to +descendants. Disabled MCP declarations stay disabled. Disabled skills are omitted with an explicit receipt because the native skill format has no disabled state. A conditional/non-boolean `disabled` value on a group or portable row refuses the import rather than assuming it is @@ -340,15 +358,14 @@ enabled. Unsupported entry policy/dependency fields, including `inject`, `intercept` and `isolate`, also refuse the import on those rows or their groups. Preserve their activation and authority rules in a manual port. -The importer never executes plugin code. Foreign runtime plugins and -`dsh.client` UI code are not executed or translated. -Other unrepresentable components are reported in the bundle's `CONVERSION.md` and +Unrepresentable portable components are reported in the bundle's `CONVERSION.md` and structured `CONVERSION.json`, with source package/version, manifest and ordered-layer SHA-256 hashes, converter version, per-row outcomes and required manual ports. Unapplied patch operations also appear in the structured manual-port list, with their source layer and one-based operation index. -The only lowered `!!js` expressions are `process.execPath` (becomes `node`) and +In portable conversion, the only lowered `!!js` expressions are +`process.execPath` (becomes `node`) and simple quoted/template literals without escapes or interpolation. Environment expressions—including fallbacks—are never resolved against this machine. Packaged Node MCP converts only when its relative entry resolves inside the @@ -357,12 +374,13 @@ package is skipped, and host paths are never copied. Rows of `@deepseek-ai/dsh-skill-filesystem` contribute their literal `customSkillDirs` children only when those directories live inside the package. Default user and project skill roots, watchers and foreign service dependencies are not imported. -Arbitrary DSH TypeScript plugin execution is outside this importer's scope. -DSH TypeScript plugin code runs only through the experimental TypeScript -extension host (`[features] extension_host`, off by default), which now supports -tools, slash commands, scoped pre-execute proposals, additive prompt sections -and owner-local storage through an explicitly authored Native entry. It does -not execute the imported `dsh.bundle.patch` composition. See [EXTENSIONS.md](EXTENSIONS.md) and +DSH modules run through the experimental TypeScript extension host +(`[features] extension_host`, off by default), using either an explicitly +authored Native entry or a generated reviewed composition. Tools, commands, +prompt sections, skills, hooks and owner-local storage use Codewhale's existing +consumers and approval rules. This is a supported compatibility subset; a +successful import is not evidence that every upstream DSH service is available. +See [EXTENSIONS.md](EXTENSIONS.md) and [design/TS_EXTENSION_HOST.md](design/TS_EXTENSION_HOST.md). ### Local Node MCP servers diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 73a91ca7ca..9e1b4ea2fa 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -2,6 +2,10 @@ > 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md) +Speech recognition, synthesis, translation and video have separate execution +paths. See [media models](MEDIA_MODELS.md) for current support and reviewed +upstream candidates; a chat catalogue row alone does not add a media backend. + This registry describes provider behavior that is wired into the current Codewhale codebase. It is intentionally conservative: shipped entries are limited to provider IDs, config keys, auth paths, base URLs, model resolution, diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 8ee5dce34b..d56e39fb40 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1056,7 +1056,7 @@ and live state comes only from a resumed thread's SSE stream. **Threads** (durable runtime data model) - `GET /v1/threads?limit=50&include_archived=false&archived_only=false` -- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false` +- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false&thread_ids=,` - `GET /v1/threads/running` - `GET /v1/threads/{id}/notices` - `DELETE /v1/threads/{id}/notices/{notice_id}` @@ -1070,6 +1070,11 @@ and live state comes only from a resumed thread's SSE stream. - `GET /v1/threads/{id}/turns/{turn_id}/receipt` — the same, for one turn; `404` for an unknown thread or a turn that is not this thread's +`thread_ids` is a comma-separated list of up to 200 IDs (each at most 128 bytes). +The route filters exact IDs before applying `limit`, preserving newest-first +order. This is a selection filter, not an ownership or authorization check; +the Runtime bearer token remains the access boundary. + `POST /v1/threads` accepts optional execution defaults in addition to the provider, model, workspace, and permission fields: @@ -2820,8 +2825,10 @@ Internal follow-ups and RLM child calls inherit the admitted preferences; they do not re-read the host operator's account in the middle of that work. Without a supplied snapshot, the Engine reads the signed-in profile from the -configured account service at turn admission. An unavailable or invalid signed-in -profile stops admission with an actionable error. An account without saved +configured account service at turn admission. When that Engine-loaded profile is +unavailable or invalid, the turn uses the signed-out local constitution and shows +a notice once per session; an invalid host-supplied `profile_constitution` snapshot still fails +the turn, and `GET /v1/constitution` still reports the error. An account without saved preferences uses an explicit default snapshot; a signed-out account uses the existing local constitution. Hosted transports always supply the owning account snapshot, including defaults, so local preferences cannot leak between diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index d4d3015424..1efa2ed553 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -63,3 +63,4 @@ claim that none is required. | `crates/tui/src/tui/frame_rate_limiter.rs` | `codex-rs/tui/src/tui/frame_rate_limiter.rs`, [openai/codex](https://github.com/openai/codex) | | `crates/tui/src/tui/display_refresh.rs` | Grok CLI's host display-refresh probe | | `patches/unicode-width-0.2.2/` | vendored patch; upstream `LICENSE-MIT` retained in-tree | +| `patches/crossterm-0.29.0/` | vendored patch (surrogate key-up handling in `src/event/sys/windows/parse.rs`, the change proposed upstream as crossterm-rs/crossterm#1073); upstream MIT `LICENSE` retained in-tree | diff --git a/docs/examples/plugins/whale-avatar/README.md b/docs/examples/plugins/whale-avatar/README.md new file mode 100644 index 0000000000..7d9ff6373f --- /dev/null +++ b/docs/examples/plugins/whale-avatar/README.md @@ -0,0 +1,29 @@ +# Whale avatar Native plugin + +This example uses the current reviewed Native plugin system. Install the folder +through Codewhale's normal plugin install/review/enable flow. Review includes +`index.mjs`, `avatars/avatar.json` and its PNG page. It asks for no network, +secret or custom renderer API. The Engine binds the contribution to this live +Native owner and automatically withdraws it when disabled or disposed. + +Select **Studio whale girl** in GPUI's whale habitat. In the Engine TUI, list +`/pet avatar`, select the returned `plugin:…:studio-girl` key, and try +`/pet action greet`. `/pet action live` returns to live owner activity. The +terminal selection is session-local. Previewing `greet` changes only artwork. + +The `greet` clip demonstrates an author-defined action name. Add your own PNG +pages, clips, state mappings or views to this manifest. Frame indices span +pages in order, then row-major cells. All pages share the declared dimensions. +`rest` must be bound; other unknown states fall back to it. Each action has +frame durations, a reduced-motion poster and a repeat policy. A pack adds no +state classifier, animation loop or executable code to the rendering client. + +`ctx.avatars.registerPack({path})` returns a Cordis effect disposer. Invoke it +when withdrawing a contribution early; ordinary plugin/scope teardown also +cleans it up. Use existing `commands`, `tools` and `events` services for other +behaviors. Avatar custom actions are visual clips, not arbitrary execution. + +Art is derived from Codewhale's whale-girl pack. The exact generation prompt +is embedded in the PNG; the full provenance lives in the vendored Ratatui +`assets/whale-girl/PROMPT.md`. The web currently includes the built-in girl; +Native plugin catalog transport is available in GPUI and the Engine TUI. diff --git a/docs/examples/plugins/whale-avatar/avatars/avatar.json b/docs/examples/plugins/whale-avatar/avatars/avatar.json new file mode 100644 index 0000000000..526b860f34 --- /dev/null +++ b/docs/examples/plugins/whale-avatar/avatars/avatar.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "id": "studio-girl", + "name": "Studio whale girl", + "columns": 2, + "rows": 2, + "tileWidth": 672, + "tileHeight": 672, + "actions": { + "idle": { + "frames": [ + 0, + 1 + ], + "durationsMs": [ + 3800, + 180 + ], + "poster": 0, + "repeat": true + }, + "greet": { + "frames": [ + 2, + 3 + ], + "durationsMs": [ + 3800, + 180 + ], + "poster": 2, + "repeat": true + } + }, + "states": { + "rest": "idle", + "listen": "greet" + }, + "views": { + "front": "idle" + }, + "atlases": [ + "page-00.png" + ] +} diff --git a/docs/examples/plugins/whale-avatar/avatars/page-00.png b/docs/examples/plugins/whale-avatar/avatars/page-00.png new file mode 100644 index 0000000000..fd22bd6e76 Binary files /dev/null and b/docs/examples/plugins/whale-avatar/avatars/page-00.png differ diff --git a/docs/examples/plugins/whale-avatar/index.mjs b/docs/examples/plugins/whale-avatar/index.mjs new file mode 100644 index 0000000000..f49c13746e --- /dev/null +++ b/docs/examples/plugins/whale-avatar/index.mjs @@ -0,0 +1,4 @@ +export const inject = ['avatars'] +export function apply(ctx) { + ctx.avatars.registerPack({ path: 'avatars/avatar.json' }) +} diff --git a/docs/examples/plugins/whale-avatar/plugin.json b/docs/examples/plugins/whale-avatar/plugin.json new file mode 100644 index 0000000000..ff48993940 --- /dev/null +++ b/docs/examples/plugins/whale-avatar/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "whale-avatar", + "version": "0.1.0", + "description": "A composable whale avatar with a named greeting clip.", + "license": "MIT", + "extensions": { + "net.codewhale": { + "native": { + "path": "index.mjs" + } + } + } +} diff --git a/docs/zh_hans/ARCHITECTURE.md b/docs/zh_hans/ARCHITECTURE.md index 44e2370538..9bb3acbb32 100644 --- a/docs/zh_hans/ARCHITECTURE.md +++ b/docs/zh_hans/ARCHITECTURE.md @@ -2,8 +2,10 @@ > 英文原文:[ARCHITECTURE.md](../ARCHITECTURE.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 补齐单一回合循环的私有阶段与 ACP 投影边界。 -本文面向开发者和贡献者,概览 Codewhale 的架构。 +Codewhale Engine 是现有 Rust 执行运行时。[Runtime API](RUNTIME_API.md) 是客户端 +接口,[TypeScript mods](EXTENSIONS.md) 提供经审查的扩展。 当前边界说明(工作区版本以 `Cargo.toml` 为准;该边界自 v0.9.1 起保持不变): - `crates/tui` 仍是 TUI、运行时 API、任务管理器和工具执行循环的现行终端用户运行时。 @@ -81,7 +83,12 @@ - **`core/`** - 主要引擎组件 - `engine.rs` - 引擎状态、操作处理、消息处理 - - `engine/turn_loop.rs` - 流式回合循环与工具执行编排 + - `engine/turn_loop.rs` - 现有 Engine 外层回合循环、共享工具规划/执行/结果处理与 + 流解码。私有 `turn_loop/` 阶段包括准备(`preparation.rs`)、模型派发与接纳 + (`model_step.rs`)、有序续接(`continuation.rs`)、内联 REPL 编排 + (`inline_repl.rs`)及直接模型工具批次(`tool_batch.rs`)。它们借用同一个 + Engine 与 TurnContext,不引入第二个运行时、会话、提示词、审批、事件或持久化 + 权威。重试、循环终止与立即返回仍不同,只有现有有效工作路径推进步骤。 - `session.rs` - 会话状态管理 - `turn.rs` - 基于回合的对话处理 - `events.rs` - 用于 UI 更新的事件系统 @@ -114,7 +121,9 @@ `crates/tui/src/core/engine/turn_loop.rs` 里的 `Engine::run_turn`,而 `crates/tui/src/core/` 是 TUI crate 内部的模块,不是这个 crate 的。这里曾有一棵 占位的 `engine/` 目录树让人误解——它没有任何调用方,还会在不接触模型的情况下 - 发出 `TurnComplete`——已在 v0.9.11 移除。 + 发出 `TurnComplete`——已在 v0.9.11 移除。源码守卫跟踪已解析的本地阶段调用, + 仍拒绝未登记的循环 owner。ACP stdio 投影现有 Runtime manager 与 Engine, + 不保留独立提供商/工具回合循环或历史。 递归 RLM 和普通 Python RPC 现在使用同一个 Engine 生产者与 Session;RLM 不再有独立循环。Python 保存上下文与变量,每轮只借用调用方已捕获的路由、 Native 选择、原有代码审批、取消信号和截止时间。任务指导有界且追加到 Core diff --git a/docs/zh_hans/CATALOG_REFRESH.md b/docs/zh_hans/CATALOG_REFRESH.md index c1e6962edf..42c1178c4e 100644 --- a/docs/zh_hans/CATALOG_REFRESH.md +++ b/docs/zh_hans/CATALOG_REFRESH.md @@ -2,6 +2,7 @@ > 英文原文:[CATALOG_REFRESH.md](../CATALOG_REFRESH.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 补齐 reviewed 元数据的唯一 owner 与退役缓存边界。 Codewhale 如何让模型元数据保持最新:哪些部分已经自动更新,哪些要人工维护, 以及定时目录任务该做什么、不该做什么。 @@ -132,10 +133,9 @@ TUI/运行时启动时(且未被禁用): | 表面 | 为什么会漂移 | |---|---| | `models_dev.bundled.json` | 离线种子,由经过审阅的 spec 和固定的 lock 生成(见下文);通过 PR 刷新,而不是在运行时刷新 | -| `model_catalog.bundled.json` | 紧凑的 TUI 种子 | -| `provider_defaults.rs` / 默认模型 ID | 属于产品选择,不是纯粹的目录导出 | -| `models.rs` 里的静态表 | 目录缺行时的兜底启发式 | -| 人工整理的 `pricing.rs` 行 | 厂商计费的怪癖;Models.dev 里不一定有 | +| `provider_descriptors.json` 默认模型 ID | 产品选择,不是纯目录导出;常量投影由生成器维护 | +| `catalog_corrections.json` 的 `reviewed` | 带精确来源回执的固有/选择器/传输兼容事实;生成到同一种子 | +| Rust 价格策略 | 厂商计费窗口、人民币换算及扣留/分级行为;纯参考观察保存在 reviewed 补充资料 | | 新的 `ProviderKind` / 线协议方言 | 需要代码,光有 JSON 不够 | 运行时的实时刷新**不会**改写这些文件。最近安装、网络正常的用户仍能看到 @@ -178,7 +178,8 @@ CI 会运行 `seed render --check`,出现任何差异都会失败。 | `scripts/catalog/models_dev_seed.toml` | 要携带哪些上游行,以及它们的 Codewhale 提供商 id、线协议 id、默认值、规范关联,还有少数上游没有列出的人工整理行 | 人工,经审阅 | | `scripts/catalog/models_dev_seed.lock.json` | 被引用的上游行(已按允许列表过滤),以及来源 URL、抓取时间和 sha256 | 只由 `seed lock` 写入 | | `crates/config/assets/catalog_corrections.json` | 有意的压制:扣留的价格、收紧的上限、推理控制 | 人工,经审阅;在线时同样生效 | -| `crates/config/assets/models_dev.bundled.json` | 渲染出的种子 | 只由 `seed render` 写入 | +| `crates/config/assets/catalog_corrections.json` 的 `reviewed` | 保留来源的固有事实、限定范围别名、补全引用、公开标签/源码支持日期、纯路由事实与参考价格 | 人工审查;保留来源回执 | +| `crates/config/assets/models_dev.bundled.json` | 包含 reviewed 补充资料的渲染种子 | 只由 `seed render` 写入 | spec 只负责选择和映射;它不能写出与上游不一致的值(未知的键会被拒绝)。 如果某个上游值对某条 Codewhale 路由不对,就加一条修正。修正对种子和实时行都生效; @@ -289,3 +290,16 @@ Codewhale 的 Anthropic 路由在推理时仍**基于 API 密钥**(`ANTHROPIC_ - 目录自动化脚本(校验 / 试运行):#4117 - 生成的离线种子与运行时修正:#6396 - 更细的元数据清单与漂移列表:`codewhale-ops` 仓库 + +### 已退役的无范围元数据读取器 + +旧 models crate 缓存读取器及其独立内置资产、TUI 专用模型注册表已经退役。 +已有旧缓存文件保留,但不作为提供商或公开标签权威导入。`config::catalog` 拥有 +不可变的编译固有事实投影;现有 Engine provider lake 与限定范围目录缓存继续拥有 +实时、账号及配置事实。不同端点上相同的线协议名称不构成规范关联、公开标签或 +无范围价格。内置新鲜度使用真实种子 lock 的抓取时间,每次查询重新读取当前时钟。 + +兼容补全列表引用提供商描述符默认值与目录组,不再复制它们。Kimi 生成默认值与 +直连/会员路由限制保持不同。未知能力及名称后缀推断的预算明确未验证。 +网站模型日期表示源码支持日期,旧已证实日期保留在同一 reviewed owner 中; +它们不声明厂商发布日期或当前 API 可用性。 diff --git a/docs/zh_hans/CNB_MIRROR.md b/docs/zh_hans/CNB_MIRROR.md index ff513c1a5c..a02bb30355 100644 --- a/docs/zh_hans/CNB_MIRROR.md +++ b/docs/zh_hans/CNB_MIRROR.md @@ -1,13 +1,27 @@ # CNB Cool 镜像 > 英文原文:[CNB_MIRROR.md](../CNB_MIRROR.md)。 -> 最后与英文同步日期(last synced with English revision):2026-09-30。 +> 最后与英文同步日期(last synced with English revision):2026-10-06。 `cnb.cool/codewhale.net/codewhale` 是这个 GitHub 仓库的单向镜像,服务那些 GitHub 慢或无法访问的网络环境(主要是中国大陆)。镜像会收到:`main` 的每一次 推送,第一方发布工作用到的每个 `fix/*`、`rebrand/*` 和 `work/v*` 分支,以及 每个完整 GitHub Release 已发布后的 `v*` 发布标签。 +## 源码与下载分别验证(2026-10-06 审核) + +本次审核确认 CNB `main` 与权威 GitHub `main` 同步;这不等于二进制发布完成。 +GitHub、npm 与 npmmirror 的最新已发布版本为 **0.10.0**,两个 npm 注册表的 +包装器压缩包字节与完整性值一致。CNB 最新二进制 release 仍是 **0.9.9**; +`v0.10.0` 的源码标签存在,但其标签构建在发布说明检查处失败,编译和上传未完成。 +该版本 CNB 校验清单返回 404,所以不能宣称 `CODEWHALE_USE_CNB_MIRROR=1` +可安装 0.10.0。0.10.1 在发布前仅是 `main` 上的源码候选版。 + +先验证标签指向相同源码,再验证该版本的 release、资产和校验清单实际存在。 +只看 GitHub 同步工作流成功,或看到源码标签,都不足以证明下载可用。 +恢复已存在标签不会产生一次新的标签推送;维护者需单独恢复 CNB 构建/发布并审核 +结果,不能移动或重打已经发布的标签。发布之后应重新审核本段版本状态。 + ## 来源 **GitHub 是唯一的权威源。** 所有发布、标签和源代码都源自 @@ -38,8 +52,8 @@ sha256sum -c codewhale-artifacts-sha256.txt --ignore-missing - **范围:** 只推送触发本次运行的那个 ref。标签推送就推送该标签。分支推送镜像 `main`、第一方 `fix/*`/`rebrand/*` 分支,或显式匹配的发布分支。其他功能分支 和 dependabot ref 有意*不*镜像。 -- **并发:** 各次运行通过 `cnb-sync` 并发组串行化,这样 `auto-tag.yml` 接连 - 发出的 `main` 推送和标签推送不会互相竞争。 +- **并发:** 按 `cnb-sync-${{ github.ref }}` 为每个 ref 单独串行化。 + 不同标签不会互相挤掉待运行任务;同一分支只保留最新待同步推送。 - **重试:** 每次推送最多重试三次,采用线性退避(5s、10s),之后工作流放弃。 CNB 流水线配置也纳入 GitHub 的源码管理,位置是 @@ -222,6 +236,8 @@ https://cnb.cool/codewhale.net/codewhale.git 镜像会收到 `main`、发布标签和匹配到的发布分支。当 CNB 工作流或凭据不健康时, GitHub 是回退方案。 -CNB 部署按钮的示例放在 `deploy/tencent-lighthouse/cnb/`。只有把它们复制进 +腾讯云 Lighthouse 自建服务器的中文/英文操作步骤见 +[`scripts/tencent-lighthouse/README.md`](../../scripts/tencent-lighthouse/README.md)。 +CNB 部署按钮示例见 [`deploy/tencent-lighthouse/cnb/`](../../deploy/tencent-lighthouse/cnb/README.md)。只有把它们复制进 `.cnb.yml` 和 `.cnb/tag_deploy.yml` 之后才会生效,因为实际部署任务需要 Lighthouse 部署密钥、目标主机,以及明确的 CNB 配额/计费策略。 diff --git a/docs/zh_hans/CONFIGURATION.md b/docs/zh_hans/CONFIGURATION.md index e8309e5839..6fcf97be07 100644 --- a/docs/zh_hans/CONFIGURATION.md +++ b/docs/zh_hans/CONFIGURATION.md @@ -2,6 +2,7 @@ > 英文原文:[CONFIGURATION.md](../CONFIGURATION.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 补齐流传输、脚本工具及用户等待契约;其余段落保留原同步日期。 > ChatGPT 登录相关内容于 2026-10-01 按当前实现更新。 Codewhale 从 TOML 文件加环境变量读取配置。进程启动时,它还可能从工作区本地的 `.env` 文件加载字面(literal)的内置 provider 凭据。请以受跟踪的 `.env.example` 为模板;把它复制为 `.env`,然后只添加凭据值。 @@ -182,11 +183,44 @@ allow_shell = true 覆盖刻意很窄——它覆盖仓库维护者最可能想要跨贡献者标准化的字段。凭据、端点、provider 选择、MCP 配置、hooks、skills、重试、热栏绑定和 `instructions = [...]` 设置保持用户全局。如果仓库本地配置声明了 `api_key`、`base_url`、`providers`、`provider`、`mcp_config_path`、`notes_path`、`hotbar`、`allow_shell = true` 或 `instructions`,Codewhale 会忽略该键并保留用户的全局设置。 -合并的 `codewhale` 运行时为 DeepSeek 认证和模型默认值使用同一个配置文件。`codewhale auth set --provider deepseek` 把 key 保存到 `~/.codewhale/config.toml`(需要时在首次启动迁移旧 `~/.deepseek/config.toml`),`codewhale --model deepseek-v4-flash` 作为 `CODEWHALE_MODEL` 转发给 TUI。分发器不再写入这些变量的 `DEEPSEEK_*` 孪生变量;你自己设置的 `DEEPSEEK_*` 值在对应的 `CODEWHALE_*` 未设置时仍作为旧别名读取。 +合并的 `codewhale` 运行时使用 `~/.codewhale/config.toml` 保存提供商和模型设置。 +`codewhale auth set --provider deepseek` 把密钥保存在本地机密存储中(默认是 +`~/.codewhale/secrets/` 下的私有文件;OS 钥匙串需显式选择),而不是写入该配置文件。 +需要时,首次启动会迁移旧 `~/.deepseek/config.toml`。 +`codewhale --model deepseek-v4-flash` 作为 `CODEWHALE_MODEL` 转发给 TUI。 +分发器不再写入对应的 `DEEPSEEK_*` 变量;用户自己设置的值在 `CODEWHALE_*` +未设置时仍作为旧别名读取。 -`codewhale login` 登录 Codewhale 账号——它与 `codewhale account login` 是同一个浏览器设备流(device flow),不是 provider-key 命令。Provider 凭据完全通过 `codewhale auth set --provider ` 配置。 +### 账号提供商密钥 -该 provider 凭据与可选的管理产品账号是分开的。`codewhale account login` 启动 Codewhale 浏览器设备流;`codewhale account status` 和 `codewhale account logout` 检查或移除所选 `--profile` 的会话。账号会话优先使用 OS 凭据管理器,在无凭据管理器可用时自动回退到私有 `0600` Codewhale secrets 文件(无头主机、SSH、容器)。`codewhale account keys list|set|remove` 管理已登录账号的 BYOK 保险库(vault),不显示机密值。更旧的 `codewhale cloud ...` 拼写仍是命令别名。 +Codewhale 账号的直接好处是:**在一个地方管理模型提供商的 API 密钥**。 +[注册](https://app.codewhale.net/register)或[登录](https://app.codewhale.net/login)后, +可以在[提供商设置](https://app.codewhale.net/providers)中添加、更换或移除密钥, +也可以使用终端: + +```bash +codewhale login # 浏览器登录;也可用 account login +codewhale account keys set deepseek # 隐藏输入,添加或更换密钥 +codewhale account keys list # 只显示配置状态,不显示密钥 +codewhale --provider codewhale # 使用账号的模型通道 +``` + +账号密钥保存在服务端,用于经过账号通道的请求。在另一台设备上登录并在 +`/provider` 中选择 Codewhale,即可使用这些密钥,无需再次粘贴。`/models` +列出该账号可用的模型。更换账号中保存的密钥会影响此通道的后续请求,但不会改变 +独立的本地副本,也不会撤销提供商侧的密钥。`codewhale account keys remove deepseek` +移除账号中保存的密钥。 + +登录**不会**自动上传已有的本地密钥。若希望复制本地密钥,请明确使用 +`codewhale account keys set deepseek --from-local`。本地提供商仍通过 +`codewhale auth set --provider ` 配置;使用本地密钥或本地模型不需要 +Codewhale 账号。登录会保留显式选择或已配置的本地提供商;需要切换到账号通道时, +使用 `--provider codewhale`。 + +账号会话与提供商密钥分开保存。`codewhale account status` 查看会话; +`codewhale account logout` 移除所选 `--profile` 的账号会话,不删除提供商密钥。 +账号会话使用私有 `0600` Codewhale secrets 文件,按 profile 和账号 API 来源隔离, +不使用 OS 钥匙串。旧的 `codewhale cloud ...` 仍是账号命令的别名。 ### 可移植配置包(Portable config bundles) @@ -216,7 +250,7 @@ allow_shell = true 运行 `codewhale auth status` 检查活动 provider 的配置文件、OS 钥匙串后端、环境变量、胜出来源和末四位标签,而不打印 key 本身。该命令只探测活动 provider 的钥匙串条目。 -对于托管、通用 OpenAI 兼容、自托管、OpenAI Responses 或原生 Anthropic provider,设置 `provider = ""` 或传 `codewhale --provider `。规范的 provider ID 是 `deepseek`、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`volcengine`、`openrouter`、`orcarouter`、`xiaomi-mimo`、`novita`、`fireworks`、`siliconflow`、`arcee`、`siliconflow-CN`、`moonshot`、`sglang`、`vllm`、`ollama`、`ollama-cloud`、`huggingface`、`modelscope`、`together`、`qianfan`、`openai-codex`、`anthropic`、`openmodel`、`zai`、`stepfun`、`minimax`、`deepinfra`、`sakana`、`longcat`、`opencode-go`、`opencode-zen`、`meta`、`xai`、`mistral`、`telecomjs`、`modelstudio-token-plan`、`google`、`edenai`、`concentrate`、`codewhale` 和 `custom`(通过 `[providers.]` 定义的用户自定义 OpenAI 兼容端点)。逐 provider 的 registry,包括线协议、认证变量、默认 base URL、模型 ID 和能力元数据,见 [PROVIDERS.md](PROVIDERS.md)。facade 把 provider 凭据保存到共享用户配置,并把解析后的 key、base URL、provider 和模型转发给 TUI 进程。使用 `codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` 或 `codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` 或 `codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"` 或 `codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"` 或 `codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY"` 或 `codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` 或 `codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY"` 或 `codewhale auth set --provider arcee --api-key "YOUR_ARCEE_API_KEY"` 或 [PROVIDERS.md](PROVIDERS.md) 中匹配的 provider ID,通过 facade 保存 provider key。通用 `openai` provider 默认 `https://api.openai.com/v1`,接受 `OPENAI_BASE_URL`,默认 `gpt-5.6`。自定义 OpenAI 兼容网关仍可显式选择自己的模型。`atlascloud` 默认 `https://api.atlascloud.ai/v1`,接受 `ATLASCLOUD_BASE_URL`,默认模型是 `deepseek-ai/deepseek-v4-flash`。`wanjie-ark` 指向 Wanjie Ark 的 OpenAI 兼容端点 `https://maas-openapi.wanjiedata.com/api/v1`,默认 `deepseek-reasoner`,并原样透传模型 ID,因为 Wanjie 模型访问按账号作用域。SGLang、vLLM 和 Ollama 是自托管的,默认可以不用 API key 运行。Ollama 默认 `http://localhost:11434/v1`,并原样发送 `codewhale-coder:1.3b` 或 `qwen2.5-coder:7b` 这样的模型标签。自托管 provider 和 loopback 自定义 URL(`localhost`、`127.0.0.1`、`[::1]`、`0.0.0.0`)不读取 secret store,除非显式请求 API key 认证;当本地服务器确实需要 bearer 认证时,用环境变量或配置文件 key。Ollama Cloud 是独立的托管 `ollama-cloud` provider。它默认 `https://ollama.com/v1` 和 `gpt-oss:120b`;用 `codewhale auth set --provider ollama-cloud` 保存它的 key。环境认证先读 `OLLAMA_CLOUD_API_KEY`,然后是 Ollama 官方的 `OLLAMA_API_KEY`。SiliconFlow 默认 `https://api.siliconflow.com/v1`,接受 `SILICONFLOW_BASE_URL`,默认用 `deepseek-ai/DeepSeek-V4-Pro`。`provider = "siliconflow-CN"` 选择中国区域默认 `https://api.siliconflow.cn/v1`,配 `[providers.siliconflow_cn]` 表和 `SILICONFLOW_API_KEY` 凭据槽位。Arcee AI 默认 `https://api.arcee.ai/api/v1`,接受 `ARCEE_BASE_URL`,对 Codewhale 智能体工作默认用 `trinity-large-thinking`。`trinity-large-preview` 也作为直接的 Arcee API 模型列出;OpenRouter 的 `arcee-ai/trinity-large-thinking` 仍是 OpenRouter 命名空间形式,而直接 Arcee provider 用裸的 `trinity-large-thinking` ID。直接的 Arcee 大模型 API 调用按 256K 上下文 BF16 服务跟踪;Thinking 具备推理能力,而 Preview 未标记为推理模型。 +对于托管、通用 OpenAI 兼容、自托管、OpenAI Responses 或原生 Anthropic provider,设置 `provider = ""` 或传 `codewhale --provider `。规范的 provider ID 是 `deepseek`、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`volcengine`、`openrouter`、`orcarouter`、`xiaomi-mimo`、`novita`、`fireworks`、`siliconflow`、`arcee`、`siliconflow-CN`、`moonshot`、`sglang`、`vllm`、`ollama`、`ollama-cloud`、`huggingface`、`modelscope`、`together`、`qianfan`、`openai-codex`、`anthropic`、`openmodel`、`zai`、`stepfun`、`minimax`、`deepinfra`、`sakana`、`longcat`、`opencode-go`、`opencode-zen`、`meta`、`xai`、`mistral`、`telecomjs`、`modelstudio-token-plan`、`google`、`edenai`、`concentrate`、`codewhale` 和 `custom`(通过 `[providers.]` 定义的用户自定义 OpenAI 兼容端点)。逐 provider 的 registry,包括线协议、认证变量、默认 base URL、模型 ID 和能力元数据,见 [PROVIDERS.md](PROVIDERS.md)。facade 把 provider 凭据保存到本地机密存储,并把解析后的 key、base URL、provider 和模型转发给 TUI 进程。使用 `codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` 或 `codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` 或 `codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"` 或 `codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"` 或 `codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY"` 或 `codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` 或 `codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY"` 或 `codewhale auth set --provider arcee --api-key "YOUR_ARCEE_API_KEY"` 或 [PROVIDERS.md](PROVIDERS.md) 中匹配的 provider ID,通过 facade 保存 provider key。通用 `openai` provider 默认 `https://api.openai.com/v1`,接受 `OPENAI_BASE_URL`,默认 `gpt-5.6`。自定义 OpenAI 兼容网关仍可显式选择自己的模型。`atlascloud` 默认 `https://api.atlascloud.ai/v1`,接受 `ATLASCLOUD_BASE_URL`,默认模型是 `deepseek-ai/deepseek-v4-flash`。`wanjie-ark` 指向 Wanjie Ark 的 OpenAI 兼容端点 `https://maas-openapi.wanjiedata.com/api/v1`,默认 `deepseek-reasoner`,并原样透传模型 ID,因为 Wanjie 模型访问按账号作用域。SGLang、vLLM 和 Ollama 是自托管的,默认可以不用 API key 运行。Ollama 默认 `http://localhost:11434/v1`,并原样发送 `codewhale-coder:1.3b` 或 `qwen2.5-coder:7b` 这样的模型标签。自托管 provider 和 loopback 自定义 URL(`localhost`、`127.0.0.1`、`[::1]`、`0.0.0.0`)不读取 secret store,除非显式请求 API key 认证;当本地服务器确实需要 bearer 认证时,用环境变量或配置文件 key。Ollama Cloud 是独立的托管 `ollama-cloud` provider。它默认 `https://ollama.com/v1` 和 `gpt-oss:120b`;用 `codewhale auth set --provider ollama-cloud` 保存它的 key。环境认证先读 `OLLAMA_CLOUD_API_KEY`,然后是 Ollama 官方的 `OLLAMA_API_KEY`。SiliconFlow 默认 `https://api.siliconflow.com/v1`,接受 `SILICONFLOW_BASE_URL`,默认用 `deepseek-ai/DeepSeek-V4-Pro`。`provider = "siliconflow-CN"` 选择中国区域默认 `https://api.siliconflow.cn/v1`,配 `[providers.siliconflow_cn]` 表和 `SILICONFLOW_API_KEY` 凭据槽位。Arcee AI 默认 `https://api.arcee.ai/api/v1`,接受 `ARCEE_BASE_URL`,对 Codewhale 智能体工作默认用 `trinity-large-thinking`。`trinity-large-preview` 也作为直接的 Arcee API 模型列出;OpenRouter 的 `arcee-ai/trinity-large-thinking` 仍是 OpenRouter 命名空间形式,而直接 Arcee provider 用裸的 `trinity-large-thinking` ID。直接的 Arcee 大模型 API 调用按 256K 上下文 BF16 服务跟踪;Thinking 具备推理能力,而 Preview 未标记为推理模型。 ### OpenRouter 提供商固定 @@ -1373,6 +1407,42 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离 - `features.*`(可选):功能标志覆盖(见下文)。 +### 流与传输设置 + +`[stream]` 是模型流策略及其 HTTP 客户端的规范配置表。 +`codewhale config dump` 和 `codewhale config get stream` 显示运行时解析后的有效值, +包括环境变量回退及范围限制;查看不会把默认值写入文件。例如: +`codewhale config set stream.open_timeout_secs 120`、 +`codewhale config unset stream.open_timeout_secs`。单次运行的 +`--set stream.open_timeout_secs=120` 使用同一验证器。 + +| `[stream]` 键 | 默认值 | 有效行为 | 旧 `[tui]` 回退键 | +| --- | --- | --- | --- | +| `open_timeout_secs` | 45 | 正数限制到 5–300;0 或省略时回退到环境变量/默认值 | `stream_open_timeout_secs` | +| `chunk_timeout_secs` | 900 | 0 使用默认值;正数限制到 1–3600 | `stream_chunk_timeout_secs` | +| `max_resumes` | 3 | 0 禁止重新发起整个请求;最大 10 | `stream_max_resumes` | +| `max_transparent_retries` | 2 | 0 禁止输出任何内容前的重试;最大 10 | `stream_max_transparent_retries` | +| `max_stream_errors` | 5 | 0 使用默认值;正数限制到 1–50 | `stream_max_errors` | +| `max_duration_secs` | 1800 | 每条流的总时长;0 使用默认值,正数限制到 10–86400 秒 | `stream_max_duration_secs` | +| `max_content_mb` | 10 | 每条流的内容上限;0 使用默认值,正数限制到 1–512 MiB | `stream_max_content_mb` | +| `connect_timeout_secs` | 30 | TCP/TLS 建连;0 使用默认值,正数限制到 1–300 秒 | `connect_timeout_secs` | +| `force_http1` | false | 布尔值;环境变量固定为真时始终启用 HTTP/1.1 | `force_http1` | +| `tcp_keepalive_secs` | 30 | TCP 保活探测前的空闲时间;0 禁用,正数限制到 1–3600 秒 | 无 | +| `http2_keep_alive_interval_secs` | 15 | 活跃 HTTP/2 连接的 PING 间隔;0 禁用,正数限制到 1–3600 秒 | 无 | +| `http2_keep_alive_timeout_secs` | 20 | PING 确认期限;0 使用默认值,正数限制到 1–3600 秒 | 无 | + +每个显式规范字段都优先于旧 `[tui]` 字段,包括 `0` 与 `false`;省略规范字段则保留旧值。 +正数建连响应头等待时间优先于 `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`(再回退到 +`DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS`);0 回退到这些变量。省略分块超时会使用 +`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS`(再回退到 `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`), +显式 0 则使用 900。`CODEWHALE_FORCE_HTTP1`(旧名 `DEEPSEEK_FORCE_HTTP1`) +与配置做逻辑或,所以配置 `false` 不能取消环境变量固定的 `true`。Profile 逐字段合并此表。 + +传输设置只影响新建的模型、目录和 HTTP/1 回退客户端,不会重建活跃客户端, +也不控制 MCP 或其他网络服务。HTTP/2 PING 不作用于空闲连接池;固定 HTTP/1.1 +时 HTTP/2 设置无效。操作系统控制 TCP 探测细节;这些键不会关闭证书校验。 +`[retry]` 仍独立拥有 HTTP 请求退避计划。 + ### 工作区笔记 `/note` 在当前工作区的 `.codewhale/notes.md` 管理一个简单的笔记文件(旧 `.deepseek/notes.md` 是尚不存在 `.codewhale/notes.md` 时的回退路径)。现有的 `/note ` 用法仍追加笔记。管理形式: @@ -1635,6 +1705,37 @@ Codewhale 默认加载一个小型核心原生工具目录,把不太常见的 always_load = ["Git", "notify"] ``` +### 脚本工具与覆盖 + +`~/.codewhale/tools/`(或 `[tools].plugin_dir`)内以 `# name:` 头声明的脚本成为 +模型可见工具,`/plugin tools` 列出它们。脚本从 stdin 读取工具 JSON 输入, +向 stdout 写 JSON `ToolResult`,例如 `{"content":"...","success":true}`。 + +```sh +#!/usr/bin/env sh +# name: word_count +# description: Count words in the given text +# schema: {"type":"object","properties":{"text":{"type":"string"}}} +# approval: required +``` + +`# approval:` 支持 `suggest`(默认)或 `required`;两者都遵守会话审批设置。 +脚本不能自行批准:`approval: auto` 不再受支持,会采用默认值,并在运行时日志 +(`~/.codewhale/logs/`)及 `/plugin tools` 中说明。 + +与已注册工具重名的脚本不会加载。`[tools.overrides]` 可以禁用内置工具,或以 +新名称添加 `script` / `command` 工具;不能用它们替换内置名称: + +```toml +[tools.overrides] +"Web" = { type = "disabled" } +"audited_shell" = { type = "script", path = "audit-shell.sh" } +"Bash" = { type = "script", path = "audit-shell.sh" } # 拒绝;Bash 仍生效 +``` + +拒绝的键每个会话显示一次状态行并写入日志。要包装内置工具,请禁用它并用新名称 +注册包装器。覆盖仍可替换同名的普通脚本;相对 `path` 按插件目录解析。 + ### `request_user_input` 限制 `request_user_input` 会向用户提出一小批选择题。两个上限都是可配置的(#5949):当研究或规划工作流确实需要更多澄清时,调高 `user_input_max_questions`;当交互式分诊应保持简洁时,调低它。 @@ -1651,6 +1752,10 @@ user_input_max_options = 4 # default 4, clamped to 2..=10 `request_user_input` 的提问默认一直等待用户回答或取消(#6003)。省略 `user_input_timeout_seconds` 或设为 `0` 都不设置超时;正数则限定本次等待的秒数,超时后取消。大于 86,400 秒(24 小时)的值会被限制为 86,400 秒。无头的 `exec` 运行没有应答者,因此默认不提供 `request_user_input`:模型会报告该工具不存在并直接结束,而不是卡住。 +0.10.1 中,TUI 的挂起工具计时器识别真实的用户输入与审批等待(#6872)。等待时 +暂停该回合的看门狗;答案回到原调用,无需另输 `continue`。回答后重新计算活动等待 +基线。用户取消及正数审批/输入期限仍生效;它不会给正在执行的工具无限时间。 + ```toml [tools] user_input_timeout_seconds = 300 # optional 5-minute limit; omitted or 0 waits indefinitely; max 86400 (24h) diff --git a/docs/zh_hans/EXTENSIONS.md b/docs/zh_hans/EXTENSIONS.md index 2304bce8a0..d26b7c5e51 100644 --- a/docs/zh_hans/EXTENSIONS.md +++ b/docs/zh_hans/EXTENSIONS.md @@ -82,7 +82,8 @@ CommonJS 解析也需要 Node;这些情况会明确提示使用 导入可在两种运行时中使用。 宿主提供同一个 Cordis、schemastery、cosmokit 及有限的 DSH 兼容导出。支持的服务名为 -`tools`、`commands`、`prompt`、`storage`、`skills`、`shellHooks`、`mcp`、`logger`、`events`、`reflect`、`registry`。 +`tools`、`commands`、`prompt`、`storage`、`skills`、`shellHooks`、`avatars`、`mcp`、`logger`、`events`、`reflect`、`registry`。`ctx.avatars.registerPack({path})` 注册已审查包内的头像包, +参见 [whale-avatar 示例](../examples/plugins/whale-avatar/README.md)。 需要未提供服务的插件会激活失败,并显示原因。目前没有已发布的插件编写 SDK; 普通 ESM 示例使用这些文档规定的适配服务。 diff --git a/docs/zh_hans/INSTALL.md b/docs/zh_hans/INSTALL.md index bf33aee53b..6296342db9 100644 --- a/docs/zh_hans/INSTALL.md +++ b/docs/zh_hans/INSTALL.md @@ -1,15 +1,56 @@ # 安装 Codewhale > 英文原文:[INSTALL.md](../INSTALL.md)。 -> 最后与英文同步日期(last synced with English revision):2026-09-29。本文按英文版当前结构(v0.10.0 安装实测版)整体重译;附录部分与英文一样,沿用上一版内容,未在 v0.10.0 上重测。 +> 最后与英文同步日期(last synced with English revision):2026-10-06。按当前英文源码复核;下文 v0.10.0 安装记录与附录的原始测试范围保留,不能当作 v0.10.1 发布安装回执。 Codewhale 是一个在终端里运行的开源编码智能体(coding agent)。你交给它一个任务("修复失败的测试"、"加一个 CLI 参数"),它会读取你的仓库、编辑文件并运行命令。在默认的 **Ask**(询问)权限级别下,它会立即应用工作区内的文件编辑(并给你看 diff),但运行 shell 命令之前会先询问,所以请先提交或暂存你在意的改动。它支持多家模型提供商,默认是 **DeepSeek**。 命令是 `codewhale`。`codew` 是同一个程序的较短别名。 -本指南是在一台全新的 **Ubuntu 24.04 x86_64** 机器上安装 **v0.10.0**(2026-09-22 发布)时写成的,这里描述的每条路径都实际走过。文中每条命令都运行过,输出也核对过(见[安装回执](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md))。在那台机器上无法运行的步骤标注为 **(该虚拟机上未测试:原因)**。macOS、Windows 和 Android 不在测试范围内,只有少量说明。第二轮在 **macOS 26.1(Apple silicon)** 上重新运行了安装器、手动下载、压缩包和 npm 路径、无密钥检查以及 zsh 补全,见 [macOS 说明](#macos-说明)。需要调用模型的步骤没有在 macOS 上重跑。 +安装路径最初是在一台全新的 **Ubuntu 24.04 x86_64** 机器上使用 **v0.10.0**(2026-09-22 发布)验证的。带日期的[安装回执](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md)记录了当时实际运行的命令和核对的输出。在那台机器上无法运行的步骤标注为 **(该虚拟机上未测试:原因)**。macOS、Windows 和 Android 不在测试范围内,只有少量说明。第二轮在 **macOS 26.1(Apple silicon)** 上重新运行了安装器、手动下载、压缩包和 npm 路径、无密钥检查以及 zsh 补全,见 [macOS 说明](#macos-说明)。需要调用模型的步骤没有在 macOS 上重跑。 -使用 `latest` 的安装命令会解析到最新**已发布**的 GitHub Release 或包。两次发布之间,`main` 可能已经在描述下一个版本(例如 2026-09-28 之前的 v0.10.1 源码候选版)。候选版在其标签、校验和与发布资源齐备之前,都不能安装。 +使用 `latest` 的安装命令会解析到最新**已发布**的 GitHub Release 或包。两次发布之间,`main` 可能已经在描述下一个版本(例如尚未打标签的 v0.10.1 源码候选版)。预编译候选版只有在标签、校验和与发布资源齐备后,才可通过官方安装器获取;贡献者可以先从 `main` 构建源码。 + +## 用一个账号管理提供商密钥 + +[注册](https://app.codewhale.net/register)或[登录](https://app.codewhale.net/login) +后,就能集中管理模型提供商的 API 密钥。在账号的[提供商设置](https://app.codewhale.net/providers) +中添加、更换或移除密钥,也可以使用下面的终端命令。换一台设备时,登录并选择 +Codewhale 模型通道,就能使用同一组账号密钥,无需再次粘贴。 + +```bash +codewhale login +codewhale account keys set deepseek # 隐藏输入,不回显密钥 +codewhale account keys list # 只显示状态,不显示密钥 +codewhale --provider codewhale # 使用账号的模型通道 +``` + +这里的账号设置说明针对 0.10.1 源码候选版。登录不会自动上传已有的本地密钥。 +如果明确希望复制本地密钥,请使用 `codewhale account keys set deepseek --from-local`。 +账号密钥保存在服务端,用于经过账号通道的请求,不会下载到每台设备的本地密钥存储。 +不注册账号也可以使用本地密钥或本地模型。更换密钥、选择账号或本地通道的说明见 +[账号密钥设置](CONFIGURATION.md#账号提供商密钥)。 + +## 使用当前源码参与测试与贡献 + +`latest` 表示最新已发布版本;想测试尚未发布的修复,请使用权威 GitHub +`main`,或确认与它提交一致的 CNB `main`。不要把仓库版本号当作发布证明。 + +```bash +git clone --branch main https://github.com/codewhale-hq/CodeWhale.git +cd CodeWhale +cargo build --release --locked -p codewhale-cli +target/release/codewhale --version +``` + +构建前置条件见下文 Cargo 章节;使用仓库选定的 stable Rust,最低声明版本 +为 1.89。首次构建可能耗时较长。Windows 的可执行文件为 +`target/release/codewhale.exe`,并需要 MSVC 工具链。开发构建不等于已签名发布包。 + +中国大陆网络可参阅 [CNB 镜像、下载与验证](CNB_MIRROR.md)。npm 的 +`--registry=https://registry.npmmirror.com` 只改变 npm 包来源;包装器仍需要 +从独立的 GitHub、有效的 CNB 发布或完整的 HTTPS 资产镜像下载二进制。 +腾讯轻量应用服务器自建路径见 [Lighthouse 中文/英文引导](../../scripts/tencent-lighthouse/README.md)。 --- @@ -245,6 +286,14 @@ codewhale --version 这个包是一个很小的包装器。它的 `postinstall` 步骤会下载同样的 `codewhale`/`codew` 发布二进制,对照该发布的 SHA-256 清单校验,并把 `codewhale` 和 `codew` 链接到 npm 的全局 `bin`。整个过程在测试机上用了 6 秒。 +**Windows npm 会话:** Node 在整个原生会话期间仍是启动器。 +`taskkill /IM node.exe` 或 `Get-Process node | Stop-Process -Force` 会影响 +所有匹配的 Node 进程,也可能中断 npm 启动的 Codewhale,破坏正常终端清理。 +只停止自己启动的服务器 PID、占用目标端口的进程,或使用 Codewhale 任务取消。 +Windows 原生压缩包/安装器可避免 npm 父进程依赖;可选 JavaScript 工具仍可能需要 +Node。Codewhale 的 Windows shell 安全底线即使在 Full Access 下也会拦住已识别的 +整类 Node 进程终止命令;外部硬终止无法靠这个检查实现优雅退出。 + ### 如果遇到 `EACCES: permission denied` 这说明 Node 是系统级安装的(apt、`/usr/local`、`/opt`),而你的用户无权写入它的全局 prefix: @@ -1123,6 +1172,19 @@ codewhale update 镜像目录必须包含 `codewhale-artifacts-sha256.txt` 以及来自 GitHub 发布的各平台二进制。旧的 `DEEPSEEK_TUI_RELEASE_BASE_URL` 镜像变量仍作为别名受支持。 +`codewhale update` 只接受 HTTPS,允许 GitHub 发布主机、CNB 镜像以及 +`CODEWHALE_RELEASE_BASE_URL` 指定的主机;每一次重定向也检查相同规则。 +私有镜像若重定向到另一个 CDN 或对象存储域名,还需显式列出该下载主机: + +```bash +CODEWHALE_UPDATE_ALLOWED_HOSTS=cdn.your-mirror.example.com,objects.example.net \ +CODEWHALE_RELEASE_BASE_URL=https://your-mirror.example.com/CodeWhale/vX.Y.Z/ \ +CODEWHALE_VERSION=X.Y.Z \ +codewhale update +``` + +被拒绝的主机和允许主机变量会出现在错误信息里。不要关闭校验或改用 HTTP。 + ### Windows 与 npm 下载故障排查 #### Windows:`rustup-init` 报 `TLS handshake eof` 或 `CRYPT_E_REVOCATION_OFFLINE` diff --git a/docs/zh_hans/MEDIA_MODELS.md b/docs/zh_hans/MEDIA_MODELS.md new file mode 100644 index 0000000000..32930ecb44 --- /dev/null +++ b/docs/zh_hans/MEDIA_MODELS.md @@ -0,0 +1,56 @@ +# 语音、翻译与视频模型 + +> 英文原文:[MEDIA_MODELS.md](../MEDIA_MODELS.md)。 +> 源码与厂商官方文档核查:2026-10-06。下表区分现有能力与候选,不宣称候选已接入或经过真实服务验证。 + +## 当前源码已有能力 + +| 任务 | Codewhale 入口 | 限制 | +| --- | --- | --- | +| 文本转语音(TTS) | `speech` 工具;`codewhale --provider xiaomi-mimo speech "你好,Codewhale"`;CLI 别名 `tts` | 客户端要求 Xiaomi MiMo。支持 `mimo-v2.5-tts`、`mimo-v2.5-tts-voicedesign`、`mimo-v2.5-tts-voiceclone` 及旧 `mimo-v2-tts`。设计/克隆需对应输入。 | +| 语音转文字(STT/ASR) | 终端语音输入、Runtime `/v1/voice/*`;GPUI 复用该路径 | 显式 `CODEWHALE_ASR_MODEL` 优先,否则检测本地 Whisper → Groq → 配置的提供商。本地 `tiny`;Groq `whisper-large-v3-turbo`;提供商 `mimo-v2.5-asr`。聊天端点不一定支持 ASR。 | +| 视频理解 | 媒体附件/读取及适用提供商能力 | 读取视频、翻译字幕与生成视频是不同任务。 | +| 视频生成 | 本次核查未发现一等生成工具/客户端 | 远程目录列出模型不等于有可执行生成路由。 | + +`CODEWHALE_ASR_MODEL=local-whisper` 显式选择离线识别,需先安装本地程序/模型。 +`groq` 需 `GROQ_API_KEY`;`mimo` 使用当前配置的提供商端点。没有配置文件 ASR +选择器或任意模型 ID 覆盖。选定本地/Groq 后失败,不会静默上传给另一提供商; +把变量设为 Qwen/Index 模型名也不会安装后端。 + +源码 owner:[语音工具](../../crates/tui/src/tools/speech.rs)、 +[合成客户端](../../crates/tui/src/client.rs)、[语音选择](../../crates/tui/src/voice.rs)、 +[Runtime 语音](../../crates/tui/src/runtime_api/voice.rs)。真实麦克风、合成请求及完整 +用户语音旅程仍需独立验收。 + +## Index-Translate 核查 + +[哔哩哔哩官方仓库](https://github.com/bilibili/Index-Translate)发布基于 Qwen3.5 的 +150 语言文本翻译(Apache-2.0)、Homura 音节约束及 NativeLong 文档翻译。 +NativeLong 实际 ID 是 `IndexTeam/Index-Nailong-2B` / `IndexTeam/Index-Nailong-9B`。 +[官方集合](https://huggingface.co/collections/IndexTeam/index-translate)含量化版本; +Echo GGUF 只有文本骨干,不是完整音频管线。 + +宣告的文本 API 为 `https://index-translate.bilibili.com/v1`,模型 +`Index-Translate-35B-A3B`。2026-10-06 小规模公开文本测试返回 **HTTP 412**, +明确表示访问策略拒绝;未发送凭据/私人内容。尚不能标作可用服务。 + +| 独立包 | 发布范围 | 接入状态 | +| --- | --- | --- | +| [Echo S2TT](https://github.com/bilibili/Index-Translate/blob/main/inference/echo-s2tt/README.md) | 中文音频/视频 → 英/日/西语时间轴字幕;2B 指南估计约 10 GB 显存 | 本地候选;不是公共文本 API 或通用 150 语言 STT。 | +| [Echo S2ST](https://github.com/bilibili/Index-Translate/blob/main/inference/echo-s2st/README.md) | 中文 → 英/西/日;英文 → 中/西/日,保留来源声音。CUDA GPU ≥12 GB(2B)/≥24 GB(9B)显存 | 配音任务候选;长视频编排另有管线,不生成新视频。 | + +## 下一批候选 + +此顺序依据已有执行路径提出;尚未做付费测试。 + +| 优先级/任务 | 官方来源 | 适合的接入与缺少的证据 | +| --- | --- | --- | +| 1:本地多语 STT | [Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR),0.6B/1.7B、Apache-2.0 | 在已有语音选择器旁评估中文/方言及本地适配器;改默认值前验证硬件、延迟与失败时隐私。 | +| 2:本地 TTS | [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS),0.6B/1.7B、Apache-2.0;[CosyVoice](https://github.com/QwenAudio/CosyVoice),`Fun-CosyVoice3-0.5B-2512` | 经现有 Engine 权限评估合成、设计及获准参考声音,独立于聊天提供商。 | +| 2:时长受控配音 | [IndexTTS](https://github.com/index-tts/index-tts) | 官方使用 bilibili Model Use License Agreement;不能从 Index-Translate 或 fork 推断 Apache-2.0,需独立核对权重/条款。 | +| 3:视频生成 | [xAI 视频 API](https://docs.x.ai/developers/model-capabilities/video/generation),`grok-imagine-video-1.5` | 异步 `/v1/videos/generations` 与轮询;需类型化费用/任务授权、取消、持久身份及输出工件验证,不能借用聊天请求。 | + +复用现有 Engine、权限门禁与工件回执。固定已审查运行时/权重,执行前明确后端与 +上传目的地,保留显式离线选择。下载、托管请求及声音样本上传仍需普通授权。 +记录实际失败、语言方向、许可范围与硬件证据。参见[提供商](PROVIDERS.md)、 +[工具](TOOL_SURFACE.md)、[插件](PLUGIN_AUTHORING.md)。 diff --git a/docs/zh_hans/PLUGIN_AUTHORING.md b/docs/zh_hans/PLUGIN_AUTHORING.md index 62680c5a13..eeb5124840 100644 --- a/docs/zh_hans/PLUGIN_AUTHORING.md +++ b/docs/zh_hans/PLUGIN_AUTHORING.md @@ -2,6 +2,8 @@ > 英文原文:[PLUGIN_AUTHORING.md](../PLUGIN_AUTHORING.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 核对 DSH 静态转换、显式 Native 入口及本地 Node MCP 工作目录边界。 +> 2026-10-07 同步 DSH 经审查 Native 组合与 skill 根目录注册的说明。 先从一个 skill 开始:把 Markdown 指令文件放进一个小型插件包。 [hello-codewhale 示例](../examples/plugins/hello-codewhale/plugin.json) @@ -182,9 +184,15 @@ ESM 插件包,不需要安装依赖或运行编译器。它注册 `mod_counter 这些是标识标签,不是运行时操作句柄。目前没有已发布的插件编写 SDK,示例使用文档 规定的宿主适配服务。 -自定义 Ratatui/GPUI 控件、DSH 浏览器 UI 插槽、skill 根目录注册、原生执行 -`dsh.bundle.patch` 及 DSH 自身的 agent 运行时尚未提供。下文的静态导入器仍只转换 -可移植子集。兼容的 Claude 插件包仍使用原有的声明组件适配器;此 Native API 不加载 +`ctx.skills.registerRoot({path: 'profiles/review-skills'})` 把已审查包内的子 `SKILL.md` +技能包加入现有的 Rust 技能目录;其 disposer 会撤销该根目录,禁用、撤销信任和宿主退出 +同样会撤销。发现或加载 skill 时 Rust 会重新检查 Native 审查凭据、文件哈希和当前注册; +进程或宿主重启后,先前保存的 Native 选择失效。路径规则与数量/字节上限见 +[技能根目录契约](./EXTENSIONS.md#注册技能目录)。该 API 只增加 +指令,工具权限仍由共享引擎掌握。 + +下文的 DSH 导入器既能转换可移植的声明式组件,也能为封闭的包准备一份经审查的 Native +组合。自定义 Ratatui/GPUI 控件、DSH 浏览器 UI 插槽及 DSH 自身的 agent 运行时尚未提供。兼容的 Claude 插件包仍使用原有的声明组件适配器;此 Native API 不加载 Claude 的 agent loop,也不会自动适配 Pi 扩展 API。可执行 mod 需按文档中的宿主契约 移植。扩展工具只有在模型直接调用它、且共享 turn gate 正在处理本次调用时, 才能使用 `exec.core.call`。命令及激活代码没有 core 句柄。嵌套 shell 和网络调用必须 @@ -287,26 +295,33 @@ npm 包)由 Codewhale 原生导入,而不是通过此脚本(`--bundle` 已 `config` **不会**做深度合并。这不是完整的 profile 解析器:其他插件包、用户叠加层和 部署配置都不在其中。普通 YAML 标量遵循 YAML 1.2 core schema,与 DSH 自己的解析器一致。 -被禁用的组会把禁用状态传递给其后代。被禁用的 MCP 声明保持禁用。被禁用的 skill 会被 +在可移植的声明式转换中,被禁用的组会把禁用状态传递给其后代。被禁用的 MCP 声明保持禁用。被禁用的 skill 会被 省略并给出明确的回执,因为原生 skill 格式没有禁用状态。组或可移植条目上带条件的/ 非布尔的 `disabled` 值会使导入被拒绝,而不会假定它已启用。不受支持的条目策略/依赖字段 (包括 `inject`、`intercept` 和 `isolate`)同样会使包含它们的条目或组的导入被拒绝。 请在手工移植时保留它们的激活规则和权限规则。 -外部运行时插件和 `dsh.client` 界面代码不会被执行或转换。其他无法表示的组件会记录在 +包含受支持的本地模块、包内 ESM 导出或受支持的 DSH 服务桥的包可以生成 Native 组合: +导入器封存包内源文件和模块哈希,然后生成一个或多个 Native 入口。只要 Native 图中有一行 +无法解析或不受支持,整个图都会被拒绝,不会悄悄安装恰好能解析的部分。经审查的 agent 预设包可以生成各自的 Native 入口, +其贡献范围由所选的默认或子预设决定。导入和审查永远 +不会执行插件代码。`dsh.client` 界面代码和上游 agent loop 不在此契约之内。无法表示的可移植组件会记录在 插件包的 `CONVERSION.md` 和结构化的 `CONVERSION.json` 中,其中包含源包名/版本、清单和 有序各层的 SHA-256 哈希、转换器版本、逐条目的结果,以及需要手工移植的内容。未应用的 patch 操作也会出现在结构化的手工移植列表中,并附带其来源层和从 1 开始的操作序号。 -能被转换(lowering)的 `!!js` 表达式只有 `process.execPath`(变成 `node`)和不含转义或插值的 +在可移植转换中,能被转换(lowering)的 `!!js` 表达式只有 `process.execPath`(变成 `node`)和不含转义或插值的 简单带引号/模板字面量。环境变量表达式(包括带回退值的)永远不会按本机环境求值。 已打包的 Node MCP 只有在其相对入口(以及声明的相对工作目录)解析后位于包内时才会被 转换;包外的服务器会被跳过,宿主路径永远不会被复制。`@deepseek-ai/dsh-skill-filesystem` 条目只有在其字面的 `customSkillDirs` 子目录位于包内时,才会贡献这些子目录。默认的用户 -和项目 skill 根目录、文件监视器以及外部服务依赖都不会被导入。任意 DSH TypeScript -插件的执行不在此静态导入器的兼容范围内。明确编写 Native 入口的插件可通过实验性的 -TypeScript 扩展宿主提供工具、用户命令、执行前提议、提示词片段和插件本地状态; -宿主不会执行导入的 `dsh.bundle.patch` 组合。参见[扩展编写指南](./EXTENSIONS.md)。 +和项目 skill 根目录、文件监视器以及外部服务依赖都不会被导入。DSH 模块仅通过实验性的 +TypeScript 扩展宿主运行(`[features] extension_host`,默认关闭),可以来自明确编写的 Native 入口, +也可以来自导入器生成并经过审查的组合。生成的 Native 组合在显式启用 `extension_host`、且已安装的包 +经过审查、信任并启用之前保持不活动;宿主在加载前校验其源文件闭包,Rust 保留工具审批和会话权限。 +工具、命令、提示词片段、技能、钩子和 owner 本地存储沿用 Codewhale 现有的消费者与审批规则。 +这是受支持的兼容子集:导入成功并不表示每个上游 DSH 服务都可用。参见[扩展编写指南](./EXTENSIONS.md)与 +[宿主设计](../design/TS_EXTENSION_HOST.md)。 ### 本地 Node MCP 服务器 diff --git a/docs/zh_hans/PROVIDERS.md b/docs/zh_hans/PROVIDERS.md index fc2f8b957f..480c7f79a9 100644 --- a/docs/zh_hans/PROVIDERS.md +++ b/docs/zh_hans/PROVIDERS.md @@ -3,6 +3,7 @@ > 英文原文:[PROVIDERS.md](../PROVIDERS.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 > ChatGPT 登录相关内容于 2026-10-02 按当前实现更新。 +> 2026-10-06 补齐已捕获身份、账号切换/限额及 OrcaRouter PKCE;媒体能力见 [MEDIA_MODELS.md](MEDIA_MODELS.md)。 本注册表描述已接入当前 Codewhale 代码库的提供商行为。它刻意保持保守:随附条目仅限于代码已知的提供商 ID、配置键、认证路径、base URL、模型解析和能力元数据。 @@ -22,7 +23,7 @@ DeepSeek 仍是默认提供商,但 `ProviderKind::ALL` 中的每个条目都 | 阿里云百炼 Model Studio(DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` | | AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`;DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao 系列 | `AICRAFT_API_KEY` | | Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast`(32,768 token 上下文) | `TSUBASA_API_KEY` | -| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`(默认)、`claude-4.6-sonnet`、`gemini-3.5-pro` | `YOLO_AUTO_API_KEY` | +| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`;`yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` | | Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`、`claude-sonnet-5`、`gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` | AICraft 的 OpenAI 兼容端点提供 DeepSeek、Anthropic Claude、Google Gemini、Qwen、GLM、MiniMax 和 Doubao 的模型 ID。权威来源是带上你的密钥请求 `GET https://aicraftapi.com/v1/models` 的结果:请从该列表中选择模型,而不是从上表中选。 @@ -40,6 +41,24 @@ OpenCode Zen 和 OpenCode Go 是一等提供商路由,配置方式与下文其 - `config.example.toml` 和 `docs/CONFIGURATION.md` —— 面向用户的配置示例和环境变量参考。 - `scripts/check-provider-registry.py` —— 对规范提供商 ID、实时 TUI 提供商 ID、TOML 表名、静态注册表行和文档化默认值的漂移检查。 +## 已捕获的提供商身份 + +名称、标签、别名及旧线协议标签来自 `provider_descriptors.json`。`ProviderKind` +仍拥有 Rust 内部的凭据、协议与地区区别。配置路由同时捕获 kind 与精确表键; +名为 `openai` 的自定义表仍是自定义路由,不会因名称而获得 OpenAI 凭据或协议规则。 +自定义键区分大小写。 + +DeepSeek 中国路由保留三个表示:已发布 TUI serde 包装的 `deepseek_c_n`、捕获路由 +ID `deepseek-cn`、配置叶子 `deepseek_cn`。固有 kind 是 DeepSeek,表与端点仍独立。 + +旧自定义路由缺少新增的 provider ID 时,只能凭经过验证的根 `base_url` 迁移回执 +恢复到活动 `providers.custom` 表。私有回执绑定解析后的表版本;仅表、仅 profile、 +冲突或后来替换的表不足以建立缺失身份,显式空 ID 会被拒绝。写入复用现有锁内配置 +变更与撤销比较,在同一锁内获得新迁移回执,核对捕获表后才改叶子。 + +在途请求保留捕获身份、端点及凭据版本。健康查询不能仅因旧请求使用过同类认证, +就把不透明凭据引用当作已就绪。 + ## 提供商选择 没有已保存的模型、没有 `default_text_model`,也没有 `CODEWHALE_MODEL` 或提供商专用的模型环境变量时,全新安装会在 DeepSeek 提供商上运行 `deepseek-flash`。优先级依次是:当前提供商已配置的默认值,然后是它的目录默认值——因此显式设置的 `default_text_model` 不会被随附目录中排在最前的模型悄悄覆盖。 @@ -423,6 +442,30 @@ model = "qwen3:8b" # 默认是 deepseek-v4-flash 对于 Kimi,官方[快速入门](https://platform.kimi.ai/docs/overview)引导用户登录、打开 **API 密钥**、创建并复制密钥,并保守秘密。Codewhale 直接链接到该控制台并接受复制的密钥。它从不探测或冒充 `kimi_cli`/`kimi_code_cli`;一等 Kimi OAuth 仍然受阻于厂商注册的 Codewhale 身份。 +### 订阅账号与使用上限 + +Codewhale 为每个订阅提供商保存一个自有登录,与浏览器、ChatGPT/Grok 应用及 +Codex/Grok CLI 分开。成功的新登录替换旧自有授权;不提供多个已保存 ChatGPT +账号的选择器。 + +- `codewhale auth status --provider openai-codex`(或 `xai`)及 `auth list` + 只读取本地状态。有 ID token 邮箱声明时显示账号标签,有套餐/工作区声明时才显示 + 相应资料;标签不证明权益。有效授权也可能没有标签。访问令牌过期但有刷新令牌 + 仍可结构性使用;存储无效、缺失或过期且无刷新令牌则不可用。 +- `/auth chatgpt` 更新当前会话;从 shell 执行 `codewhale auth chatgpt` 后需重启 + 已打开会话。普通登录复用所选账号/工作区已签发的 client ID,并验证账号一致。 + `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1` 可在发行者拒绝 `prompt=login` 时省略它。 +- 从 shell 执行 `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt` 更换 + 账号/工作区,随后刷新模型目录并重启会话;只有完整验证的新授权替换活动授权。 + xAI 使用 `/auth xai-device` 或 shell `codewhale auth xai-device`;设备页批准 + 浏览器当前账号,必要时先退出浏览器账号或使用隐私窗口。 +- `subscription_sharing_usage_limit_exceeded` / `subscription_sharing_usage_unavailable` + 以及其他订阅配额错误给出当前请求账号(已知时)与恢复指引;不会自动重试终态 + 错误或悄悄换计费路由。xAI OAuth 配置若实际使用 API key,则不显示登录恢复指引。 +- 官方 ChatGPT 套餐路由只使用所选、已验证的 Codewhale 自有授权。 + `OPENAI_CODEX_ACCESS_TOKEN`、`CODEX_ACCESS_TOKEN` 或获准读取的 Codex CLI + token 都不能覆盖或授权该路由。成功登录回执不会打印 token。 + ### 外部 CLI 凭据授权 归另一 CLI 所有的凭据文件默认禁用。没有显式授权,提供商发现、设置、路由、`auth status` 和 doctor 不会 stat、读取、刷新、联系身份提供商或重写 Codex、Grok、Kimi 或未来的外部凭据文件。 @@ -589,6 +632,30 @@ Codewhale 在 OpenRouter 提供商范围内保留 `deepseek/` 线协议模型前 OrcaRouter(`https://api.orcarouter.ai/v1`)是专用的具名路由([OrcaRouter](https://www.orcarouter.ai)),它说同样的 OpenAI Chat Completions 线协议,并提供同样的命名空间化 `vendor/model` 目录。它不需要 OpenRouter 兼容的 `base_url` 覆盖:选择 `provider = "orcarouter"`,它的命名空间化线协议模型(例如 `deepseek/deepseek-v4-pro` 或它自己的 `orcarouter/auto` 路由器)会原样通过,就像它们在 OpenRouter 提供商范围上一样。 +#### OrcaRouter:API key 或 OAuth 2.0 + PKCE + +已有 key 可使用 `codewhale auth set --provider orcarouter`、`/provider` 或 +`ORCAROUTER_API_KEY`。`codewhale auth orcarouter` / `/auth orcarouter` 通过 +PKCE S256、回环回调与恒定时间 state 校验获得持久 `sk-orca-…` key;两条路径写入 +同一个 `orcarouter` 秘密槽位,`auth_mode = "api_key"`。 + +浏览器授权为 `https://www.orcarouter.ai/auth`;交换为 +`POST https://www.orcarouter.ai/api/v1/auth/keys`,无需 client secret 或预注册 +回调 URI。推理和目录使用独立的 `https://api.orcarouter.ai/v1`,不能由另一个 +源站猜测路径。自托管可显式用 `ORCA_AUTH_BASE_URL` 与 +`ORCA_API_BASE_URL` / `ORCAROUTER_BASE_URL` 覆盖各自源站;远程必须 HTTPS, +HTTP 只允许回环。 + +所得 key 不是刷新令牌:重启后复用,不发送 refresh grant。请在 OrcaRouter +控制台撤销;`/auth orcarouter-revoke` 只清除本地副本。Relay 401 将该凭据版本 +标为需重新认证,不静默重试。 + +实时 `GET https://api.orcarouter.ai/v1/models` 用所配置 key 获取目录。 +聊天选择器仅保留 `supported_endpoint_types` 含 `openai`、`anthropic`、`gemini` +或 `openai-response` 的行;图像生成、视频与 rerank 行被过滤,不按名称猜测。 +`architecture.input_modalities` 含 `image` 才确认图像输入;无 architecture 的行 +能力为未知。 + ### 近期 OpenRouter 大模型 OpenRouter completions 和静态注册表行包括自 2026 年 4 月起通过 OpenRouter 模型元数据验证的大模型:`arcee-ai/trinity-large-thinking`、`qwen/qwen3.6-flash`、`qwen/qwen3.6-35b-a3b`、`qwen/qwen3.6-max-preview`、`qwen/qwen3.6-27b`、`qwen/qwen3.6-plus`、`minimax/minimax-m3`、`xiaomi/mimo-v2.5-pro`、`xiaomi/mimo-v2.5`、`moonshotai/kimi-k2.7-code`、`moonshotai/kimi-k2.6`、`z-ai/glm-5.1`、`z-ai/glm-5.2`、`z-ai/glm-5-turbo`、`tencent/hy3-preview`、`google/gemma-4-31b-it`、`google/gemma-4-26b-a4b-it` 和 `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`。`minimax/minimax-m3` 是从 OpenRouter 2026 年 5 月 31 日的列表中新增的,作为 1M 上下文的多模态模型,用于编码、工具调用和长周期的智能体工作。`GLM-5.3` 现在是直接 Z.AI Coding 套餐的默认模型;`GLM-5.2` / `z-ai/glm-5.2` 仍然可用(显式选择保留自己的 id),`GLM-5.1` / `z-ai/glm-5.1` 作为更小的模型仍然可用,`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` 是 `GLM-5.3` 的更快的探索型同系模型,`GLM-5-Turbo` / `z-ai/glm-5-turbo` 则仍是 `GLM-5.2` 的更快的同系模型。`GLM-5.3` / `z-ai/glm-5.3` 与 `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` 是 Z.ai 和 OpenRouter 路由上的一等选择器 id(先执行 `/provider zai` 再用 `/model`,或设置 `model = "GLM-5.3-Flash"`)。在 Z.ai 发布不同的 5.3 元数据之前,5.3 的限制和推理选项继承自 `GLM-5.2`,而且 5.3 不带价格。Flash 采用已公布的 $0.15/$0.50 价目。在未开通 5.3 的账户上,实时调用仍可能以权限代码 1311 返回 429。 diff --git a/docs/zh_hans/RUNTIME_API.md b/docs/zh_hans/RUNTIME_API.md index 5cfb6f9c8f..07c324e5a1 100644 --- a/docs/zh_hans/RUNTIME_API.md +++ b/docs/zh_hans/RUNTIME_API.md @@ -2,6 +2,7 @@ > 英文原文:[RUNTIME_API.md](../RUNTIME_API.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 补齐设备令牌意图、逐调用变更、技能详情、持久配置与账号宪章契约。 `codewhale app-server` 是本地运行时的规范 API 与控制面。本地 SDK、移动端/远程控制客户端 以及编辑器集成都与它对话,而不是去抓取终端输出。它提供完整的 HTTP/SSE 运行时 @@ -63,6 +64,21 @@ local supervisor / SDK / automation harness `--auth-token` 或 `CODEWHALE_APP_SERVER_TOKEN`;它生成的一次性 `cwapp_*` 令牌 只能用于回环。 +### 设备令牌的 Watch / Drive 意图 + +主令牌通过 `POST /v1/auth/client-tokens` 铸造的设备令牌有不可变的 `intent`: +省略时为 `watch`,控制需显式指定 `drive`。标签不授予权限。Watch 可读取普通 +GET/HEAD、查看 Computer 显示并申请一次性显示票据;不能修改 Runtime 状态、 +把受保护的 HTTP 读取升级成写通道、取得/释放控制或转发输入。 +Drive 保留现有 Runtime/控制权限,但不能铸造、列举或撤销设备令牌。 +显示票据保留签发主体的意图;输入仍需该精确主体当前有效的控制租约。 + +`GET /v1/runtime/info` 宣告 `capabilities.client_token_intents: true`, +铸造回执返回 `device_id`、`intent` 和 `expires_at`。Relay 签发者必须先确认此能力, +再核对回执与请求的设备/意图相同,才可暴露令牌。旧 Engine 没有此强制边界, +签发者必须拒绝为其发放 relay grant;意图式标签或旧版铸造成功不足以证明权限。 +这不为 Engine 本地设备令牌添加账号/Computer 所有权范围。 + ### 工作区文件建议 `GET /v1/workspace/files/search?query=runtime&limit=20` 通过既有的、需认证的 @@ -249,6 +265,32 @@ delta 意味着什么,以及它看不到什么: Fleet 回执工件保留自己的路由 (`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`)。 +### 一个工具调用实际改了什么 + +`GET /v1/threads/{id}/turns/{turn_id}/calls/{tool_call_id}/changes?limit=` +从同一回合的 `tool:` / `post-tool:` 两个恢复点读取逐调用变更。Shell 自己写的文件 +也会出现;它没有 `metadata.mutation`,不能仅靠文件工具事件归因。 +响应含 `thread_id`、`turn_id`、`tool_call_id`、`tool_name`、`state`、`reason`、 +`truncated` 和 `files`。每个文件有 `path`、`change`、`added`、`removed`、`size`、 +`revision`、`restore_snapshot_id`、`diff` 与 `diff_truncated`。 + +- `change` 为 `created`、`updated` 或 `deleted`;类型变更是 `updated`, + 移动是删除加创建。二进制文件的行数是 `null`。 +- `size` / `revision` 属于区间末尾,绝不从当前工作树猜测。`revision` 是裸 + SHA-256;传给 `file-revert` 时用 `sha256:`,该调用删除的路径用 `absent`。 + 删除或文件过大无法读取时,`size` / `revision` 为 `null`。 +- `diff` 在字符边界截到 64 KiB。二进制、无内容差异或无补丁时为 `null`。 +- `pending` 表示调用排队/执行中,快照对未完整;`files` 为空,结算后再读取。 + `captured` 也可能是空文件列表,表示完整记录的调用没有改动。 +- `unavailable` 的 `reason` 可以是 `call_not_bounded`、`post_snapshot_missing`、 + `pre_snapshot_missing` 或 `snapshots_pruned`。这不是“没有改动”:回执中的 + `changed_paths` 仍可读取,但旧快照可能已被修剪或删除。 +- `limit` 默认 200、最大 1000;被截断时 `truncated` 为真,不返回省略数量。 + 读取只在旁仓执行 `git diff`,不更改工作树或索引。 + +未知线程/回合、别的线程的回合或没有对应记录的调用返回 404;非法 `limit` +返回 400;记录读取/解析或快照仓操作故障返回 500,不能当作快照已被修剪。 + ### 运行时与账号身份 `GET /v1/runtime/info` 报告 `codewhale_version`,以及由共享的 CLI/TUI 构建嵌入的 @@ -465,6 +507,14 @@ owner 存储中的原始操作;响应丢失或来源历史后来增长不会 活动 owner 或不确定的交接会拒绝挂接。`--operation-key ` 恢复原始结果; 可以完成已验证并准备好的目标;尚未准备好或无法验证的结果保留不确定性。 +### 持久配置写入 + +`app/config/set` 与 `app/config/unset` 在回复前写入规范 owner 使用的配置文件: +显式 `--config` 路径,或默认 `config.toml`。设置在 app-server 退出后仍保留。 +变更在锁内重新读取并合并磁盘当前文件,保留其他进程已保存的编辑。 +文件无法读取、解析或写入时返回 `ok: false`,不会更改它;解析失败的文件不被重写, +请先手工修好再 `app/config/reload`。HTTP `/app` 的非法键/值返回 400,I/O 失败返回 500。 + ### 回答澄清提问 当一个无头回合调用 `request_user_input` 时,运行时会发出一个 @@ -860,7 +910,7 @@ bootstrap URL。该能力会创建一个 30 分钟的进程内 **线程**(持久运行时数据模型) - `GET /v1/threads?limit=50&include_archived=false&archived_only=false` -- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false` +- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false&thread_ids=,` - `GET /v1/threads/running` - `GET /v1/threads/{id}/notices` - `DELETE /v1/threads/{id}/notices/{notice_id}` @@ -874,6 +924,10 @@ bootstrap URL。该能力会创建一个 30 分钟的进程内 - `GET /v1/threads/{id}/turns/{turn_id}/receipt` — 同上,针对一个回合; 未知线程或不属于该线程的回合返回 `404` +`thread_ids` 是逗号分隔的列表,最多 200 个 ID(每个 ID 最多 128 字节)。路由会在应用 +`limit` 前按精确 ID 过滤,并保留最新优先顺序。这只是选择过滤器,不是 +所有权或授权检查;Runtime bearer token 仍是访问边界。 + `POST /v1/threads` 除了提供商、模型、工作区与权限字段外,还接受可选的执行默认值: ```json @@ -1459,6 +1513,12 @@ direction 为空、等待 lead 计划、缺少凭据或有 - `GET /v1/workspace/files?path=&limit=<1-2000>`、`GET /v1/workspace/files/read?path=&offset=&limit=` 与 `PUT /v1/workspace/files`(参见上文的工作区文件与会话工件) - `GET /v1/skills` +- `GET /v1/skills/{name}` — 返回路由元数据(`source`、`invocation`、`aliases`、 + `bundled_tier`、`enabled`)及完整 `SKILL.md`,让客户端为自己的下一回合组成激活指令。 + 不存在的技能或已删除的原生文件返回 404;权限已过期的插件快照返回 403。 + 客户端先查 `GET /v1/runtime/info` 的 `capabilities.skill_detail`;单凭 404 + 无法区分“没有这项技能”与“没有这条路由”。 + - `GET /v1/apps/mcp/servers` - `GET /v1/apps/mcp/tools?server=` @@ -2471,3 +2531,28 @@ cargo test -p codewhale-app-server capabilities scripts/release/app-server-smoke.sh --matrix # dry-run plan bash scripts/release/app-server-smoke.test.sh # parser self-test (fake binary) ``` + + +## 账号宪章快照 + +Runtime 能力 `profile_constitution` 允许 `POST /v1/threads/{id}/turns` 携带 +`profile_constitution: {accountId, revision, constitution}`。`constitution` 严格为 +`{schemaVersion: 1, detail, initiative, collaboration, notes}`,选项分别是 +`brief|balanced|detailed`、`check|judgment|moving`、`direct|critical|coach`; +`notes` 最多 4,000 个 Unicode 字符。无效数据被拒绝。 + +已认证的账号传输提供快照,快照参与回合重放身份。Engine 使用现有个人宪章渲染器, +记录到原生会话历史;提供商重试、压缩、内部续接与 RLM 子调用保持已接纳的快照, +不会中途重读宿主操作者的账号。新快照完整替换旧个人偏好;权限与审批策略不变。 + +没有随请求提供快照时,Engine 在回合接纳时从配置的账号服务读取已登录资料。 +Engine 自行读取的资料不可用或无效时,本回合改用未登录的本地宪章,并在本会话中只提示一次;宿主随请求 +提交的 `profile_constitution` 快照无效时仍会使回合失败,`GET /v1/constitution` 仍会报告该错误。 +没有已保存偏好的账号使用显式默认 +快照,未登录账号使用现有本地宪章。托管传输始终提交所属账号快照(包括默认值), +以免本地偏好跨账号泄露。 + +`GET /v1/constitution` 读取下一回合的资料与模型指导,不是活跃回合已采用新编辑 +的回执。`POST /v1/constitution/preview` 接受宪章文档,返回 +`{modelGuidance, saved:false}`,不保存。两条路由遵循普通 Runtime 授权; +旧 Runtime 必须升级后才能接收携带账号宪章的回合。 diff --git a/docs/zh_hans/SKILLS.md b/docs/zh_hans/SKILLS.md index 0806d10b92..0ef2a9e211 100644 --- a/docs/zh_hans/SKILLS.md +++ b/docs/zh_hans/SKILLS.md @@ -2,6 +2,7 @@ > 英文原文:[SKILLS.md](../SKILLS.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 补齐平铺根目录、冲突优先级与 Codewhale 专用扫描范围。 在终端中,`/skills` 会打开 **Extensions → Skills**。在某个 skill 上按 Enter 会打开专用管理器(也可直接用 `/skills manage` 打开),用于现有 skill 的安装、更新、移除和信任操作。`/skill ` 仍然用于激活 skill,显式的检查、远程、同步和建议子命令也仍然可用。 @@ -56,10 +57,20 @@ Skills 是可复用的 `SKILL.md` 指令包。Codewhale 从多个根目录发现 **仅审计(不参与运行时)** +- 平铺的 `/skills` 默认只是审计候选;设置 `[skills] flat_workspace_root = true` + 后才参加发现,显式 `skills_dir` 也可以选中它。 - `.codex/skills` 会出现在**兼容**审计扫描中,方便操作员查看。它**不会**加入运行时发现集合。 配置的 `skills_dir` 如果不是 Codewhale 拥有的根目录之一,则保持只读。发现与管理器可以列出它;变更仍只作用于拥有的项目 / 全局根目录。 +同一范围内,自有 `.codewhale/skills` 优先。项目顺序为 `.codewhale`、`.agents`、 +`.claude`、`.opencode`、`.cursor`,最后是明确启用的平铺 `skills`;全局顺序为 +`.codewhale`、`.agents`、`.claude`、旧 `.deepseek`。项目先于全局。 +遮蔽警告说明两个副本;安装/更新选择该范围的自有副本。 + +`[skills] scan_codewhale_only = true` 将运行时扫描限制到自有项目/全局根及显式 +`skills_dir`。管理器的兼容审计开关仍独立工作;它不会让只读来源获得写入权限。 + 工作区内的每个根目录——无论是 Codewhale 自有目录、兼容目录,还是解析到工作区内的自定义 `skills_dir`——都只会在工作区受信任后加载(`/trust on --save`)。在此之前,发现过程会在警告中列出跳过的目录,会话的技能目录则回退到全局目录。 ## 斜杠命令 diff --git a/docs/zh_hans/TOOL_SURFACE.md b/docs/zh_hans/TOOL_SURFACE.md index a96422da20..ab3170935f 100644 --- a/docs/zh_hans/TOOL_SURFACE.md +++ b/docs/zh_hans/TOOL_SURFACE.md @@ -2,6 +2,7 @@ > 英文原文:[TOOL_SURFACE.md](../TOOL_SURFACE.md)。 > 最后与英文同步日期(last synced with English revision):2026-09-29。 +> 2026-10-06 补齐本地解释器权限与后端限制;现有 MCP 有界发现段落已核对。 本文描述当前面向模型的工具(tool)契约。产生它的 v0.9.1 切换记录在 `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` 中;工作区版本请从 `Cargo.toml` 读取, @@ -65,6 +66,19 @@ Full Access(完全访问)会改变常规审批行为,但不会绕过硬性 `tasks`、`Git`、`Run`、`Web`、`remember` 以及其他专门能力都是可搜索的, 而不是首回合的必备仪式。 +## 本地代码执行 + +`code_execution`(Python)与 `js_execution`(Node.js)使用工作区任务门禁/测试 +共用的权限感知启动器。常规调用保留会话策略。对被拒绝的精确调用申请更宽权限时, +提供 `sandbox_permissions`(`workspace-write` 或 `danger-full-access`)与非空 +`justification`;Ask 模式需用户明确批准一次。获准策略只作用于该调用。 + +两种工具从 stdin 读代码,返回 stdout / stderr / return_code,保留超时及进程树 +清理。它们不是持久 REPL;异常堆栈指向 stdin。独立 RLM/REPL 内核不受此次启动器 +整合影响。共享启动器的平台限制仍适用:没有可用的本地包装器时,workspace-write +无法强制执行,read-only 则被拒绝。外部 sandbox 会话必须使用自己的 shell 路径, +不能借此运行本地解释器。参见[沙箱限制](SANDBOX.md)。 + ## 延迟与动态工具 `Web` 是有条件的、延迟的。只有当生效的策略与运行时(runtime)后端允许时, diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 7cb5fd1dad..79f7f634ab 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -11,47 +11,30 @@ "devDependencies": { "@types/node": "^20.19.27", "@types/vscode": "1.96.0", - "@vscode/vsce": "^3.7.0", + "@vscode/vsce": "^4.0.0", "typescript": "^5.9.3" }, "engines": { "vscode": "^1.96.2" } }, - "node_modules/@azu/format-text": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", - "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@azu/style-format": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", - "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "@azu/format-text": "^1.0.1" - } - }, "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", "dev": true, "license": "MIT", "dependencies": { @@ -60,13 +43,13 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-client": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", - "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -79,13 +62,23 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", - "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", "dev": true, "license": "MIT", "dependencies": { @@ -98,26 +91,26 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", "dev": true, "license": "MIT", "dependencies": { @@ -126,36 +119,37 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/identity": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", - "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz", + "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==", "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^5.1.0", + "@azure/msal-node": "^6.0.0", "open": "^10.1.0", "tslib": "^2.2.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", "dev": true, "license": "MIT", "dependencies": { @@ -163,26 +157,26 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/msal-browser": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.12.0.tgz", - "integrity": "sha512-eNf2aqx1C6I0yT1GEu5ukblFrmaBXGfe1bivpmlfqvK7giPZvoXLa404C8EfeHVsy6EIryfQuPRzuW1fPxWlHg==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.25.0.tgz", + "integrity": "sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.7.0" + "@azure/msal-common": "16.14.2" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.7.0.tgz", - "integrity": "sha512-Jb8Y7pX6KM42SIT7KWP6YbY3+vLbwB5b5m+tpiiOzMU1QeyelQzs9lO8jv1e7/Uj9r7tg7VjPvW4T0KB1jF3UQ==", + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.2.tgz", + "integrity": "sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==", "dev": true, "license": "MIT", "engines": { @@ -190,180 +184,272 @@ } }, "node_modules/@azure/msal-node": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.3.tgz", - "integrity": "sha512-YYX4TchEVddVBiybKvKhV9QO/q22jgewP+BVxKG7Uh115voPcviGlypbKERDsqQdAiSTJrwi80gcWFjYKdo8+Q==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", + "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.7.0", + "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" }, "engines": { "node": ">=20" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" + "node": ">= 10" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" + "node": ">= 10" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 8" + "node": ">= 10" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">= 10" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">= 10" } }, - "node_modules/@secretlint/config-creator": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", - "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@secretlint/types": "^10.2.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@secretlint/config-loader": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", - "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "ajv": "^8.17.1", - "debug": "^4.4.1", - "rc-config-loader": "^4.1.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@secretlint/core": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", - "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "structured-source": "^4.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@secretlint/formatter": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", - "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "@textlint/linter-formatter": "^15.2.0", - "@textlint/module-interop": "^15.2.0", - "@textlint/types": "^15.2.0", - "chalk": "^5.4.1", - "debug": "^4.4.1", - "pluralize": "^8.0.0", - "strip-ansi": "^7.1.0", - "table": "^6.9.0", - "terminal-link": "^4.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@secretlint/node": { + "node_modules/@secretlint/core": { "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", - "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "^10.2.2", - "@secretlint/core": "^10.2.2", - "@secretlint/formatter": "^10.2.2", "@secretlint/profiler": "^10.2.2", - "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", - "p-map": "^7.0.3" + "structured-source": "^4.0.0" }, "engines": { "node": ">=20.0.0" @@ -376,23 +462,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@secretlint/resolver": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", - "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/secretlint-formatter-sarif": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", - "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-sarif-builder": "^3.2.0" - } - }, "node_modules/@secretlint/secretlint-rule-no-dotenv": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", @@ -440,103 +509,6 @@ "node": ">=20.0.0" } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@textlint/ast-node-types": { - "version": "15.7.1", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", - "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter": { - "version": "15.7.1", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", - "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azu/format-text": "^1.0.2", - "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.7.1", - "@textlint/resolver": "15.7.1", - "@textlint/types": "15.7.1", - "chalk": "^4.1.2", - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "lodash": "^4.18.1", - "pluralize": "^2.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "table": "^6.9.0", - "text-table": "^0.2.0" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/pluralize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", - "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/module-interop": { - "version": "15.7.1", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", - "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/resolver": { - "version": "15.7.1", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", - "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/types": { - "version": "15.7.1", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", - "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@textlint/ast-node-types": "15.7.1" - } - }, "node_modules/@types/node": { "version": "20.19.42", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", @@ -547,20 +519,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/sarif": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", - "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/vscode": { "version": "1.96.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.96.0.tgz", @@ -569,9 +527,9 @@ "license": "MIT" }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", - "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "dev": true, "license": "MIT", "dependencies": { @@ -580,60 +538,54 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@vscode/vsce": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", - "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-4.0.0.tgz", + "integrity": "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A==", "dev": true, "license": "MIT", "dependencies": { - "@azure/identity": "^4.1.0", - "@secretlint/node": "^10.1.2", - "@secretlint/secretlint-formatter-sarif": "^10.1.2", - "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", - "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", - "@vscode/vsce-sign": "^2.0.0", + "@azure/identity": "^4.13.2", + "@napi-rs/keyring": "^1.3.0", + "@secretlint/core": "^10.2.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.2.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@vscode/vsce-sign": "^2.1.0", "azure-devops-node-api": "^12.5.0", - "chalk": "^4.1.2", - "cheerio": "^1.0.0-rc.9", - "cockatiel": "^3.1.2", + "cockatiel": "^3.2.1", "commander": "^12.1.0", - "form-data": "^4.0.0", - "glob": "^13.0.6", - "hosted-git-info": "^4.0.2", - "jsonc-parser": "^3.2.0", - "leven": "^3.1.0", - "markdown-it": "^14.1.0", - "mime": "^1.3.4", - "minimatch": "^10.2.2", - "parse-semver": "^1.1.1", + "hosted-git-info": "^4.1.0", + "jsonc-parser": "^3.3.1", + "marked": "^18.0.11", + "mime": "^1.6.0", + "minimatch": "^10.2.6", + "parse5": "^8.0.1", + "proper-lockfile": "^4.1.2", "read": "^1.0.7", - "secretlint": "^10.1.2", - "semver": "^7.5.2", - "tmp": "^0.2.3", - "typed-rest-client": "^1.8.4", + "semver": "^7.8.5", + "tinyglobby": "^0.2.17", + "typed-rest-client": "^1.8.11", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^3.2.1", - "yazl": "^2.2.2" + "yauzl": "^3.4.0", + "yazl": "^2.5.1" }, "bin": { "vsce": "vsce" }, "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "keytar": "^7.7.0" + "node": ">= 22" } }, "node_modules/@vscode/vsce-sign": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", - "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz", + "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==", "dev": true, "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", @@ -785,92 +737,6 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -892,28 +758,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -930,26 +774,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, "node_modules/boundary": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", @@ -970,45 +794,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1027,9 +812,9 @@ "license": "BSD-3-Clause" }, "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "dev": true, "license": "MIT", "dependencies": { @@ -1073,75 +858,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cheerio": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.1.0", - "parse5": "^7.3.0", - "parse5-htmlparser2-tree-adapter": "^7.1.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^7.19.0", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=20.18.1" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -1152,39 +868,6 @@ "node": ">=16" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1195,36 +878,6 @@ "node": ">=18" } }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1243,38 +896,10 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", "dev": true, "license": "MIT", "dependencies": { @@ -1314,86 +939,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1436,68 +981,23 @@ "url": "https://bevry.me/fund" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding-sniffer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" - }, - "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { @@ -1527,135 +1027,22 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, "engines": { - "node": ">=8" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "node": ">=12.0.0" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "peerDependencies": { + "picomatch": "^3 || ^4" }, - "engines": { - "node": ">=14.14" + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/function-bind": { @@ -1707,66 +1094,6 @@ "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1787,16 +1114,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1810,22 +1127,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1852,39 +1153,6 @@ "node": ">=10" } }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1913,80 +1181,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -2003,39 +1197,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -2055,16 +1216,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -2099,56 +1250,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -2156,19 +1257,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2215,60 +1303,10 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/keytar": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "dev": true, "license": "MIT" }, @@ -2314,13 +1352,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2334,32 +1365,17 @@ "node": ">=10" } }, - "node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "node_modules/marked": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz", + "integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.5.0", - "linkify-it": "^5.0.2", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, "bin": { - "markdown-it": "bin/markdown-it.mjs" + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" } }, "node_modules/math-intrinsics": { @@ -2372,37 +1388,6 @@ "node": ">= 0.4" } }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -2416,51 +1401,14 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2469,35 +1417,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2512,98 +1431,6 @@ "dev": true, "license": "ISC" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-sarif-builder": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", - "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sarif": "^2.1.7", - "fs-extra": "^11.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2617,17 +1444,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -2639,910 +1455,248 @@ "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-semver": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", - "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^5.1.0" - } - }, - "node_modules/parse-semver/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc-config-loader": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", - "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "json5": "^2.2.3", - "require-from-string": "^2.0.2" - } - }, - "node_modules/read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "mute-stream": "~0.0.4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/secretlint": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", - "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/config-creator": "^10.2.2", - "@secretlint/formatter": "^10.2.2", - "@secretlint/node": "^10.2.2", - "@secretlint/profiler": "^10.2.2", - "debug": "^4.4.1", - "globby": "^14.1.0", - "read-pkg": "^9.0.1" - }, - "bin": { - "secretlint": "bin/secretlint.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "entities": "^8.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "ansi-regex": "^6.2.2" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { - "node": ">=12" + "node": ">=0.6" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/structured-source": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", - "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", "dependencies": { - "boundary": "^2.0.0" + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">= 4" } }, - "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, "engines": { - "node": ">=14.18" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10.0.0" + "node": ">=11.0.0" } }, - "node_modules/table/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/table/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terminal-link": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", - "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^3.2.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "MIT" + "license": "ISC" + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } }, "node_modules/textextensions": { "version": "6.11.0", @@ -3560,27 +1714,21 @@ "url": "https://bevry.me/fund" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=8.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/tslib": { @@ -3600,33 +1748,6 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -3653,13 +1774,6 @@ "node": ">=14.17" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, - "license": "MIT" - }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -3667,16 +1781,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3684,29 +1788,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -3714,25 +1795,6 @@ "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -3746,38 +1808,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -3826,9 +1856,9 @@ "license": "ISC" }, "node_modules/yauzl": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.2.tgz", - "integrity": "sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index d26453f060..ba6e991065 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -202,7 +202,7 @@ "devDependencies": { "@types/node": "^20.19.27", "@types/vscode": "1.96.0", - "@vscode/vsce": "^3.7.0", + "@vscode/vsce": "^4.0.0", "typescript": "^5.9.3" } } diff --git a/integrations/bridge-core/src/lib.mjs b/integrations/bridge-core/src/lib.mjs index 41ca24d13e..284e62cd4c 100644 --- a/integrations/bridge-core/src/lib.mjs +++ b/integrations/bridge-core/src/lib.mjs @@ -1,6 +1,7 @@ import { chmod, mkdir, open, readFile, rename, rm } from "node:fs/promises"; import path from "node:path"; import { randomBytes } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; const DEFAULT_ACTION_TTL_MS = 24 * 60 * 60 * 1000; @@ -36,7 +37,17 @@ export async function writeFileDurable(filePath, contents, { mode = 0o600 } = {} await handle.sync(); await handle.close(); handle = null; - await rename(tmp, filePath); + for (let attempt = 0; ; attempt++) { + try { + await rename(tmp, filePath); + break; + } catch (error) { + // Windows readers/other writers can temporarily deny replacement. + // Never unlink the old record to make the rename succeed. + if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error.code) || attempt >= 10) throw error; + await delay(50 * (attempt + 1)); + } + } } catch (error) { await handle?.close().catch(() => {}); await rm(tmp, { force: true }).catch(() => {}); diff --git a/integrations/bridge-core/test/approvals.test.mjs b/integrations/bridge-core/test/approvals.test.mjs index 0bb0d9a3ab..58026cc1d6 100644 --- a/integrations/bridge-core/test/approvals.test.mjs +++ b/integrations/bridge-core/test/approvals.test.mjs @@ -351,11 +351,27 @@ test("Telegram and Feishu shipping inbound events retain the actual admitted hum const lib = await import(`../../${bridge}/src/lib.mjs`); let delivered; const delivery = new Promise((resolve) => { delivered = resolve; }); + let markCleared; + const activeCleared = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${bridge} did not durably clear its accepted turn`)), 5000); + markCleared = () => { clearTimeout(timer); resolve(); }; + }); + const patchChat = store.patchChat.bind(store); + store.patchChat = async (chat, patch) => { + const result = await patchChat(chat, patch); + if (patch?.activeTurnId === null && runtime.state.accepted.length) { + const reopened = await ThreadStore.open(store.filePath, { actions: true }); + assert.equal((await reopened.getChat(chat)).activeTurnId, null, `${bridge} clear must be durable before fixture cleanup`); + markCleared(); + } + return result; + }; const values = environment(store, runtime, { ...lib, activeTurnTasks: new Map(), config: { allowGroups: true, allowUnlisted: false, allowlist: [chatId], requirePrefixInGroup: false }, streamTurnEvents: async (chat, thread, turn) => { const reopened = await ThreadStore.open(store.filePath, { actions: true }); + assert.equal((await reopened.getChat(chat)).activeTurnId, turn, `${bridge} must persist the accepted turn before streaming`); assert.equal(reopened.turnOrigin(chat, thread, turn).actorId, actors[bridge]); delivered(); } @@ -373,6 +389,8 @@ test("Telegram and Feishu shipping inbound events retain the actual admitted hum message: { chat_id: chatId, chat_type: "group", message_id: "message-a", message_type: "text", content: JSON.stringify({ text: "hello" }) } }); } + await delivery; + await activeCleared; assert.equal(runtime.state.accepted.length, 1); assert.equal(store.turnOrigin(chatId, threadId, runtime.state.accepted[0]).actorId, actors[bridge]); })); diff --git a/integrations/bridge-core/test/lib.test.mjs b/integrations/bridge-core/test/lib.test.mjs index b7f178a4d0..bc50476ca0 100644 --- a/integrations/bridge-core/test/lib.test.mjs +++ b/integrations/bridge-core/test/lib.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { spawn } from "node:child_process"; import { activeTurnBlock, @@ -270,6 +271,56 @@ test("writeFileDurable replaces the file through unique temp names and leaves no } }); +test("Windows sharing locks retry replacement and preserve old bytes on refusal", { skip: process.platform !== "win32", timeout: 30000 }, async () => { + const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-lock-")); + const target = path.join(dir, "state.txt"); + const lockers = []; + async function lock() { + const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", + '$file = [System.IO.File]::Open($env:CODEWHALE_TEST_LOCK_FILE, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read); try { [Console]::WriteLine("LOCKED"); [Console]::Out.Flush(); [Console]::ReadLine() | Out-Null } finally { $file.Dispose() }'], + { env: { ...process.env, CODEWHALE_TEST_LOCK_FILE: target }, stdio: ["pipe", "pipe", "pipe"] }); + lockers.push(child); + // The child may already have exited when failure cleanup releases its pipe. + child.stdin.on("error", () => {}); + await new Promise((resolve, reject) => { + let output = "", errors = ""; + const timeout = setTimeout(() => reject(new Error(`file lock did not open: ${errors}`)), 10000); + child.once("error", (error) => { clearTimeout(timeout); reject(error); }); + child.once("exit", (code) => { clearTimeout(timeout); reject(new Error(`file lock exited ${code}: ${errors}`)); }); + child.stderr.on("data", (data) => { errors += data; }); + child.stdout.on("data", (data) => { + output += data; + if (output.includes("LOCKED")) { clearTimeout(timeout); resolve(); } + }); + }); + return child; + } + try { + await writeFileDurable(target, "old"); + const transient = await lock(); + const release = setTimeout(() => transient.stdin.end("release\n"), 200); + try { await writeFileDurable(target, "new"); } finally { clearTimeout(release); if (!transient.stdin.writableEnded) transient.stdin.end("release\n"); } + assert.equal(await readFile(target, "utf8"), "new"); + assert.deepEqual(await readdir(dir), ["state.txt"]); + + const held = await lock(); + const started = Date.now(); + await assert.rejects(writeFileDurable(target, "must-not-publish"), (error) => ["EPERM", "EACCES", "EBUSY"].includes(error.code)); + assert.ok(Date.now() - started < 10000, "permanent refusal must stay bounded"); + assert.equal(await readFile(target, "utf8"), "new", "failed publication retains the old record"); + assert.deepEqual(await readdir(dir), ["state.txt"], "failed publication cleans up its temporary file"); + held.stdin.end("release\n"); + } finally { + await Promise.all(lockers.map((child) => new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) return resolve(); + child.once("exit", resolve); + if (!child.stdin.writableEnded) child.stdin.end("release\n"); + child.kill(); + }))); + await rm(dir, { recursive: true, force: true }); + } +}); + test("ThreadStore claims survive a restart: finished messages skip, an in-flight one is reported", async () => { const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-")); try { diff --git a/integrations/weixin-bridge/README.md b/integrations/weixin-bridge/README.md index 7934962653..a2eadafce8 100644 --- a/integrations/weixin-bridge/README.md +++ b/integrations/weixin-bridge/README.md @@ -97,8 +97,19 @@ Engine 必须同时声明 `turn_operation_idempotency` 和 `turn_operation_looku 能力,bridge 才会查询并恢复没有收到确认的提交。旧 Engine 的不确定提交会 保留供操作员核对,不会自动重新执行。发送结果不确定时,回答保留在私有的 线程映射文件中;`/status` 会提示,bridge 不会自动重发。操作员须先核对 -Engine turn 与微信记录,避免重复执行或重复发送。仍有待核对工作时, -`/new`、`/resume` 和新提示不会覆盖它。 +Engine turn 与微信记录,避免重复执行或重复发送。同一账户仍有待核对工作时, +`/new`、`/resume` 和新提示不会覆盖它。更换 bot 账户后,明确发送 `/new` +会先把旧账户的完整状态保留在线程映射的私有 `retiredAccountStates` 中, +再建立新对话;旧提交与不确定回答仍须人工核对,不会自动执行或发送。 +每个聊天最多保留 200 份旧账户记录,合计不超过 1 MiB;新增记录会超出任一 +上限时,`/new` 会明确拒绝,且不会创建新线程或删除旧记录。已经超限的旧文件 +也不会被自动裁剪。操作员应先停止 bridge、备份私有的 `thread-map.json`, +再核对并清理已确认不再需要的 `retiredAccountStates`;这些记录可能包含提示、 +回答和旧上下文令牌,不应上传到公开问题或日志。 + +`/threads` 只显示当前账户与当前聊天拥有的最近对话,`/resume` 只接受该 +账户和聊天已经绑定的对话。创建新对话会保留最多 200 条绑定记录;超出记录 +范围的旧对话或其他聊天的对话不能通过任意线程 ID 接管。 注意:bridge **不读取 `.env` 文件**,手动运行时环境变量必须通过 `export` 传入, 或使用 `node --env-file=.env src/index.mjs`。 diff --git a/integrations/weixin-bridge/src/index.mjs b/integrations/weixin-bridge/src/index.mjs index 385f34c12e..e6d0f87107 100644 --- a/integrations/weixin-bridge/src/index.mjs +++ b/integrations/weixin-bridge/src/index.mjs @@ -22,6 +22,7 @@ import { compactRuntimeError, latestRunningTurn, helpText, + processUpdateBatch, } from "./lib.mjs"; import { renderQrToText } from "./qr.mjs"; import { ThreadStore as CoreThreadStore, writeFileDurable, ApprovalOwnershipError, decideApproval as decideRuntimeApproval } from "../../bridge-core/src/lib.mjs"; @@ -254,7 +255,11 @@ async function sendText(chatId, text, { clientId } = {}) { async function getContextToken(chatId) { const state = await threadStore.getChat(chatId); - return state?.contextToken || undefined; + // Legacy tokens without an account identity are ambiguous; the next inbound + // message stores a fresh token bound to the active bot account. + return state?.contextTokenAccountId === accountIdentity() + ? state.contextToken || undefined + : undefined; } // ============================================================================ @@ -274,8 +279,18 @@ async function handleCommand(chatId, command, inbound = {}) { await sendThreads(chatId); return; case "new_thread": { - const state = await ensureThread(chatId, { forceNew: true }); - await sendText(chatId, `Created thread ${state.threadId}`); + try { + const state = await ensureThread(chatId, { forceNew: true }); + if (inbound.contextToken) { + await threadStore.patchChat(chatId, { + contextToken: inbound.contextToken, + contextTokenAccountId: accountIdentity(), + }); + } + await sendText(chatId, `Created thread ${state.threadId}`); + } catch (error) { + await sendText(chatId, `New conversation failed: ${error.message}`); + } return; } case "resume": @@ -302,13 +317,30 @@ async function handleCommand(chatId, command, inbound = {}) { } async function ensureThread(chatId, { forceNew = false, threadRequest } = {}) { - const existing = await threadStore.getChat(chatId); - if (forceNew && hasPendingWork(existing)) throw new Error("This chat still has a pending turn or retained reply. Use /status first."); + let existing = await threadStore.getChat(chatId); + const previousAccounts = new Set([existing?.bindingAccountId, existing?.pendingAdmission?.accountId, existing?.turnDelivery?.accountId].filter(Boolean)); + const replacingAccount = forceNew && previousAccounts.size === 1 && !previousAccounts.has(accountIdentity()); + if (forceNew && hasPendingWork(existing) && !replacingAccount) throw new Error("This chat still has a pending turn or retained reply. Use /status first."); if (existing?.threadId && !forceNew) { if (existing.bindingAccountId !== accountIdentity()) throw new Error("This thread belongs to another or unverified bot account. Use /new to create an explicit binding."); return existing; } + if (replacingAccount) { + // Explicit /new retires the previous account's private receipt before any + // new side effect. It remains in this store, outside automatic recovery. + const { retiredAccountStates = {}, ...retired } = existing; + const receipt = crypto.createHash("sha256").update(JSON.stringify(retired)).digest("hex"); + const retained = { ...retiredAccountStates, [receipt]: retired }; + // Fail before creating a new thread; never evict an uncertain submission + // or reply merely to make room. Existing over-limit stores remain intact. + if (Object.keys(retained).length > threadStore.options.actionLimit || Buffer.byteLength(JSON.stringify(retained), "utf8") > 1024 * 1024) { + throw new Error("Retired account receipt storage is full. Ask the local operator to stop the bridge, back up thread-map.json, and reconcile retiredAccountStates before retrying /new."); + } + await threadStore.patchChat(chatId, { retiredAccountStates: retained }); + existing = await threadStore.getChat(chatId); + } + const effectiveModel = existing?.model || config.model; const thread = await runtimeJson("/v1/threads", { @@ -327,7 +359,10 @@ async function ensureThread(chatId, { forceNew = false, threadRequest } = {}) { }); const state = { - ...preservedChatStateFields(existing, ["model", "authorizedIdentity", "contextToken", "pendingAdmission"]), + ...preservedChatStateFields(existing, replacingAccount + ? ["model", "retiredAccountStates"] + : ["model", "authorizedIdentity", "contextToken", "contextTokenAccountId", "pendingAdmission", "retiredAccountStates"]), + threadBindings: [...threadBindings(chatId, existing), { threadId: thread.id, accountId: accountIdentity(), actorId: turnActor(chatId) }].slice(-threadStore.options.actionLimit), threadId: thread.id, bindingAccountId: accountIdentity(), lastSeq: 0, @@ -346,6 +381,19 @@ function turnActor(chatId) { return accountIdentity() && chatId ? `weixin-account:${accountIdentity()}:user:${chatId}` : ""; } +function threadBindings(chatId, state) { + const bindings = (Array.isArray(state?.threadBindings) ? state.threadBindings : []).filter((entry) => entry?.threadId && entry.accountId && entry.actorId); + if (state?.threadId && state.bindingAccountId) { + const current = { threadId: state.threadId, accountId: state.bindingAccountId, actorId: `weixin-account:${state.bindingAccountId}:user:${chatId}` }; + if (!bindings.some((entry) => entry.threadId === current.threadId && entry.accountId === current.accountId && entry.actorId === current.actorId)) bindings.push(current); + } + return bindings.slice(-threadStore.options.actionLimit); +} + +function ownsThread(chatId, state, threadId) { + return threadBindings(chatId, state).some((entry) => entry.threadId === threadId && entry.accountId === accountIdentity() && entry.actorId === turnActor(chatId)); +} + function inboundKey(msg) { const id = msg.message_id; if (!accountIdentity() || !msg.from_user_id || id === undefined || id === null || String(id).trim() === "") return null; @@ -609,7 +657,7 @@ async function sendStatus(chatId) { [ `user_id=${chatId}`, state?.pendingAdmission ? "Turn admission is pending; its original request is retained." : "", - state?.activeTurnId ? `accepted_turn=${state.activeTurnId}` : "", + state?.activeTurnId && ownsRecovery(chatId, state) ? `accepted_turn=${state.activeTurnId}` : "", state?.turnDelivery?.outputs?.some((entry) => entry.status === "uncertain") ? "A reply has unconfirmed API acceptance. It is retained for review and will not be resent automatically." : "", `runtime=${health.status || "unknown"}`, `version=${runtimeInfo.version || "unknown"}`, @@ -630,11 +678,19 @@ async function sendStatus(chatId) { async function sendThreads(chatId) { try { - const threads = await runtimeJson( - "/v1/threads/summary?limit=8&include_archived=true" - ); + const state = await threadStore.getChat(chatId); + const ownedIds = threadBindings(chatId, state) + .filter((entry) => entry.accountId === accountIdentity() && entry.actorId === turnActor(chatId)) + .map((entry) => entry.threadId); + if (!ownedIds.length) { + await sendText(chatId, "No recent runtime threads owned by this account and chat."); + return; + } + const query = new URLSearchParams({ limit: "8", include_archived: "true", thread_ids: ownedIds.join(",") }); + const threads = (await runtimeJson(`/v1/threads/summary?${query}`)) + .filter((thread) => ownsThread(chatId, state, thread.id)); if (!threads.length) { - await sendText(chatId, "No runtime threads yet."); + await sendText(chatId, "No recent runtime threads owned by this account and chat."); return; } await sendText( @@ -660,11 +716,13 @@ async function resumeThread(chatId, args) { try { const existing = await threadStore.getChat(chatId); if (hasPendingWork(existing)) throw new Error("This chat still has a pending turn or retained reply. Use /status first."); + if (!ownsThread(chatId, existing, threadId)) throw new ApprovalOwnershipError("Thread is not owned by this account and chat"); const detail = await runtimeJson( `/v1/threads/${encodeURIComponent(threadId)}` ); await threadStore.setChat(chatId, { - ...preservedChatStateFields(existing, ["model", "authorizedIdentity", "contextToken"]), + ...preservedChatStateFields(existing, ["model", "authorizedIdentity", "contextToken", "contextTokenAccountId", "retiredAccountStates"]), + threadBindings: threadBindings(chatId, existing), threadId, bindingAccountId: accountIdentity(), lastSeq: Number(detail.latest_seq || 0), @@ -807,10 +865,25 @@ async function handleInbound(msg) { const fromUser = msg.from_user_id || ""; if (msg.message_type !== undefined && msg.message_type !== 1) return; if (!isAllowed(fromUser)) return; - if (msg.context_token) await threadStore.patchChat(fromUser, { contextToken: msg.context_token }); const text = extractText(msg.item_list); + if (msg.context_token) { + const state = await threadStore.getChat(fromUser); + // A pending admission already owns its context before a thread is bound. + // Preserve foreign or unattributed receipts until /new retires them. + if ((!state?.bindingAccountId || state.bindingAccountId === accountIdentity()) + && (!state?.pendingAdmission || state.pendingAdmission.accountId === accountIdentity()) + && (!state?.turnDelivery || state.turnDelivery.accountId === accountIdentity())) { + await threadStore.patchChat(fromUser, { + contextToken: msg.context_token, + contextTokenAccountId: accountIdentity(), + }); + } + } if (!text) return sendText(fromUser, "仅支持文本消息。图片/语音/视频/文件暂不支持。"); - await handleCommand(fromUser, parseCommand(text), { messageKey: inboundKey(msg) }); + await handleCommand(fromUser, parseCommand(text), { + messageKey: inboundKey(msg), + contextToken: msg.context_token, + }); } async function monitorLoop() { @@ -865,16 +938,18 @@ async function monitorLoop() { consecutiveFailures = 0; - // Claims stay inflight until durable admission/handling succeeds. A crash - // replays commands conservatively and reconciles the persisted prompt receipt. - for (const msg of resp.msgs || []) { - const key = inboundKey(msg); - if (!key) { console.warn("Ignored inbound message without a stable account/peer/message identity."); continue; } - // Core claimMessage retires an interrupted claim before returning it. - // Keep this caller's inflight marker until its recovery payload is durable. - const claim = threadStore.data.inflight?.[key] ? "interrupted" : await threadStore.claimMessage(key); - if (claim === "done") continue; - if (claim === "interrupted") { + await processUpdateBatch({ + messages: resp.msgs, + nextCursor: resp.get_updates_buf, + store: threadStore, + keyOf: (msg) => { + const key = inboundKey(msg); + if (!key) console.warn("Ignored inbound message without a stable account/peer/message identity."); + return key; + }, + handle: handleInbound, + interrupted: async (msg) => { + const key = inboundKey(msg); const state = await threadStore.getChat(msg.from_user_id); if (state?.pendingAdmission?.messageKey === key || state?.turnDelivery?.messageKey === key) { startChatRecovery(msg.from_user_id); @@ -885,16 +960,12 @@ async function monitorLoop() { } else if (isAllowed(msg.from_user_id)) { await sendText(msg.from_user_id, "The bridge restarted while handling this command. Its effect is uncertain; check /status before repeating it."); } - await threadStore.completeMessage(key); - continue; - } - await handleInbound(msg); - await threadStore.completeMessage(key); - } - if (resp.get_updates_buf) { - await saveSyncBuf(config.stateDir, resp.get_updates_buf); - getUpdatesBuf = resp.get_updates_buf; - } + }, + commitCursor: async (cursor) => { + await saveSyncBuf(config.stateDir, cursor); + getUpdatesBuf = cursor; + }, + }); for (const [chatId, state] of threadStore.listChats()) { if (hasPendingWork(state)) startChatRecovery(chatId); } diff --git a/integrations/weixin-bridge/src/lib.mjs b/integrations/weixin-bridge/src/lib.mjs index 964970997f..af3ee240da 100644 --- a/integrations/weixin-bridge/src/lib.mjs +++ b/integrations/weixin-bridge/src/lib.mjs @@ -47,17 +47,15 @@ export async function processUpdateBatch({ messages, nextCursor, store, keyOf, h for (const msg of messages || []) { const key = keyOf(msg); if (!key) continue; - const claim = await store.claimMessage(key); + const claim = store.data.inflight?.[key] ? "interrupted" : await store.claimMessage(key); if (claim === "done") continue; if (claim === "interrupted") { await interrupted(msg); - continue; - } - try { - await handle(msg); - } finally { await store.completeMessage(key); + continue; } + await handle(msg); + await store.completeMessage(key); } if (nextCursor) await commitCursor(nextCursor); } diff --git a/integrations/weixin-bridge/test/lib.test.mjs b/integrations/weixin-bridge/test/lib.test.mjs index 7e41cf4af8..6e7fe141aa 100644 --- a/integrations/weixin-bridge/test/lib.test.mjs +++ b/integrations/weixin-bridge/test/lib.test.mjs @@ -77,8 +77,6 @@ test("a crash mid-batch replays the batch without losing or re-running prompts", messages: batch, nextCursor: "buf-2", store: first, keyOf, handle: async (msg) => { if (msg.message_id === 2) { - // What a restarted process finds on disk while message 2 runs. - afterCrash = await ThreadStore.open(statePath, { messageLimit: 50 }); throw crash; } handled.push(msg.message_id); @@ -86,6 +84,8 @@ test("a crash mid-batch replays the batch without losing or re-running prompts", interrupted: async () => assert.fail("nothing is interrupted on the first pass"), commitCursor: async (buf) => cursors.push(buf), }), crash); + afterCrash = await ThreadStore.open(statePath, { messageLimit: 50 }); + assert.ok(afterCrash.data.inflight["u:2"], "a failed handler must remain inflight on disk after rejection"); assert.deepEqual(cursors, [], "the cursor does not move past unhandled messages"); const reported = []; diff --git a/integrations/weixin-bridge/test/runtime.test.mjs b/integrations/weixin-bridge/test/runtime.test.mjs index 35a33edf49..03457fdaab 100644 --- a/integrations/weixin-bridge/test/runtime.test.mjs +++ b/integrations/weixin-bridge/test/runtime.test.mjs @@ -14,12 +14,12 @@ async function until(check, message, timeout = 8000) { while (Date.now() < deadline) { if (await check()) return; await delay(25); } assert.fail(message); } -function incoming(id, text, user = "alice") { - return { message_id: id, from_user_id: user, message_type: 1, context_token: "context-fixture", item_list: [{ type: 1, text_item: { text } }] }; +function incoming(id, text, user = "alice", contextToken = "context-fixture") { + return { message_id: id, from_user_id: user, message_type: 1, context_token: contextToken, item_list: [{ type: 1, text_item: { text } }] }; } async function fixture(t, options = {}) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "weixin-runtime-")); - const state = { polls: 0, batches: [], posts: [], sent: [], lookups: 0, streams: new Set(), turns: [], items: [], seq: 0, approvals: [], decisions: [], logs: "" }; + const state = { polls: 0, batches: [], posts: [], sent: [], summaryQueries: [], lookups: 0, threadCreates: 0, threadReads: [], streams: new Set(), turns: [], items: [], seq: 0, approvals: [], decisions: [], logs: "" }; const json = (response, body, status = 200) => { response.writeHead(status, { "Content-Type": "application/json" }); response.end(JSON.stringify(body)); }; const server = http.createServer(async (req, res) => { let raw = ""; for await (const chunk of req) raw += chunk; @@ -42,9 +42,13 @@ async function fixture(t, options = {}) { if (url.pathname === "/v1/workspace/status") return json(res, { workspace: dir }); if (url.pathname === "/v1/threads" && req.method === "POST") { state.threadRequest = body; - return json(res, { id: "thread-1" }); + return json(res, { id: `thread-${++state.threadCreates}` }); + } + if (url.pathname === "/v1/threads/summary") { state.summaryQueries.push((url.searchParams.get("thread_ids") || "").split(",").filter(Boolean)); return json(res, options.summaries || []); } + if (/^\/v1\/threads\/[^/]+$/.test(url.pathname)) { + state.threadReads.push(url.pathname); + return json(res, { id: url.pathname.split("/").at(-1), latest_seq: state.seq, turns: state.turns, items: state.items, pending_approvals: state.approvals }); } - if (url.pathname === "/v1/threads/thread-1") return json(res, { id: "thread-1", latest_seq: state.seq, turns: state.turns, items: state.items, pending_approvals: state.approvals }); if (url.pathname === "/v1/threads/thread-1/turns") { state.posts.push(body); const turn = { id: "turn-1", thread_id: "thread-1", status: "in_progress" }; @@ -153,9 +157,21 @@ test("changed bot account cannot reuse or flush a retained old conversation", as f.batches.push([incoming(2, "new account prompt")]); f.start(); await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("pending turn or retained reply")), "replacement account prompt was not handled"); assert.equal(f.posts.length, 1); assert.equal(f.sent.some((msg) => msg.item_list[0].text_item.text.includes("old private answer")), false); + assert.equal(f.sent.find((msg) => msg.item_list[0].text_item.text.includes("pending turn or retained reply")).context_token, undefined); assert.equal((await f.disk()).chats.alice.turnDelivery.accountId, "bot-A"); }); +test("unattributed legacy context token is not reused for an outgoing message", async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ + chats: { alice: { bindingAccountId: "bot-A", contextToken: "legacy-context" } }, + messages: [], inflight: {}, + })); + f.batches.push([incoming(1, "/help", "alice", "")]); f.start(); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("显示此帮助")), "help response was not sent"); + assert.equal(f.sent.at(-1).context_token, undefined); +}); + test("both Engine capability flags are required before reconciling an uncertain admission", async (t) => { const f = await fixture(t, { capabilities: { turn_operation_idempotency: true, turn_operation_lookup: false }, admission: (_req, response) => response.destroy() }); f.batches.push([incoming(1, "legacy admission")]); f.start(); @@ -211,3 +227,141 @@ test("unanswered admission response leaves polling and status controls live", as assert.ok(Date.now() - started < 7000); assert.equal(f.posts.length, 1); assert.equal((await f.disk()).chats.alice.pendingAdmission.submitted, true); }); + +test("replacement account explicitly starts fresh while retaining the old uncertain receipt", async (t) => { + const f = await fixture(t); + const old = { threadId: "old-thread", bindingAccountId: "bot-A", activeTurnId: "old-turn", contextToken: "old-context", turnDelivery: { + accountId: "bot-A", actorId: "weixin-account:bot-A:user:alice", messageKey: "old-key", turnId: "old-turn", + outputs: [{ text: "old private answer", status: "uncertain", clientId: "old-client" }], + } }; + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ chats: { alice: old }, messages: [], inflight: {} })); + await fs.writeFile(path.join(f.dir, "account.json"), JSON.stringify({ ...f.account, accountId: "bot-B" })); + f.batches.push([incoming(1, "/new", "alice", "bot-B-context")]); f.start(); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text === "Created thread thread-1"), "replacement account remained blocked"); + const current = (await f.disk()).chats.alice; + assert.equal(current.bindingAccountId, "bot-B"); assert.equal(current.activeTurnId, null); assert.equal(current.turnDelivery, undefined); + assert.equal(current.contextToken, "bot-B-context"); assert.equal(current.contextTokenAccountId, "bot-B"); + const retained = Object.values(current.retiredAccountStates); + assert.equal(retained.length, 1); assert.equal(retained[0].threadId, "old-thread"); + assert.deepEqual(retained[0].turnDelivery, old.turnDelivery); assert.equal(retained[0].activeTurnId, "old-turn"); + assert.equal(retained[0].contextToken, "old-context"); + assert.equal(f.sent.find((msg) => msg.item_list[0].text_item.text === "Created thread thread-1").context_token, "bot-B-context"); + await delay(1100); assert.equal(f.posts.length, 0); assert.equal(f.threadReads.length, 0); + assert.equal(f.sent.some((msg) => msg.item_list[0].text_item.text.includes("old private answer")), false); +}); + +for (const receiptKind of ["pendingAdmission", "turnDelivery"]) test(`unbound ${receiptKind} keeps its account context until explicit replacement`, async (t) => { + const f = await fixture(t); + const receipt = { accountId: "bot-A", actorId: "weixin-account:bot-A:user:alice", messageKey: "old-key", + ...(receiptKind === "pendingAdmission" + ? { request: { prompt: "old private prompt" }, submitted: false } + : { outputs: [{ text: "old private answer", status: "uncertain" }] }), + }; + const old = { contextToken: "bot-A-context", contextTokenAccountId: "bot-A", [receiptKind]: receipt }; + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ chats: { alice: old }, messages: [], inflight: {} })); + await fs.writeFile(path.join(f.dir, "account.json"), JSON.stringify({ ...f.account, accountId: "bot-B" })); + f.batches.push([incoming(1, "/help", "alice", "bot-B-context")]); f.start(); + await until(() => f.sent.length === 1, "new account help was not handled"); + assert.equal(f.sent[0].context_token, undefined); + assert.equal((await f.disk()).chats.alice.contextToken, "bot-A-context"); + assert.deepEqual((await f.disk()).chats.alice[receiptKind], receipt); + f.batches.push([incoming(2, "/new", "alice", "bot-B-context")]); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text === "Created thread thread-1"), "explicit replacement did not complete"); + const current = (await f.disk()).chats.alice; + const retained = Object.values(current.retiredAccountStates); + assert.equal(retained.length, 1); + assert.equal(retained[0].contextToken, "bot-A-context"); + assert.equal(retained[0].contextTokenAccountId, "bot-A"); + assert.deepEqual(retained[0][receiptKind], receipt); + assert.equal(current.contextToken, "bot-B-context"); + assert.equal(current.contextTokenAccountId, "bot-B"); + assert.equal(current.bindingAccountId, "bot-B"); + assert.equal(current[receiptKind], undefined); + assert.equal(f.sent.at(-1).context_token, "bot-B-context"); + assert.equal(f.posts.length, 0); + assert.equal(f.threadReads.length, 0); +}); + +test("unattributed pending admission cannot have its context replaced by an inbound token", async (t) => { + const f = await fixture(t); + const old = { contextToken: "unattributed-context", pendingAdmission: { request: { prompt: "retained prompt" } } }; + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ chats: { alice: old }, messages: [], inflight: {} })); + f.batches.push([incoming(1, "/help")]); f.start(); + await until(() => f.sent.length === 1, "help was not handled"); + assert.equal(f.sent[0].context_token, undefined); + assert.equal((await f.disk()).chats.alice.contextToken, old.contextToken); + assert.deepEqual((await f.disk()).chats.alice.pendingAdmission, old.pendingAdmission); + assert.equal(f.threadCreates, 0); +}); + +for (const limit of ["count", "bytes"]) test(`replacement account refuses ${limit} overflow without losing private receipts`, async (t) => { + const f = await fixture(t); + const retiredAccountStates = limit === "count" + ? Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`receipt-${i}`, { threadId: `old-${i}` }])) + : { receipt: { prompt: "x".repeat(1024 * 1024) } }; + const old = { threadId: "old-thread", bindingAccountId: "bot-A", retiredAccountStates, + pendingAdmission: { accountId: "bot-A", request: { prompt: "retained private prompt" } } }; + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ chats: { alice: old }, messages: [], inflight: {} })); + await fs.writeFile(path.join(f.dir, "account.json"), JSON.stringify({ ...f.account, accountId: "bot-B" })); + f.batches.push([incoming(1, "/new", "alice", "bot-B-context")]); f.start(); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("Retired account receipt storage is full")), "retirement storage limit was not reported"); + const current = (await f.disk()).chats.alice; + assert.equal(f.threadCreates, 0); + assert.equal(current.threadId, old.threadId); + assert.equal(current.bindingAccountId, old.bindingAccountId); + assert.deepEqual(current.retiredAccountStates, retiredAccountStates); + assert.deepEqual(current.pendingAdmission, old.pendingAdmission); + assert.equal(f.posts.length, 0); + assert.equal(f.sent.some((msg) => msg.item_list[0].text_item.text.includes("retained private prompt")), false); +}); + +test("same-account new-thread command cannot discard a pending admission", async (t) => { + const f = await fixture(t, { admission: () => {} }); f.batches.push([incoming(1, "pending")]); f.start(); + await until(() => f.posts.length === 1, "pending admission missing"); + f.batches.push([incoming(2, "/new")]); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("New conversation failed: This chat still has a pending turn or retained reply")), "same-account pending state was overwritten or the control gave no answer"); + assert.equal(f.threadCreates, 1); assert.equal((await f.disk()).chats.alice.pendingAdmission.request.prompt, "pending"); +}); + +test("resume and thread summaries retain account-and-chat ownership across new conversations", async (t) => { + const f = await fixture(t, { summaries: [ + { id: "thread-1", title: "Alice private title" }, { id: "unbound-thread", title: "Other account private title" }, + ] }); + f.batches.push([incoming(1, "/new"), incoming(2, "/resume thread-1", "bob"), incoming(3, "/threads", "bob"), incoming(4, "/threads")]); + f.start(); + await until(() => f.sent.length === 4, "ownership controls were not handled"); + const texts = f.sent.map((msg) => msg.item_list[0].text_item.text); + assert.match(texts[1], /not owned by this account and chat/); + assert.match(texts[2], /No recent runtime threads owned/); + assert.match(texts[3], /Alice private title/); assert.ok(!texts.some((text) => text.includes("Other account private title"))); + assert.deepEqual(f.summaryQueries, [["thread-1"]], "the bridge requests only locally bound threads owned by this account and chat"); + assert.equal(f.threadReads.length, 0, "rejected resume must not read another chat's thread"); + assert.equal((await f.disk()).chats.bob.threadId, undefined); + f.batches.push([incoming(5, "/new"), incoming(6, "/resume thread-1")]); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text === "Resumed thread thread-1"), "owner could not resume its prior conversation"); + assert.equal((await f.disk()).chats.alice.threadId, "thread-1"); assert.equal(f.threadCreates, 2); assert.equal(f.threadReads.length, 1); +}); + +test("replacement account cannot resume the retired account's owned thread", async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ chats: { alice: { threadId: "thread-1", bindingAccountId: "bot-A" } }, messages: [], inflight: {} })); + await fs.writeFile(path.join(f.dir, "account.json"), JSON.stringify({ ...f.account, accountId: "bot-B" })); + f.batches.push([incoming(1, "/resume thread-1")]); f.start(); + await until(() => f.sent.some((msg) => msg.item_list[0].text_item.text.includes("not owned by this account and chat")), "foreign account resumed a private conversation"); + assert.equal(f.threadReads.length, 0); assert.equal((await f.disk()).chats.alice.bindingAccountId, "bot-A"); +}); + +test("restart between durable thread binding and admission update keeps the frozen prompt", async (t) => { + const f = await fixture(t); const key = JSON.stringify(["bot-A", "alice", "1"]); + const request = { prompt: "durable prompt", input_summary: "durable prompt", model: "frozen-model", mode: "agent", allow_shell: false, trust_mode: false, auto_approve: false, operation_key: "frozen-operation" }; + await fs.writeFile(path.join(f.dir, "thread-map.json"), JSON.stringify({ chats: { alice: { + threadId: "thread-1", bindingAccountId: "bot-A", pendingAdmission: { + accountId: "bot-A", actorId: "weixin-account:bot-A:user:alice", messageKey: key, request, + operationKey: request.operation_key, threadId: null, submitted: false, sinceSeq: null, + }, + } }, messages: [key], inflight: { [key]: "interrupted" } })); + f.batches.push([incoming(1, "durable prompt")]); f.start(); + await until(() => f.streams.size, "durable thread binding lost its pending prompt on restart"); + assert.equal(f.threadCreates, 0); assert.equal(f.posts.length, 1); assert.deepEqual(f.posts[0], request); + assert.equal((await f.disk()).chats.alice.turnDelivery.messageKey, key); +}); diff --git a/package-lock.json b/package-lock.json index c8b4934b87..92c1de9eca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,1431 +9,20 @@ "npm/codewhale", "npm/deepseek-tui", "npm/runtime-sdk" - ], - "devDependencies": { - "wrangler": "^4.103.0" - } - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", - "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", - "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", - "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", - "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", - "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@codewhale/runtime-sdk": { - "resolved": "npm/runtime-sdk", - "link": true - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", - "dev": true, - "license": "CC0-1.0" + ] }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" + "node_modules/@codewhale/runtime-sdk": { + "resolved": "npm/runtime-sdk", + "link": true }, "node_modules/codewhale": { "resolved": "npm/codewhale", "link": true }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/deepseek-tui": { "resolved": "npm/deepseek-tui", "link": true }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/miniflare": { - "version": "4.20260721.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", - "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.28.0", - "workerd": "1.20260721.1", - "ws": "8.21.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1448,130 +37,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/workerd": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", - "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260721.1", - "@cloudflare/workerd-darwin-arm64": "1.20260721.1", - "@cloudflare/workerd-linux-64": "1.20260721.1", - "@cloudflare/workerd-linux-arm64": "1.20260721.1", - "@cloudflare/workerd-windows-64": "1.20260721.1" - } - }, - "node_modules/wrangler": { - "version": "4.113.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", - "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "4.20260721.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260721.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^5.20260721.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } - }, "npm/codewhale": { "version": "0.10.1", "funding": [ @@ -1616,11 +81,11 @@ "name": "@codewhale/runtime-sdk", "version": "0.10.1", "license": "MIT", - "engines": { - "node": ">=18" - }, "devDependencies": { "typescript": "5.9.3" + }, + "engines": { + "node": ">=18" } } } diff --git a/package.json b/package.json index 9a9b2bf059..eabe6d23ab 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,8 @@ "npm/deepseek-tui", "npm/runtime-sdk" ], - "devDependencies": { - "wrangler": "^4.103.0" - }, "overrides": { "esbuild": "0.28.1", - "sharp": "0.35.4", "ws": "8.21.0", "undici": "7.29.1" } diff --git a/patches/crossterm-0.29.0/.gitignore b/patches/crossterm-0.29.0/.gitignore new file mode 100644 index 0000000000..a9d37c560c --- /dev/null +++ b/patches/crossterm-0.29.0/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/patches/crossterm-0.29.0/CHANGELOG.md b/patches/crossterm-0.29.0/CHANGELOG.md new file mode 100644 index 0000000000..00e88ee506 --- /dev/null +++ b/patches/crossterm-0.29.0/CHANGELOG.md @@ -0,0 +1,794 @@ +# Unreleased + +# Version 0.29 + +## Added ⭐ + +- Copy to clipboard using OSC52 (#974) +- Derive standard traits for "SetCursorStyle" (#909) +- Add query_keyboard_enhancement_flags to read enabled flags (#958) +- Add is_* and as_* methods to the event enums (#949) +- Add a feature flag for derive_more impls (#970) +- Update rustix to 1.0 (#982) + +## Breaking ⚠️ + +- Correctly fix KeyModifiers Display impl Properly adding + in between modifiers (#979) + + +# Version 0.28.1 + +## Fixed 🐛 + +- Fix broken build on linux when using `use-dev-tty` with (#906) + +## Breaking ⚠️ + +- Fix desync with mio and signalhook between repo and published crate. (upgrade to mio 1.0) + +# Version 0.28 + +## Added ⭐ + +- Capture double click mouse events on windows (#826) +- (De)serialize Reset color (#824) +- Add functions to allow constructing `Attributes` in a const context (#817) +- Implement `Display` for `KeyCode` and `KeyModifiers` (#862) + +## Changed ⚙️ + +- Use Rustix by default instead of libc. Libc can be re-enabled if necessary with the `libc` feature flag (#892) +- `FileDesc` now requires a lifetime annotation. +- Improve available color detection (#885) +- Speed up `SetColors` by ~15-25% (#879) +- Remove unsafe and unnecessary size argument from `FileDesc::read()` (#821) + +## Breaking ⚠️ + +- Fix duplicate bit masks for caps lock and num lock (#863). + This breaks serialization of `KeyEventState` + +# Version 0.27.1 + +## Added ⭐ +- Add support for (de)serializing `Reset` `Color` + +# Version 0.27 + +## Added ⭐ + +- Add `NO_COLOR` support (https://no-color.org/) +- Add option to force overwrite `NO_COLOR` (#802) +- Add support for scroll left/right events on windows and unix systems (#788). +- Add `window_size` function to fetch pixel width/height of screen for more sophisticated rendering in terminals. +- Add support for deserializing hex color strings to `Color` e.g #fffff. + +## Changed ⚙️ + +- Make the events module an optional feature `events` (to make crossterm more lightweight) (#776) + +## Breaking ⚠️ + +- Set minimum rustc version to 1.58 (#798) +- Change all error types to `std::io::Result` (#765) + +# Version 0.26.1 + +## Added ⭐ + +- Add synchronized output/update control (#756) +- Add kitty report alternate keys functionality (#754) +- Updates dev dependencies. + +## Fixed 🐛 +- Fix icorrect return in kitty keyboard enhancement check (#751) +- Fix panic when using `use-dev-tty` feature flag (#762) + +# Version 0.26.0 +## Added ⭐ + +- Add `SetCursorStyle` to set the cursor apearance and visibility. (#742) +- Add a function to check if kitty keyboard enhancement protocol is available. (#732) +- Add filedescriptors poll in order to move away from mio in the future (can be used via `use-dev-tty`). (#735) + +## Fixed 🐛 +- Improved F1-F4 handling for kitty keyboard protocol. (#736) +- Improved parsing of event types/modifiers with certain keys for kitty protocol. (#716) + +## Breaking ⚠️ +- Remove `SetCursorShape` in favour of `SetCursorStyle`. (#742) +- Make Windows resize event match `terminal::size` (#714) +- Rust 1.58 or later is now required. +- Add key release event for windows. (#745) + +# Version 0.25.0 +BREAKING: `Copy` trait is removed from `Event`, you can keep it by removing the "bracked-paste" feature flag. However this flag might be standardized in the future. +We removed the `Copy` from `Event` because the new `Paste` event, which contains a pasted string into the terminal, which is a non-copy string. + +- Add ability to paste a string in into the terminal and fetch the pasted string via events (see `Event::Paste` and `EnableBracketedPaste `). +- Add support for functional key codes from kitty keyboard protocol. Try out by `PushKeyboardEnhancementFlags`. This protocol allows for: + - See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers + - Press, Repeat, Release event kinds. + - SUPER, HYPER, META modifiers. + - Media keycodes + - Right/left SHIFT, Control, Alt, Super, Hyper, Meta + - IsoLevel3Shift, IsoLevel5Shift + - Capslock, scroll lock, numlock + - Printscreen, pauze, menue, keyboard begin. +- Create `SetStyle` command to allow setting various styling in one command. +- Terminal Focus events (see `Event::FocusGained` and `Event::FocusLost`) + +# Version 0.24.0 +- Add DoubleUnderlined, Undercurled, Underdots the text, Underdotted, Underdashes, Underdashed attributes and allow coloring their foreground / background color. +- Fix windows unicode character parsing, this fixed various key combinations and support typing unicode characters. +- Consistency and better documentation on mouse cursor operations (BREAKING CHANGE). + - MoveTo, MoveToColumn, MoveToRow are 0-based. (left top most cell is 0,0). Moving like this is absolute + - MoveToNextLine, MoveToPreviousLine, MoveUp, MoveDown, MoveRight, MoveLeft are 1-based,. Moving like this is relative. Moving 1 left means moving 1 left. Moving 0 to the left is not possible, wikipedia states that most terminals will just default to 1. +- terminal::size returns error when previously it returned (0,0). +- Remove println from serialisation code. +- Fix mouse up for middle and right buttons. +- Fix escape codes on Git-Bash + Windows Terminal / Alacritty / WezTerm. +- Add support for cursor keys in application mode. +# Version 0.23.2 +- Update signal-hook and mio to version 0.8. + +# Version 0.23.1 +- Fix control key parsing problem. + +# Version 0.23 +- Update dependencies. +- Add 0 check for all cursor functions to prevent undefined behaviour. +- Add CSIu key parsing for unix. +- Improve control character window key parsing supporting (e.g. CTRL [ and ]) +- Update library to 2021 edition. + +# Version 0.22.1 +- Update yanked version crossterm-winapi and move to crossterm-winapi 0.9.0. +- Changed panic to error when calling disable-mouse capture without setting it first. +- Update bitflags dependency. + +# Version 0.22 +- Fix serde Color serialisation/deserialization inconsistency. +- Update crossterm-winapi 0.8.1 to fix panic for certain mouse events + +# Version 0.21 +- Expose `is_raw` function. +- Add 'purge' option on unix system, this clears the entire screen buffer. +- Improve serialisation for color enum values. + +# Version 0.20 +- Update from signal-hook with 'mio-feature flag' to signal-hook-mio 0.2.1. +- Manually implements Eq, PartialEq and Hash for KeyEvent improving equality checks and hash calculation. +- `crossterm::ErrorKind` to `io::Error`. +- Added Cursor Shape Support. +- Add support for function keys F13...F20. +- Support taking any Display in `SetTitle` command. +- Remove lazy_static dependency. +- Remove extra Clone bounds in the style module. + - Add `MoveToRow` command. + - Remove writer parameter from execute_winapi + +# Version 0.19 +- Use single thread for async event reader. +- Patch timeout handling for event polling this was not working correctly. +- Add unix support for more key combinations mainly complex ones with ALT/SHIFT/CTRL. +- Derive `PartialEq` and `Eq` for ContentStyle +- Fix windows resize event size, this used to be the buffer size but is screen size now. +- Change `Command::ansi_code` to `Command::write_ansi`, this way the ansi code will be written to given formatter. + +# Version 0.18.2 +- Fix panic when only setting bold and redirecting stdout. +- Use `tty_fd` for set/get terminal attributes + +# Version 0.18.1 +- Fix enabling ANSI support when stdout is redirected +- Update crossterm-winapi to 0.6.2 + +# Version 0.18.0 +- Fix get position bug +- Fix windows 8 or lower write to user-given stdout instead of stdout. +- Make MoveCursor(Left/Right/Up/Dow) command with input 0 not move. +- Switch to futures-core to reduce dependencies. +- Command API restricts to only accept `std::io::Write` +- Make `supports_ansi` public +- Implement ALT + numbers windows systems. + +# Version 0.17.7 +- Fix cursor position retrieval bug linux. + +# Version 0.17.6 +- Add functionality to retrieve color based on passed ansi code. +- Switch from 'futures' to 'futures-util' crate to reduce dependency count +- Mio 0.7 update +- signal-hook update +- Make windows raw_mode act on CONIN$ +- Added From<(u8, u8, u8)> Trait to Color::Rgb Enum +- Implement Color::try_from() +- Implement styler traits for `&'a str` + +# Version 0.17.5 +- Improved support of keymodifier for linux, arrow keys, function keys, home keys etc. +- Add `SetTitle` command to change the terminal title. +- Mio 0.7 update + +# Version 0.17.4 +- Add macros for `Colorize` and `Styler` impls, add an impl for `String` +- Add shift modifier to uppercase char events on unix + +# Version 0.17.3 +- Fix get terminal size mac os, this did not report the correct size. + +# Version 0.17.2 +- Windows unicode support + +# Version 0.17.1 +- Reverted bug in 0.17.0: "Make terminal size function fallback to `STDOUT_FILENO` if `/dev/tty` is missing.". +- Support for querying whether the current instance is a TTY. + +# Version 0.17 +- Impl Display for MoveToColumn, MoveToNextLine, MoveToPreviousLine +- Make unix event reader always use `/dev/tty`. +- Direct write command ansi_codes into formatter instead of double allocation. +- Add NONE flag to KeyModifiers +- Add support for converting chars to StylizedContent +- Make terminal size function fallback to `STDOUT_FILENO` if `/dev/tty` is missing. + +# Version 0.16.0 +- Change attribute vector in `ContentStyle` to bitmask. +- Add `SetAttributes` command. +- Add `Attributes` type, which is a bitfield of enabled attributes. +- Remove `exit()`, was useless. + +# Version 0.15.0 +- Fix CTRL + J key combination. This used to return an ENTER event. +- Add a generic implementation `Command` for `&T: Command`. This allows commands to be queued by reference, as well as by value. +- Remove unnecessary `Clone` trait bounds from `StyledContent`. +- Add `StyledContent::style_mut`. +- Handle error correctly for `execute!` and `queue!`. +- Fix minor syntax bug in `execute!` and `queue!`. +- Change `ContentStyle::apply` to take self by value instead of reference, to prevent an unnecessary extra clone. +- Added basic trait implementations (`Debug`, `Clone`, `Copy`, etc) to all of the command structs +- `ResetColor` uses `&'static str` instead of `String` + +# Version 0.14.2 +- Fix TIOCGWINSZ for FreeBSD + +# Version 0.14.1 +- Made windows cursor position relative to the window instead absolute to the screen buffer windows. +- Fix windows bug with `queue` macro were it consumed a type and required an type to be `Copy`. + +# Version 0.14 + +- Replace the `input` module with brand new `event` module + - Terminal Resize Events + - Advanced modifier (SHIFT | ALT | CTRL) support for both mouse and key events and + - futures Stream (feature 'event-stream') + - Poll/read API + - It's **highly recommended** to read the + [Upgrade from 0.13 to 0.14](https://github.com/crossterm-rs/crossterm/wiki/Upgrade-from-0.13-to-0.14) + documentation +- Replace `docs/UPGRADE.md` with the [Upgrade Paths](https://github.com/crossterm-rs/crossterm/wiki#upgrade-paths) + documentation +- Add `MoveToColumn`, `MoveToPreviousLine`, `MoveToNextLine` commands +- Merge `screen` module into `terminal` + - Remove `screen::AlternateScreen` + - Remove `screen::Rawscreen` + * Move and rename `Rawscreen::into_raw_mode` and `Rawscreen::disable_raw_mode` to `terminal::enable_raw_mode` and `terminal::disable_raw_mode` + - Move `screen::EnterAlternateScreen` and `screen::LeaveAlternateScreen` to `terminal::EnterAlternateScreen` and `terminal::LeaveAlternateScreen` + - Replace `utils::Output` command with `style::Print` command +- Fix enable/disable mouse capture commands on Windows +- Allow trailing comma `queue!` & `execute!` macros + +# Version 0.13.3 + +- Remove thread from AsyncReader on Windows. +- Improve HANDLE management windows. + +# Version 0.13.2 + +- New `input::stop_reading_thread()` function + - Temporary workaround for the UNIX platform to stop the background + reading thread and close the file descriptor + - This function will be removed in the next version + +# Version 0.13.1 + +- Async Reader fix, join background thread and avoid looping forever on windows. + +# Version 0.13.0 + +**Major API-change, removed old-api** + +- Remove `Crossterm` type +- Remove `TerminalCursor`, `TerminalColor`, `Terminal` +- Remove `cursor()`, `color()` , `terminal()` +- Remove re-exports at root, accessible via `module::types` (`cursor::MoveTo`) +- `input` module + - Derive 'Copy' for 'KeyEvent' + - Add the `EnableMouseCapture` and `EnableMouseCapture` commands +- `cursor` module + - Introduce static function `crossterm::cursor::position` in place of `TerminalCursor::pos` + - Rename `Goto` to `MoveTo` + - Rename `Up` to `MoveLeft` + - Rename `Right` to `MoveRight` + - Rename `Down` to `MoveDown` + - Rename `BlinkOn` to `EnableBlinking` + - Rename `BlinkOff` to `DisableBlinking` + - Rename `ResetPos` to `ResetPosition` + - Rename `SavePos` to `SavePosition` +- `terminal` + - Introduce static function `crossterm::terminal::size` in place of `Terminal::size` + - Introduce static function `crossterm::terminal::exit` in place of `Terminal::exit` +- `style module` + - Rename `ObjectStyle` to `ContentStyle`. Now full names are used for methods + - Rename `StyledObject` to `StyledContent` and made members private + - Rename `PrintStyledFont` to `PrintStyledContent` + - Rename `attr` method to `attribute`. + - Rename `Attribute::NoInverse` to `NoReverse` + - Update documentation + - Made `Colored` private, user should use commands instead + - Rename `SetFg` -> `SetForegroundColor` + - Rename `SetBg` -> `SetBackgroundColor` + - Rename `SetAttr` -> `SetAttribute` + - Rename `ContentStyle::fg_color` -> `ContentStyle::foreground_color` + - Rename `ContentStyle::bg_color` -> `ContentStyle::background_color` + - Rename `ContentStyle::attrs` -> `ContentStyle::attributes` +- Improve documentation +- Unix terminal size calculation with TPUT + +# Version 0.12.1 + +- Move all the `crossterm_` crates code was moved to the `crossterm` crate + - `crossterm_cursor` is in the `cursor` module, etc. + - All these modules are public +- No public API breaking changes + +# Version 0.12.0 + +- Following crates are deprecated and no longer maintained + - `crossterm_cursor` + - `crossterm_input` + - `crossterm_screen` + - `crossterm_style` + - `crossterm_terminal` + - `crossterm_utils` + +## `crossterm_cursor` 0.4.0 + +- Fix examples link ([PR #6](https://github.com/crossterm-rs/crossterm-cursor/pull/6)) +- Sync documentation style ([PR #7](https://github.com/crossterm-rs/crossterm-cursor/pull/7)) +- Remove all references to the crossterm book ([PR #8](https://github.com/crossterm-rs/crossterm-cursor/pull/8)) +- Replace `RAW_MODE_ENABLED` with `is_raw_mode_enabled` ([PR #9](https://github.com/crossterm-rs/crossterm-cursor/pull/9)) +- Use `SyncReader` & `InputEvent::CursorPosition` for `pos_raw()` ([PR #10](https://github.com/crossterm-rs/crossterm-cursor/pull/10)) + +## `crossterm_input` 0.5.0 + +- Sync documentation style ([PR #4](https://github.com/crossterm-rs/crossterm-input/pull/4)) +- Sync `SyncReader::next()` Windows and UNIX behavior ([PR #5](https://github.com/crossterm-rs/crossterm-input/pull/5)) +- Remove all references to the crossterm book ([PR #6](https://github.com/crossterm-rs/crossterm-input/pull/6)) +- Mouse coordinates synchronized with the cursor ([PR #7](https://github.com/crossterm-rs/crossterm-input/pull/7)) + - Upper/left reported as `(0, 0)` +- Fix bug that read sync didn't block (Windows) ([PR #8](https://github.com/crossterm-rs/crossterm-input/pull/8)) +- Refactor UNIX readers ([PR #9](https://github.com/crossterm-rs/crossterm-input/pull/9)) + - AsyncReader produces mouse events + - One reading thread per application, not per `AsyncReader` + - Cursor position no longer consumed by another `AsyncReader` + - Implement sync reader for read_char (requires raw mode) + - Fix `SIGTTIN` when executed under the LLDB + - Add mio for reading from FD and more efficient polling (UNIX only) +- Sync UNIX and Windows vertical mouse position ([PR #11](https://github.com/crossterm-rs/crossterm-input/pull/11)) + - Top is always reported as `0` + +## `crossterm_screen` 0.3.2 + +- `to_alternate` switch back to main screen if it fails to switch into raw mode ([PR #4](https://github.com/crossterm-rs/crossterm-screen/pull/4)) +- Improve the documentation ([PR #5](https://github.com/crossterm-rs/crossterm-screen/pull/5)) + - Public API + - Include the book content in the documentation +- Remove all references to the crossterm book ([PR #6](https://github.com/crossterm-rs/crossterm-screen/pull/6)) +- New commands introduced ([PR #7](https://github.com/crossterm-rs/crossterm-screen/pull/7)) + - `EnterAlternateScreen` + - `LeaveAlternateScreen` +- Sync Windows and UNIX raw mode behavior ([PR #8](https://github.com/crossterm-rs/crossterm-screen/pull/8)) + +## `crossterm_style` 0.5.2 + +- Refactor ([PR #2](https://github.com/crossterm-rs/crossterm-style/pull/2)) + - Added unit tests + - Improved documentation and added book page to `lib.rs` + - Fixed bug with `SetBg` command, WinApi logic + - Fixed bug with `StyledObject`, used stdout for resetting terminal color + - Introduced `ResetColor` command +- Sync documentation style ([PR #3](https://github.com/crossterm-rs/crossterm-style/pull/3)) +- Remove all references to the crossterm book ([PR #4](https://github.com/crossterm-rs/crossterm-style/pull/4)) +- Windows 7 grey/white foreground/intensity swapped ([PR #5](https://github.com/crossterm-rs/crossterm-style/pull/5)) + +## `crossterm_terminal` 0.3.2 + +- Removed `crossterm_cursor::sys` dependency ([PR #2](https://github.com/crossterm-rs/crossterm-terminal/pull/2)) +- Internal refactoring & documentation ([PR #3](https://github.com/crossterm-rs/crossterm-terminal/pull/3)) +- Removed all references to the crossterm book ([PR #4](https://github.com/crossterm-rs/crossterm-terminal/pull/4)) + +## `crossterm_utils` 0.4.0 + +- Add deprecation note ([PR #3](https://github.com/crossterm-rs/crossterm-utils/pull/3)) +- Remove all references to the crossterm book ([PR #4](https://github.com/crossterm-rs/crossterm-utils/pull/4)) +- Remove unsafe static mut ([PR #5](https://github.com/crossterm-rs/crossterm-utils/pull/5)) + - `sys::unix::RAW_MODE_ENABLED` replaced with `sys::unix::is_raw_mode_enabled()` (breaking) + - New `lazy_static` dependency + +## `crossterm_winapi` 0.3.0 + +- Make read sync block for windows systems ([PR #2](https://github.com/crossterm-rs/crossterm-winapi/pull/2)) + +# Version 0.11.1 + +- Maintenance release +- All sub-crates were moved to their own repositories in the `crossterm-rs` organization + +# Version 0.11.0 + +As a preparation for crossterm 0.1.0 we have moved crossterm to an organisation called 'crossterm-rs'. + +### Code Quality + +- Code Cleanup: [warning-cleanup], [crossterm_style-cleanup], [crossterm_screen-cleanup], [crossterm_terminal-cleanup], [crossterm_utils-cleanup], [2018-cleanup], [api-cleanup-1], [api-cleanup-2], [api-cleanup-3] +- Examples: [example-cleanup_1], [example-cleanup_2], [example-fix], [commandbar-fix], [snake-game-improved] +- Fixed all broken tests and added tests + +### Important Changes + +- Return written bytes: [return-written-bytes] +- Added derives: `Debug` for `ObjectStyle` [debug-derive], Serialize/Deserialize for key events [serde] +- Improved error handling: + - Return `crossterm::Result` from all api's: [return_crossterm_result] + * `TerminalCursor::pos()` returns `Result<(u16, u16)>` + * `Terminal::size()` returns `Result<(u16, u16)>` + * `TerminalCursor::move_*` returns `crossterm::Result` + * `ExecutableCommand::queue` returns `crossterm::Result` + * `QueueableCommand::queue` returns `crossterm::Result` + * `get_available_color_count` returns no result + * `RawScreen::into_raw_mode` returns `crossterm::Result` instead of `io::Result` + * `RawScreen::disable_raw_mode` returns `crossterm::Result` instead of `io::Result` + * `AlternateScreen::to_alternate` returns `crossterm::Result` instead of `io::Result` + * `TerminalInput::read_line` returns `crossterm::Result` instead of `io::Result` + * `TerminalInput::read_char` returns `crossterm::Result` instead of `io::Result` + * Maybe I forgot something, a lot of functions have changed + - Removed all unwraps/expects from library +- Add KeyEvent::Enter and KeyEvent::Tab: [added-key-event-enter], [added-key-event-tab] +- Sync set/get terminal size behaviour: [fixed-get-set-terminal-size] +- Method renames: + * `AsyncReader::stop_reading()` to `stop()` + * `RawScreen::disable_raw_mode_on_drop` to `keep_raw_mode_on_drop` + * `TerminalCursor::reset_position()` to `restore_position()` + * `Command::get_anis_code()` to `ansi_code()` + * `available_color_count` to `available_color_count()` + * `Terminal::terminal_size` to `Terminal::size` + * `Console::get_handle` to `Console::handle` +- All `i16` values for indexing: set size, set cursor pos, scrolling synced to `u16` values +- Command API takes mutable self instead of self + +[serde]: https://github.com/crossterm-rs/crossterm/pull/190 + +[debug-derive]: https://github.com/crossterm-rs/crossterm/pull/192 +[example-fix]: https://github.com/crossterm-rs/crossterm/pull/193 +[commandbar-fix]: https://github.com/crossterm-rs/crossterm/pull/204 + +[warning-cleanup]: https://github.com/crossterm-rs/crossterm/pull/198 +[example-cleanup_1]: https://github.com/crossterm-rs/crossterm/pull/196 +[example-cleanup_2]: https://github.com/crossterm-rs/crossterm/pull/225 +[snake-game-improved]: https://github.com/crossterm-rs/crossterm/pull/231 +[crossterm_style-cleanup]: https://github.com/crossterm-rs/crossterm/pull/208 +[crossterm_screen-cleanup]: https://github.com/crossterm-rs/crossterm/pull/209 +[crossterm_terminal-cleanup]: https://github.com/crossterm-rs/crossterm/pull/210 +[crossterm_utils-cleanup]: https://github.com/crossterm-rs/crossterm/pull/211 +[2018-cleanup]: https://github.com/crossterm-rs/crossterm/pull/222 +[wild-card-cleanup]: https://github.com/crossterm-rs/crossterm/pull/224 + +[api-cleanup-1]: https://github.com/crossterm-rs/crossterm/pull/235 +[api-cleanup-2]: https://github.com/crossterm-rs/crossterm/pull/238 +[api-cleanup-3]: https://github.com/crossterm-rs/crossterm/pull/240 + +[return-written-bytes]: https://github.com/crossterm-rs/crossterm/pull/212 + +[return_crossterm_result]: https://github.com/crossterm-rs/crossterm/pull/232 +[added-key-event-tab]: https://github.com/crossterm-rs/crossterm/pull/239 +[added-key-event-enter]: https://github.com/crossterm-rs/crossterm/pull/236 +[fixed-get-set-terminal-size]: https://github.com/crossterm-rs/crossterm/pull/242 + +# Version 0.10.1 + +# Version 0.10.0 ~ yanked +- Implement command API, to have better performance and more control over how and when commands are executed. [PR](https://github.com/crossterm-rs/crossterm/commit/1a60924abd462ab169b6706aab68f4cca31d7bc2), [issue](https://github.com/crossterm-rs/crossterm/issues/171) +- Fix showing, hiding cursor windows implementation +- Remove some of the parsing logic from windows keys to ansi codes to key events [PR](https://github.com/crossterm-rs/crossterm/commit/762c3a9b8e3d1fba87acde237f8ed09e74cd9ecd) +- Made terminal size 1-based [PR](https://github.com/crossterm-rs/crossterm/commit/d689d7e8ed46a335474b8262bd76f21feaaf0c50) +- Add some derives + +# Version 0.9.6 + +- Copy for KeyEvent +- CTRL + Left, Down, Up, Right key support +- SHIFT + Left, Down, Up, Right key support +- Fixed UNIX cursor position bug [issue](https://github.com/crossterm-rs/crossterm/issues/140), [PR](https://github.com/crossterm-rs/crossterm/pull/152) + +# Version 0.9.5 + +- Prefetch buffer size for more efficient windows input reads. [PR](https://github.com/crossterm-rs/crossterm/pull/144) + +# Version 0.9.4 + +- Reset foreground and background color individually. [PR](https://github.com/crossterm-rs/crossterm/pull/138) +- Backtap input support. [PR](https://github.com/crossterm-rs/crossterm/pull/129) +- Corrected white/grey and added dark grey. +- Fixed getting cursor position with raw screen enabled. [PR](https://github.com/crossterm-rs/crossterm/pull/134) +- Removed one redundant stdout lock + +# Version 0.9.3 + +- Removed println from `SyncReader` + +## Version 0.9.2 + +- Terminal size linux was not 0-based +- Windows mouse input event position was 0-based and should be 1-based +- Result, ErrorKind are made re-exported +- Fixed some special key combination detections for UNIX systems +- Made FreeBSD compile + +## Version 0.9.1 + +- Fixed libc compile error + +## Version 0.9.0 (yanked) + +This release is all about moving to a stabilized API for 1.0. + +- Major refactor and cleanup. +- Improved performance; + - No locking when writing to stdout. + - UNIX doesn't have any dynamic dispatch anymore. + - Windows has improved the way to check if ANSI modes are enabled. + - Removed lot's of complex API calls: `from_screen`, `from_output` + - Removed `Arc` from all internal Api's. +- Removed termios dependency for UNIX systems. +- Upgraded deps. +- Removed about 1000 lines of code + - `TerminalOutput` + - `Screen` + - unsafe code + - Some duplicated code introduced by a previous refactor. +- Raw modes UNIX systems improved +- Added `NoItalic` attribute + +## Version 0.8.2 + +- Bug fix for sync reader UNIX. + +## Version 0.8.1 + +- Added public re-exports for input. + +# Version 0.8.0 + +- Introduced KeyEvents +- Introduced MouseEvents +- Upgraded crossterm_winapi 0.2 + +# Version 0.7.0 + +- Introduced more `Attributes` +- Introduced easier ways to style text [issue 87](https://github.com/crossterm-rs/crossterm/issues/87). +- Removed `ColorType` since it was unnecessary. + +# Version 0.6.0 + +- Introduced feature flags; input, cursor, style, terminal, screen. +- All modules are moved to their own crate. +- Introduced crossterm workspace +- Less dependencies. +- Improved namespaces. + +[PR 84](https://github.com/crossterm-rs/crossterm/pull/84) + +# Version 0.5.5 + +- Error module is made public [PR 78](https://github.com/crossterm-rs/crossterm/pull/78). + +# Version 0.5.4 + +- WinApi rewrite and correctly error handled [PR 67](https://github.com/crossterm-rs/crossterm/pull/67) +- Windows attribute support [PR 62](https://github.com/crossterm-rs/crossterm/pull/62) +- Readline bug fix windows systems [PR 62](https://github.com/crossterm-rs/crossterm/pull/62) +- Error handling improvement. +- General refactoring, all warnings removed. +- Documentation improvement. + +# Version 0.5.1 + +- Documentation refactor. +- Fixed broken API documentation [PR 53](https://github.com/crossterm-rs/crossterm/pull/53). + +# Version 0.5.0 + +- Added ability to pause the terminal [issue](https://github.com/crossterm-rs/crossterm/issues/39) +- RGB support for Windows 10 systems +- ANSI color value (255) color support +- More convenient API, no need to care about `Screen` unless working with when working with alternate or raw screen [PR](https://github.com/crossterm-rs/crossterm/pull/44) +- Implemented Display for styled object + +# Version 0.4.3 + +- Fixed bug [issue 41](https://github.com/crossterm-rs/crossterm/issues/41) + +# Version 0.4.2 + +- Added functionality to make a styled object writable to screen [issue 33](https://github.com/crossterm-rs/crossterm/issues/33) +- Added unit tests. +- Bugfix with getting terminal size unix. +- Bugfix with returning written bytes [pull request 31](https://github.com/crossterm-rs/crossterm/pull/31) +- removed methods calls: `as_any()` and `as_any_mut()` from `TerminalOutput` + +# Version 0.4.1 + +- Fixed resizing of ansi terminal with and height where in the wrong order. + +# Version 0.4.0 + +- Input support (read_line, read_char, read_async, read_until_async) +- Styling module improved +- Everything is multithreaded (`Send`, `Sync`) +- Performance enhancements: removed mutexes, removed state manager, removed context type removed unnecessarily RC types. +- Bug fix resetting console color. +- Bug fix whit undoing raw modes. +- More correct error handling. +- Overall command improvement. +- Overall refactor of code. + +# Version 0.3.0 + +This version has some braking changes check [upgrade manual](UPGRADE%20Manual.md) for more information about what is changed. +I think you should not switch to version `0.3.0` if you aren't going to use the AlternateScreen feature. +Because you will have some work to get to the new version of crossterm depending on your situation. + +Some Features crossterm 0.3.0 +- Alternate Screen for windows and unix systems. +- Raw screen for unix and windows systems [Issue 5](https://github.com/crossterm-rs/crossterm/issues/5).. +- Hiding an showing the cursor. +- Control over blinking of the terminal cursor (only some terminals are supporting this). +- The terminal state will be set to its original state when process ends [issue7](https://github.com/crossterm-rs/crossterm/issues/7). +- exit the current process. + +## Alternate screen + +This create supports alternate screen for both windows and unix systems. You can use + +*Nix style applications often utilize an alternate screen buffer, so that they can modify the entire contents of the buffer, without affecting the application that started them. +The alternate buffer is exactly the dimensions of the window, without any scrollback region. +For an example of this behavior, consider when vim is launched from bash. +Vim uses the entirety of the screen to edit the file, then returning to bash leaves the original buffer unchanged. + +I Highly recommend you to check the `examples/program_examples/first_depth_search` for seeing this in action. + +## Raw screen + +This crate now supports raw screen for both windows and unix systems. +What exactly is raw state: +- No line buffering. + Normally the terminals uses line buffering. This means that the input will be send to the terminal line by line. + With raw mode the input will be send one byte at a time. +- Input + All input has to be written manually by the programmer. +- Characters + The characters are not processed by the terminal driver, but are sent straight through. + Special character have no meaning, like backspace will not be interpret as backspace but instead will be directly send to the terminal. +With these modes you can easier design the terminal screen. + +## Some functionalities added + +- Hiding and showing terminal cursor +- Enable or disabling blinking of the cursor for unix systems (this is not widely supported) +- Restoring the terminal to original modes. +- Added a [wrapper](https://github.com/crossterm-rs/crossterm/blob/master/src/shared/crossterm.rs) for managing all the functionalities of crossterm `Crossterm`. +- Exit the current running process + +## Examples +Added [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) for each version of the crossterm version. +Also added a folder with some [real life examples](https://github.com/crossterm-rs/crossterm/tree/master/examples/program_examples). + +## Context + +What is the `Context` all about? This `Context` has several reasons why it is introduced into `crossterm version 0.3.0`. +These points are related to the features like `Alternatescreen` and managing the terminal state. + +- At first `Terminal state`: + + Because this is a terminal manipulating library there will be made changes to terminal when running an process. + If you stop the process you want the terminal back in its original state. + Therefore, I need to track the changes made to the terminal. + +- At second `Handle to the console` + + In Rust we can use `stdout()` to get an handle to the current default console handle. + For example when in unix systems you want to print something to the main screen you can use the following code: + + write!(std::io::stdout(), "{}", "some text"). + + But things change when we are in alternate screen modes. + We can not simply use `stdout()` to get a handle to the alternate screen, since this call returns the current default console handle (handle to mainscreen). + + Because of that we need to store an handle to the current screen. + This handle could be used to put into alternate screen modes and back into main screen modes. + Through this stored handle Crossterm can execute its command and write on and to the current screen whether it be alternate screen or main screen. + + For unix systems we store the handle gotten from `stdout()` for windows systems that are not supporting ANSI escape codes we store WinApi `HANDLE` struct witch will provide access to the current screen. + +So to recap this `Context` struct is a wrapper for a type that manges terminal state changes. +When this `Context` goes out of scope all changes made will be undone. +Also is this `Context` is a wrapper for access to the current console screen. + +Because Crossterm needs access to the above to types quite often I have chosen to add those two in one struct called `Context` so that this type could be shared throughout library. +Check this link for more info: [cleanup of rust code](https://stackoverflow.com/questions/48732387/how-can-i-run-clean-up-code-in-a-rust-library). +More info over writing to alternate screen buffer on windows and unix see this [link](https://github.com/crossterm-rs/crossterm/issues/17) + +__Now the user has to pass an context type to the modules of Crossterm like this:__ + + let context = Context::new(); + + let cursor = cursor(&context); + let terminal = terminal(&context); + let color = color(&context); + +Because this looks a little odd I will provide a type widths will manage the `Context` for you. You can call the different modules like the following: + + let crossterm = Crossterm::new(); + let color = crossterm.color(); + let cursor = crossterm.cursor(); + let terminal = crossterm.terminal(); + + +### Alternate screen +When you want to switch to alternate screen there are a couple of things to keep in mind for it to work correctly. +First off some code of how to switch to Alternate screen, for more info check the [alternate screen example](https://github.com/crossterm-rs/crossterm/blob/master/examples/alternate_screen.rs). + +_Create alternate screen from `Context`_ + + // create context. + let context = crossterm::Context::new(); + // create instance of Alternatescreen by the given context, this will also switch to it. + let mut screen = crossterm::AlternateScreen::from(context.clone()); + // write to the alternate screen. + write!(screen, "test"); + +_Create alternate screen from `Crossterm`:_ + + // create context. + let crossterm = ::crossterm::Crossterm::new(); + // create instance of Alternatescreen by the given reference to crossterm, this will also switch to it. + let mut screen = crossterm::AlternateScreen::from(&crossterm); + // write to the alternate screen. + write!(screen, "test"); + +like demonstrated above, to get the functionalities of `cursor(), color(), terminal()` also working on alternate screen. +You need to pass it the same `Context` as you have passed to the previous three called functions, +If you don't use the same `Context` in `cursor(), color(), terminal()` than these modules will be using the main screen and you will not see anything at the alternate screen. If you use the [Crossterm](https://github.com/crossterm-rs/crossterm/blob/master/src/shared/crossterm.rs) type you can get the `Context` from it by calling the crossterm.get_context() whereafter you can create the AlternateScreen from it. + +# Version 0.2.2 + +- Bug see [issue 15](https://github.com/crossterm-rs/crossterm/issues/15) + +# Version 0.2.1 + +- Default ANSI escape codes for windows machines, if windows does not support ANSI switch back to WinApi. +- method grammar mistake fixed [Issue 3](https://github.com/crossterm-rs/crossterm/issues/3) +- Some Refactorings in method names see [issue 4](https://github.com/crossterm-rs/crossterm/issues/4) +- Removed bin reference from crate [Issue 6](https://github.com/crossterm-rs/crossterm/issues/6) +- Get position unix fixed [issue 8](https://github.com/crossterm-rs/crossterm/issues/8) + +# Version 0.2 + +- 256 color support. +- Text Attributes like: bold, italic, underscore and crossed word etc. +- Custom ANSI color code input to set fore- and background color for unix. +- Storing the current cursor position and resetting to that stored cursor position later. +- Resizing the terminal. diff --git a/patches/crossterm-0.29.0/Cargo.toml b/patches/crossterm-0.29.0/Cargo.toml new file mode 100644 index 0000000000..9735437988 --- /dev/null +++ b/patches/crossterm-0.29.0/Cargo.toml @@ -0,0 +1,240 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.63.0" +name = "crossterm" +version = "0.29.0" +authors = ["T. Post"] +build = false +exclude = [ + "target", + "Cargo.lock", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A crossplatform terminal library for manipulating terminals." +documentation = "https://docs.rs/crossterm/" +readme = "README.md" +keywords = [ + "event", + "color", + "cli", + "input", + "terminal", +] +categories = [ + "command-line-interface", + "command-line-utilities", +] +license = "MIT" +repository = "https://github.com/crossterm-rs/crossterm" + +[package.metadata.docs.rs] +all-features = true + +[features] +bracketed-paste = [] +default = [ + "bracketed-paste", + "events", + "windows", + "derive-more", +] +derive-more = ["dep:derive_more"] +event-stream = [ + "dep:futures-core", + "events", +] +events = [ + "dep:mio", + "dep:signal-hook", + "dep:signal-hook-mio", +] +osc52 = ["dep:base64"] +serde = [ + "dep:serde", + "bitflags/serde", +] +use-dev-tty = [ + "filedescriptor", + "rustix/process", +] +windows = [ + "dep:winapi", + "dep:crossterm_winapi", +] + +[lib] +name = "crossterm" +path = "src/lib.rs" + +[[example]] +name = "copy-to-clipboard" +path = "examples/copy-to-clipboard.rs" +required-features = ["osc52"] + +[[example]] +name = "event-match-modifiers" +path = "examples/event-match-modifiers.rs" +required-features = [ + "bracketed-paste", + "events", +] + +[[example]] +name = "event-poll-read" +path = "examples/event-poll-read.rs" +required-features = [ + "bracketed-paste", + "events", +] + +[[example]] +name = "event-read" +path = "examples/event-read.rs" +required-features = [ + "bracketed-paste", + "events", +] + +[[example]] +name = "event-read-char-line" +path = "examples/event-read-char-line.rs" +required-features = ["events"] + +[[example]] +name = "event-stream-async-std" +path = "examples/event-stream-async-std.rs" +required-features = [ + "event-stream", + "events", +] + +[[example]] +name = "event-stream-tokio" +path = "examples/event-stream-tokio.rs" +required-features = [ + "event-stream", + "events", +] + +[[example]] +name = "is_tty" +path = "examples/is_tty.rs" + +[[example]] +name = "key-display" +path = "examples/key-display.rs" +required-features = ["events"] + +[[example]] +name = "stderr" +path = "examples/stderr.rs" +required-features = ["events"] + +[dependencies.base64] +version = "0.22" +optional = true + +[dependencies.bitflags] +version = "2.9" + +[dependencies.derive_more] +version = "2.0.0" +features = ["is_variant"] +optional = true + +[dependencies.document-features] +version = "0.2.11" + +[dependencies.futures-core] +version = "0.3" +optional = true +default-features = false + +[dependencies.parking_lot] +version = "0.12" + +[dependencies.serde] +version = "1.0" +features = ["derive"] +optional = true + +[dev-dependencies.async-std] +version = "1.13" + +[dev-dependencies.futures] +version = "0.3" + +[dev-dependencies.futures-timer] +version = "3.0" + +[dev-dependencies.serde_json] +version = "1.0" + +[dev-dependencies.serial_test] +version = "3.0.0" + +[dev-dependencies.temp-env] +version = "0.3.6" + +[dev-dependencies.tokio] +version = "1.44" +features = ["full"] + +[target."cfg(unix)".dependencies.filedescriptor] +version = "0.8" +optional = true + +[target."cfg(unix)".dependencies.libc] +version = "0.2" +optional = true +default-features = false + +[target."cfg(unix)".dependencies.mio] +version = "1.0" +features = ["os-poll"] +optional = true + +[target."cfg(unix)".dependencies.rustix] +version = "1" +features = [ + "std", + "stdio", + "termios", +] +default-features = false + +[target."cfg(unix)".dependencies.signal-hook] +version = "0.3.17" +optional = true + +[target."cfg(unix)".dependencies.signal-hook-mio] +version = "0.2.4" +features = ["support-v1_0"] +optional = true + +[target."cfg(windows)".dependencies.crossterm_winapi] +version = "0.9.1" +optional = true + +[target."cfg(windows)".dependencies.winapi] +version = "0.3.9" +features = [ + "winuser", + "winerror", +] +optional = true diff --git a/patches/crossterm-0.29.0/Cargo.toml.orig b/patches/crossterm-0.29.0/Cargo.toml.orig new file mode 100644 index 0000000000..41c3797dd9 --- /dev/null +++ b/patches/crossterm-0.29.0/Cargo.toml.orig @@ -0,0 +1,122 @@ +[package] +name = "crossterm" +version = "0.29.0" +authors = ["T. Post"] +description = "A crossplatform terminal library for manipulating terminals." +repository = "https://github.com/crossterm-rs/crossterm" +documentation = "https://docs.rs/crossterm/" +license = "MIT" +keywords = ["event", "color", "cli", "input", "terminal"] +exclude = ["target", "Cargo.lock"] +readme = "README.md" +edition = "2021" +rust-version = "1.63.0" +categories = ["command-line-interface", "command-line-utilities"] + +[lib] +name = "crossterm" +path = "src/lib.rs" + +[package.metadata.docs.rs] +all-features = true + +[features] +default = ["bracketed-paste", "events", "windows", "derive-more"] + +#! ### Default features +## Enables triggering [`Event::Paste`](event::Event::Paste) when pasting text into the terminal. +bracketed-paste = [] + +## Enables reading input/events from the system using the [`event`] module. +events = ["dep:mio", "dep:signal-hook", "dep:signal-hook-mio"] + +## Enables windows specific crates. +windows = ["dep:winapi", "dep:crossterm_winapi"] + +#! ### Optional Features + +## Enables the [EventStream](event::EventStream) struct for async event reading. +event-stream = ["dep:futures-core", "events"] + +## Enables [`serde`] for various types. +serde = ["dep:serde", "bitflags/serde"] + +## Enables raw file descriptor polling / selecting instead of mio. +use-dev-tty = ["filedescriptor", "rustix/process"] + +## Enables `is_*` helper functions for event enums. +derive-more = ["dep:derive_more"] + +## Enables interacting with a host clipboard via [`clipboard`](clipboard/index.html) +osc52 = ["dep:base64"] + +[dependencies] +base64 = { version = "0.22", optional = true } +bitflags = { version = "2.9" } +derive_more = { version = "2.0.0", features = ["is_variant"], optional = true } +document-features = "0.2.11" +futures-core = { version = "0.3", optional = true, default-features = false } +parking_lot = "0.12" +serde = { version = "1.0", features = ["derive"], optional = true } + +# Windows dependencies +[target.'cfg(windows)'.dependencies] +crossterm_winapi = { version = "0.9.1", optional = true } +winapi = { version = "0.3.9", optional = true, features = ["winuser", "winerror"] } + +# UNIX dependencies +[target.'cfg(unix)'.dependencies] +filedescriptor = { version = "0.8", optional = true } +# Default to using rustix for UNIX systems, but provide an option to use libc for backwards +# compatibility. +libc = { version = "0.2", default-features = false, optional = true } +mio = { version = "1.0", features = ["os-poll"], optional = true } +rustix = { version = "1", default-features = false, features = ["std", "stdio", "termios"] } +signal-hook = { version = "0.3.17", optional = true } +signal-hook-mio = { version = "0.2.4", features = ["support-v1_0"], optional = true } + +[dev-dependencies] +async-std = "1.13" +futures = "0.3" +futures-timer = "3.0" +serde_json = "1.0" +serial_test = "3.0.0" +temp-env = "0.3.6" +tokio = { version = "1.44", features = ["full"] } + +# Examples +[[example]] +name = "event-read" +required-features = ["bracketed-paste", "events"] + +[[example]] +name = "event-match-modifiers" +required-features = ["bracketed-paste", "events"] + +[[example]] +name = "event-poll-read" +required-features = ["bracketed-paste", "events"] + +[[example]] +name = "event-stream-async-std" +required-features = ["event-stream", "events"] + +[[example]] +name = "event-stream-tokio" +required-features = ["event-stream", "events"] + +[[example]] +name = "event-read-char-line" +required-features = ["events"] + +[[example]] +name = "stderr" +required-features = ["events"] + +[[example]] +name = "key-display" +required-features = ["events"] + +[[example]] +name = "copy-to-clipboard" +required-features = ["osc52"] diff --git a/patches/crossterm-0.29.0/LICENSE b/patches/crossterm-0.29.0/LICENSE new file mode 100644 index 0000000000..8b02a7fcef --- /dev/null +++ b/patches/crossterm-0.29.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Timon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/patches/crossterm-0.29.0/README.md b/patches/crossterm-0.29.0/README.md new file mode 100644 index 0000000000..f4bc72d331 --- /dev/null +++ b/patches/crossterm-0.29.0/README.md @@ -0,0 +1,222 @@ +

+ +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=Z8QK6XU749JB2) ![Travis][s7] [![Latest Version][s1]][l1] [![MIT][s2]][l2] [![docs][s3]][l3] ![Lines of Code][s6] [![Join us on Discord][s5]][l5] + +# Cross-platform Terminal Manipulation Library + +Crossterm is a pure-rust, terminal manipulation library that makes it possible to write cross-platform text-based interfaces (see [features](#features)). It supports all UNIX and Windows terminals down to Windows 7 (not all terminals are tested, +see [Tested Terminals](#tested-terminals) for more info). + +## Table of Contents + +- [Cross-platform Terminal Manipulation Library](#cross-platform-terminal-manipulation-library) + - [Table of Contents](#table-of-contents) + - [Features](#features) + - [Tested Terminals](#tested-terminals) + - [Getting Started](#getting-started) + - [Feature Flags](#feature-flags) + - [Dependency Justification](#dependency-justification) + - [Other Resources](#other-resources) + - [Used By](#used-by) + - [Contributing](#contributing) + - [Authors](#authors) + - [License](#license) + +## Features + +- Cross-platform +- Multi-threaded (send, sync) +- Detailed documentation +- Few dependencies +- Full control over writing and flushing output buffer +- Is tty +- Cursor + - Move the cursor N times (up, down, left, right) + - Move to previous / next line + - Move to column + - Set/get the cursor position + - Store the cursor position and restore to it later + - Hide/show the cursor + - Enable/disable cursor blinking (not all terminals do support this feature) +- Styled output + - Foreground color (16 base colors) + - Background color (16 base colors) + - 256 (ANSI) color support (Windows 10 and UNIX only) + - RGB color support (Windows 10 and UNIX only) + - Text attributes like bold, italic, underscore, crossed, etc +- Terminal + - Clear (all lines, current line, from cursor down and up, until new line) + - Scroll up, down + - Set/get the terminal size + - Exit current process + - Alternate screen + - Raw screen + - Set terminal title + - Enable/disable line wrapping +- Event + - Input Events + - Mouse Events (press, release, position, button, drag) + - Terminal Resize Events + - Advanced modifier (SHIFT | ALT | CTRL) support for both mouse and key events and + - futures Stream (feature 'event-stream') + - Poll/read API + + + +### Tested Terminals + +- Console Host + - Windows 10 (Pro) + - Windows 8.1 (N) +- Windows Terminal + - Windows 10 x86_64 (Enterprise) + - Windows 11 arm64 (Enterprise) +- Ubuntu Desktop Terminal + - Ubuntu 23.04 64-bit + - Ubuntu 17.10 + - Pop!_OS ( Ubuntu ) 20.04 +- (Arch, Manjaro) KDE Konsole +- (Arch, NixOS) Kitty +- Linux Mint +- (OpenSuse) Alacritty +- (Chrome OS) Crostini +- Apple + - macOS Monterey 12.7.1 (Intel-Chip) + - macOS Sonama 14.4 (M1 Max, Apple Silicon-Chip) + +This crate supports all UNIX terminals and Windows terminals down to Windows 7; however, not all of the +terminals have been tested. If you have used this library for a terminal other than the above list without +issues, then feel free to add it to the above list - I really would appreciate it! + +## Getting Started +_see the [examples directory](examples/) and [documentation](https://docs.rs/crossterm/) for more advanced examples._ + +
+ +Click to show Cargo.toml. + + +```toml +[dependencies] +crossterm = "0.27" +``` + +
+

+ +```rust +use std::io::{stdout, Write}; + +use crossterm::{ + execute, + style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor}, + ExecutableCommand, + event, +}; + +fn main() -> std::io::Result<()> { + // using the macro + execute!( + stdout(), + SetForegroundColor(Color::Blue), + SetBackgroundColor(Color::Red), + Print("Styled text here."), + ResetColor + )?; + + // or using functions + stdout() + .execute(SetForegroundColor(Color::Blue))? + .execute(SetBackgroundColor(Color::Red))? + .execute(Print("Styled text here."))? + .execute(ResetColor)?; + + Ok(()) +} +``` + +Checkout this [list](https://docs.rs/crossterm/latest/crossterm/index.html#supported-commands) with all possible commands. + +### Feature Flags + +```toml +[dependencies.crossterm] +version = "0.27" +features = ["event-stream"] +``` + +| Feature | Description | +|:---------------|:---------------------------------------------| +| `event-stream` | `futures::Stream` producing `Result`. | +| `serde` | (De)serializing of events. | +| `events` | Reading input/system events (enabled by default) | +| `filedescriptor` | Use raw filedescriptor for all events rather then mio dependency | +| `derive-more` | Adds `is_*` helper functions for event types | +| `osc52` | Enables crossterm::clipboard | + + +To use crossterm as a very thin layer you can disable the `events` feature or use `filedescriptor` feature. +This can disable `mio` / `signal-hook` / `signal-hook-mio` dependencies. + +### Dependency Justification + +| Dependency | Used for | Included | +|:---------------|:---------------------------------------------------------------------------------|:--------------------------------------| +| `bitflags` | `KeyModifiers`, those are differ based on input. | always | +| `parking_lot` | locking `RwLock`s with a timeout, const mutexes. | always | +| `libc` | UNIX terminal_size/raw modes/set_title and several other low level functionality. | optional (`events` feature), UNIX only | +| `Mio` | event readiness polling, waking up poller | optional (`events` feature), UNIX only | +| `signal-hook` | signal-hook is used to handle terminal resize SIGNAL with Mio. | optional (`events` feature),UNIX only | +| `winapi` | Used for low-level windows system calls which ANSI codes can't replace | windows only | +| `futures-core` | For async stream of events | only with `event-stream` feature flag | +| `serde` | ***ser***ializing and ***de***serializing of events | only with `serde` feature flag | +| `derive_more` | Adds `is_*` helper functions for event types | optional (`derive-more` feature), included by default | +| `base64` | Encoding clipboard data for OSC52 sequences in crossterm::clipboard | only with `osc52` feature flag | + +### Other Resources + +- [API documentation](https://docs.rs/crossterm/) +- [Deprecated examples repository](https://github.com/crossterm-rs/examples) + +## Used By + +- [Broot](https://dystroy.org/broot/) +- [Cursive](https://github.com/gyscos/Cursive) +- [Ratatui](https://github.com/ratatui/ratatui) +- [Rust-sloth](https://github.com/ecumene/rust-sloth) +- [Rusty-rain](https://github.com/cowboy8625/rusty-rain) + +## Contributing + +We highly appreciate when anyone contributes to this crate. Before you do, please, +read the [Contributing](docs/CONTRIBUTING.md) guidelines. + +## Authors + +* **Timon Post** - *Project Owner & creator* + +## License + +This project, `crossterm` and all its sub-crates: `crossterm_screen`, `crossterm_cursor`, `crossterm_style`, +`crossterm_input`, `crossterm_terminal`, `crossterm_winapi`, `crossterm_utils` are licensed under the MIT +License - see the [LICENSE](https://github.com/crossterm-rs/crossterm/blob/master/LICENSE) file for details. + +[s1]: https://img.shields.io/crates/v/crossterm.svg +[l1]: https://crates.io/crates/crossterm + +[s2]: https://img.shields.io/badge/license-MIT-blue.svg +[l2]: ./LICENSE + +[s3]: https://docs.rs/crossterm/badge.svg +[l3]: https://docs.rs/crossterm/ + +[s3]: https://docs.rs/crossterm/badge.svg +[l3]: https://docs.rs/crossterm/ + +[s5]: https://img.shields.io/discord/560857607196377088.svg?logo=discord +[l5]: https://discord.gg/K4nyTDB + +[s6]: https://tokei.rs/b1/github/crossterm-rs/crossterm?category=code +[s7]: https://travis-ci.org/crossterm-rs/crossterm.svg?branch=master diff --git a/patches/crossterm-0.29.0/examples/README.md b/patches/crossterm-0.29.0/examples/README.md new file mode 100644 index 0000000000..c9884ec3c0 --- /dev/null +++ b/patches/crossterm-0.29.0/examples/README.md @@ -0,0 +1,40 @@ +![Lines of Code][s7] [![MIT][s2]][l2] [![Join us on Discord][s5]][l5] + +# Crossterm Examples + +The examples are compatible with the latest release. + +## Structure + +``` +├── examples +│   └── interactive-test +│   └── event-* +│   └── stderr +``` +| File Name | Description | Topics | +|:----------------------------|:-------------------------------|:------------------------------------------| +| `examples/interactive-test` | interactive, walk through, demo | cursor, style, event | +| `event-*` | event reading demos | (async) event reading | +| `stderr` | crossterm over stderr demo | raw mode, alternate screen, custom output | +| `is_tty` | Is this instance a tty ? | tty | + +## Run examples + +```bash +$ cargo run --example [file name] +``` + +To run the interactive-demo go into the folder `examples/interactive-demo` and run `cargo run`. + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE) file for details. + +[s2]: https://img.shields.io/badge/license-MIT-blue.svg +[l2]: LICENSE + +[s5]: https://img.shields.io/discord/560857607196377088.svg?logo=discord +[l5]: https://discord.gg/K4nyTDB + +[s7]: https://travis-ci.org/crossterm-rs/examples.svg?branch=master diff --git a/patches/crossterm-0.29.0/examples/copy-to-clipboard.rs b/patches/crossterm-0.29.0/examples/copy-to-clipboard.rs new file mode 100644 index 0000000000..d584b4c98e --- /dev/null +++ b/patches/crossterm-0.29.0/examples/copy-to-clipboard.rs @@ -0,0 +1,46 @@ +//! Demonstrates copying a string to clipboard +//! +//! This example uses OSC control sequence `Pr = 5 2` (See +//! +//! to copy data to the terminal host clipboard. +//! +//! This only works if it is enabled on the respective terminal emulator. If a terminal multiplexer +//! is used, the multiplexer will likely need to support it, too. +//! +//! ```no_run +//! cargo run --example copy-to-clipboard -- --clipboard "Some String" +//! cargo run --example copy-to-clipboard -- --primary "Some String" +//! cargo run --example copy-to-clipboard -- "Some String" +//! ``` + +use std::io; + +use crossterm::clipboard; +use crossterm::execute; + +fn main() -> io::Result<()> { + let mut stdout = io::stdout(); + let mut args = std::env::args(); + args.next(); // Skip to first argument + + let default_text = String::from("Example text"); + let (text, dest) = match args.next().as_deref() { + Some("--clipboard") => ( + args.next().unwrap_or(default_text), + clipboard::ClipboardType::Clipboard, + ), + Some("--primary") => ( + args.next().unwrap_or(default_text), + clipboard::ClipboardType::Primary, + ), + Some(text) => (text.to_owned(), clipboard::ClipboardType::Clipboard), + None => (default_text, clipboard::ClipboardType::Clipboard), + }; + execute!( + stdout, + clipboard::CopyToClipboard { + content: text, + destination: clipboard::ClipboardSelection(vec![dest]) + } + ) +} diff --git a/patches/crossterm-0.29.0/examples/event-match-modifiers.rs b/patches/crossterm-0.29.0/examples/event-match-modifiers.rs new file mode 100644 index 0000000000..20b6405f03 --- /dev/null +++ b/patches/crossterm-0.29.0/examples/event-match-modifiers.rs @@ -0,0 +1,67 @@ +//! Demonstrates how to match on modifiers like: Control, alt, shift. +//! +//! cargo run --example event-match-modifiers + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + +fn match_event(event: Event) { + if let Some(key) = event.as_key_press_event() { + match key { + KeyEvent { + modifiers: KeyModifiers::CONTROL, + code, + .. + } => { + println!("Control + {:?}", code); + } + KeyEvent { + modifiers: KeyModifiers::SHIFT, + code, + .. + } => { + println!("Shift + {:?}", code); + } + KeyEvent { + modifiers: KeyModifiers::ALT, + code, + .. + } => { + println!("Alt + {:?}", code); + } + + // Match on multiple modifiers: + KeyEvent { + code, modifiers, .. + } => { + if modifiers == (KeyModifiers::ALT | KeyModifiers::SHIFT) { + println!("Alt + Shift {:?}", code); + } else { + println!("({:?}) with key: {:?}", modifiers, code) + } + } + } + } +} + +fn main() { + match_event(Event::Key(KeyEvent::new( + KeyCode::Char('z'), + KeyModifiers::CONTROL, + ))); + match_event(Event::Key(KeyEvent::new( + KeyCode::Left, + KeyModifiers::SHIFT, + ))); + match_event(Event::Key(KeyEvent::new( + KeyCode::Delete, + KeyModifiers::ALT, + ))); + match_event(Event::Key(KeyEvent::new( + KeyCode::Right, + KeyModifiers::ALT | KeyModifiers::SHIFT, + ))); + match_event(Event::Key(KeyEvent::new( + KeyCode::Home, + KeyModifiers::ALT | KeyModifiers::CONTROL, + ))); +} diff --git a/patches/crossterm-0.29.0/examples/event-poll-read.rs b/patches/crossterm-0.29.0/examples/event-poll-read.rs new file mode 100644 index 0000000000..df960ab209 --- /dev/null +++ b/patches/crossterm-0.29.0/examples/event-poll-read.rs @@ -0,0 +1,61 @@ +//! Demonstrates how to match on modifiers like: Control, alt, shift. +//! +//! cargo run --example event-poll-read + +use std::{io, time::Duration}; + +use crossterm::{ + cursor::position, + event::{poll, read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode}, +}; + +const HELP: &str = r#"Blocking poll() & non-blocking read() + - Keyboard, mouse and terminal resize events enabled + - Prints "." every second if there's no event + - Hit "c" to print current cursor position + - Use Esc to quit +"#; + +fn print_events() -> io::Result<()> { + loop { + // Wait up to 1s for another event + if poll(Duration::from_millis(1_000))? { + // It's guaranteed that read() won't block if `poll` returns `Ok(true)` + let event = read()?; + + println!("Event::{:?}\r", event); + + if event == Event::Key(KeyCode::Char('c').into()) { + println!("Cursor position: {:?}\r", position()); + } + + if event == Event::Key(KeyCode::Esc.into()) { + break; + } + } else { + // Timeout expired, no event for 1s + println!(".\r"); + } + } + + Ok(()) +} + +fn main() -> io::Result<()> { + println!("{}", HELP); + + enable_raw_mode()?; + + let mut stdout = io::stdout(); + execute!(stdout, EnableMouseCapture)?; + + if let Err(e) = print_events() { + println!("Error: {:?}\r", e); + } + + execute!(stdout, DisableMouseCapture)?; + + disable_raw_mode() +} diff --git a/patches/crossterm-0.29.0/examples/event-read-char-line.rs b/patches/crossterm-0.29.0/examples/event-read-char-line.rs new file mode 100644 index 0000000000..82e695a32b --- /dev/null +++ b/patches/crossterm-0.29.0/examples/event-read-char-line.rs @@ -0,0 +1,61 @@ +//! Demonstrates how to block read characters or a full line. +//! Just note that crossterm is not required to do this and can be done with `io::stdin()`. +//! +//! cargo run --example event-read-char-line + +use std::io; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, + terminal, +}; + +pub fn read_char() -> io::Result { + loop { + if let Event::Key(KeyEvent { + code: KeyCode::Char(c), + kind: KeyEventKind::Press, + .. + }) = event::read()? + { + return Ok(c); + } + } +} + +pub fn read_line() -> io::Result { + let mut line = String::new(); + loop { + if let Event::Key(KeyEvent { + code, + kind: KeyEventKind::Press, + .. + }) = event::read()? + { + match code { + KeyCode::Enter => { + break; + } + KeyCode::Char(c) => { + line.push(c); + } + _ => {} + } + } + } + + Ok(line) +} + +fn main() -> io::Result<()> { + terminal::enable_raw_mode()?; + + println!("read line:\r"); + println!("{:?}\r", read_line()); + println!("read char:\r"); + println!("{:?}\r", read_char()); + println!("read char again:\r"); + println!("{:?}\r", read_char()); + + terminal::disable_raw_mode() +} diff --git a/patches/crossterm-0.29.0/examples/event-read.rs b/patches/crossterm-0.29.0/examples/event-read.rs new file mode 100644 index 0000000000..aaf74fe21a --- /dev/null +++ b/patches/crossterm-0.29.0/examples/event-read.rs @@ -0,0 +1,112 @@ +//! Demonstrates how to block read events. +//! +//! cargo run --example event-read + +use std::io; + +use crossterm::event::{ + poll, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; +use crossterm::{ + cursor::position, + event::{ + read, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, Event, KeyCode, + }, + execute, queue, + terminal::{disable_raw_mode, enable_raw_mode}, +}; +use std::time::Duration; + +const HELP: &str = r#"Blocking read() + - Keyboard, mouse, focus and terminal resize events enabled + - Hit "c" to print current cursor position + - Use Esc to quit +"#; + +fn print_events() -> io::Result<()> { + loop { + // Blocking read + let event = read()?; + + println!("Event: {:?}\r", event); + + if event == Event::Key(KeyCode::Char('c').into()) { + println!("Cursor position: {:?}\r", position()); + } + + if let Event::Resize(x, y) = event { + let (original_size, new_size) = flush_resize_events((x, y)); + println!("Resize from: {:?}, to: {:?}\r", original_size, new_size); + } + + if event == Event::Key(KeyCode::Esc.into()) { + break; + } + } + + Ok(()) +} + +// Resize events can occur in batches. +// With a simple loop they can be flushed. +// This function will keep the first and last resize event. +fn flush_resize_events(first_resize: (u16, u16)) -> ((u16, u16), (u16, u16)) { + let mut last_resize = first_resize; + while let Ok(true) = poll(Duration::from_millis(50)) { + if let Ok(Event::Resize(x, y)) = read() { + last_resize = (x, y); + } + } + + (first_resize, last_resize) +} + +fn main() -> io::Result<()> { + println!("{}", HELP); + + enable_raw_mode()?; + + let mut stdout = io::stdout(); + + let supports_keyboard_enhancement = matches!( + crossterm::terminal::supports_keyboard_enhancement(), + Ok(true) + ); + + if supports_keyboard_enhancement { + queue!( + stdout, + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + ) + )?; + } + + execute!( + stdout, + EnableBracketedPaste, + EnableFocusChange, + EnableMouseCapture, + )?; + + if let Err(e) = print_events() { + println!("Error: {:?}\r", e); + } + + if supports_keyboard_enhancement { + queue!(stdout, PopKeyboardEnhancementFlags)?; + } + + execute!( + stdout, + DisableBracketedPaste, + DisableFocusChange, + DisableMouseCapture + )?; + + disable_raw_mode() +} diff --git a/patches/crossterm-0.29.0/examples/event-stream-async-std.rs b/patches/crossterm-0.29.0/examples/event-stream-async-std.rs new file mode 100644 index 0000000000..abd54495e3 --- /dev/null +++ b/patches/crossterm-0.29.0/examples/event-stream-async-std.rs @@ -0,0 +1,67 @@ +//! Demonstrates how to read events asynchronously with async-std. +//! +//! cargo run --features="event-stream" --example event-stream-async-std + +use std::{io::stdout, time::Duration}; + +use futures::{future::FutureExt, select, StreamExt}; +use futures_timer::Delay; + +use crossterm::{ + cursor::position, + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode}, +}; + +const HELP: &str = r#"EventStream based on futures_util::stream::Stream with async-std + - Keyboard, mouse and terminal resize events enabled + - Prints "." every second if there's no event + - Hit "c" to print current cursor position + - Use Esc to quit +"#; + +async fn print_events() { + let mut reader = EventStream::new(); + + loop { + let mut delay = Delay::new(Duration::from_millis(1_000)).fuse(); + let mut event = reader.next().fuse(); + + select! { + _ = delay => { println!(".\r"); }, + maybe_event = event => { + match maybe_event { + Some(Ok(event)) => { + println!("Event::{:?}\r", event); + + if event == Event::Key(KeyCode::Char('c').into()) { + println!("Cursor position: {:?}\r", position()); + } + + if event == Event::Key(KeyCode::Esc.into()) { + break; + } + } + Some(Err(e)) => println!("Error: {:?}\r", e), + None => break, + } + } + }; + } +} + +fn main() -> std::io::Result<()> { + println!("{}", HELP); + + enable_raw_mode()?; + + let mut stdout = stdout(); + execute!(stdout, EnableMouseCapture)?; + + async_std::task::block_on(print_events()); + + execute!(stdout, DisableMouseCapture)?; + + disable_raw_mode() +} diff --git a/patches/crossterm-0.29.0/examples/event-stream-tokio.rs b/patches/crossterm-0.29.0/examples/event-stream-tokio.rs new file mode 100644 index 0000000000..6cea39ce1e --- /dev/null +++ b/patches/crossterm-0.29.0/examples/event-stream-tokio.rs @@ -0,0 +1,68 @@ +//! Demonstrates how to read events asynchronously with tokio. +//! +//! cargo run --features="event-stream" --example event-stream-tokio + +use std::{io::stdout, time::Duration}; + +use futures::{future::FutureExt, select, StreamExt}; +use futures_timer::Delay; + +use crossterm::{ + cursor::position, + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode}, +}; + +const HELP: &str = r#"EventStream based on futures_util::Stream with tokio + - Keyboard, mouse and terminal resize events enabled + - Prints "." every second if there's no event + - Hit "c" to print current cursor position + - Use Esc to quit +"#; + +async fn print_events() { + let mut reader = EventStream::new(); + + loop { + let mut delay = Delay::new(Duration::from_millis(1_000)).fuse(); + let mut event = reader.next().fuse(); + + select! { + _ = delay => { println!(".\r"); }, + maybe_event = event => { + match maybe_event { + Some(Ok(event)) => { + println!("Event::{:?}\r", event); + + if event == Event::Key(KeyCode::Char('c').into()) { + println!("Cursor position: {:?}\r", position()); + } + + if event == Event::Key(KeyCode::Esc.into()) { + break; + } + } + Some(Err(e)) => println!("Error: {:?}\r", e), + None => break, + } + } + }; + } +} + +#[tokio::main] +async fn main() -> std::io::Result<()> { + println!("{}", HELP); + + enable_raw_mode()?; + + let mut stdout = stdout(); + execute!(stdout, EnableMouseCapture)?; + + print_events().await; + + execute!(stdout, DisableMouseCapture)?; + + disable_raw_mode() +} diff --git a/patches/crossterm-0.29.0/examples/is_tty.rs b/patches/crossterm-0.29.0/examples/is_tty.rs new file mode 100644 index 0000000000..85770e23b8 --- /dev/null +++ b/patches/crossterm-0.29.0/examples/is_tty.rs @@ -0,0 +1,18 @@ +use crossterm::{ + execute, + terminal::{size, SetSize}, + tty::IsTty, +}; +use std::io::{stdin, stdout}; + +pub fn main() { + println!("size: {:?}", size().unwrap()); + execute!(stdout(), SetSize(10, 10)).unwrap(); + println!("resized: {:?}", size().unwrap()); + + if stdin().is_tty() { + println!("Is TTY"); + } else { + println!("Is not TTY"); + } +} diff --git a/patches/crossterm-0.29.0/examples/key-display.rs b/patches/crossterm-0.29.0/examples/key-display.rs new file mode 100644 index 0000000000..ed60c9d8cd --- /dev/null +++ b/patches/crossterm-0.29.0/examples/key-display.rs @@ -0,0 +1,46 @@ +//! Demonstrates the display format of key events. +//! +//! This example demonstrates the display format of key events, which is useful for displaying in +//! the help section of a terminal application. +//! +//! cargo run --example key-display + +use std::io; + +use crossterm::event::KeyModifiers; +use crossterm::{ + event::{read, KeyCode}, + terminal::{disable_raw_mode, enable_raw_mode}, +}; + +const HELP: &str = r#"Key display + - Press any key to see its display format + - Use Esc to quit +"#; + +fn main() -> io::Result<()> { + println!("{}", HELP); + enable_raw_mode()?; + if let Err(e) = print_events() { + println!("Error: {:?}\r", e); + } + disable_raw_mode()?; + Ok(()) +} + +fn print_events() -> io::Result<()> { + while let Ok(event) = read() { + let Some(event) = event.as_key_press_event() else { + continue; + }; + let modifier = match event.modifiers { + KeyModifiers::NONE => "".to_string(), + _ => format!("{:}+", event.modifiers), + }; + println!("Key pressed: {modifier}{code}\r", code = event.code); + if event.code == KeyCode::Esc { + break; + } + } + Ok(()) +} diff --git a/patches/crossterm-0.29.0/examples/stderr.rs b/patches/crossterm-0.29.0/examples/stderr.rs new file mode 100644 index 0000000000..ce523c6378 --- /dev/null +++ b/patches/crossterm-0.29.0/examples/stderr.rs @@ -0,0 +1,95 @@ +//! This shows how an application can write on stderr +//! instead of stdout, thus making it possible to +//! the command API instead of the "old style" direct +//! unbuffered API. +//! +//! This particular example is only suited to Unix +//! for now. +//! +//! cargo run --example stderr + +use std::io; + +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + event, + event::{Event, KeyCode, KeyEvent}, + execute, queue, + style::Print, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +const TEXT: &str = r#" +This screen is ran on stderr. +And when you hit enter, it prints on stdout. +This makes it possible to run an application and choose what will +be sent to any application calling yours. + +For example, assuming you build this example with + + cargo build --bin stderr + +and then you run it with + + cd "$(target/debug/stderr)" + +what the application prints on stdout is used as argument to cd. + +Try it out. + +Hit any key to quit this screen: + +1 will print `..` +2 will print `/` +3 will print `~` +Any other key will print this text (so that you may copy-paste) +"#; + +fn run_app(write: &mut W) -> io::Result +where + W: io::Write, +{ + queue!( + write, + EnterAlternateScreen, // enter alternate screen + Hide // hide the cursor + )?; + + let mut y = 1; + for line in TEXT.split('\n') { + queue!(write, MoveTo(1, y), Print(line.to_string()))?; + y += 1; + } + + write.flush()?; + + terminal::enable_raw_mode()?; + let user_char = read_char()?; // we wait for the user to hit a key + execute!(write, Show, LeaveAlternateScreen)?; // restore the cursor and leave the alternate screen + + terminal::disable_raw_mode()?; + + Ok(user_char) +} + +pub fn read_char() -> io::Result { + loop { + if let Event::Key(KeyEvent { + code: KeyCode::Char(c), + .. + }) = event::read()? + { + return Ok(c); + } + } +} + +// cargo run --example stderr +fn main() { + match run_app(&mut io::stderr()).unwrap() { + '1' => print!(".."), + '2' => print!("/"), + '3' => print!("~"), + _ => println!("{}", TEXT), + } +} diff --git a/patches/crossterm-0.29.0/src/ansi_support.rs b/patches/crossterm-0.29.0/src/ansi_support.rs new file mode 100644 index 0000000000..fc2f6f1447 --- /dev/null +++ b/patches/crossterm-0.29.0/src/ansi_support.rs @@ -0,0 +1,46 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use crossterm_winapi::{ConsoleMode, Handle}; +use parking_lot::Once; +use winapi::um::wincon::ENABLE_VIRTUAL_TERMINAL_PROCESSING; + +/// Enable virtual terminal processing. +/// +/// This method attempts to enable virtual terminal processing for this +/// console. If there was a problem enabling it, then an error returned. +/// On success, the caller may assume that enabling it was successful. +/// +/// When virtual terminal processing is enabled, characters emitted to the +/// console are parsed for VT100 and similar control character sequences +/// that control color and other similar operations. +fn enable_vt_processing() -> std::io::Result<()> { + let mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING; + + let console_mode = ConsoleMode::from(Handle::current_out_handle()?); + let old_mode = console_mode.mode()?; + + if old_mode & mask == 0 { + console_mode.set_mode(old_mode | mask)?; + } + + Ok(()) +} + +static SUPPORTS_ANSI_ESCAPE_CODES: AtomicBool = AtomicBool::new(false); +static INITIALIZER: Once = Once::new(); + +/// Checks if the current terminal supports ANSI escape sequences +pub fn supports_ansi() -> bool { + INITIALIZER.call_once(|| { + // Some terminals on Windows like GitBash can't use WinAPI calls directly + // so when we try to enable the ANSI-flag for Windows this won't work. + // Because of that we should check first if the TERM-variable is set + // and see if the current terminal is a terminal who does support ANSI. + let supported = enable_vt_processing().is_ok() + || std::env::var("TERM").map_or(false, |term| term != "dumb"); + + SUPPORTS_ANSI_ESCAPE_CODES.store(supported, Ordering::SeqCst); + }); + + SUPPORTS_ANSI_ESCAPE_CODES.load(Ordering::SeqCst) +} diff --git a/patches/crossterm-0.29.0/src/clipboard.rs b/patches/crossterm-0.29.0/src/clipboard.rs new file mode 100644 index 0000000000..75913112d7 --- /dev/null +++ b/patches/crossterm-0.29.0/src/clipboard.rs @@ -0,0 +1,308 @@ +//! # Clipboard +//! +//! The `clipboard` module provides functionality to work with a host clipboard. +//! +//! ## Implemented operations: +//! +//! - Copy: [`CopyToClipboard`](struct.CopyToClipboard.html) +use base64::prelude::{Engine, BASE64_STANDARD}; + +use std::fmt; +use std::str::FromStr; + +use crate::{osc, Command}; + +/// Different clipboard types +/// +/// Some operating systems and desktop environments support multiple buffers +/// for copy/cut/paste. Their details differ between operating systems. +/// See +/// for a detailed survey of supported types based on the X window system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClipboardType { + /// Default clipboard when using Ctrl+C or Ctrl+V + Clipboard, + + /// Clipboard on Linux/X/Wayland when using selection and middle mouse button + Primary, + + /// Other clipboard type not explicitly supported by crossterm + /// + /// See + /// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) + /// for potential values. + /// + /// Note that support for these in terminal emulators is very limited. + Other(char), +} + +impl From<&ClipboardType> for char { + fn from(val: &ClipboardType) -> Self { + match val { + ClipboardType::Clipboard => 'c', + ClipboardType::Primary => 'p', + ClipboardType::Other(other) => *other, + } + } +} + +impl From for ClipboardType { + fn from(value: char) -> Self { + match value { + 'c' => ClipboardType::Clipboard, + 'p' => ClipboardType::Primary, + other => ClipboardType::Other(other), + } + } +} + +/// A sequence of clipboard types +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardSelection( + /// An ordered list of clipboards which will be the destination for the copied selection. + /// + /// Order matters due to implementations deviating from the + /// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) + /// reference. Some terminal emulators may only interpret the first character of this + /// parameter. For differences, see + /// [`CopyToClipboard` (Terminal Support)](struct.CopyToClipboard.html#terminal-support). + pub Vec, +); + +impl ClipboardSelection { + /// Returns a String corresponsing to the "Pc" parameter of the OSC52 + /// sequence. + fn to_osc52_pc(&self) -> String { + self.0.iter().map(Into::::into).collect() + } +} + +impl fmt::Display for ClipboardSelection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_osc52_pc()) + } +} + +impl FromStr for ClipboardSelection { + type Err = (); + fn from_str(s: &str) -> Result { + Ok(ClipboardSelection( + s.chars().map(From::::from).collect(), + )) + } +} + +/// A command that copies to clipboard +/// +/// This command uses OSC control sequence `Pr = 5 2` (See +/// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) ) +/// to copy data to the terminal host clipboard. +/// +/// This only works if it is enabled on the user's terminal emulator. If a terminal multiplexer +/// is used, the multiplexer must support it, too. +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +/// +/// # Examples +/// +/// ```no_run +/// use crossterm::execute; +/// use crossterm::clipboard::CopyToClipboard; +/// // Copy foo to clipboard +/// execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from("foo")); +/// // Copy bar to primary +/// execute!(std::io::stdout(), CopyToClipboard::to_primary_from("bar")); +/// ``` +/// +/// See also examples/copy-to-clipboard.rs. +/// +/// # Terminal Support +/// +/// The following table shows what destinations are filled by different terminal emulators when +/// asked to copy to different destination sequences. +/// +/// | Terminal (Version) | dest '' | dest 'c' | dest 'p' | dest 'cp' | dest'pc' | +/// | --------------------- | --------- | --------- | -------- | ------------- | ------------- | +/// | xterm (397) *3 | primary | clipboard | primary | clipb., prim. | clipb., prim. | +/// | Alacritty (0.15.1) *3 | clipboard | clipboard | primary | clipb. | prim. | +/// | Wezterm (*1) *3 | clipboard | clipboard | primary | clipb. | clipb. | +/// | Konsole (24.12.3) *3 | clipboard | clipboard | primary | clipb., prim. | clipb., prim. | +/// | Kitty (0.40.0) *3 | clipboard | clipboard | primary | clipb. | clipb. | +/// | foot (1.20.2) *3 | clipboard | clipboard | primary | clipb., prim. | clipb., prim. | +/// | tmux (3.5a) *2 *3 | primary | clipboard | primary | clipb., prim. | clipb., prim. | +/// +/// Asterisks: +/// 1. 20240203-110809-5046fc22 +/// 2. set-clipboard set to [external](https://github.com/tmux/tmux/wiki/Clipboard#how-it-works), +/// i.e. this is OSC52 pass-through. +/// 3. This was tested on wayland with the +/// [primary selection protocol](https://wayland.app/protocols/primary-selection-unstable-v1) +/// enabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyToClipboard { + /// Content to be copied + pub content: T, + /// Sequence of copy destinations + /// + /// Not all sequences are equally supported by terminal emulators. See + /// [`CopyToClipboard` (Terminal Support)](struct.CopyToClipboard.html#terminal-support). + pub destination: ClipboardSelection, +} + +impl> Command for CopyToClipboard { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!( + f, + osc!("52;{destination};{encoded_text}"), + destination = self.destination.to_osc52_pc(), + encoded_text = BASE64_STANDARD.encode(&self.content) + ) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + use std::io; + + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Copying is not implemented for the Windows API.", + )) + } +} + +impl> CopyToClipboard { + /// Construct a [`CopyToClipboard`] that writes content into the + /// "clipboard" (or 'c') clipboard selection. + /// + /// # Example + /// + /// ```no_run + /// use crossterm::{execute, Command}; + /// use crossterm::clipboard::CopyToClipboard; + /// execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from("foo")); + /// ``` + pub fn to_clipboard_from(content: T) -> CopyToClipboard { + CopyToClipboard { + content, + destination: ClipboardSelection(vec![ClipboardType::Clipboard]), + } + } + + /// Construct a [`CopyToClipboard`] that writes content into the "primary" + /// (or 'p') clipboard selection. + /// + /// # Example + /// + /// ```no_run + /// use crossterm::execute; + /// use crossterm::clipboard::CopyToClipboard; + /// execute!(std::io::stdout(), CopyToClipboard::to_primary_from("foo")); + /// ``` + pub fn to_primary_from(content: T) -> CopyToClipboard { + CopyToClipboard { + content, + destination: ClipboardSelection(vec![ClipboardType::Primary]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_string_to_selection() { + assert_eq!( + ClipboardSelection::from_str("p").unwrap(), + ClipboardSelection(vec![ClipboardType::Primary]) + ); + assert_eq!( + ClipboardSelection::from_str("").unwrap(), + ClipboardSelection(vec![]) + ); + assert_eq!( + ClipboardSelection::from_str("cp").unwrap(), + ClipboardSelection(vec![ClipboardType::Clipboard, ClipboardType::Primary]) + ); + } + #[test] + fn test_clipboard_selection_to_osc52_pc() { + assert_eq!(ClipboardSelection(vec![]).to_osc52_pc(), ""); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Clipboard]).to_osc52_pc(), + "c" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Primary]).to_osc52_pc(), + "p" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Primary, ClipboardType::Clipboard]) + .to_osc52_pc(), + "pc" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Clipboard, ClipboardType::Primary]) + .to_osc52_pc(), + "cp" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Other('s')]).to_osc52_pc(), + "s" + ); + } + + #[test] + fn test_clipboard_copy_string_osc52() { + let mut buffer = String::new(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![ClipboardType::Clipboard]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;c;Zm9v\x1b\\"); + + buffer.clear(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![ClipboardType::Primary]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;p;Zm9v\x1b\\"); + + buffer.clear(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![ClipboardType::Primary, ClipboardType::Clipboard]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;pc;Zm9v\x1b\\"); + + buffer.clear(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;;Zm9v\x1b\\"); + } + + #[test] + fn test_clipboard_copy_string_osc52_constructor() { + let mut buffer = String::new(); + super::CopyToClipboard::to_clipboard_from("foo") + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;c;Zm9v\x1b\\"); + + let mut buffer = String::new(); + super::CopyToClipboard::to_primary_from("foo") + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;p;Zm9v\x1b\\"); + } +} diff --git a/patches/crossterm-0.29.0/src/command.rs b/patches/crossterm-0.29.0/src/command.rs new file mode 100644 index 0000000000..0a04fc5fbc --- /dev/null +++ b/patches/crossterm-0.29.0/src/command.rs @@ -0,0 +1,295 @@ +use std::fmt; +use std::io::{self, Write}; + +use crate::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}; + +/// An interface for a command that performs an action on the terminal. +/// +/// Crossterm provides a set of commands, +/// and there is no immediate reason to implement a command yourself. +/// In order to understand how to use and execute commands, +/// it is recommended that you take a look at [Command API](./index.html#command-api) chapter. +pub trait Command { + /// Write an ANSI representation of this command to the given writer. + /// An ANSI code can manipulate the terminal by writing it to the terminal buffer. + /// However, only Windows 10 and UNIX systems support this. + /// + /// This method does not need to be accessed manually, as it is used by the crossterm's [Command API](./index.html#command-api) + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result; + + /// Execute this command. + /// + /// Windows versions lower than windows 10 do not support ANSI escape codes, + /// therefore a direct WinAPI call is made. + /// + /// This method does not need to be accessed manually, as it is used by the crossterm's [Command API](./index.html#command-api) + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()>; + + /// Returns whether the ANSI code representation of this command is supported by windows. + /// + /// A list of supported ANSI escape codes + /// can be found [here](https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences). + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + super::ansi_support::supports_ansi() + } +} + +impl Command for &T { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + (**self).write_ansi(f) + } + + #[inline] + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + T::execute_winapi(self) + } + + #[cfg(windows)] + #[inline] + fn is_ansi_code_supported(&self) -> bool { + T::is_ansi_code_supported(self) + } +} + +/// An interface for types that can queue commands for further execution. +pub trait QueueableCommand { + /// Queues the given command for further execution. + fn queue(&mut self, command: impl Command) -> io::Result<&mut Self>; +} + +/// An interface for types that can directly execute commands. +pub trait ExecutableCommand { + /// Executes the given command directly. + fn execute(&mut self, command: impl Command) -> io::Result<&mut Self>; +} + +impl QueueableCommand for T { + /// Queues the given command for further execution. + /// + /// Queued commands will be executed in the following cases: + /// + /// * When `flush` is called manually on the given type implementing `io::Write`. + /// * The terminal will `flush` automatically if the buffer is full. + /// * Each line is flushed in case of `stdout`, because it is line buffered. + /// + /// # Arguments + /// + /// - [Command](./trait.Command.html) + /// + /// The command that you want to queue for later execution. + /// + /// # Examples + /// + /// ```rust + /// use std::io::{self, Write}; + /// use crossterm::{QueueableCommand, style::Print}; + /// + /// fn main() -> io::Result<()> { + /// let mut stdout = io::stdout(); + /// + /// // `Print` will executed executed when `flush` is called. + /// stdout + /// .queue(Print("foo 1\n".to_string()))? + /// .queue(Print("foo 2".to_string()))?; + /// + /// // some other code (no execution happening here) ... + /// + /// // when calling `flush` on `stdout`, all commands will be written to the stdout and therefore executed. + /// stdout.flush()?; + /// + /// Ok(()) + /// + /// // ==== Output ==== + /// // foo 1 + /// // foo 2 + /// } + /// ``` + /// + /// Have a look over at the [Command API](./index.html#command-api) for more details. + /// + /// # Notes + /// + /// * In the case of UNIX and Windows 10, ANSI codes are written to the given 'writer'. + /// * In case of Windows versions lower than 10, a direct WinAPI call will be made. + /// The reason for this is that Windows versions lower than 10 do not support ANSI codes, + /// and can therefore not be written to the given `writer`. + /// Therefore, there is no difference between [execute](./trait.ExecutableCommand.html) + /// and [queue](./trait.QueueableCommand.html) for those old Windows versions. + fn queue(&mut self, command: impl Command) -> io::Result<&mut Self> { + #[cfg(windows)] + if !command.is_ansi_code_supported() { + // There may be queued commands in this writer, but `execute_winapi` will execute the + // command immediately. To prevent commands being executed out of order we flush the + // writer now. + self.flush()?; + command.execute_winapi()?; + return Ok(self); + } + + write_command_ansi(self, command)?; + Ok(self) + } +} + +impl ExecutableCommand for T { + /// Executes the given command directly. + /// + /// The given command its ANSI escape code will be written and flushed onto `Self`. + /// + /// # Arguments + /// + /// - [Command](./trait.Command.html) + /// + /// The command that you want to execute directly. + /// + /// # Example + /// + /// ```rust + /// use std::io; + /// use crossterm::{ExecutableCommand, style::Print}; + /// + /// fn main() -> io::Result<()> { + /// // will be executed directly + /// io::stdout() + /// .execute(Print("sum:\n".to_string()))? + /// .execute(Print(format!("1 + 1= {} ", 1 + 1)))?; + /// + /// Ok(()) + /// + /// // ==== Output ==== + /// // sum: + /// // 1 + 1 = 2 + /// } + /// ``` + /// + /// Have a look over at the [Command API](./index.html#command-api) for more details. + /// + /// # Notes + /// + /// * In the case of UNIX and Windows 10, ANSI codes are written to the given 'writer'. + /// * In case of Windows versions lower than 10, a direct WinAPI call will be made. + /// The reason for this is that Windows versions lower than 10 do not support ANSI codes, + /// and can therefore not be written to the given `writer`. + /// Therefore, there is no difference between [execute](./trait.ExecutableCommand.html) + /// and [queue](./trait.QueueableCommand.html) for those old Windows versions. + fn execute(&mut self, command: impl Command) -> io::Result<&mut Self> { + self.queue(command)?; + self.flush()?; + Ok(self) + } +} + +/// An interface for types that support synchronized updates. +pub trait SynchronizedUpdate { + /// Performs a set of actions against the given type. + fn sync_update(&mut self, operations: impl FnOnce(&mut Self) -> T) -> io::Result; +} + +impl SynchronizedUpdate for W { + /// Performs a set of actions within a synchronous update. + /// + /// Updates will be suspended in the terminal, the function will be executed against self, + /// updates will be resumed, and a flush will be performed. + /// + /// # Arguments + /// + /// - Function + /// + /// A function that performs the operations that must execute in a synchronized update. + /// + /// # Examples + /// + /// ```rust + /// use std::io; + /// use crossterm::{ExecutableCommand, SynchronizedUpdate, style::Print}; + /// + /// fn main() -> io::Result<()> { + /// let mut stdout = io::stdout(); + /// + /// stdout.sync_update(|stdout| { + /// stdout.execute(Print("foo 1\n".to_string()))?; + /// stdout.execute(Print("foo 2".to_string()))?; + /// // The effects of the print command will not be present in the terminal + /// // buffer, but not visible in the terminal. + /// std::io::Result::Ok(()) + /// })?; + /// + /// // The effects of the commands will be visible. + /// + /// Ok(()) + /// + /// // ==== Output ==== + /// // foo 1 + /// // foo 2 + /// } + /// ``` + /// + /// # Notes + /// + /// This command is performed only using ANSI codes, and will do nothing on terminals that do not support ANSI + /// codes, or this specific extension. + /// + /// When rendering the screen of the terminal, the Emulator usually iterates through each visible grid cell and + /// renders its current state. With applications updating the screen a at higher frequency this can cause tearing. + /// + /// This mode attempts to mitigate that. + /// + /// When the synchronization mode is enabled following render calls will keep rendering the last rendered state. + /// The terminal Emulator keeps processing incoming text and sequences. When the synchronized update mode is disabled + /// again the renderer may fetch the latest screen buffer state again, effectively avoiding the tearing effect + /// by unintentionally rendering in the middle a of an application screen update. + /// + fn sync_update(&mut self, operations: impl FnOnce(&mut Self) -> T) -> io::Result { + self.queue(BeginSynchronizedUpdate)?; + let result = operations(self); + self.execute(EndSynchronizedUpdate)?; + Ok(result) + } +} +/// Writes the ANSI representation of a command to the given writer. +fn write_command_ansi( + io: &mut (impl io::Write + ?Sized), + command: C, +) -> io::Result<()> { + struct Adapter { + inner: T, + res: io::Result<()>, + } + + impl fmt::Write for Adapter { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.inner.write_all(s.as_bytes()).map_err(|e| { + self.res = Err(e); + fmt::Error + }) + } + } + + let mut adapter = Adapter { + inner: io, + res: Ok(()), + }; + + command + .write_ansi(&mut adapter) + .map_err(|fmt::Error| match adapter.res { + Ok(()) => panic!( + "<{}>::write_ansi incorrectly errored", + std::any::type_name::() + ), + Err(e) => e, + }) +} + +/// Executes the ANSI representation of a command, using the given `fmt::Write`. +pub(crate) fn execute_fmt(f: &mut impl fmt::Write, command: impl Command) -> fmt::Result { + #[cfg(windows)] + if !command.is_ansi_code_supported() { + return command.execute_winapi().map_err(|_| fmt::Error); + } + + command.write_ansi(f) +} diff --git a/patches/crossterm-0.29.0/src/cursor.rs b/patches/crossterm-0.29.0/src/cursor.rs new file mode 100644 index 0000000000..24796dc450 --- /dev/null +++ b/patches/crossterm-0.29.0/src/cursor.rs @@ -0,0 +1,504 @@ +//! # Cursor +//! +//! The `cursor` module provides functionality to work with the terminal cursor. +//! +//! This documentation does not contain a lot of examples. The reason is that it's fairly +//! obvious how to use this crate. Although, we do provide +//! [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) repository +//! to demonstrate the capabilities. +//! +//! ## Examples +//! +//! Cursor actions can be performed with commands. +//! Please have a look at [command documentation](../index.html#command-api) for a more detailed documentation. +//! +//! ```no_run +//! use std::io::{self, Write}; +//! +//! use crossterm::{ +//! ExecutableCommand, execute, +//! cursor::{DisableBlinking, EnableBlinking, MoveTo, RestorePosition, SavePosition} +//! }; +//! +//! fn main() -> io::Result<()> { +//! // with macro +//! execute!( +//! io::stdout(), +//! SavePosition, +//! MoveTo(10, 10), +//! EnableBlinking, +//! DisableBlinking, +//! RestorePosition +//! ); +//! +//! // with function +//! io::stdout() +//! .execute(MoveTo(11,11))? +//! .execute(RestorePosition); +//! +//! Ok(()) +//! } +//! ``` +//! +//! For manual execution control check out [crossterm::queue](../macro.queue.html). + +use std::fmt; + +use crate::{csi, impl_display, Command}; + +pub(crate) mod sys; + +#[cfg(feature = "events")] +pub use sys::position; + +/// A command that moves the terminal cursor to the given position (column, row). +/// +/// # Notes +/// * Top left cell is represented as `0,0`. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveTo(pub u16, pub u16); + +impl Command for MoveTo { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{};{}H"), self.1 + 1, self.0 + 1) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_to(self.0, self.1) + } +} + +/// A command that moves the terminal cursor down the given number of lines, +/// and moves it to the first column. +/// +/// # Notes +/// * This command is 1 based, meaning `MoveToNextLine(1)` moves to the next line. +/// * Most terminals default 0 argument to 1. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveToNextLine(pub u16); + +impl Command for MoveToNextLine { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}E"), self.0)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + if self.0 != 0 { + sys::move_to_next_line(self.0)?; + } + Ok(()) + } +} + +/// A command that moves the terminal cursor up the given number of lines, +/// and moves it to the first column. +/// +/// # Notes +/// * This command is 1 based, meaning `MoveToPreviousLine(1)` moves to the previous line. +/// * Most terminals default 0 argument to 1. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveToPreviousLine(pub u16); + +impl Command for MoveToPreviousLine { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}F"), self.0)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + if self.0 != 0 { + sys::move_to_previous_line(self.0)?; + } + Ok(()) + } +} + +/// A command that moves the terminal cursor to the given column on the current row. +/// +/// # Notes +/// * This command is 0 based, meaning 0 is the leftmost column. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveToColumn(pub u16); + +impl Command for MoveToColumn { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}G"), self.0 + 1)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_to_column(self.0) + } +} + +/// A command that moves the terminal cursor to the given row on the current column. +/// +/// # Notes +/// * This command is 0 based, meaning 0 is the topmost row. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveToRow(pub u16); + +impl Command for MoveToRow { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}d"), self.0 + 1)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_to_row(self.0) + } +} + +/// A command that moves the terminal cursor a given number of rows up. +/// +/// # Notes +/// * This command is 1 based, meaning `MoveUp(1)` moves the cursor up one cell. +/// * Most terminals default 0 argument to 1. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveUp(pub u16); + +impl Command for MoveUp { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}A"), self.0)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_up(self.0) + } +} + +/// A command that moves the terminal cursor a given number of columns to the right. +/// +/// # Notes +/// * This command is 1 based, meaning `MoveRight(1)` moves the cursor right one cell. +/// * Most terminals default 0 argument to 1. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveRight(pub u16); + +impl Command for MoveRight { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}C"), self.0)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_right(self.0) + } +} + +/// A command that moves the terminal cursor a given number of rows down. +/// +/// # Notes +/// * This command is 1 based, meaning `MoveDown(1)` moves the cursor down one cell. +/// * Most terminals default 0 argument to 1. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveDown(pub u16); + +impl Command for MoveDown { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}B"), self.0)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_down(self.0) + } +} + +/// A command that moves the terminal cursor a given number of columns to the left. +/// +/// # Notes +/// * This command is 1 based, meaning `MoveLeft(1)` moves the cursor left one cell. +/// * Most terminals default 0 argument to 1. +/// * Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveLeft(pub u16); + +impl Command for MoveLeft { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}D"), self.0)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::move_left(self.0) + } +} + +/// A command that saves the current terminal cursor position. +/// +/// See the [RestorePosition](./struct.RestorePosition.html) command. +/// +/// # Notes +/// +/// - The cursor position is stored globally. +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SavePosition; + +impl Command for SavePosition { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1B7") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::save_position() + } +} + +/// A command that restores the saved terminal cursor position. +/// +/// See the [SavePosition](./struct.SavePosition.html) command. +/// +/// # Notes +/// +/// - The cursor position is stored globally. +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RestorePosition; + +impl Command for RestorePosition { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1B8") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::restore_position() + } +} + +/// A command that hides the terminal cursor. +/// +/// # Notes +/// +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Hide; + +impl Command for Hide { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?25l")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::show_cursor(false) + } +} + +/// A command that shows the terminal cursor. +/// +/// # Notes +/// +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Show; + +impl Command for Show { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?25h")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::show_cursor(true) + } +} + +/// A command that enables blinking of the terminal cursor. +/// +/// # Notes +/// +/// - Some Unix terminals (ex: GNOME and Konsole) as well as Windows versions lower than Windows 10 do not support this functionality. +/// Use `SetCursorStyle` for better cross-compatibility. +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnableBlinking; +impl Command for EnableBlinking { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?12h")) + } + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A command that disables blinking of the terminal cursor. +/// +/// # Notes +/// +/// - Some Unix terminals (ex: GNOME and Konsole) as well as Windows versions lower than Windows 10 do not support this functionality. +/// Use `SetCursorStyle` for better cross-compatibility. +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisableBlinking; +impl Command for DisableBlinking { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?12l")) + } + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A command that sets the style of the cursor. +/// It uses two types of escape codes, one to control blinking, and the other the shape. +/// +/// # Note +/// +/// - Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SetCursorStyle { + /// Default cursor shape configured by the user. + DefaultUserShape, + /// A blinking block cursor shape (■). + BlinkingBlock, + /// A non blinking block cursor shape (inverse of `BlinkingBlock`). + SteadyBlock, + /// A blinking underscore cursor shape(_). + BlinkingUnderScore, + /// A non blinking underscore cursor shape (inverse of `BlinkingUnderScore`). + SteadyUnderScore, + /// A blinking cursor bar shape (|) + BlinkingBar, + /// A steady cursor bar shape (inverse of `BlinkingBar`). + SteadyBar, +} + +impl Command for SetCursorStyle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + match self { + SetCursorStyle::DefaultUserShape => f.write_str("\x1b[0 q"), + SetCursorStyle::BlinkingBlock => f.write_str("\x1b[1 q"), + SetCursorStyle::SteadyBlock => f.write_str("\x1b[2 q"), + SetCursorStyle::BlinkingUnderScore => f.write_str("\x1b[3 q"), + SetCursorStyle::SteadyUnderScore => f.write_str("\x1b[4 q"), + SetCursorStyle::BlinkingBar => f.write_str("\x1b[5 q"), + SetCursorStyle::SteadyBar => f.write_str("\x1b[6 q"), + } + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +impl_display!(for MoveTo); +impl_display!(for MoveToColumn); +impl_display!(for MoveToRow); +impl_display!(for MoveToNextLine); +impl_display!(for MoveToPreviousLine); +impl_display!(for MoveUp); +impl_display!(for MoveDown); +impl_display!(for MoveLeft); +impl_display!(for MoveRight); +impl_display!(for SavePosition); +impl_display!(for RestorePosition); +impl_display!(for Hide); +impl_display!(for Show); +impl_display!(for EnableBlinking); +impl_display!(for DisableBlinking); +impl_display!(for SetCursorStyle); + +#[cfg(test)] +#[cfg(feature = "events")] +mod tests { + use std::io::{self, stdout}; + + use crate::execute; + + use super::{ + sys::position, MoveDown, MoveLeft, MoveRight, MoveTo, MoveUp, RestorePosition, SavePosition, + }; + + // Test is disabled, because it's failing on Travis + #[test] + #[ignore] + fn test_move_to() { + let (saved_x, saved_y) = position().unwrap(); + + execute!(stdout(), MoveTo(saved_x + 1, saved_y + 1)).unwrap(); + assert_eq!(position().unwrap(), (saved_x + 1, saved_y + 1)); + + execute!(stdout(), MoveTo(saved_x, saved_y)).unwrap(); + assert_eq!(position().unwrap(), (saved_x, saved_y)); + } + + // Test is disabled, because it's failing on Travis + #[test] + #[ignore] + fn test_move_right() { + let (saved_x, saved_y) = position().unwrap(); + execute!(io::stdout(), MoveRight(1)).unwrap(); + assert_eq!(position().unwrap(), (saved_x + 1, saved_y)); + } + + // Test is disabled, because it's failing on Travis + #[test] + #[ignore] + fn test_move_left() { + execute!(stdout(), MoveTo(2, 0), MoveLeft(2)).unwrap(); + assert_eq!(position().unwrap(), (0, 0)); + } + + // Test is disabled, because it's failing on Travis + #[test] + #[ignore] + fn test_move_up() { + execute!(stdout(), MoveTo(0, 2), MoveUp(2)).unwrap(); + assert_eq!(position().unwrap(), (0, 0)); + } + + // Test is disabled, because it's failing on Travis + #[test] + #[ignore] + fn test_move_down() { + execute!(stdout(), MoveTo(0, 0), MoveDown(2)).unwrap(); + + assert_eq!(position().unwrap(), (0, 2)); + } + + // Test is disabled, because it's failing on Travis + #[test] + #[ignore] + fn test_save_restore_position() { + let (saved_x, saved_y) = position().unwrap(); + + execute!( + stdout(), + SavePosition, + MoveTo(saved_x + 1, saved_y + 1), + RestorePosition + ) + .unwrap(); + + let (x, y) = position().unwrap(); + + assert_eq!(x, saved_x); + assert_eq!(y, saved_y); + } +} diff --git a/patches/crossterm-0.29.0/src/cursor/sys.rs b/patches/crossterm-0.29.0/src/cursor/sys.rs new file mode 100644 index 0000000000..1623740a74 --- /dev/null +++ b/patches/crossterm-0.29.0/src/cursor/sys.rs @@ -0,0 +1,20 @@ +//! This module provides platform related functions. + +#[cfg(unix)] +#[cfg(feature = "events")] +pub use self::unix::position; +#[cfg(windows)] +#[cfg(feature = "events")] +pub use self::windows::position; +#[cfg(windows)] +pub(crate) use self::windows::{ + move_down, move_left, move_right, move_to, move_to_column, move_to_next_line, + move_to_previous_line, move_to_row, move_up, restore_position, save_position, show_cursor, +}; + +#[cfg(windows)] +pub(crate) mod windows; + +#[cfg(unix)] +#[cfg(feature = "events")] +pub(crate) mod unix; diff --git a/patches/crossterm-0.29.0/src/cursor/sys/unix.rs b/patches/crossterm-0.29.0/src/cursor/sys/unix.rs new file mode 100644 index 0000000000..473421207e --- /dev/null +++ b/patches/crossterm-0.29.0/src/cursor/sys/unix.rs @@ -0,0 +1,56 @@ +use std::{ + io::{self, Error, ErrorKind, Write}, + time::Duration, +}; + +use crate::{ + event::{filter::CursorPositionFilter, poll_internal, read_internal, InternalEvent}, + terminal::{disable_raw_mode, enable_raw_mode, sys::is_raw_mode_enabled}, +}; + +/// Returns the cursor position (column, row). +/// +/// The top left cell is represented as `(0, 0)`. +/// +/// On unix systems, this function will block and possibly time out while +/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called. +pub fn position() -> io::Result<(u16, u16)> { + if is_raw_mode_enabled() { + read_position_raw() + } else { + read_position() + } +} + +fn read_position() -> io::Result<(u16, u16)> { + enable_raw_mode()?; + let pos = read_position_raw(); + disable_raw_mode()?; + pos +} + +fn read_position_raw() -> io::Result<(u16, u16)> { + // Use `ESC [ 6 n` to and retrieve the cursor position. + let mut stdout = io::stdout(); + stdout.write_all(b"\x1B[6n")?; + stdout.flush()?; + + loop { + match poll_internal(Some(Duration::from_millis(2000)), &CursorPositionFilter) { + Ok(true) => { + if let Ok(InternalEvent::CursorPosition(x, y)) = + read_internal(&CursorPositionFilter) + { + return Ok((x, y)); + } + } + Ok(false) => { + return Err(Error::new( + ErrorKind::Other, + "The cursor position could not be read within a normal duration", + )); + } + Err(_) => {} + } + } +} diff --git a/patches/crossterm-0.29.0/src/cursor/sys/windows.rs b/patches/crossterm-0.29.0/src/cursor/sys/windows.rs new file mode 100644 index 0000000000..575346d488 --- /dev/null +++ b/patches/crossterm-0.29.0/src/cursor/sys/windows.rs @@ -0,0 +1,343 @@ +//! WinAPI related logic to cursor manipulation. + +use std::convert::TryFrom; +use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crossterm_winapi::{result, Coord, Handle, HandleType, ScreenBuffer}; +use winapi::{ + shared::minwindef::{FALSE, TRUE}, + um::wincon::{SetConsoleCursorInfo, SetConsoleCursorPosition, CONSOLE_CURSOR_INFO, COORD}, +}; + +/// The position of the cursor, written when you save the cursor's position. +/// +/// This is `u64::MAX` initially. Otherwise, it stores the cursor's x position bit-shifted left 16 +/// times or-ed with the cursor's y position, where both are `i16`s. +static SAVED_CURSOR_POS: AtomicU64 = AtomicU64::new(u64::MAX); + +// The 'y' position of the cursor is not relative to the window but absolute to screen buffer. +// We can calculate the relative cursor position by subtracting the top position of the terminal window from the y position. +// This results in an 1-based coord zo subtract 1 to make cursor position 0-based. +pub fn parse_relative_y(y: i16) -> std::io::Result { + let window = ScreenBuffer::current()?.info()?; + + let window_size = window.terminal_window(); + let screen_size = window.terminal_size(); + + if y <= screen_size.height { + Ok(y) + } else { + Ok(y - window_size.top) + } +} + +/// Returns the cursor position (column, row). +/// +/// The top left cell is represented `0,0`. +pub fn position() -> io::Result<(u16, u16)> { + let cursor = ScreenBufferCursor::output()?; + let mut position = cursor.position()?; + // if position.y != 0 { + position.y = parse_relative_y(position.y)?; + // } + Ok(position.into()) +} + +pub(crate) fn show_cursor(show_cursor: bool) -> std::io::Result<()> { + ScreenBufferCursor::from(Handle::current_out_handle()?).set_visibility(show_cursor) +} + +pub(crate) fn move_to(column: u16, row: u16) -> std::io::Result<()> { + let cursor = ScreenBufferCursor::output()?; + cursor.move_to(column as i16, row as i16)?; + Ok(()) +} + +pub(crate) fn move_up(count: u16) -> std::io::Result<()> { + let (column, row) = position()?; + move_to(column, row - count)?; + Ok(()) +} + +pub(crate) fn move_right(count: u16) -> std::io::Result<()> { + let (column, row) = position()?; + move_to(column + count, row)?; + Ok(()) +} + +pub(crate) fn move_down(count: u16) -> std::io::Result<()> { + let (column, row) = position()?; + move_to(column, row + count)?; + Ok(()) +} + +pub(crate) fn move_left(count: u16) -> std::io::Result<()> { + let (column, row) = position()?; + move_to(column - count, row)?; + Ok(()) +} + +pub(crate) fn move_to_column(new_column: u16) -> std::io::Result<()> { + let (_, row) = position()?; + move_to(new_column, row)?; + Ok(()) +} + +pub(crate) fn move_to_row(new_row: u16) -> std::io::Result<()> { + let (col, _) = position()?; + move_to(col, new_row)?; + Ok(()) +} + +pub(crate) fn move_to_next_line(count: u16) -> std::io::Result<()> { + let (_, row) = position()?; + move_to(0, row + count)?; + Ok(()) +} + +pub(crate) fn move_to_previous_line(count: u16) -> std::io::Result<()> { + let (_, row) = position()?; + move_to(0, row - count)?; + Ok(()) +} + +pub(crate) fn save_position() -> std::io::Result<()> { + ScreenBufferCursor::output()?.save_position()?; + Ok(()) +} + +pub(crate) fn restore_position() -> std::io::Result<()> { + ScreenBufferCursor::output()?.restore_position()?; + Ok(()) +} + +/// WinAPI wrapper over terminal cursor behaviour. +struct ScreenBufferCursor { + screen_buffer: ScreenBuffer, +} + +impl ScreenBufferCursor { + fn output() -> std::io::Result { + Ok(ScreenBufferCursor { + screen_buffer: ScreenBuffer::from(Handle::new(HandleType::CurrentOutputHandle)?), + }) + } + + fn position(&self) -> std::io::Result { + Ok(self.screen_buffer.info()?.cursor_pos()) + } + + fn move_to(&self, x: i16, y: i16) -> std::io::Result<()> { + if x < 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Argument Out of Range Exception when setting cursor position to X: {x}"), + )); + } + + if y < 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Argument Out of Range Exception when setting cursor position to Y: {y}"), + )); + } + + let position = COORD { X: x, Y: y }; + + unsafe { + if result(SetConsoleCursorPosition( + **self.screen_buffer.handle(), + position, + )) + .is_err() + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } + + fn set_visibility(&self, visible: bool) -> std::io::Result<()> { + let cursor_info = CONSOLE_CURSOR_INFO { + dwSize: 100, + bVisible: if visible { TRUE } else { FALSE }, + }; + + unsafe { + if result(SetConsoleCursorInfo( + **self.screen_buffer.handle(), + &cursor_info, + )) + .is_err() + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } + + fn restore_position(&self) -> std::io::Result<()> { + if let Ok(val) = u32::try_from(SAVED_CURSOR_POS.load(Ordering::Relaxed)) { + let x = (val >> 16) as i16; + let y = val as i16; + self.move_to(x, y)?; + } + + Ok(()) + } + + fn save_position(&self) -> std::io::Result<()> { + let position = self.position()?; + + let upper = u32::from(position.x as u16) << 16; + let lower = u32::from(position.y as u16); + let bits = u64::from(upper | lower); + SAVED_CURSOR_POS.store(bits, Ordering::Relaxed); + + Ok(()) + } +} + +impl From for ScreenBufferCursor { + fn from(handle: Handle) -> Self { + ScreenBufferCursor { + screen_buffer: ScreenBuffer::from(handle), + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + move_down, move_left, move_right, move_to, move_to_column, move_to_next_line, + move_to_previous_line, move_to_row, move_up, position, restore_position, save_position, + }; + use crate::terminal::sys::temp_screen_buffer; + use serial_test::serial; + + #[test] + #[serial] + fn test_move_to_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + let (saved_x, saved_y) = position().unwrap(); + + move_to(saved_x + 1, saved_y + 1).unwrap(); + assert_eq!(position().unwrap(), (saved_x + 1, saved_y + 1)); + + move_to(saved_x, saved_y).unwrap(); + assert_eq!(position().unwrap(), (saved_x, saved_y)); + } + + #[test] + #[serial] + fn test_move_right_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + let (saved_x, saved_y) = position().unwrap(); + move_right(1).unwrap(); + assert_eq!(position().unwrap(), (saved_x + 1, saved_y)); + } + + #[test] + #[serial] + fn test_move_left_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(2, 0).unwrap(); + + move_left(2).unwrap(); + + assert_eq!(position().unwrap(), (0, 0)); + } + + #[test] + #[serial] + fn test_move_up_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(0, 2).unwrap(); + + move_up(2).unwrap(); + + assert_eq!(position().unwrap(), (0, 0)); + } + + #[test] + #[serial] + fn test_move_to_next_line_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(0, 2).unwrap(); + + move_to_next_line(2).unwrap(); + + assert_eq!(position().unwrap(), (0, 4)); + } + + #[test] + #[serial] + fn test_move_to_previous_line_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(0, 2).unwrap(); + + move_to_previous_line(2).unwrap(); + + assert_eq!(position().unwrap(), (0, 0)); + } + + #[test] + #[serial] + fn test_move_to_column_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(0, 2).unwrap(); + + move_to_column(12).unwrap(); + + assert_eq!(position().unwrap(), (12, 2)); + } + + #[test] + #[serial] + fn test_move_to_row_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(0, 2).unwrap(); + + move_to_row(5).unwrap(); + + assert_eq!(position().unwrap(), (0, 5)); + } + + #[test] + #[serial] + fn test_move_down_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + move_to(0, 0).unwrap(); + + move_down(2).unwrap(); + + assert_eq!(position().unwrap(), (0, 2)); + } + + #[test] + #[serial] + fn test_save_restore_position_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + let (saved_x, saved_y) = position().unwrap(); + + save_position().unwrap(); + move_to(saved_x + 1, saved_y + 1).unwrap(); + restore_position().unwrap(); + + let (x, y) = position().unwrap(); + + assert_eq!(x, saved_x); + assert_eq!(y, saved_y); + } +} diff --git a/patches/crossterm-0.29.0/src/event.rs b/patches/crossterm-0.29.0/src/event.rs new file mode 100644 index 0000000000..1e838fc653 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event.rs @@ -0,0 +1,1747 @@ +//! # Event +//! +//! The `event` module provides the functionality to read keyboard, mouse and terminal resize events. +//! +//! * The [`read`](fn.read.html) function returns an [`Event`](enum.Event.html) immediately +//! (if available) or blocks until an [`Event`](enum.Event.html) is available. +//! +//! * The [`poll`](fn.poll.html) function allows you to check if there is or isn't an [`Event`](enum.Event.html) available +//! within the given period of time. In other words - if subsequent call to the [`read`](fn.read.html) +//! function will block or not. +//! +//! It's **not allowed** to call these functions from different threads or combine them with the +//! [`EventStream`](struct.EventStream.html). You're allowed to either: +//! +//! * use the [`read`](fn.read.html) & [`poll`](fn.poll.html) functions on any, but same, thread +//! * or the [`EventStream`](struct.EventStream.html). +//! +//! **Make sure to enable [raw mode](../terminal/index.html#raw-mode) in order for keyboard events to work properly** +//! +//! ## Mouse and Focus Events +//! +//! Mouse and focus events are not enabled by default. You have to enable them with the +//! [`EnableMouseCapture`](struct.EnableMouseCapture.html) / [`EnableFocusChange`](struct.EnableFocusChange.html) command. +//! See [Command API](../index.html#command-api) for more information. +//! +//! ## Examples +//! +//! Blocking read: +//! +//! ```no_run +//! #![cfg(feature = "bracketed-paste")] +//! use crossterm::{ +//! event::{ +//! read, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, +//! EnableFocusChange, EnableMouseCapture, Event, +//! }, +//! execute, +//! }; +//! +//! fn print_events() -> std::io::Result<()> { +//! execute!( +//! std::io::stdout(), +//! EnableBracketedPaste, +//! EnableFocusChange, +//! EnableMouseCapture +//! )?; +//! loop { +//! // `read()` blocks until an `Event` is available +//! match read()? { +//! Event::FocusGained => println!("FocusGained"), +//! Event::FocusLost => println!("FocusLost"), +//! Event::Key(event) => println!("{:?}", event), +//! Event::Mouse(event) => println!("{:?}", event), +//! #[cfg(feature = "bracketed-paste")] +//! Event::Paste(data) => println!("{:?}", data), +//! Event::Resize(width, height) => println!("New size {}x{}", width, height), +//! } +//! } +//! execute!( +//! std::io::stdout(), +//! DisableBracketedPaste, +//! DisableFocusChange, +//! DisableMouseCapture +//! )?; +//! Ok(()) +//! } +//! ``` +//! +//! Non-blocking read: +//! +//! ```no_run +//! #![cfg(feature = "bracketed-paste")] +//! use std::{time::Duration, io}; +//! +//! use crossterm::{ +//! event::{ +//! poll, read, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, +//! EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, Event, +//! }, +//! execute, +//! }; +//! +//! fn print_events() -> io::Result<()> { +//! execute!( +//! std::io::stdout(), +//! EnableBracketedPaste, +//! EnableFocusChange, +//! EnableMouseCapture +//! )?; +//! loop { +//! // `poll()` waits for an `Event` for a given time period +//! if poll(Duration::from_millis(500))? { +//! // It's guaranteed that the `read()` won't block when the `poll()` +//! // function returns `true` +//! match read()? { +//! Event::FocusGained => println!("FocusGained"), +//! Event::FocusLost => println!("FocusLost"), +//! Event::Key(event) => println!("{:?}", event), +//! Event::Mouse(event) => println!("{:?}", event), +//! #[cfg(feature = "bracketed-paste")] +//! Event::Paste(data) => println!("Pasted {:?}", data), +//! Event::Resize(width, height) => println!("New size {}x{}", width, height), +//! } +//! } else { +//! // Timeout expired and no `Event` is available +//! } +//! } +//! execute!( +//! std::io::stdout(), +//! DisableBracketedPaste, +//! DisableFocusChange, +//! DisableMouseCapture +//! )?; +//! Ok(()) +//! } +//! ``` +//! +//! Check the [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) folder for more of +//! them (`event-*`). + +pub(crate) mod filter; +pub(crate) mod read; +pub(crate) mod source; +#[cfg(feature = "event-stream")] +pub(crate) mod stream; +pub(crate) mod sys; +pub(crate) mod timeout; + +#[cfg(feature = "derive-more")] +use derive_more::derive::IsVariant; +#[cfg(feature = "event-stream")] +pub use stream::EventStream; + +use crate::event::{ + filter::{EventFilter, Filter}, + read::InternalEventReader, + timeout::PollTimeout, +}; +use crate::{csi, Command}; +use parking_lot::{MappedMutexGuard, Mutex, MutexGuard}; +use std::fmt::{self, Display}; +use std::time::Duration; + +use bitflags::bitflags; +use std::hash::{Hash, Hasher}; + +/// Static instance of `InternalEventReader`. +/// This needs to be static because there can be one event reader. +static INTERNAL_EVENT_READER: Mutex> = parking_lot::const_mutex(None); + +pub(crate) fn lock_internal_event_reader() -> MappedMutexGuard<'static, InternalEventReader> { + MutexGuard::map(INTERNAL_EVENT_READER.lock(), |reader| { + reader.get_or_insert_with(InternalEventReader::default) + }) +} +fn try_lock_internal_event_reader_for( + duration: Duration, +) -> Option> { + Some(MutexGuard::map( + INTERNAL_EVENT_READER.try_lock_for(duration)?, + |reader| reader.get_or_insert_with(InternalEventReader::default), + )) +} + +/// Checks if there is an [`Event`](enum.Event.html) available. +/// +/// Returns `Ok(true)` if an [`Event`](enum.Event.html) is available otherwise it returns `Ok(false)`. +/// +/// `Ok(true)` guarantees that subsequent call to the [`read`](fn.read.html) function +/// won't block. +/// +/// # Arguments +/// +/// * `timeout` - maximum waiting time for event availability +/// +/// # Examples +/// +/// Return immediately: +/// +/// ```no_run +/// use std::{time::Duration, io}; +/// use crossterm::{event::poll}; +/// +/// fn is_event_available() -> io::Result { +/// // Zero duration says that the `poll` function must return immediately +/// // with an `Event` availability information +/// poll(Duration::from_secs(0)) +/// } +/// ``` +/// +/// Wait up to 100ms: +/// +/// ```no_run +/// use std::{time::Duration, io}; +/// +/// use crossterm::event::poll; +/// +/// fn is_event_available() -> io::Result { +/// // Wait for an `Event` availability for 100ms. It returns immediately +/// // if an `Event` is/becomes available. +/// poll(Duration::from_millis(100)) +/// } +/// ``` +pub fn poll(timeout: Duration) -> std::io::Result { + poll_internal(Some(timeout), &EventFilter) +} + +/// Reads a single [`Event`](enum.Event.html). +/// +/// This function blocks until an [`Event`](enum.Event.html) is available. Combine it with the +/// [`poll`](fn.poll.html) function to get non-blocking reads. +/// +/// # Examples +/// +/// Blocking read: +/// +/// ```no_run +/// use crossterm::event::read; +/// use std::io; +/// +/// fn print_events() -> io::Result { +/// loop { +/// // Blocks until an `Event` is available +/// println!("{:?}", read()?); +/// } +/// } +/// ``` +/// +/// Non-blocking read: +/// +/// ```no_run +/// use std::time::Duration; +/// use std::io; +/// +/// use crossterm::event::{read, poll}; +/// +/// fn print_events() -> io::Result { +/// loop { +/// if poll(Duration::from_millis(100))? { +/// // It's guaranteed that `read` won't block, because `poll` returned +/// // `Ok(true)`. +/// println!("{:?}", read()?); +/// } else { +/// // Timeout expired, no `Event` is available +/// } +/// } +/// } +/// ``` +pub fn read() -> std::io::Result { + match read_internal(&EventFilter)? { + InternalEvent::Event(event) => Ok(event), + #[cfg(unix)] + _ => unreachable!(), + } +} + +/// Polls to check if there are any `InternalEvent`s that can be read within the given duration. +pub(crate) fn poll_internal(timeout: Option, filter: &F) -> std::io::Result +where + F: Filter, +{ + let (mut reader, timeout) = if let Some(timeout) = timeout { + let poll_timeout = PollTimeout::new(Some(timeout)); + if let Some(reader) = try_lock_internal_event_reader_for(timeout) { + (reader, poll_timeout.leftover()) + } else { + return Ok(false); + } + } else { + (lock_internal_event_reader(), None) + }; + reader.poll(timeout, filter) +} + +/// Reads a single `InternalEvent`. +pub(crate) fn read_internal(filter: &F) -> std::io::Result +where + F: Filter, +{ + let mut reader = lock_internal_event_reader(); + reader.read(filter) +} + +bitflags! { + /// Represents special flags that tell compatible terminals to add extra information to keyboard events. + /// + /// See for more information. + /// + /// Alternate keys and Unicode codepoints are not yet supported by crossterm. + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(transparent))] + #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] + pub struct KeyboardEnhancementFlags: u8 { + /// Represent Escape and modified keys using CSI-u sequences, so they can be unambiguously + /// read. + const DISAMBIGUATE_ESCAPE_CODES = 0b0000_0001; + /// Add extra events with [`KeyEvent.kind`] set to [`KeyEventKind::Repeat`] or + /// [`KeyEventKind::Release`] when keys are autorepeated or released. + const REPORT_EVENT_TYPES = 0b0000_0010; + /// Send [alternate keycodes](https://sw.kovidgoyal.net/kitty/keyboard-protocol/#key-codes) + /// in addition to the base keycode. The alternate keycode overrides the base keycode in + /// resulting `KeyEvent`s. + const REPORT_ALTERNATE_KEYS = 0b0000_0100; + /// Represent all keyboard events as CSI-u sequences. This is required to get repeat/release + /// events for plain-text keys. + const REPORT_ALL_KEYS_AS_ESCAPE_CODES = 0b0000_1000; + // Send the Unicode codepoint as well as the keycode. + // + // *Note*: this is not yet supported by crossterm. + // const REPORT_ASSOCIATED_TEXT = 0b0001_0000; + } +} + +/// A command that enables mouse event capturing. +/// +/// Mouse events can be captured with [read](./fn.read.html)/[poll](./fn.poll.html). +#[cfg(feature = "events")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnableMouseCapture; + +#[cfg(feature = "events")] +impl Command for EnableMouseCapture { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(concat!( + // Normal tracking: Send mouse X & Y on button press and release + csi!("?1000h"), + // Button-event tracking: Report button motion events (dragging) + csi!("?1002h"), + // Any-event tracking: Report all motion events + csi!("?1003h"), + // RXVT mouse mode: Allows mouse coordinates of >223 + csi!("?1015h"), + // SGR mouse mode: Allows mouse coordinates of >223, preferred over RXVT mode + csi!("?1006h"), + )) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::windows::enable_mouse_capture() + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +/// A command that disables mouse event capturing. +/// +/// Mouse events can be captured with [read](./fn.read.html)/[poll](./fn.poll.html). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisableMouseCapture; + +impl Command for DisableMouseCapture { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(concat!( + // The inverse commands of EnableMouseCapture, in reverse order. + csi!("?1006l"), + csi!("?1015l"), + csi!("?1003l"), + csi!("?1002l"), + csi!("?1000l"), + )) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::windows::disable_mouse_capture() + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +/// A command that enables focus event emission. +/// +/// It should be paired with [`DisableFocusChange`] at the end of execution. +/// +/// Focus events can be captured with [read](./fn.read.html)/[poll](./fn.poll.html). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnableFocusChange; + +impl Command for EnableFocusChange { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?1004h")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + // Focus events are always enabled on Windows + Ok(()) + } +} + +/// A command that disables focus event emission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisableFocusChange; + +impl Command for DisableFocusChange { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?1004l")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + // Focus events can't be disabled on Windows + Ok(()) + } +} + +/// A command that enables [bracketed paste mode](https://en.wikipedia.org/wiki/Bracketed-paste). +/// +/// It should be paired with [`DisableBracketedPaste`] at the end of execution. +/// +/// This is not supported in older Windows terminals without +/// [virtual terminal sequences](https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences). +#[cfg(feature = "bracketed-paste")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnableBracketedPaste; + +#[cfg(feature = "bracketed-paste")] +impl Command for EnableBracketedPaste { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?2004h")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Bracketed paste not implemented in the legacy Windows API.", + )) + } +} + +/// A command that disables bracketed paste mode. +#[cfg(feature = "bracketed-paste")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisableBracketedPaste; + +#[cfg(feature = "bracketed-paste")] +impl Command for DisableBracketedPaste { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?2004l")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A command that enables the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/), which adds extra information to keyboard events and removes ambiguity for modifier keys. +/// +/// It should be paired with [`PopKeyboardEnhancementFlags`] at the end of execution. +/// +/// Example usage: +/// ```no_run +/// use std::io::{Write, stdout}; +/// use crossterm::execute; +/// use crossterm::event::{ +/// KeyboardEnhancementFlags, +/// PushKeyboardEnhancementFlags, +/// PopKeyboardEnhancementFlags +/// }; +/// +/// let mut stdout = stdout(); +/// +/// execute!( +/// stdout, +/// PushKeyboardEnhancementFlags( +/// KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES +/// ) +/// ); +/// +/// // ... +/// +/// execute!(stdout, PopKeyboardEnhancementFlags); +/// ``` +/// +/// Note that, currently, only the following support this protocol: +/// * [kitty terminal](https://sw.kovidgoyal.net/kitty/) +/// * [foot terminal](https://codeberg.org/dnkl/foot/issues/319) +/// * [WezTerm terminal](https://wezfurlong.org/wezterm/config/lua/config/enable_kitty_keyboard.html) +/// * [alacritty terminal](https://github.com/alacritty/alacritty/issues/6378) +/// * [notcurses library](https://github.com/dankamongmen/notcurses/issues/2131) +/// * [neovim text editor](https://github.com/neovim/neovim/pull/18181) +/// * [kakoune text editor](https://github.com/mawww/kakoune/issues/4103) +/// * [dte text editor](https://gitlab.com/craigbarnes/dte/-/issues/138) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PushKeyboardEnhancementFlags(pub KeyboardEnhancementFlags); + +impl Command for PushKeyboardEnhancementFlags { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "{}{}u", csi!(">"), self.0.bits()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + use std::io; + + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Keyboard progressive enhancement not implemented for the legacy Windows API.", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +/// A command that disables extra kinds of keyboard events. +/// +/// Specifically, it pops one level of keyboard enhancement flags. +/// +/// See [`PushKeyboardEnhancementFlags`] and for more information. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PopKeyboardEnhancementFlags; + +impl Command for PopKeyboardEnhancementFlags { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("<1u")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + use std::io; + + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Keyboard progressive enhancement not implemented for the legacy Windows API.", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +/// Represents an event. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "derive-more", derive(IsVariant))] +#[cfg_attr(not(feature = "bracketed-paste"), derive(Copy))] +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Hash)] +pub enum Event { + /// The terminal gained focus + FocusGained, + /// The terminal lost focus + FocusLost, + /// A single key event with additional pressed modifiers. + Key(KeyEvent), + /// A single mouse event with additional pressed modifiers. + Mouse(MouseEvent), + /// A string that was pasted into the terminal. Only emitted if bracketed paste has been + /// enabled. + #[cfg(feature = "bracketed-paste")] + Paste(String), + /// An resize event with new dimensions after resize (columns, rows). + /// **Note** that resize events can occur in batches. + Resize(u16, u16), +} + +impl Event { + /// Returns `true` if the event is a key press event. + /// + /// This is useful for waiting for any key press event, regardless of the key that was pressed. + /// + /// Returns `false` for key release and repeat events (as well as for non-key events). + /// + /// # Examples + /// + /// The following code runs a loop that processes events until a key press event is encountered: + /// + /// ```no_run + /// use crossterm::event; + /// + /// while !event::read()?.is_key_press() { + /// // ... + /// } + /// # Ok::<(), std::io::Error>(()) + /// ``` + #[inline] + pub fn is_key_press(&self) -> bool { + matches!( + self, + Event::Key(KeyEvent { + kind: KeyEventKind::Press, + .. + }) + ) + } + + /// Returns `true` if the event is a key release event. + #[inline] + pub fn is_key_release(&self) -> bool { + matches!( + self, + Event::Key(KeyEvent { + kind: KeyEventKind::Release, + .. + }) + ) + } + + /// Returns `true` if the event is a key repeat event. + #[inline] + pub fn is_key_repeat(&self) -> bool { + matches!( + self, + Event::Key(KeyEvent { + kind: KeyEventKind::Repeat, + .. + }) + ) + } + + /// Returns the key event if the event is a key event, otherwise `None`. + /// + /// This is a convenience method that makes apps that only care about key events easier to write. + /// + /// # Examples + /// + /// The following code runs a loop that only processes key events: + /// + /// ```no_run + /// use crossterm::event; + /// + /// while let Some(key_event) = event::read()?.as_key_event() { + /// // ... + /// } + /// # std::io::Result::Ok(()) + /// ``` + #[inline] + pub fn as_key_event(&self) -> Option { + match self { + Event::Key(event) => Some(*event), + _ => None, + } + } + + /// Returns an Option containing the KeyEvent if the event is a key press event. + /// + /// This is a convenience method that makes apps that only care about key press events, and not + /// key release or repeat events (or non-key events), easier to write. + /// + /// Returns `None` for key release and repeat events (as well as for non-key events). + /// + /// # Examples + /// + /// The following code runs a loop that only processes key press events: + /// + /// ```no_run + /// use crossterm::event; + /// + /// while let Ok(event) = event::read() { + /// if let Some(key) = event.as_key_press_event() { + /// // ... + /// } + /// } + #[inline] + pub fn as_key_press_event(&self) -> Option { + match self { + Event::Key(event) if self.is_key_press() => Some(*event), + _ => None, + } + } + + /// Returns an Option containing the `KeyEvent` if the event is a key release event. + #[inline] + pub fn as_key_release_event(&self) -> Option { + match self { + Event::Key(event) if self.is_key_release() => Some(*event), + _ => None, + } + } + + /// Returns an Option containing the `KeyEvent` if the event is a key repeat event. + #[inline] + pub fn as_key_repeat_event(&self) -> Option { + match self { + Event::Key(event) if self.is_key_repeat() => Some(*event), + _ => None, + } + } + + /// Returns the mouse event if the event is a mouse event, otherwise `None`. + /// + /// This is a convenience method that makes code which only cares about mouse events easier to + /// write. + /// + /// # Examples + /// + /// ```no_run + /// use crossterm::event; + /// + /// while let Some(mouse_event) = event::read()?.as_mouse_event() { + /// // ... + /// } + /// # std::io::Result::Ok(()) + /// ``` + #[inline] + pub fn as_mouse_event(&self) -> Option { + match self { + Event::Mouse(event) => Some(*event), + _ => None, + } + } + + /// Returns the pasted string if the event is a paste event, otherwise `None`. + /// + /// This is a convenience method that makes code which only cares about paste events easier to write. + /// + /// # Examples + /// + /// ```no_run + /// use crossterm::event; + /// + /// while let Some(paste) = event::read()?.as_paste_event() { + /// // ... + /// } + /// # std::io::Result::Ok(()) + /// ``` + #[cfg(feature = "bracketed-paste")] + #[inline] + pub fn as_paste_event(&self) -> Option<&str> { + match self { + Event::Paste(paste) => Some(paste), + _ => None, + } + } + + /// Returns the size as a tuple if the event is a resize event, otherwise `None`. + /// + /// This is a convenience method that makes code which only cares about resize events easier to write. + /// + /// # Examples + /// + /// ```no_run + /// use crossterm::event; + /// + /// while let Some((columns, rows)) = event::read()?.as_resize_event() { + /// // ... + /// } + /// # std::io::Result::Ok(()) + /// ``` + #[inline] + pub fn as_resize_event(&self) -> Option<(u16, u16)> { + match self { + Event::Resize(columns, rows) => Some((*columns, *rows)), + _ => None, + } + } +} + +/// Represents a mouse event. +/// +/// # Platform-specific Notes +/// +/// ## Mouse Buttons +/// +/// Some platforms/terminals do not report mouse button for the +/// `MouseEventKind::Up` and `MouseEventKind::Drag` events. `MouseButton::Left` +/// is returned if we don't know which button was used. +/// +/// ## Key Modifiers +/// +/// Some platforms/terminals does not report all key modifiers +/// combinations for all mouse event types. For example - macOS reports +/// `Ctrl` + left mouse button click as a right mouse button click. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +pub struct MouseEvent { + /// The kind of mouse event that was caused. + pub kind: MouseEventKind, + /// The column that the event occurred on. + pub column: u16, + /// The row that the event occurred on. + pub row: u16, + /// The key modifiers active when the event occurred. + pub modifiers: KeyModifiers, +} + +/// A mouse event kind. +/// +/// # Platform-specific Notes +/// +/// ## Mouse Buttons +/// +/// Some platforms/terminals do not report mouse button for the +/// `MouseEventKind::Up` and `MouseEventKind::Drag` events. `MouseButton::Left` +/// is returned if we don't know which button was used. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "derive-more", derive(IsVariant))] +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +pub enum MouseEventKind { + /// Pressed mouse button. Contains the button that was pressed. + Down(MouseButton), + /// Released mouse button. Contains the button that was released. + Up(MouseButton), + /// Moved the mouse cursor while pressing the contained mouse button. + Drag(MouseButton), + /// Moved the mouse cursor while not pressing a mouse button. + Moved, + /// Scrolled mouse wheel downwards (towards the user). + ScrollDown, + /// Scrolled mouse wheel upwards (away from the user). + ScrollUp, + /// Scrolled mouse wheel left (mostly on a laptop touchpad). + ScrollLeft, + /// Scrolled mouse wheel right (mostly on a laptop touchpad). + ScrollRight, +} + +/// Represents a mouse button. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "derive-more", derive(IsVariant))] +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +pub enum MouseButton { + /// Left mouse button. + Left, + /// Right mouse button. + Right, + /// Middle mouse button. + Middle, +} + +bitflags! { + /// Represents key modifiers (shift, control, alt, etc.). + /// + /// **Note:** `SUPER`, `HYPER`, and `META` can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(transparent))] + #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] + pub struct KeyModifiers: u8 { + const SHIFT = 0b0000_0001; + const CONTROL = 0b0000_0010; + const ALT = 0b0000_0100; + const SUPER = 0b0000_1000; + const HYPER = 0b0001_0000; + const META = 0b0010_0000; + const NONE = 0b0000_0000; + } +} + +impl Display for KeyModifiers { + /// Formats the key modifiers using the given formatter. + /// + /// The key modifiers are joined by a `+` character. + /// + /// # Platform-specific Notes + /// + /// On macOS, the control, alt, and super keys is displayed as "Control", "Option", and + /// "Command" respectively. See + /// . + /// + /// On Windows, the super key is displayed as "Windows" and the control key is displayed as + /// "Ctrl". See + /// . + /// + /// On other platforms, the super key is referred to as "Super" and the control key is + /// displayed as "Ctrl". + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for modifier in self.iter() { + if !first { + f.write_str("+")?; + } + + first = false; + match modifier { + KeyModifiers::SHIFT => f.write_str("Shift")?, + #[cfg(unix)] + KeyModifiers::CONTROL => f.write_str("Control")?, + #[cfg(windows)] + KeyModifiers::CONTROL => f.write_str("Ctrl")?, + #[cfg(target_os = "macos")] + KeyModifiers::ALT => f.write_str("Option")?, + #[cfg(not(target_os = "macos"))] + KeyModifiers::ALT => f.write_str("Alt")?, + #[cfg(target_os = "macos")] + KeyModifiers::SUPER => f.write_str("Command")?, + #[cfg(target_os = "windows")] + KeyModifiers::SUPER => f.write_str("Windows")?, + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + KeyModifiers::SUPER => f.write_str("Super")?, + KeyModifiers::HYPER => f.write_str("Hyper")?, + KeyModifiers::META => f.write_str("Meta")?, + _ => unreachable!(), + } + } + Ok(()) + } +} + +/// Represents a keyboard event kind. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "derive-more", derive(IsVariant))] +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +pub enum KeyEventKind { + Press, + Repeat, + Release, +} + +bitflags! { + /// Represents extra state about the key event. + /// + /// **Note:** This state can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(transparent))] + pub struct KeyEventState: u8 { + /// The key event origins from the keypad. + const KEYPAD = 0b0000_0001; + /// Caps Lock was enabled for this key event. + /// + /// **Note:** this is set for the initial press of Caps Lock itself. + const CAPS_LOCK = 0b0000_0010; + /// Num Lock was enabled for this key event. + /// + /// **Note:** this is set for the initial press of Num Lock itself. + const NUM_LOCK = 0b0000_0100; + const NONE = 0b0000_0000; + } +} + +/// Represents a key event. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, PartialOrd, Clone, Copy)] +pub struct KeyEvent { + /// The key itself. + pub code: KeyCode, + /// Additional key modifiers. + pub modifiers: KeyModifiers, + /// Kind of event. + /// + /// Only set if: + /// - Unix: [`KeyboardEnhancementFlags::REPORT_EVENT_TYPES`] has been enabled with [`PushKeyboardEnhancementFlags`]. + /// - Windows: always + pub kind: KeyEventKind, + /// Keyboard state. + /// + /// Only set if [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + pub state: KeyEventState, +} + +impl KeyEvent { + pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + } + } + + pub const fn new_with_kind( + code: KeyCode, + modifiers: KeyModifiers, + kind: KeyEventKind, + ) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind, + state: KeyEventState::empty(), + } + } + + pub const fn new_with_kind_and_state( + code: KeyCode, + modifiers: KeyModifiers, + kind: KeyEventKind, + state: KeyEventState, + ) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind, + state, + } + } + + // modifies the KeyEvent, + // so that KeyModifiers::SHIFT is present iff + // an uppercase char is present. + fn normalize_case(mut self) -> KeyEvent { + let c = match self.code { + KeyCode::Char(c) => c, + _ => return self, + }; + + if c.is_ascii_uppercase() { + self.modifiers.insert(KeyModifiers::SHIFT); + } else if self.modifiers.contains(KeyModifiers::SHIFT) { + self.code = KeyCode::Char(c.to_ascii_uppercase()) + } + self + } + + /// Returns whether the key event is a press event. + pub fn is_press(&self) -> bool { + matches!(self.kind, KeyEventKind::Press) + } + + /// Returns whether the key event is a release event. + pub fn is_release(&self) -> bool { + matches!(self.kind, KeyEventKind::Release) + } + + /// Returns whether the key event is a repeat event. + pub fn is_repeat(&self) -> bool { + matches!(self.kind, KeyEventKind::Repeat) + } +} + +impl From for KeyEvent { + fn from(code: KeyCode) -> Self { + KeyEvent { + code, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + } + } +} + +impl PartialEq for KeyEvent { + fn eq(&self, other: &KeyEvent) -> bool { + let KeyEvent { + code: lhs_code, + modifiers: lhs_modifiers, + kind: lhs_kind, + state: lhs_state, + } = self.normalize_case(); + let KeyEvent { + code: rhs_code, + modifiers: rhs_modifiers, + kind: rhs_kind, + state: rhs_state, + } = other.normalize_case(); + (lhs_code == rhs_code) + && (lhs_modifiers == rhs_modifiers) + && (lhs_kind == rhs_kind) + && (lhs_state == rhs_state) + } +} + +impl Eq for KeyEvent {} + +impl Hash for KeyEvent { + fn hash(&self, hash_state: &mut H) { + let KeyEvent { + code, + modifiers, + kind, + state, + } = self.normalize_case(); + code.hash(hash_state); + modifiers.hash(hash_state); + kind.hash(hash_state); + state.hash(hash_state); + } +} + +/// Represents a media key (as part of [`KeyCode::Media`]). +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum MediaKeyCode { + /// Play media key. + Play, + /// Pause media key. + Pause, + /// Play/Pause media key. + PlayPause, + /// Reverse media key. + Reverse, + /// Stop media key. + Stop, + /// Fast-forward media key. + FastForward, + /// Rewind media key. + Rewind, + /// Next-track media key. + TrackNext, + /// Previous-track media key. + TrackPrevious, + /// Record media key. + Record, + /// Lower-volume media key. + LowerVolume, + /// Raise-volume media key. + RaiseVolume, + /// Mute media key. + MuteVolume, +} + +impl Display for MediaKeyCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MediaKeyCode::Play => write!(f, "Play"), + MediaKeyCode::Pause => write!(f, "Pause"), + MediaKeyCode::PlayPause => write!(f, "Play/Pause"), + MediaKeyCode::Reverse => write!(f, "Reverse"), + MediaKeyCode::Stop => write!(f, "Stop"), + MediaKeyCode::FastForward => write!(f, "Fast Forward"), + MediaKeyCode::Rewind => write!(f, "Rewind"), + MediaKeyCode::TrackNext => write!(f, "Next Track"), + MediaKeyCode::TrackPrevious => write!(f, "Previous Track"), + MediaKeyCode::Record => write!(f, "Record"), + MediaKeyCode::LowerVolume => write!(f, "Lower Volume"), + MediaKeyCode::RaiseVolume => write!(f, "Raise Volume"), + MediaKeyCode::MuteVolume => write!(f, "Mute Volume"), + } + } +} + +/// Represents a modifier key (as part of [`KeyCode::Modifier`]). +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ModifierKeyCode { + /// Left Shift key. + LeftShift, + /// Left Control key. (Control on macOS, Ctrl on other platforms) + LeftControl, + /// Left Alt key. (Option on macOS, Alt on other platforms) + LeftAlt, + /// Left Super key. (Command on macOS, Windows on Windows, Super on other platforms) + LeftSuper, + /// Left Hyper key. + LeftHyper, + /// Left Meta key. + LeftMeta, + /// Right Shift key. + RightShift, + /// Right Control key. (Control on macOS, Ctrl on other platforms) + RightControl, + /// Right Alt key. (Option on macOS, Alt on other platforms) + RightAlt, + /// Right Super key. (Command on macOS, Windows on Windows, Super on other platforms) + RightSuper, + /// Right Hyper key. + RightHyper, + /// Right Meta key. + RightMeta, + /// Iso Level3 Shift key. + IsoLevel3Shift, + /// Iso Level5 Shift key. + IsoLevel5Shift, +} + +impl Display for ModifierKeyCode { + /// Formats the modifier key using the given formatter. + /// + /// # Platform-specific Notes + /// + /// On macOS, the control, alt, and super keys are displayed as "Control", "Option", and + /// "Command" respectively. See + /// . + /// + /// On Windows, the super key is displayed as "Windows" and the control key is displayed as + /// "Ctrl". See + /// . + /// + /// On other platforms, the super key is referred to as "Super". + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ModifierKeyCode::LeftShift => write!(f, "Left Shift"), + ModifierKeyCode::LeftHyper => write!(f, "Left Hyper"), + ModifierKeyCode::LeftMeta => write!(f, "Left Meta"), + ModifierKeyCode::RightShift => write!(f, "Right Shift"), + ModifierKeyCode::RightHyper => write!(f, "Right Hyper"), + ModifierKeyCode::RightMeta => write!(f, "Right Meta"), + ModifierKeyCode::IsoLevel3Shift => write!(f, "Iso Level 3 Shift"), + ModifierKeyCode::IsoLevel5Shift => write!(f, "Iso Level 5 Shift"), + + #[cfg(target_os = "macos")] + ModifierKeyCode::LeftControl => write!(f, "Left Control"), + #[cfg(not(target_os = "macos"))] + ModifierKeyCode::LeftControl => write!(f, "Left Ctrl"), + + #[cfg(target_os = "macos")] + ModifierKeyCode::LeftAlt => write!(f, "Left Option"), + #[cfg(not(target_os = "macos"))] + ModifierKeyCode::LeftAlt => write!(f, "Left Alt"), + + #[cfg(target_os = "macos")] + ModifierKeyCode::LeftSuper => write!(f, "Left Command"), + #[cfg(target_os = "windows")] + ModifierKeyCode::LeftSuper => write!(f, "Left Windows"), + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + ModifierKeyCode::LeftSuper => write!(f, "Left Super"), + + #[cfg(target_os = "macos")] + ModifierKeyCode::RightControl => write!(f, "Right Control"), + #[cfg(not(target_os = "macos"))] + ModifierKeyCode::RightControl => write!(f, "Right Ctrl"), + + #[cfg(target_os = "macos")] + ModifierKeyCode::RightAlt => write!(f, "Right Option"), + #[cfg(not(target_os = "macos"))] + ModifierKeyCode::RightAlt => write!(f, "Right Alt"), + + #[cfg(target_os = "macos")] + ModifierKeyCode::RightSuper => write!(f, "Right Command"), + #[cfg(target_os = "windows")] + ModifierKeyCode::RightSuper => write!(f, "Right Windows"), + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + ModifierKeyCode::RightSuper => write!(f, "Right Super"), + } + } +} + +/// Represents a key. +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +#[cfg_attr(feature = "derive-more", derive(IsVariant))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum KeyCode { + /// Backspace key (Delete on macOS, Backspace on other platforms). + Backspace, + /// Enter key. + Enter, + /// Left arrow key. + Left, + /// Right arrow key. + Right, + /// Up arrow key. + Up, + /// Down arrow key. + Down, + /// Home key. + Home, + /// End key. + End, + /// Page up key. + PageUp, + /// Page down key. + PageDown, + /// Tab key. + Tab, + /// Shift + Tab key. + BackTab, + /// Delete key. (Fn+Delete on macOS, Delete on other platforms) + Delete, + /// Insert key. + Insert, + /// F key. + /// + /// `KeyCode::F(1)` represents F1 key, etc. + #[cfg_attr(feature = "derive-more", is_variant(ignore))] + F(u8), + /// A character. + /// + /// `KeyCode::Char('c')` represents `c` character, etc. + #[cfg_attr(feature = "derive-more", is_variant(ignore))] + Char(char), + /// Null. + Null, + /// Escape key. + Esc, + /// Caps Lock key. + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + CapsLock, + /// Scroll Lock key. + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + ScrollLock, + /// Num Lock key. + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + NumLock, + /// Print Screen key. + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + PrintScreen, + /// Pause key. + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + Pause, + /// Menu key. + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + Menu, + /// The "Begin" key (often mapped to the 5 key when Num Lock is turned on). + /// + /// **Note:** this key can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + KeypadBegin, + /// A media key. + /// + /// **Note:** these keys can only be read if + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with + /// [`PushKeyboardEnhancementFlags`]. + #[cfg_attr(feature = "derive-more", is_variant(ignore))] + Media(MediaKeyCode), + /// A modifier key. + /// + /// **Note:** these keys can only be read if **both** + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] and + /// [`KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES`] have been enabled with + /// [`PushKeyboardEnhancementFlags`]. + #[cfg_attr(feature = "derive-more", is_variant(ignore))] + Modifier(ModifierKeyCode), +} + +impl KeyCode { + /// Returns `true` if the key code is the given function key. + /// + /// # Examples + /// + /// ``` + /// # use crossterm::event::KeyCode; + /// assert!(KeyCode::F(1).is_function_key(1)); + /// assert!(!KeyCode::F(1).is_function_key(2)); + /// ``` + pub fn is_function_key(&self, n: u8) -> bool { + matches!(self, KeyCode::F(m) if *m == n) + } + + /// Returns `true` if the key code is the given character. + /// + /// # Examples + /// + /// ``` + /// # use crossterm::event::KeyCode; + /// assert!(KeyCode::Char('a').is_char('a')); + /// assert!(!KeyCode::Char('a').is_char('b')); + /// assert!(!KeyCode::F(1).is_char('a')); + /// ``` + pub fn is_char(&self, c: char) -> bool { + matches!(self, KeyCode::Char(m) if *m == c) + } + + /// Returns the character if the key code is a character key. + /// + /// Returns `None` if the key code is not a character key. + /// + /// # Examples + /// + /// ``` + /// # use crossterm::event::KeyCode; + /// assert_eq!(KeyCode::Char('a').as_char(), Some('a')); + /// assert_eq!(KeyCode::F(1).as_char(), None); + /// ``` + pub fn as_char(&self) -> Option { + match self { + KeyCode::Char(c) => Some(*c), + _ => None, + } + } + + /// Returns `true` if the key code is the given media key. + /// + /// **Note:** this method requires + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] to be enabled with + /// [`PushKeyboardEnhancementFlags`]. + /// + /// # Examples + /// + /// ``` + /// # use crossterm::event::{KeyCode, MediaKeyCode}; + /// assert!(KeyCode::Media(MediaKeyCode::Play).is_media_key(MediaKeyCode::Play)); + /// assert!(!KeyCode::Media(MediaKeyCode::Play).is_media_key(MediaKeyCode::Pause)); + /// ``` + pub fn is_media_key(&self, media: MediaKeyCode) -> bool { + matches!(self, KeyCode::Media(m) if *m == media) + } + + /// Returns `true` if the key code is the given modifier key. + /// + /// **Note:** this method requires both + /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] and + /// [`KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES`] to be enabled with + /// [`PushKeyboardEnhancementFlags`]. + /// + /// # Examples + /// + /// ``` + /// # use crossterm::event::{KeyCode, ModifierKeyCode}; + /// assert!(KeyCode::Modifier(ModifierKeyCode::LeftShift).is_modifier(ModifierKeyCode::LeftShift)); + /// assert!(!KeyCode::Modifier(ModifierKeyCode::LeftShift).is_modifier(ModifierKeyCode::RightShift)); + /// ``` + pub fn is_modifier(&self, modifier: ModifierKeyCode) -> bool { + matches!(self, KeyCode::Modifier(m) if *m == modifier) + } +} + +impl Display for KeyCode { + /// Formats the `KeyCode` using the given formatter. + /// + /// # Platform-specific Notes + /// + /// On macOS, the Backspace key is displayed as "Delete", the Delete key is displayed as "Fwd + /// Del", and the Enter key is displayed as "Return". See + /// . + /// + /// On other platforms, the Backspace key is displayed as "Backspace", the Delete key is + /// displayed as "Del", and the Enter key is displayed as "Enter". + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // On macOS, the Backspace key is called "Delete" and the Delete key is called "Fwd Del". + #[cfg(target_os = "macos")] + KeyCode::Backspace => write!(f, "Delete"), + #[cfg(target_os = "macos")] + KeyCode::Delete => write!(f, "Fwd Del"), + + #[cfg(not(target_os = "macos"))] + KeyCode::Backspace => write!(f, "Backspace"), + #[cfg(not(target_os = "macos"))] + KeyCode::Delete => write!(f, "Del"), + + #[cfg(target_os = "macos")] + KeyCode::Enter => write!(f, "Return"), + #[cfg(not(target_os = "macos"))] + KeyCode::Enter => write!(f, "Enter"), + KeyCode::Left => write!(f, "Left"), + KeyCode::Right => write!(f, "Right"), + KeyCode::Up => write!(f, "Up"), + KeyCode::Down => write!(f, "Down"), + KeyCode::Home => write!(f, "Home"), + KeyCode::End => write!(f, "End"), + KeyCode::PageUp => write!(f, "Page Up"), + KeyCode::PageDown => write!(f, "Page Down"), + KeyCode::Tab => write!(f, "Tab"), + KeyCode::BackTab => write!(f, "Back Tab"), + KeyCode::Insert => write!(f, "Insert"), + KeyCode::F(n) => write!(f, "F{}", n), + KeyCode::Char(c) => match c { + // special case for non-visible characters + ' ' => write!(f, "Space"), + c => write!(f, "{}", c), + }, + KeyCode::Null => write!(f, "Null"), + KeyCode::Esc => write!(f, "Esc"), + KeyCode::CapsLock => write!(f, "Caps Lock"), + KeyCode::ScrollLock => write!(f, "Scroll Lock"), + KeyCode::NumLock => write!(f, "Num Lock"), + KeyCode::PrintScreen => write!(f, "Print Screen"), + KeyCode::Pause => write!(f, "Pause"), + KeyCode::Menu => write!(f, "Menu"), + KeyCode::KeypadBegin => write!(f, "Begin"), + KeyCode::Media(media) => write!(f, "{}", media), + KeyCode::Modifier(modifier) => write!(f, "{}", modifier), + } + } +} + +/// An internal event. +/// +/// Encapsulates publicly available `Event` with additional internal +/// events that shouldn't be publicly available to the crate users. +#[derive(Debug, PartialOrd, PartialEq, Hash, Clone, Eq)] +pub(crate) enum InternalEvent { + /// An event. + Event(Event), + /// A cursor position (`col`, `row`). + #[cfg(unix)] + CursorPosition(u16, u16), + /// The progressive keyboard enhancement flags enabled by the terminal. + #[cfg(unix)] + KeyboardEnhancementFlags(KeyboardEnhancementFlags), + /// Attributes and architectural class of the terminal. + #[cfg(unix)] + PrimaryDeviceAttributes, +} + +#[cfg(test)] +mod tests { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + use super::*; + use KeyCode::*; + use MediaKeyCode::*; + use ModifierKeyCode::*; + + #[test] + fn test_equality() { + let lowercase_d_with_shift = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::SHIFT); + let uppercase_d_with_shift = KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT); + let uppercase_d = KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE); + assert_eq!(lowercase_d_with_shift, uppercase_d_with_shift); + assert_eq!(uppercase_d, uppercase_d_with_shift); + } + + #[test] + fn test_hash() { + let lowercase_d_with_shift_hash = { + let mut hasher = DefaultHasher::new(); + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::SHIFT).hash(&mut hasher); + hasher.finish() + }; + let uppercase_d_with_shift_hash = { + let mut hasher = DefaultHasher::new(); + KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT).hash(&mut hasher); + hasher.finish() + }; + let uppercase_d_hash = { + let mut hasher = DefaultHasher::new(); + KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE).hash(&mut hasher); + hasher.finish() + }; + assert_eq!(lowercase_d_with_shift_hash, uppercase_d_with_shift_hash); + assert_eq!(uppercase_d_hash, uppercase_d_with_shift_hash); + } + + #[test] + fn keycode_display() { + #[cfg(target_os = "macos")] + { + assert_eq!(format!("{}", Backspace), "Delete"); + assert_eq!(format!("{}", Delete), "Fwd Del"); + assert_eq!(format!("{}", Enter), "Return"); + } + #[cfg(not(target_os = "macos"))] + { + assert_eq!(format!("{}", Backspace), "Backspace"); + assert_eq!(format!("{}", Delete), "Del"); + assert_eq!(format!("{}", Enter), "Enter"); + } + assert_eq!(format!("{}", Left), "Left"); + assert_eq!(format!("{}", Right), "Right"); + assert_eq!(format!("{}", Up), "Up"); + assert_eq!(format!("{}", Down), "Down"); + assert_eq!(format!("{}", Home), "Home"); + assert_eq!(format!("{}", End), "End"); + assert_eq!(format!("{}", PageUp), "Page Up"); + assert_eq!(format!("{}", PageDown), "Page Down"); + assert_eq!(format!("{}", Tab), "Tab"); + assert_eq!(format!("{}", BackTab), "Back Tab"); + assert_eq!(format!("{}", Insert), "Insert"); + assert_eq!(format!("{}", F(1)), "F1"); + assert_eq!(format!("{}", Char('a')), "a"); + assert_eq!(format!("{}", Null), "Null"); + assert_eq!(format!("{}", Esc), "Esc"); + assert_eq!(format!("{}", CapsLock), "Caps Lock"); + assert_eq!(format!("{}", ScrollLock), "Scroll Lock"); + assert_eq!(format!("{}", NumLock), "Num Lock"); + assert_eq!(format!("{}", PrintScreen), "Print Screen"); + assert_eq!(format!("{}", KeyCode::Pause), "Pause"); + assert_eq!(format!("{}", Menu), "Menu"); + assert_eq!(format!("{}", KeypadBegin), "Begin"); + } + + #[test] + fn media_keycode_display() { + assert_eq!(format!("{}", Media(Play)), "Play"); + assert_eq!(format!("{}", Media(MediaKeyCode::Pause)), "Pause"); + assert_eq!(format!("{}", Media(PlayPause)), "Play/Pause"); + assert_eq!(format!("{}", Media(Reverse)), "Reverse"); + assert_eq!(format!("{}", Media(Stop)), "Stop"); + assert_eq!(format!("{}", Media(FastForward)), "Fast Forward"); + assert_eq!(format!("{}", Media(Rewind)), "Rewind"); + assert_eq!(format!("{}", Media(TrackNext)), "Next Track"); + assert_eq!(format!("{}", Media(TrackPrevious)), "Previous Track"); + assert_eq!(format!("{}", Media(Record)), "Record"); + assert_eq!(format!("{}", Media(LowerVolume)), "Lower Volume"); + assert_eq!(format!("{}", Media(RaiseVolume)), "Raise Volume"); + assert_eq!(format!("{}", Media(MuteVolume)), "Mute Volume"); + } + + #[test] + fn modifier_keycode_display() { + assert_eq!(format!("{}", Modifier(LeftShift)), "Left Shift"); + assert_eq!(format!("{}", Modifier(LeftHyper)), "Left Hyper"); + assert_eq!(format!("{}", Modifier(LeftMeta)), "Left Meta"); + assert_eq!(format!("{}", Modifier(RightShift)), "Right Shift"); + assert_eq!(format!("{}", Modifier(RightHyper)), "Right Hyper"); + assert_eq!(format!("{}", Modifier(RightMeta)), "Right Meta"); + assert_eq!(format!("{}", Modifier(IsoLevel3Shift)), "Iso Level 3 Shift"); + assert_eq!(format!("{}", Modifier(IsoLevel5Shift)), "Iso Level 5 Shift"); + } + + #[cfg(target_os = "macos")] + #[test] + fn modifier_keycode_display_macos() { + assert_eq!(format!("{}", Modifier(LeftControl)), "Left Control"); + assert_eq!(format!("{}", Modifier(LeftAlt)), "Left Option"); + assert_eq!(format!("{}", Modifier(LeftSuper)), "Left Command"); + assert_eq!(format!("{}", Modifier(RightControl)), "Right Control"); + assert_eq!(format!("{}", Modifier(RightAlt)), "Right Option"); + assert_eq!(format!("{}", Modifier(RightSuper)), "Right Command"); + } + + #[cfg(target_os = "windows")] + #[test] + fn modifier_keycode_display_windows() { + assert_eq!(format!("{}", Modifier(LeftControl)), "Left Ctrl"); + assert_eq!(format!("{}", Modifier(LeftAlt)), "Left Alt"); + assert_eq!(format!("{}", Modifier(LeftSuper)), "Left Windows"); + assert_eq!(format!("{}", Modifier(RightControl)), "Right Ctrl"); + assert_eq!(format!("{}", Modifier(RightAlt)), "Right Alt"); + assert_eq!(format!("{}", Modifier(RightSuper)), "Right Windows"); + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[test] + fn modifier_keycode_display_other() { + assert_eq!(format!("{}", Modifier(LeftControl)), "Left Ctrl"); + assert_eq!(format!("{}", Modifier(LeftAlt)), "Left Alt"); + assert_eq!(format!("{}", Modifier(LeftSuper)), "Left Super"); + assert_eq!(format!("{}", Modifier(RightControl)), "Right Ctrl"); + assert_eq!(format!("{}", Modifier(RightAlt)), "Right Alt"); + assert_eq!(format!("{}", Modifier(RightSuper)), "Right Super"); + } + + #[test] + fn key_modifiers_display() { + let modifiers = KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT; + + #[cfg(target_os = "macos")] + assert_eq!(modifiers.to_string(), "Shift+Control+Option"); + + #[cfg(target_os = "windows")] + assert_eq!(modifiers.to_string(), "Shift+Ctrl+Alt"); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + assert_eq!(modifiers.to_string(), "Shift+Control+Alt"); + } + + const ESC_PRESSED: KeyEvent = + KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Press); + const ESC_RELEASED: KeyEvent = + KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Release); + const ESC_REPEAT: KeyEvent = + KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Repeat); + const MOUSE_CLICK: MouseEvent = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 1, + modifiers: KeyModifiers::empty(), + }; + + #[cfg(feature = "derive-more")] + #[test] + fn event_is() { + let event = Event::FocusGained; + assert!(event.is_focus_gained()); + assert!(event.is_focus_gained()); + assert!(!event.is_key()); + + let event = Event::FocusLost; + assert!(event.is_focus_lost()); + assert!(!event.is_focus_gained()); + assert!(!event.is_key()); + + let event = Event::Resize(1, 1); + assert!(event.is_resize()); + assert!(!event.is_key()); + + let event = Event::Key(ESC_PRESSED); + assert!(event.is_key()); + assert!(event.is_key_press()); + assert!(!event.is_key_release()); + assert!(!event.is_key_repeat()); + assert!(!event.is_focus_gained()); + + let event = Event::Key(ESC_RELEASED); + assert!(event.is_key()); + assert!(!event.is_key_press()); + assert!(event.is_key_release()); + assert!(!event.is_key_repeat()); + assert!(!event.is_focus_gained()); + + let event = Event::Key(ESC_REPEAT); + assert!(event.is_key()); + assert!(!event.is_key_press()); + assert!(!event.is_key_release()); + assert!(event.is_key_repeat()); + assert!(!event.is_focus_gained()); + + let event = Event::Mouse(MOUSE_CLICK); + assert!(event.is_mouse()); + assert!(!event.is_key()); + + #[cfg(feature = "bracketed-paste")] + { + let event = Event::Paste("".to_string()); + assert!(event.is_paste()); + assert!(!event.is_key()); + } + } + + #[test] + fn event_as() { + let event = Event::FocusGained; + assert_eq!(event.as_key_event(), None); + + let event = Event::Key(ESC_PRESSED); + assert_eq!(event.as_key_event(), Some(ESC_PRESSED)); + assert_eq!(event.as_key_press_event(), Some(ESC_PRESSED)); + assert_eq!(event.as_key_release_event(), None); + assert_eq!(event.as_key_repeat_event(), None); + assert_eq!(event.as_resize_event(), None); + + let event = Event::Key(ESC_RELEASED); + assert_eq!(event.as_key_event(), Some(ESC_RELEASED)); + assert_eq!(event.as_key_release_event(), Some(ESC_RELEASED)); + assert_eq!(event.as_key_press_event(), None); + assert_eq!(event.as_key_repeat_event(), None); + assert_eq!(event.as_resize_event(), None); + + let event = Event::Key(ESC_REPEAT); + assert_eq!(event.as_key_event(), Some(ESC_REPEAT)); + assert_eq!(event.as_key_repeat_event(), Some(ESC_REPEAT)); + assert_eq!(event.as_key_press_event(), None); + assert_eq!(event.as_key_release_event(), None); + assert_eq!(event.as_resize_event(), None); + + let event = Event::Resize(1, 1); + assert_eq!(event.as_resize_event(), Some((1, 1))); + assert_eq!(event.as_key_event(), None); + + let event = Event::Mouse(MOUSE_CLICK); + assert_eq!(event.as_mouse_event(), Some(MOUSE_CLICK)); + assert_eq!(event.as_key_event(), None); + + #[cfg(feature = "bracketed-paste")] + { + let event = Event::Paste("".to_string()); + assert_eq!(event.as_paste_event(), Some("")); + assert_eq!(event.as_key_event(), None); + } + } +} diff --git a/patches/crossterm-0.29.0/src/event/filter.rs b/patches/crossterm-0.29.0/src/event/filter.rs new file mode 100644 index 0000000000..cd7abce2b9 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/filter.rs @@ -0,0 +1,115 @@ +use crate::event::InternalEvent; + +/// Interface for filtering an `InternalEvent`. +pub(crate) trait Filter: Send + Sync + 'static { + /// Returns whether the given event fulfills the filter. + fn eval(&self, event: &InternalEvent) -> bool; +} + +#[cfg(unix)] +#[derive(Debug, Clone)] +pub(crate) struct CursorPositionFilter; + +#[cfg(unix)] +impl Filter for CursorPositionFilter { + fn eval(&self, event: &InternalEvent) -> bool { + matches!(*event, InternalEvent::CursorPosition(_, _)) + } +} + +#[cfg(unix)] +#[derive(Debug, Clone)] +pub(crate) struct KeyboardEnhancementFlagsFilter; + +#[cfg(unix)] +impl Filter for KeyboardEnhancementFlagsFilter { + fn eval(&self, event: &InternalEvent) -> bool { + // This filter checks for either a KeyboardEnhancementFlags response or + // a PrimaryDeviceAttributes response. If we receive the PrimaryDeviceAttributes + // response but not KeyboardEnhancementFlags, the terminal does not support + // progressive keyboard enhancement. + matches!( + *event, + InternalEvent::KeyboardEnhancementFlags(_) | InternalEvent::PrimaryDeviceAttributes + ) + } +} + +#[cfg(unix)] +#[derive(Debug, Clone)] +pub(crate) struct PrimaryDeviceAttributesFilter; + +#[cfg(unix)] +impl Filter for PrimaryDeviceAttributesFilter { + fn eval(&self, event: &InternalEvent) -> bool { + matches!(*event, InternalEvent::PrimaryDeviceAttributes) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct EventFilter; + +impl Filter for EventFilter { + #[cfg(unix)] + fn eval(&self, event: &InternalEvent) -> bool { + matches!(*event, InternalEvent::Event(_)) + } + + #[cfg(windows)] + fn eval(&self, _: &InternalEvent) -> bool { + true + } +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::{ + super::Event, CursorPositionFilter, EventFilter, Filter, InternalEvent, + KeyboardEnhancementFlagsFilter, PrimaryDeviceAttributesFilter, + }; + + #[derive(Debug, Clone)] + pub(crate) struct InternalEventFilter; + + impl Filter for InternalEventFilter { + fn eval(&self, _: &InternalEvent) -> bool { + true + } + } + + #[test] + fn test_cursor_position_filter_filters_cursor_position() { + assert!(!CursorPositionFilter.eval(&InternalEvent::Event(Event::Resize(10, 10)))); + assert!(CursorPositionFilter.eval(&InternalEvent::CursorPosition(0, 0))); + } + + #[test] + fn test_keyboard_enhancement_status_filter_filters_keyboard_enhancement_status() { + assert!(!KeyboardEnhancementFlagsFilter.eval(&InternalEvent::Event(Event::Resize(10, 10)))); + assert!( + KeyboardEnhancementFlagsFilter.eval(&InternalEvent::KeyboardEnhancementFlags( + crate::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + )) + ); + assert!(KeyboardEnhancementFlagsFilter.eval(&InternalEvent::PrimaryDeviceAttributes)); + } + + #[test] + fn test_primary_device_attributes_filter_filters_primary_device_attributes() { + assert!(!PrimaryDeviceAttributesFilter.eval(&InternalEvent::Event(Event::Resize(10, 10)))); + assert!(PrimaryDeviceAttributesFilter.eval(&InternalEvent::PrimaryDeviceAttributes)); + } + + #[test] + fn test_event_filter_filters_events() { + assert!(EventFilter.eval(&InternalEvent::Event(Event::Resize(10, 10)))); + assert!(!EventFilter.eval(&InternalEvent::CursorPosition(0, 0))); + } + + #[test] + fn test_event_filter_filters_internal_events() { + assert!(InternalEventFilter.eval(&InternalEvent::Event(Event::Resize(10, 10)))); + assert!(InternalEventFilter.eval(&InternalEvent::CursorPosition(0, 0))); + } +} diff --git a/patches/crossterm-0.29.0/src/event/read.rs b/patches/crossterm-0.29.0/src/event/read.rs new file mode 100644 index 0000000000..22f989b4dd --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/read.rs @@ -0,0 +1,436 @@ +use std::{collections::vec_deque::VecDeque, io, time::Duration}; + +#[cfg(unix)] +use crate::event::source::unix::UnixInternalEventSource; +#[cfg(windows)] +use crate::event::source::windows::WindowsEventSource; +#[cfg(feature = "event-stream")] +use crate::event::sys::Waker; +use crate::event::{filter::Filter, source::EventSource, timeout::PollTimeout, InternalEvent}; + +/// Can be used to read `InternalEvent`s. +pub(crate) struct InternalEventReader { + events: VecDeque, + source: Option>, + skipped_events: Vec, +} + +impl Default for InternalEventReader { + fn default() -> Self { + #[cfg(windows)] + let source = WindowsEventSource::new(); + #[cfg(unix)] + let source = UnixInternalEventSource::new(); + + let source = source.ok().map(|x| Box::new(x) as Box); + + InternalEventReader { + source, + events: VecDeque::with_capacity(32), + skipped_events: Vec::with_capacity(32), + } + } +} + +impl InternalEventReader { + /// Returns a `Waker` allowing to wake/force the `poll` method to return `Ok(false)`. + #[cfg(feature = "event-stream")] + pub(crate) fn waker(&self) -> Waker { + self.source.as_ref().expect("reader source not set").waker() + } + + pub(crate) fn poll(&mut self, timeout: Option, filter: &F) -> io::Result + where + F: Filter, + { + for event in &self.events { + if filter.eval(event) { + return Ok(true); + } + } + + let event_source = match self.source.as_mut() { + Some(source) => source, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to initialize input reader", + )) + } + }; + + let poll_timeout = PollTimeout::new(timeout); + + loop { + let maybe_event = match event_source.try_read(poll_timeout.leftover()) { + Ok(None) => None, + Ok(Some(event)) => { + if filter.eval(&event) { + Some(event) + } else { + self.skipped_events.push(event); + None + } + } + Err(e) => { + if e.kind() == io::ErrorKind::Interrupted { + return Ok(false); + } + + return Err(e); + } + }; + + if poll_timeout.elapsed() || maybe_event.is_some() { + self.events.extend(self.skipped_events.drain(..)); + + if let Some(event) = maybe_event { + self.events.push_front(event); + return Ok(true); + } + + return Ok(false); + } + } + } + + pub(crate) fn read(&mut self, filter: &F) -> io::Result + where + F: Filter, + { + let mut skipped_events = VecDeque::new(); + + loop { + while let Some(event) = self.events.pop_front() { + if filter.eval(&event) { + while let Some(event) = skipped_events.pop_front() { + self.events.push_back(event); + } + + return Ok(event); + } else { + // We can not directly write events back to `self.events`. + // If we did, we would put our self's into an endless loop + // that would enqueue -> dequeue -> enqueue etc. + // This happens because `poll` in this function will always return true if there are events in it's. + // And because we just put the non-fulfilling event there this is going to be the case. + // Instead we can store them into the temporary buffer, + // and then when the filter is fulfilled write all events back in order. + skipped_events.push_back(event); + } + } + + let _ = self.poll(None, filter)?; + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + use std::{collections::VecDeque, time::Duration}; + + #[cfg(unix)] + use super::super::filter::CursorPositionFilter; + use super::{super::Event, EventSource, Filter, InternalEvent, InternalEventReader}; + + #[derive(Debug, Clone)] + pub(crate) struct InternalEventFilter; + + impl Filter for InternalEventFilter { + fn eval(&self, _: &InternalEvent) -> bool { + true + } + } + + #[test] + fn test_poll_fails_without_event_source() { + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: None, + skipped_events: Vec::with_capacity(32), + }; + + assert!(reader.poll(None, &InternalEventFilter).is_err()); + assert!(reader + .poll(Some(Duration::from_secs(0)), &InternalEventFilter) + .is_err()); + assert!(reader + .poll(Some(Duration::from_secs(10)), &InternalEventFilter) + .is_err()); + } + + #[test] + fn test_poll_returns_true_for_matching_event_in_queue_at_front() { + let mut reader = InternalEventReader { + events: vec![InternalEvent::Event(Event::Resize(10, 10))].into(), + source: None, + skipped_events: Vec::with_capacity(32), + }; + + assert!(reader.poll(None, &InternalEventFilter).unwrap()); + } + + #[test] + #[cfg(unix)] + fn test_poll_returns_true_for_matching_event_in_queue_at_back() { + let mut reader = InternalEventReader { + events: vec![ + InternalEvent::Event(Event::Resize(10, 10)), + InternalEvent::CursorPosition(10, 20), + ] + .into(), + source: None, + skipped_events: Vec::with_capacity(32), + }; + + assert!(reader.poll(None, &CursorPositionFilter).unwrap()); + } + + #[test] + fn test_read_returns_matching_event_in_queue_at_front() { + const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + + let mut reader = InternalEventReader { + events: vec![EVENT].into(), + source: None, + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + } + + #[test] + #[cfg(unix)] + fn test_read_returns_matching_event_in_queue_at_back() { + const CURSOR_EVENT: InternalEvent = InternalEvent::CursorPosition(10, 20); + + let mut reader = InternalEventReader { + events: vec![InternalEvent::Event(Event::Resize(10, 10)), CURSOR_EVENT].into(), + source: None, + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&CursorPositionFilter).unwrap(), CURSOR_EVENT); + } + + #[test] + #[cfg(unix)] + fn test_read_does_not_consume_skipped_event() { + const SKIPPED_EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + const CURSOR_EVENT: InternalEvent = InternalEvent::CursorPosition(10, 20); + + let mut reader = InternalEventReader { + events: vec![SKIPPED_EVENT, CURSOR_EVENT].into(), + source: None, + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&CursorPositionFilter).unwrap(), CURSOR_EVENT); + assert_eq!(reader.read(&InternalEventFilter).unwrap(), SKIPPED_EVENT); + } + + #[test] + fn test_poll_timeouts_if_source_has_no_events() { + let source = FakeSource::default(); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert!(!reader + .poll(Some(Duration::from_secs(0)), &InternalEventFilter) + .unwrap()); + } + + #[test] + fn test_poll_returns_true_if_source_has_at_least_one_event() { + let source = FakeSource::with_events(&[InternalEvent::Event(Event::Resize(10, 10))]); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert!(reader.poll(None, &InternalEventFilter).unwrap()); + assert!(reader + .poll(Some(Duration::from_secs(0)), &InternalEventFilter) + .unwrap()); + } + + #[test] + fn test_reads_returns_event_if_source_has_at_least_one_event() { + const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + + let source = FakeSource::with_events(&[EVENT]); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + } + + #[test] + fn test_read_returns_events_if_source_has_events() { + const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + + let source = FakeSource::with_events(&[EVENT, EVENT, EVENT]); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + } + + #[test] + fn test_poll_returns_false_after_all_source_events_are_consumed() { + const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + + let source = FakeSource::with_events(&[EVENT, EVENT, EVENT]); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert!(!reader + .poll(Some(Duration::from_secs(0)), &InternalEventFilter) + .unwrap()); + } + + #[test] + fn test_poll_propagates_error() { + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(FakeSource::new(&[]))), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!( + reader + .poll(Some(Duration::from_secs(0)), &InternalEventFilter) + .err() + .map(|e| format!("{:?}", &e.kind())), + Some(format!("{:?}", io::ErrorKind::Other)) + ); + } + + #[test] + fn test_read_propagates_error() { + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(FakeSource::new(&[]))), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!( + reader + .read(&InternalEventFilter) + .err() + .map(|e| format!("{:?}", &e.kind())), + Some(format!("{:?}", io::ErrorKind::Other)) + ); + } + + #[test] + fn test_poll_continues_after_error() { + const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + + let source = FakeSource::new(&[EVENT, EVENT]); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert!(reader.read(&InternalEventFilter).is_err()); + assert!(reader + .poll(Some(Duration::from_secs(0)), &InternalEventFilter) + .unwrap()); + } + + #[test] + fn test_read_continues_after_error() { + const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10)); + + let source = FakeSource::new(&[EVENT, EVENT]); + + let mut reader = InternalEventReader { + events: VecDeque::new(), + source: Some(Box::new(source)), + skipped_events: Vec::with_capacity(32), + }; + + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + assert!(reader.read(&InternalEventFilter).is_err()); + assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT); + } + + #[derive(Default)] + struct FakeSource { + events: VecDeque, + error: Option, + } + + impl FakeSource { + fn new(events: &[InternalEvent]) -> FakeSource { + FakeSource { + events: events.to_vec().into(), + error: Some(io::Error::new(io::ErrorKind::Other, "")), + } + } + + fn with_events(events: &[InternalEvent]) -> FakeSource { + FakeSource { + events: events.to_vec().into(), + error: None, + } + } + } + + impl EventSource for FakeSource { + fn try_read(&mut self, _timeout: Option) -> io::Result> { + // Return error if set in case there's just one remaining event + if self.events.len() == 1 { + if let Some(error) = self.error.take() { + return Err(error); + } + } + + // Return all events from the queue + if let Some(event) = self.events.pop_front() { + return Ok(Some(event)); + } + + // Return error if there're no more events + if let Some(error) = self.error.take() { + return Err(error); + } + + // Timeout + Ok(None) + } + + #[cfg(feature = "event-stream")] + fn waker(&self) -> super::super::sys::Waker { + unimplemented!(); + } + } +} diff --git a/patches/crossterm-0.29.0/src/event/source.rs b/patches/crossterm-0.29.0/src/event/source.rs new file mode 100644 index 0000000000..e53bd31eac --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/source.rs @@ -0,0 +1,27 @@ +use std::{io, time::Duration}; + +#[cfg(feature = "event-stream")] +use super::sys::Waker; +use super::InternalEvent; + +#[cfg(unix)] +pub(crate) mod unix; +#[cfg(windows)] +pub(crate) mod windows; + +/// An interface for trying to read an `InternalEvent` within an optional `Duration`. +pub(crate) trait EventSource: Sync + Send { + /// Tries to read an `InternalEvent` within the given duration. + /// + /// # Arguments + /// + /// * `timeout` - `None` block indefinitely until an event is available, `Some(duration)` blocks + /// for the given timeout + /// + /// Returns `Ok(None)` if there's no event available and timeout expires. + fn try_read(&mut self, timeout: Option) -> io::Result>; + + /// Returns a `Waker` allowing to wake/force the `try_read` method to return `Ok(None)`. + #[cfg(feature = "event-stream")] + fn waker(&self) -> Waker; +} diff --git a/patches/crossterm-0.29.0/src/event/source/unix.rs b/patches/crossterm-0.29.0/src/event/source/unix.rs new file mode 100644 index 0000000000..810bad38c0 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/source/unix.rs @@ -0,0 +1,11 @@ +#[cfg(feature = "use-dev-tty")] +pub(crate) mod tty; + +#[cfg(not(feature = "use-dev-tty"))] +pub(crate) mod mio; + +#[cfg(feature = "use-dev-tty")] +pub(crate) use self::tty::UnixInternalEventSource; + +#[cfg(not(feature = "use-dev-tty"))] +pub(crate) use self::mio::UnixInternalEventSource; diff --git a/patches/crossterm-0.29.0/src/event/source/unix/mio.rs b/patches/crossterm-0.29.0/src/event/source/unix/mio.rs new file mode 100644 index 0000000000..6d19e0c5ca --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/source/unix/mio.rs @@ -0,0 +1,229 @@ +use std::{collections::VecDeque, io, time::Duration}; + +use mio::{unix::SourceFd, Events, Interest, Poll, Token}; +use signal_hook_mio::v1_0::Signals; + +#[cfg(feature = "event-stream")] +use crate::event::sys::Waker; +use crate::event::{ + source::EventSource, sys::unix::parse::parse_event, timeout::PollTimeout, Event, InternalEvent, +}; +use crate::terminal::sys::file_descriptor::{tty_fd, FileDesc}; + +// Tokens to identify file descriptor +const TTY_TOKEN: Token = Token(0); +const SIGNAL_TOKEN: Token = Token(1); +#[cfg(feature = "event-stream")] +const WAKE_TOKEN: Token = Token(2); + +// I (@zrzka) wasn't able to read more than 1_022 bytes when testing +// reading on macOS/Linux -> we don't need bigger buffer and 1k of bytes +// is enough. +const TTY_BUFFER_SIZE: usize = 1_024; + +pub(crate) struct UnixInternalEventSource { + poll: Poll, + events: Events, + parser: Parser, + tty_buffer: [u8; TTY_BUFFER_SIZE], + tty_fd: FileDesc<'static>, + signals: Signals, + #[cfg(feature = "event-stream")] + waker: Waker, +} + +impl UnixInternalEventSource { + pub fn new() -> io::Result { + UnixInternalEventSource::from_file_descriptor(tty_fd()?) + } + + pub(crate) fn from_file_descriptor(input_fd: FileDesc<'static>) -> io::Result { + let poll = Poll::new()?; + let registry = poll.registry(); + + let tty_raw_fd = input_fd.raw_fd(); + let mut tty_ev = SourceFd(&tty_raw_fd); + registry.register(&mut tty_ev, TTY_TOKEN, Interest::READABLE)?; + + let mut signals = Signals::new([signal_hook::consts::SIGWINCH])?; + registry.register(&mut signals, SIGNAL_TOKEN, Interest::READABLE)?; + + #[cfg(feature = "event-stream")] + let waker = Waker::new(registry, WAKE_TOKEN)?; + + Ok(UnixInternalEventSource { + poll, + events: Events::with_capacity(3), + parser: Parser::default(), + tty_buffer: [0u8; TTY_BUFFER_SIZE], + tty_fd: input_fd, + signals, + #[cfg(feature = "event-stream")] + waker, + }) + } +} + +impl EventSource for UnixInternalEventSource { + fn try_read(&mut self, timeout: Option) -> io::Result> { + if let Some(event) = self.parser.next() { + return Ok(Some(event)); + } + + let timeout = PollTimeout::new(timeout); + + loop { + if let Err(e) = self.poll.poll(&mut self.events, timeout.leftover()) { + // Mio will throw an interrupted error in case of cursor position retrieval. We need to retry until it succeeds. + // Previous versions of Mio (< 0.7) would automatically retry the poll call if it was interrupted (if EINTR was returned). + // https://docs.rs/mio/0.7.0/mio/struct.Poll.html#notes + if e.kind() == io::ErrorKind::Interrupted { + continue; + } else { + return Err(e); + } + }; + + if self.events.is_empty() { + // No readiness events = timeout + return Ok(None); + } + + for token in self.events.iter().map(|x| x.token()) { + match token { + TTY_TOKEN => { + loop { + match self.tty_fd.read(&mut self.tty_buffer) { + Ok(read_count) => { + if read_count > 0 { + self.parser.advance( + &self.tty_buffer[..read_count], + read_count == TTY_BUFFER_SIZE, + ); + } + } + Err(e) => { + // No more data to read at the moment. We will receive another event + if e.kind() == io::ErrorKind::WouldBlock { + break; + } + // once more data is available to read. + else if e.kind() == io::ErrorKind::Interrupted { + continue; + } + } + }; + + if let Some(event) = self.parser.next() { + return Ok(Some(event)); + } + } + } + SIGNAL_TOKEN => { + if self.signals.pending().next() == Some(signal_hook::consts::SIGWINCH) { + // TODO Should we remove tput? + // + // This can take a really long time, because terminal::size can + // launch new process (tput) and then it parses its output. It's + // not a really long time from the absolute time point of view, but + // it's a really long time from the mio, async-std/tokio executor, ... + // point of view. + let new_size = crate::terminal::size()?; + return Ok(Some(InternalEvent::Event(Event::Resize( + new_size.0, new_size.1, + )))); + } + } + #[cfg(feature = "event-stream")] + WAKE_TOKEN => { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Poll operation was woken up by `Waker::wake`", + )); + } + _ => unreachable!("Synchronize Evented handle registration & token handling"), + } + } + + // Processing above can take some time, check if timeout expired + if timeout.elapsed() { + return Ok(None); + } + } + } + + #[cfg(feature = "event-stream")] + fn waker(&self) -> Waker { + self.waker.clone() + } +} + +// +// Following `Parser` structure exists for two reasons: +// +// * mimic anes Parser interface +// * move the advancing, parsing, ... stuff out of the `try_read` method +// +#[derive(Debug)] +struct Parser { + buffer: Vec, + internal_events: VecDeque, +} + +impl Default for Parser { + fn default() -> Self { + Parser { + // This buffer is used for -> 1 <- ANSI escape sequence. Are we + // aware of any ANSI escape sequence that is bigger? Can we make + // it smaller? + // + // Probably not worth spending more time on this as "there's a plan" + // to use the anes crate parser. + buffer: Vec::with_capacity(256), + // TTY_BUFFER_SIZE is 1_024 bytes. How many ANSI escape sequences can + // fit? What is an average sequence length? Let's guess here + // and say that the average ANSI escape sequence length is 8 bytes. Thus + // the buffer size should be 1024/8=128 to avoid additional allocations + // when processing large amounts of data. + // + // There's no need to make it bigger, because when you look at the `try_read` + // method implementation, all events are consumed before the next TTY_BUFFER + // is processed -> events pushed. + internal_events: VecDeque::with_capacity(128), + } + } +} + +impl Parser { + fn advance(&mut self, buffer: &[u8], more: bool) { + for (idx, byte) in buffer.iter().enumerate() { + let more = idx + 1 < buffer.len() || more; + + self.buffer.push(*byte); + + match parse_event(&self.buffer, more) { + Ok(Some(ie)) => { + self.internal_events.push_back(ie); + self.buffer.clear(); + } + Ok(None) => { + // Event can't be parsed, because we don't have enough bytes for + // the current sequence. Keep the buffer and process next bytes. + } + Err(_) => { + // Event can't be parsed (not enough parameters, parameter is not a number, ...). + // Clear the buffer and continue with another sequence. + self.buffer.clear(); + } + } + } + } +} + +impl Iterator for Parser { + type Item = InternalEvent; + + fn next(&mut self) -> Option { + self.internal_events.pop_front() + } +} diff --git a/patches/crossterm-0.29.0/src/event/source/unix/tty.rs b/patches/crossterm-0.29.0/src/event/source/unix/tty.rs new file mode 100644 index 0000000000..a30063dbe9 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/source/unix/tty.rs @@ -0,0 +1,278 @@ +#[cfg(feature = "libc")] +use std::os::unix::prelude::AsRawFd; +use std::{collections::VecDeque, io, os::unix::net::UnixStream, time::Duration}; + +#[cfg(not(feature = "libc"))] +use rustix::fd::{AsFd, AsRawFd}; + +use signal_hook::low_level::pipe; + +use crate::event::timeout::PollTimeout; +use crate::event::Event; +use filedescriptor::{poll, pollfd, POLLIN}; + +#[cfg(feature = "event-stream")] +use crate::event::sys::Waker; +use crate::event::{source::EventSource, sys::unix::parse::parse_event, InternalEvent}; +use crate::terminal::sys::file_descriptor::{tty_fd, FileDesc}; + +/// Holds a prototypical Waker and a receiver we can wait on when doing select(). +#[cfg(feature = "event-stream")] +struct WakePipe { + receiver: UnixStream, + waker: Waker, +} + +#[cfg(feature = "event-stream")] +impl WakePipe { + fn new() -> io::Result { + let (receiver, sender) = nonblocking_unix_pair()?; + Ok(WakePipe { + receiver, + waker: Waker::new(sender), + }) + } +} + +// I (@zrzka) wasn't able to read more than 1_022 bytes when testing +// reading on macOS/Linux -> we don't need bigger buffer and 1k of bytes +// is enough. +const TTY_BUFFER_SIZE: usize = 1_024; + +pub(crate) struct UnixInternalEventSource { + parser: Parser, + tty_buffer: [u8; TTY_BUFFER_SIZE], + tty: FileDesc<'static>, + winch_signal_receiver: UnixStream, + #[cfg(feature = "event-stream")] + wake_pipe: WakePipe, +} + +fn nonblocking_unix_pair() -> io::Result<(UnixStream, UnixStream)> { + let (receiver, sender) = UnixStream::pair()?; + receiver.set_nonblocking(true)?; + sender.set_nonblocking(true)?; + Ok((receiver, sender)) +} + +impl UnixInternalEventSource { + pub fn new() -> io::Result { + UnixInternalEventSource::from_file_descriptor(tty_fd()?) + } + + pub(crate) fn from_file_descriptor(input_fd: FileDesc<'static>) -> io::Result { + Ok(UnixInternalEventSource { + parser: Parser::default(), + tty_buffer: [0u8; TTY_BUFFER_SIZE], + tty: input_fd, + winch_signal_receiver: { + let (receiver, sender) = nonblocking_unix_pair()?; + // Unregistering is unnecessary because EventSource is a singleton + #[cfg(feature = "libc")] + pipe::register(libc::SIGWINCH, sender)?; + #[cfg(not(feature = "libc"))] + pipe::register(rustix::process::Signal::WINCH.as_raw(), sender)?; + receiver + }, + #[cfg(feature = "event-stream")] + wake_pipe: WakePipe::new()?, + }) + } +} + +/// read_complete reads from a non-blocking file descriptor +/// until the buffer is full or it would block. +/// +/// Similar to `std::io::Read::read_to_end`, except this function +/// only fills the given buffer and does not read beyond that. +fn read_complete(fd: &FileDesc, buf: &mut [u8]) -> io::Result { + loop { + match fd.read(buf) { + Ok(x) => return Ok(x), + Err(e) => match e.kind() { + io::ErrorKind::WouldBlock => return Ok(0), + io::ErrorKind::Interrupted => continue, + _ => return Err(e), + }, + } + } +} + +impl EventSource for UnixInternalEventSource { + fn try_read(&mut self, timeout: Option) -> io::Result> { + let timeout = PollTimeout::new(timeout); + + fn make_pollfd(fd: &F) -> pollfd { + pollfd { + fd: fd.as_raw_fd(), + events: POLLIN, + revents: 0, + } + } + + #[cfg(not(feature = "event-stream"))] + let mut fds = [ + make_pollfd(&self.tty), + make_pollfd(&self.winch_signal_receiver), + ]; + + #[cfg(feature = "event-stream")] + let mut fds = [ + make_pollfd(&self.tty), + make_pollfd(&self.winch_signal_receiver), + make_pollfd(&self.wake_pipe.receiver), + ]; + + while timeout.leftover().map_or(true, |t| !t.is_zero()) { + // check if there are buffered events from the last read + if let Some(event) = self.parser.next() { + return Ok(Some(event)); + } + match poll(&mut fds, timeout.leftover()) { + Err(filedescriptor::Error::Poll(e)) | Err(filedescriptor::Error::Io(e)) => { + match e.kind() { + // retry on EINTR + io::ErrorKind::Interrupted => continue, + _ => return Err(e), + } + } + Err(e) => { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("got unexpected error while polling: {:?}", e), + )) + } + Ok(_) => (), + }; + if fds[0].revents & POLLIN != 0 { + loop { + let read_count = read_complete(&self.tty, &mut self.tty_buffer)?; + if read_count > 0 { + self.parser.advance( + &self.tty_buffer[..read_count], + read_count == TTY_BUFFER_SIZE, + ); + } + + if let Some(event) = self.parser.next() { + return Ok(Some(event)); + } + + if read_count == 0 { + break; + } + } + } + if fds[1].revents & POLLIN != 0 { + #[cfg(feature = "libc")] + let fd = FileDesc::new(self.winch_signal_receiver.as_raw_fd(), false); + #[cfg(not(feature = "libc"))] + let fd = FileDesc::Borrowed(self.winch_signal_receiver.as_fd()); + // drain the pipe + while read_complete(&fd, &mut [0; 1024])? != 0 {} + // TODO Should we remove tput? + // + // This can take a really long time, because terminal::size can + // launch new process (tput) and then it parses its output. It's + // not a really long time from the absolute time point of view, but + // it's a really long time from the mio, async-std/tokio executor, ... + // point of view. + let new_size = crate::terminal::size()?; + return Ok(Some(InternalEvent::Event(Event::Resize( + new_size.0, new_size.1, + )))); + } + + #[cfg(feature = "event-stream")] + if fds[2].revents & POLLIN != 0 { + #[cfg(feature = "libc")] + let fd = FileDesc::new(self.wake_pipe.receiver.as_raw_fd(), false); + #[cfg(not(feature = "libc"))] + let fd = FileDesc::Borrowed(self.wake_pipe.receiver.as_fd()); + // drain the pipe + while read_complete(&fd, &mut [0; 1024])? != 0 {} + + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Poll operation was woken up by `Waker::wake`", + )); + } + } + Ok(None) + } + + #[cfg(feature = "event-stream")] + fn waker(&self) -> Waker { + self.wake_pipe.waker.clone() + } +} + +// +// Following `Parser` structure exists for two reasons: +// +// * mimic anes Parser interface +// * move the advancing, parsing, ... stuff out of the `try_read` method +// +#[derive(Debug)] +struct Parser { + buffer: Vec, + internal_events: VecDeque, +} + +impl Default for Parser { + fn default() -> Self { + Parser { + // This buffer is used for -> 1 <- ANSI escape sequence. Are we + // aware of any ANSI escape sequence that is bigger? Can we make + // it smaller? + // + // Probably not worth spending more time on this as "there's a plan" + // to use the anes crate parser. + buffer: Vec::with_capacity(256), + // TTY_BUFFER_SIZE is 1_024 bytes. How many ANSI escape sequences can + // fit? What is an average sequence length? Let's guess here + // and say that the average ANSI escape sequence length is 8 bytes. Thus + // the buffer size should be 1024/8=128 to avoid additional allocations + // when processing large amounts of data. + // + // There's no need to make it bigger, because when you look at the `try_read` + // method implementation, all events are consumed before the next TTY_BUFFER + // is processed -> events pushed. + internal_events: VecDeque::with_capacity(128), + } + } +} + +impl Parser { + fn advance(&mut self, buffer: &[u8], more: bool) { + for (idx, byte) in buffer.iter().enumerate() { + let more = idx + 1 < buffer.len() || more; + + self.buffer.push(*byte); + + match parse_event(&self.buffer, more) { + Ok(Some(ie)) => { + self.internal_events.push_back(ie); + self.buffer.clear(); + } + Ok(None) => { + // Event can't be parsed, because we don't have enough bytes for + // the current sequence. Keep the buffer and process next bytes. + } + Err(_) => { + // Event can't be parsed (not enough parameters, parameter is not a number, ...). + // Clear the buffer and continue with another sequence. + self.buffer.clear(); + } + } + } + } +} + +impl Iterator for Parser { + type Item = InternalEvent; + + fn next(&mut self) -> Option { + self.internal_events.pop_front() + } +} diff --git a/patches/crossterm-0.29.0/src/event/source/windows.rs b/patches/crossterm-0.29.0/src/event/source/windows.rs new file mode 100644 index 0000000000..fa40cc5027 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/source/windows.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use crossterm_winapi::{Console, Handle, InputRecord}; + +use crate::event::{ + sys::windows::{parse::MouseButtonsPressed, poll::WinApiPoll}, + Event, +}; + +#[cfg(feature = "event-stream")] +use crate::event::sys::Waker; +use crate::event::{ + source::EventSource, + sys::windows::parse::{handle_key_event, handle_mouse_event}, + timeout::PollTimeout, + InternalEvent, +}; + +pub(crate) struct WindowsEventSource { + console: Console, + poll: WinApiPoll, + surrogate_buffer: Option, + mouse_buttons_pressed: MouseButtonsPressed, +} + +impl WindowsEventSource { + pub(crate) fn new() -> std::io::Result { + let console = Console::from(Handle::current_in_handle()?); + Ok(WindowsEventSource { + console, + + #[cfg(not(feature = "event-stream"))] + poll: WinApiPoll::new(), + #[cfg(feature = "event-stream")] + poll: WinApiPoll::new()?, + + surrogate_buffer: None, + mouse_buttons_pressed: MouseButtonsPressed::default(), + }) + } +} + +impl EventSource for WindowsEventSource { + fn try_read(&mut self, timeout: Option) -> std::io::Result> { + let poll_timeout = PollTimeout::new(timeout); + + loop { + if let Some(event_ready) = self.poll.poll(poll_timeout.leftover())? { + let number = self.console.number_of_console_input_events()?; + if event_ready && number != 0 { + let event = match self.console.read_single_input_event()? { + InputRecord::KeyEvent(record) => { + handle_key_event(record, &mut self.surrogate_buffer) + } + InputRecord::MouseEvent(record) => { + let mouse_event = + handle_mouse_event(record, &self.mouse_buttons_pressed); + self.mouse_buttons_pressed = MouseButtonsPressed { + left: record.button_state.left_button(), + right: record.button_state.right_button(), + middle: record.button_state.middle_button(), + }; + + mouse_event + } + InputRecord::WindowBufferSizeEvent(record) => { + // windows starts counting at 0, unix at 1, add one to replicate unix behaviour. + Some(Event::Resize( + (record.size.x as i32 + 1) as u16, + (record.size.y as i32 + 1) as u16, + )) + } + InputRecord::FocusEvent(record) => { + let event = if record.set_focus { + Event::FocusGained + } else { + Event::FocusLost + }; + Some(event) + } + _ => None, + }; + + if let Some(event) = event { + return Ok(Some(InternalEvent::Event(event))); + } + } + } + + if poll_timeout.elapsed() { + return Ok(None); + } + } + } + + #[cfg(feature = "event-stream")] + fn waker(&self) -> Waker { + self.poll.waker() + } +} diff --git a/patches/crossterm-0.29.0/src/event/stream.rs b/patches/crossterm-0.29.0/src/event/stream.rs new file mode 100644 index 0000000000..e74dceae69 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/stream.rs @@ -0,0 +1,146 @@ +use std::{ + io, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, SyncSender}, + Arc, + }, + task::{Context, Poll}, + thread, + time::Duration, +}; + +use futures_core::stream::Stream; + +use crate::event::{ + filter::EventFilter, lock_internal_event_reader, poll_internal, read_internal, sys::Waker, + Event, InternalEvent, +}; + +/// A stream of `Result`. +/// +/// **This type is not available by default. You have to use the `event-stream` feature flag +/// to make it available.** +/// +/// It implements the [Stream](futures_core::stream::Stream) +/// trait and allows you to receive [`Event`]s with [`async-std`](https://crates.io/crates/async-std) +/// or [`tokio`](https://crates.io/crates/tokio) crates. +/// +/// Check the [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) folder to see how to use +/// it (`event-stream-*`). +#[derive(Debug)] +pub struct EventStream { + poll_internal_waker: Waker, + stream_wake_task_executed: Arc, + stream_wake_task_should_shutdown: Arc, + task_sender: SyncSender, +} + +impl Default for EventStream { + fn default() -> Self { + let (task_sender, receiver) = mpsc::sync_channel::(1); + + thread::spawn(move || { + while let Ok(task) = receiver.recv() { + loop { + if let Ok(true) = poll_internal(None, &EventFilter) { + break; + } + + if task.stream_wake_task_should_shutdown.load(Ordering::SeqCst) { + break; + } + } + task.stream_wake_task_executed + .store(false, Ordering::SeqCst); + task.stream_waker.wake(); + } + }); + + EventStream { + poll_internal_waker: lock_internal_event_reader().waker(), + stream_wake_task_executed: Arc::new(AtomicBool::new(false)), + stream_wake_task_should_shutdown: Arc::new(AtomicBool::new(false)), + task_sender, + } + } +} + +impl EventStream { + /// Constructs a new instance of `EventStream`. + pub fn new() -> EventStream { + EventStream::default() + } +} + +struct Task { + stream_waker: std::task::Waker, + stream_wake_task_executed: Arc, + stream_wake_task_should_shutdown: Arc, +} + +// Note to future me +// +// We need two wakers in order to implement EventStream correctly. +// +// 1. futures::Stream waker +// +// Stream::poll_next can return Poll::Pending which means that there's no +// event available. We are going to spawn a thread with the +// poll_internal(None, &EventFilter) call. This call blocks until an +// event is available and then we have to wake up the executor with notification +// that the task can be resumed. +// +// 2. poll_internal waker +// +// There's no event available, Poll::Pending was returned, stream waker thread +// is up and sitting in the poll_internal. User wants to drop the EventStream. +// We have to wake up the poll_internal (force it to return Ok(false)) and quit +// the thread before we drop. +impl Stream for EventStream { + type Item = io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = match poll_internal(Some(Duration::from_secs(0)), &EventFilter) { + Ok(true) => match read_internal(&EventFilter) { + Ok(InternalEvent::Event(event)) => Poll::Ready(Some(Ok(event))), + Err(e) => Poll::Ready(Some(Err(e))), + #[cfg(unix)] + _ => unreachable!(), + }, + Ok(false) => { + if !self + .stream_wake_task_executed + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + // https://github.com/rust-lang/rust/issues/80486#issuecomment-752244166 + .unwrap_or_else(|x| x) + { + let stream_waker = cx.waker().clone(); + let stream_wake_task_executed = self.stream_wake_task_executed.clone(); + let stream_wake_task_should_shutdown = + self.stream_wake_task_should_shutdown.clone(); + + stream_wake_task_should_shutdown.store(false, Ordering::SeqCst); + + let _ = self.task_sender.send(Task { + stream_waker, + stream_wake_task_executed, + stream_wake_task_should_shutdown, + }); + } + Poll::Pending + } + Err(e) => Poll::Ready(Some(Err(e))), + }; + result + } +} + +impl Drop for EventStream { + fn drop(&mut self) { + self.stream_wake_task_should_shutdown + .store(true, Ordering::SeqCst); + let _ = self.poll_internal_waker.wake(); + } +} diff --git a/patches/crossterm-0.29.0/src/event/sys.rs b/patches/crossterm-0.29.0/src/event/sys.rs new file mode 100644 index 0000000000..bd79307351 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys.rs @@ -0,0 +1,9 @@ +#[cfg(all(unix, feature = "event-stream"))] +pub(crate) use unix::waker::Waker; +#[cfg(all(windows, feature = "event-stream"))] +pub(crate) use windows::waker::Waker; + +#[cfg(unix)] +pub(crate) mod unix; +#[cfg(windows)] +pub(crate) mod windows; diff --git a/patches/crossterm-0.29.0/src/event/sys/unix.rs b/patches/crossterm-0.29.0/src/event/sys/unix.rs new file mode 100644 index 0000000000..2106ca0cc5 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/unix.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "event-stream")] +pub(crate) mod waker; + +#[cfg(feature = "events")] +pub(crate) mod parse; diff --git a/patches/crossterm-0.29.0/src/event/sys/unix/parse.rs b/patches/crossterm-0.29.0/src/event/sys/unix/parse.rs new file mode 100644 index 0000000000..29377438f7 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/unix/parse.rs @@ -0,0 +1,1506 @@ +use std::io; + +use crate::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags, + MediaKeyCode, ModifierKeyCode, MouseButton, MouseEvent, MouseEventKind, +}; + +use super::super::super::InternalEvent; + +// Event parsing +// +// This code (& previous one) are kind of ugly. We have to think about this, +// because it's really not maintainable, no tests, etc. +// +// Every fn returns Result> +// +// Ok(None) -> wait for more bytes +// Err(_) -> failed to parse event, clear the buffer +// Ok(Some(event)) -> we have event, clear the buffer +// + +fn could_not_parse_event_error() -> io::Error { + io::Error::new(io::ErrorKind::Other, "Could not parse an event.") +} + +pub(crate) fn parse_event( + buffer: &[u8], + input_available: bool, +) -> io::Result> { + if buffer.is_empty() { + return Ok(None); + } + + match buffer[0] { + b'\x1B' => { + if buffer.len() == 1 { + if input_available { + // Possible Esc sequence + Ok(None) + } else { + Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Esc.into())))) + } + } else { + match buffer[1] { + b'O' => { + if buffer.len() == 2 { + Ok(None) + } else { + match buffer[2] { + b'D' => { + Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Left.into())))) + } + b'C' => Ok(Some(InternalEvent::Event(Event::Key( + KeyCode::Right.into(), + )))), + b'A' => { + Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Up.into())))) + } + b'B' => { + Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Down.into())))) + } + b'H' => { + Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Home.into())))) + } + b'F' => { + Ok(Some(InternalEvent::Event(Event::Key(KeyCode::End.into())))) + } + // F1-F4 + val @ b'P'..=b'S' => Ok(Some(InternalEvent::Event(Event::Key( + KeyCode::F(1 + val - b'P').into(), + )))), + _ => Err(could_not_parse_event_error()), + } + } + } + b'[' => parse_csi(buffer), + b'\x1B' => Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Esc.into())))), + _ => parse_event(&buffer[1..], input_available).map(|event_option| { + event_option.map(|event| { + if let InternalEvent::Event(Event::Key(key_event)) = event { + let mut alt_key_event = key_event; + alt_key_event.modifiers |= KeyModifiers::ALT; + InternalEvent::Event(Event::Key(alt_key_event)) + } else { + event + } + }) + }), + } + } + } + b'\r' => Ok(Some(InternalEvent::Event(Event::Key( + KeyCode::Enter.into(), + )))), + // Issue #371: \n = 0xA, which is also the keycode for Ctrl+J. The only reason we get + // newlines as input is because the terminal converts \r into \n for us. When we + // enter raw mode, we disable that, so \n no longer has any meaning - it's better to + // use Ctrl+J. Waiting to handle it here means it gets picked up later + b'\n' if !crate::terminal::sys::is_raw_mode_enabled() => Ok(Some(InternalEvent::Event( + Event::Key(KeyCode::Enter.into()), + ))), + b'\t' => Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Tab.into())))), + b'\x7F' => Ok(Some(InternalEvent::Event(Event::Key( + KeyCode::Backspace.into(), + )))), + c @ b'\x01'..=b'\x1A' => Ok(Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char((c - 0x1 + b'a') as char), + KeyModifiers::CONTROL, + ))))), + c @ b'\x1C'..=b'\x1F' => Ok(Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char((c - 0x1C + b'4') as char), + KeyModifiers::CONTROL, + ))))), + b'\0' => Ok(Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::CONTROL, + ))))), + _ => parse_utf8_char(buffer).map(|maybe_char| { + maybe_char + .map(KeyCode::Char) + .map(char_code_to_event) + .map(Event::Key) + .map(InternalEvent::Event) + }), + } +} + +// converts KeyCode to KeyEvent (adds shift modifier in case of uppercase characters) +fn char_code_to_event(code: KeyCode) -> KeyEvent { + let modifiers = match code { + KeyCode::Char(c) if c.is_uppercase() => KeyModifiers::SHIFT, + _ => KeyModifiers::empty(), + }; + KeyEvent::new(code, modifiers) +} + +pub(crate) fn parse_csi(buffer: &[u8]) -> io::Result> { + assert!(buffer.starts_with(b"\x1B[")); // ESC [ + + if buffer.len() == 2 { + return Ok(None); + } + + let input_event = match buffer[2] { + b'[' => { + if buffer.len() == 3 { + None + } else { + match buffer[3] { + // NOTE (@imdaveho): cannot find when this occurs; + // having another '[' after ESC[ not a likely scenario + val @ b'A'..=b'E' => Some(Event::Key(KeyCode::F(1 + val - b'A').into())), + _ => return Err(could_not_parse_event_error()), + } + } + } + b'D' => Some(Event::Key(KeyCode::Left.into())), + b'C' => Some(Event::Key(KeyCode::Right.into())), + b'A' => Some(Event::Key(KeyCode::Up.into())), + b'B' => Some(Event::Key(KeyCode::Down.into())), + b'H' => Some(Event::Key(KeyCode::Home.into())), + b'F' => Some(Event::Key(KeyCode::End.into())), + b'Z' => Some(Event::Key(KeyEvent::new_with_kind( + KeyCode::BackTab, + KeyModifiers::SHIFT, + KeyEventKind::Press, + ))), + b'M' => return parse_csi_normal_mouse(buffer), + b'<' => return parse_csi_sgr_mouse(buffer), + b'I' => Some(Event::FocusGained), + b'O' => Some(Event::FocusLost), + b';' => return parse_csi_modifier_key_code(buffer), + // P, Q, and S for compatibility with Kitty keyboard protocol, + // as the 1 in 'CSI 1 P' etc. must be omitted if there are no + // modifiers pressed: + // https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-functional-keys + b'P' => Some(Event::Key(KeyCode::F(1).into())), + b'Q' => Some(Event::Key(KeyCode::F(2).into())), + b'S' => Some(Event::Key(KeyCode::F(4).into())), + b'?' => match buffer[buffer.len() - 1] { + b'u' => return parse_csi_keyboard_enhancement_flags(buffer), + b'c' => return parse_csi_primary_device_attributes(buffer), + _ => None, + }, + b'0'..=b'9' => { + // Numbered escape code. + if buffer.len() == 3 { + None + } else { + // The final byte of a CSI sequence can be in the range 64-126, so + // let's keep reading anything else. + let last_byte = buffer[buffer.len() - 1]; + if !(64..=126).contains(&last_byte) { + None + } else { + #[cfg(feature = "bracketed-paste")] + if buffer.starts_with(b"\x1B[200~") { + return parse_csi_bracketed_paste(buffer); + } + match last_byte { + b'M' => return parse_csi_rxvt_mouse(buffer), + b'~' => return parse_csi_special_key_code(buffer), + b'u' => return parse_csi_u_encoded_key_code(buffer), + b'R' => return parse_csi_cursor_position(buffer), + _ => return parse_csi_modifier_key_code(buffer), + } + } + } + } + _ => return Err(could_not_parse_event_error()), + }; + + Ok(input_event.map(InternalEvent::Event)) +} + +pub(crate) fn next_parsed(iter: &mut dyn Iterator) -> io::Result +where + T: std::str::FromStr, +{ + iter.next() + .ok_or_else(could_not_parse_event_error)? + .parse::() + .map_err(|_| could_not_parse_event_error()) +} + +fn modifier_and_kind_parsed(iter: &mut dyn Iterator) -> io::Result<(u8, u8)> { + let mut sub_split = iter + .next() + .ok_or_else(could_not_parse_event_error)? + .split(':'); + + let modifier_mask = next_parsed::(&mut sub_split)?; + + if let Ok(kind_code) = next_parsed::(&mut sub_split) { + Ok((modifier_mask, kind_code)) + } else { + Ok((modifier_mask, 1)) + } +} + +pub(crate) fn parse_csi_cursor_position(buffer: &[u8]) -> io::Result> { + // ESC [ Cy ; Cx R + // Cy - cursor row number (starting from 1) + // Cx - cursor column number (starting from 1) + assert!(buffer.starts_with(b"\x1B[")); // ESC [ + assert!(buffer.ends_with(b"R")); + + let s = std::str::from_utf8(&buffer[2..buffer.len() - 1]) + .map_err(|_| could_not_parse_event_error())?; + + let mut split = s.split(';'); + + let y = next_parsed::(&mut split)? - 1; + let x = next_parsed::(&mut split)? - 1; + + Ok(Some(InternalEvent::CursorPosition(x, y))) +} + +fn parse_csi_keyboard_enhancement_flags(buffer: &[u8]) -> io::Result> { + // ESC [ ? flags u + assert!(buffer.starts_with(b"\x1B[?")); // ESC [ ? + assert!(buffer.ends_with(b"u")); + + if buffer.len() < 5 { + return Ok(None); + } + + let bits = buffer[3]; + let mut flags = KeyboardEnhancementFlags::empty(); + + if bits & 1 != 0 { + flags |= KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES; + } + if bits & 2 != 0 { + flags |= KeyboardEnhancementFlags::REPORT_EVENT_TYPES; + } + if bits & 4 != 0 { + flags |= KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS; + } + if bits & 8 != 0 { + flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; + } + // *Note*: this is not yet supported by crossterm. + // if bits & 16 != 0 { + // flags |= KeyboardEnhancementFlags::REPORT_ASSOCIATED_TEXT; + // } + + Ok(Some(InternalEvent::KeyboardEnhancementFlags(flags))) +} + +fn parse_csi_primary_device_attributes(buffer: &[u8]) -> io::Result> { + // ESC [ 64 ; attr1 ; attr2 ; ... ; attrn ; c + assert!(buffer.starts_with(b"\x1B[?")); + assert!(buffer.ends_with(b"c")); + + // This is a stub for parsing the primary device attributes. This response is not + // exposed in the crossterm API so we don't need to parse the individual attributes yet. + // See + + Ok(Some(InternalEvent::PrimaryDeviceAttributes)) +} + +fn parse_modifiers(mask: u8) -> KeyModifiers { + let modifier_mask = mask.saturating_sub(1); + let mut modifiers = KeyModifiers::empty(); + if modifier_mask & 1 != 0 { + modifiers |= KeyModifiers::SHIFT; + } + if modifier_mask & 2 != 0 { + modifiers |= KeyModifiers::ALT; + } + if modifier_mask & 4 != 0 { + modifiers |= KeyModifiers::CONTROL; + } + if modifier_mask & 8 != 0 { + modifiers |= KeyModifiers::SUPER; + } + if modifier_mask & 16 != 0 { + modifiers |= KeyModifiers::HYPER; + } + if modifier_mask & 32 != 0 { + modifiers |= KeyModifiers::META; + } + modifiers +} + +fn parse_modifiers_to_state(mask: u8) -> KeyEventState { + let modifier_mask = mask.saturating_sub(1); + let mut state = KeyEventState::empty(); + if modifier_mask & 64 != 0 { + state |= KeyEventState::CAPS_LOCK; + } + if modifier_mask & 128 != 0 { + state |= KeyEventState::NUM_LOCK; + } + state +} + +fn parse_key_event_kind(kind: u8) -> KeyEventKind { + match kind { + 1 => KeyEventKind::Press, + 2 => KeyEventKind::Repeat, + 3 => KeyEventKind::Release, + _ => KeyEventKind::Press, + } +} + +pub(crate) fn parse_csi_modifier_key_code(buffer: &[u8]) -> io::Result> { + assert!(buffer.starts_with(b"\x1B[")); // ESC [ + // + let s = std::str::from_utf8(&buffer[2..buffer.len() - 1]) + .map_err(|_| could_not_parse_event_error())?; + let mut split = s.split(';'); + + split.next(); + + let (modifiers, kind) = + if let Ok((modifier_mask, kind_code)) = modifier_and_kind_parsed(&mut split) { + ( + parse_modifiers(modifier_mask), + parse_key_event_kind(kind_code), + ) + } else if buffer.len() > 3 { + ( + parse_modifiers( + (buffer[buffer.len() - 2] as char) + .to_digit(10) + .ok_or_else(could_not_parse_event_error)? as u8, + ), + KeyEventKind::Press, + ) + } else { + (KeyModifiers::NONE, KeyEventKind::Press) + }; + let key = buffer[buffer.len() - 1]; + + let keycode = match key { + b'A' => KeyCode::Up, + b'B' => KeyCode::Down, + b'C' => KeyCode::Right, + b'D' => KeyCode::Left, + b'F' => KeyCode::End, + b'H' => KeyCode::Home, + b'P' => KeyCode::F(1), + b'Q' => KeyCode::F(2), + b'R' => KeyCode::F(3), + b'S' => KeyCode::F(4), + _ => return Err(could_not_parse_event_error()), + }; + + let input_event = Event::Key(KeyEvent::new_with_kind(keycode, modifiers, kind)); + + Ok(Some(InternalEvent::Event(input_event))) +} + +fn translate_functional_key_code(codepoint: u32) -> Option<(KeyCode, KeyEventState)> { + if let Some(keycode) = match codepoint { + 57399 => Some(KeyCode::Char('0')), + 57400 => Some(KeyCode::Char('1')), + 57401 => Some(KeyCode::Char('2')), + 57402 => Some(KeyCode::Char('3')), + 57403 => Some(KeyCode::Char('4')), + 57404 => Some(KeyCode::Char('5')), + 57405 => Some(KeyCode::Char('6')), + 57406 => Some(KeyCode::Char('7')), + 57407 => Some(KeyCode::Char('8')), + 57408 => Some(KeyCode::Char('9')), + 57409 => Some(KeyCode::Char('.')), + 57410 => Some(KeyCode::Char('/')), + 57411 => Some(KeyCode::Char('*')), + 57412 => Some(KeyCode::Char('-')), + 57413 => Some(KeyCode::Char('+')), + 57414 => Some(KeyCode::Enter), + 57415 => Some(KeyCode::Char('=')), + 57416 => Some(KeyCode::Char(',')), + 57417 => Some(KeyCode::Left), + 57418 => Some(KeyCode::Right), + 57419 => Some(KeyCode::Up), + 57420 => Some(KeyCode::Down), + 57421 => Some(KeyCode::PageUp), + 57422 => Some(KeyCode::PageDown), + 57423 => Some(KeyCode::Home), + 57424 => Some(KeyCode::End), + 57425 => Some(KeyCode::Insert), + 57426 => Some(KeyCode::Delete), + 57427 => Some(KeyCode::KeypadBegin), + _ => None, + } { + return Some((keycode, KeyEventState::KEYPAD)); + } + + if let Some(keycode) = match codepoint { + 57358 => Some(KeyCode::CapsLock), + 57359 => Some(KeyCode::ScrollLock), + 57360 => Some(KeyCode::NumLock), + 57361 => Some(KeyCode::PrintScreen), + 57362 => Some(KeyCode::Pause), + 57363 => Some(KeyCode::Menu), + 57376 => Some(KeyCode::F(13)), + 57377 => Some(KeyCode::F(14)), + 57378 => Some(KeyCode::F(15)), + 57379 => Some(KeyCode::F(16)), + 57380 => Some(KeyCode::F(17)), + 57381 => Some(KeyCode::F(18)), + 57382 => Some(KeyCode::F(19)), + 57383 => Some(KeyCode::F(20)), + 57384 => Some(KeyCode::F(21)), + 57385 => Some(KeyCode::F(22)), + 57386 => Some(KeyCode::F(23)), + 57387 => Some(KeyCode::F(24)), + 57388 => Some(KeyCode::F(25)), + 57389 => Some(KeyCode::F(26)), + 57390 => Some(KeyCode::F(27)), + 57391 => Some(KeyCode::F(28)), + 57392 => Some(KeyCode::F(29)), + 57393 => Some(KeyCode::F(30)), + 57394 => Some(KeyCode::F(31)), + 57395 => Some(KeyCode::F(32)), + 57396 => Some(KeyCode::F(33)), + 57397 => Some(KeyCode::F(34)), + 57398 => Some(KeyCode::F(35)), + 57428 => Some(KeyCode::Media(MediaKeyCode::Play)), + 57429 => Some(KeyCode::Media(MediaKeyCode::Pause)), + 57430 => Some(KeyCode::Media(MediaKeyCode::PlayPause)), + 57431 => Some(KeyCode::Media(MediaKeyCode::Reverse)), + 57432 => Some(KeyCode::Media(MediaKeyCode::Stop)), + 57433 => Some(KeyCode::Media(MediaKeyCode::FastForward)), + 57434 => Some(KeyCode::Media(MediaKeyCode::Rewind)), + 57435 => Some(KeyCode::Media(MediaKeyCode::TrackNext)), + 57436 => Some(KeyCode::Media(MediaKeyCode::TrackPrevious)), + 57437 => Some(KeyCode::Media(MediaKeyCode::Record)), + 57438 => Some(KeyCode::Media(MediaKeyCode::LowerVolume)), + 57439 => Some(KeyCode::Media(MediaKeyCode::RaiseVolume)), + 57440 => Some(KeyCode::Media(MediaKeyCode::MuteVolume)), + 57441 => Some(KeyCode::Modifier(ModifierKeyCode::LeftShift)), + 57442 => Some(KeyCode::Modifier(ModifierKeyCode::LeftControl)), + 57443 => Some(KeyCode::Modifier(ModifierKeyCode::LeftAlt)), + 57444 => Some(KeyCode::Modifier(ModifierKeyCode::LeftSuper)), + 57445 => Some(KeyCode::Modifier(ModifierKeyCode::LeftHyper)), + 57446 => Some(KeyCode::Modifier(ModifierKeyCode::LeftMeta)), + 57447 => Some(KeyCode::Modifier(ModifierKeyCode::RightShift)), + 57448 => Some(KeyCode::Modifier(ModifierKeyCode::RightControl)), + 57449 => Some(KeyCode::Modifier(ModifierKeyCode::RightAlt)), + 57450 => Some(KeyCode::Modifier(ModifierKeyCode::RightSuper)), + 57451 => Some(KeyCode::Modifier(ModifierKeyCode::RightHyper)), + 57452 => Some(KeyCode::Modifier(ModifierKeyCode::RightMeta)), + 57453 => Some(KeyCode::Modifier(ModifierKeyCode::IsoLevel3Shift)), + 57454 => Some(KeyCode::Modifier(ModifierKeyCode::IsoLevel5Shift)), + _ => None, + } { + return Some((keycode, KeyEventState::empty())); + } + + None +} + +pub(crate) fn parse_csi_u_encoded_key_code(buffer: &[u8]) -> io::Result> { + assert!(buffer.starts_with(b"\x1B[")); // ESC [ + assert!(buffer.ends_with(b"u")); + + // This function parses `CSI … u` sequences. These are sequences defined in either + // the `CSI u` (a.k.a. "Fix Keyboard Input on Terminals - Please", https://www.leonerd.org.uk/hacks/fixterms/) + // or Kitty Keyboard Protocol (https://sw.kovidgoyal.net/kitty/keyboard-protocol/) specifications. + // This CSI sequence is a tuple of semicolon-separated numbers. + let s = std::str::from_utf8(&buffer[2..buffer.len() - 1]) + .map_err(|_| could_not_parse_event_error())?; + let mut split = s.split(';'); + + // In `CSI u`, this is parsed as: + // + // CSI codepoint ; modifiers u + // codepoint: ASCII Dec value + // + // The Kitty Keyboard Protocol extends this with optional components that can be + // enabled progressively. The full sequence is parsed as: + // + // CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u + let mut codepoints = split + .next() + .ok_or_else(could_not_parse_event_error)? + .split(':'); + + let codepoint = codepoints + .next() + .ok_or_else(could_not_parse_event_error)? + .parse::() + .map_err(|_| could_not_parse_event_error())?; + + let (mut modifiers, kind, state_from_modifiers) = + if let Ok((modifier_mask, kind_code)) = modifier_and_kind_parsed(&mut split) { + ( + parse_modifiers(modifier_mask), + parse_key_event_kind(kind_code), + parse_modifiers_to_state(modifier_mask), + ) + } else { + (KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE) + }; + + let (mut keycode, state_from_keycode) = { + if let Some((special_key_code, state)) = translate_functional_key_code(codepoint) { + (special_key_code, state) + } else if let Some(c) = char::from_u32(codepoint) { + ( + match c { + '\x1B' => KeyCode::Esc, + '\r' => KeyCode::Enter, + // Issue #371: \n = 0xA, which is also the keycode for Ctrl+J. The only reason we get + // newlines as input is because the terminal converts \r into \n for us. When we + // enter raw mode, we disable that, so \n no longer has any meaning - it's better to + // use Ctrl+J. Waiting to handle it here means it gets picked up later + '\n' if !crate::terminal::sys::is_raw_mode_enabled() => KeyCode::Enter, + '\t' => { + if modifiers.contains(KeyModifiers::SHIFT) { + KeyCode::BackTab + } else { + KeyCode::Tab + } + } + '\x7F' => KeyCode::Backspace, + _ => KeyCode::Char(c), + }, + KeyEventState::empty(), + ) + } else { + return Err(could_not_parse_event_error()); + } + }; + + if let KeyCode::Modifier(modifier_keycode) = keycode { + match modifier_keycode { + ModifierKeyCode::LeftAlt | ModifierKeyCode::RightAlt => { + modifiers.set(KeyModifiers::ALT, true) + } + ModifierKeyCode::LeftControl | ModifierKeyCode::RightControl => { + modifiers.set(KeyModifiers::CONTROL, true) + } + ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift => { + modifiers.set(KeyModifiers::SHIFT, true) + } + ModifierKeyCode::LeftSuper | ModifierKeyCode::RightSuper => { + modifiers.set(KeyModifiers::SUPER, true) + } + ModifierKeyCode::LeftHyper | ModifierKeyCode::RightHyper => { + modifiers.set(KeyModifiers::HYPER, true) + } + ModifierKeyCode::LeftMeta | ModifierKeyCode::RightMeta => { + modifiers.set(KeyModifiers::META, true) + } + _ => {} + } + } + + // When the "report alternate keys" flag is enabled in the Kitty Keyboard Protocol + // and the terminal sends a keyboard event containing shift, the sequence will + // contain an additional codepoint separated by a ':' character which contains + // the shifted character according to the keyboard layout. + if modifiers.contains(KeyModifiers::SHIFT) { + if let Some(shifted_c) = codepoints + .next() + .and_then(|codepoint| codepoint.parse::().ok()) + .and_then(char::from_u32) + { + keycode = KeyCode::Char(shifted_c); + modifiers.set(KeyModifiers::SHIFT, false); + } + } + + let input_event = Event::Key(KeyEvent::new_with_kind_and_state( + keycode, + modifiers, + kind, + state_from_keycode | state_from_modifiers, + )); + + Ok(Some(InternalEvent::Event(input_event))) +} + +pub(crate) fn parse_csi_special_key_code(buffer: &[u8]) -> io::Result> { + assert!(buffer.starts_with(b"\x1B[")); // ESC [ + assert!(buffer.ends_with(b"~")); + + let s = std::str::from_utf8(&buffer[2..buffer.len() - 1]) + .map_err(|_| could_not_parse_event_error())?; + let mut split = s.split(';'); + + // This CSI sequence can be a list of semicolon-separated numbers. + let first = next_parsed::(&mut split)?; + + let (modifiers, kind, state) = + if let Ok((modifier_mask, kind_code)) = modifier_and_kind_parsed(&mut split) { + ( + parse_modifiers(modifier_mask), + parse_key_event_kind(kind_code), + parse_modifiers_to_state(modifier_mask), + ) + } else { + (KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE) + }; + + let keycode = match first { + 1 | 7 => KeyCode::Home, + 2 => KeyCode::Insert, + 3 => KeyCode::Delete, + 4 | 8 => KeyCode::End, + 5 => KeyCode::PageUp, + 6 => KeyCode::PageDown, + v @ 11..=15 => KeyCode::F(v - 10), + v @ 17..=21 => KeyCode::F(v - 11), + v @ 23..=26 => KeyCode::F(v - 12), + v @ 28..=29 => KeyCode::F(v - 15), + v @ 31..=34 => KeyCode::F(v - 17), + _ => return Err(could_not_parse_event_error()), + }; + + let input_event = Event::Key(KeyEvent::new_with_kind_and_state( + keycode, modifiers, kind, state, + )); + + Ok(Some(InternalEvent::Event(input_event))) +} + +pub(crate) fn parse_csi_rxvt_mouse(buffer: &[u8]) -> io::Result> { + // rxvt mouse encoding: + // ESC [ Cb ; Cx ; Cy ; M + + assert!(buffer.starts_with(b"\x1B[")); // ESC [ + assert!(buffer.ends_with(b"M")); + + let s = std::str::from_utf8(&buffer[2..buffer.len() - 1]) + .map_err(|_| could_not_parse_event_error())?; + let mut split = s.split(';'); + + let cb = next_parsed::(&mut split)? + .checked_sub(32) + .ok_or_else(could_not_parse_event_error)?; + let (kind, modifiers) = parse_cb(cb)?; + + let cx = next_parsed::(&mut split)? - 1; + let cy = next_parsed::(&mut split)? - 1; + + Ok(Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind, + column: cx, + row: cy, + modifiers, + })))) +} + +pub(crate) fn parse_csi_normal_mouse(buffer: &[u8]) -> io::Result> { + // Normal mouse encoding: ESC [ M CB Cx Cy (6 characters only). + + assert!(buffer.starts_with(b"\x1B[M")); // ESC [ M + + if buffer.len() < 6 { + return Ok(None); + } + + let cb = buffer[3] + .checked_sub(32) + .ok_or_else(could_not_parse_event_error)?; + let (kind, modifiers) = parse_cb(cb)?; + + // See http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking + // The upper left character position on the terminal is denoted as 1,1. + // Subtract 1 to keep it synced with cursor + let cx = u16::from(buffer[4].saturating_sub(32)) - 1; + let cy = u16::from(buffer[5].saturating_sub(32)) - 1; + + Ok(Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind, + column: cx, + row: cy, + modifiers, + })))) +} + +pub(crate) fn parse_csi_sgr_mouse(buffer: &[u8]) -> io::Result> { + // ESC [ < Cb ; Cx ; Cy (;) (M or m) + + assert!(buffer.starts_with(b"\x1B[<")); // ESC [ < + + if !buffer.ends_with(b"m") && !buffer.ends_with(b"M") { + return Ok(None); + } + + let s = std::str::from_utf8(&buffer[3..buffer.len() - 1]) + .map_err(|_| could_not_parse_event_error())?; + let mut split = s.split(';'); + + let cb = next_parsed::(&mut split)?; + let (kind, modifiers) = parse_cb(cb)?; + + // See http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking + // The upper left character position on the terminal is denoted as 1,1. + // Subtract 1 to keep it synced with cursor + let cx = next_parsed::(&mut split)? - 1; + let cy = next_parsed::(&mut split)? - 1; + + // When button 3 in Cb is used to represent mouse release, you can't tell which button was + // released. SGR mode solves this by having the sequence end with a lowercase m if it's a + // button release and an uppercase M if it's a button press. + // + // We've already checked that the last character is a lowercase or uppercase M at the start of + // this function, so we just need one if. + let kind = if buffer.last() == Some(&b'm') { + match kind { + MouseEventKind::Down(button) => MouseEventKind::Up(button), + other => other, + } + } else { + kind + }; + + Ok(Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind, + column: cx, + row: cy, + modifiers, + })))) +} + +/// Cb is the byte of a mouse input that contains the button being used, the key modifiers being +/// held and whether the mouse is dragging or not. +/// +/// Bit layout of cb, from low to high: +/// +/// - button number +/// - button number +/// - shift +/// - meta (alt) +/// - control +/// - mouse is dragging +/// - button number +/// - button number +fn parse_cb(cb: u8) -> io::Result<(MouseEventKind, KeyModifiers)> { + let button_number = (cb & 0b0000_0011) | ((cb & 0b1100_0000) >> 4); + let dragging = cb & 0b0010_0000 == 0b0010_0000; + + let kind = match (button_number, dragging) { + (0, false) => MouseEventKind::Down(MouseButton::Left), + (1, false) => MouseEventKind::Down(MouseButton::Middle), + (2, false) => MouseEventKind::Down(MouseButton::Right), + (0, true) => MouseEventKind::Drag(MouseButton::Left), + (1, true) => MouseEventKind::Drag(MouseButton::Middle), + (2, true) => MouseEventKind::Drag(MouseButton::Right), + (3, false) => MouseEventKind::Up(MouseButton::Left), + (3, true) | (4, true) | (5, true) => MouseEventKind::Moved, + (4, false) => MouseEventKind::ScrollUp, + (5, false) => MouseEventKind::ScrollDown, + (6, false) => MouseEventKind::ScrollLeft, + (7, false) => MouseEventKind::ScrollRight, + // We do not support other buttons. + _ => return Err(could_not_parse_event_error()), + }; + + let mut modifiers = KeyModifiers::empty(); + + if cb & 0b0000_0100 == 0b0000_0100 { + modifiers |= KeyModifiers::SHIFT; + } + if cb & 0b0000_1000 == 0b0000_1000 { + modifiers |= KeyModifiers::ALT; + } + if cb & 0b0001_0000 == 0b0001_0000 { + modifiers |= KeyModifiers::CONTROL; + } + + Ok((kind, modifiers)) +} + +#[cfg(feature = "bracketed-paste")] +pub(crate) fn parse_csi_bracketed_paste(buffer: &[u8]) -> io::Result> { + // ESC [ 2 0 0 ~ pasted text ESC 2 0 1 ~ + assert!(buffer.starts_with(b"\x1B[200~")); + + if !buffer.ends_with(b"\x1b[201~") { + Ok(None) + } else { + let paste = String::from_utf8_lossy(&buffer[6..buffer.len() - 6]).to_string(); + Ok(Some(InternalEvent::Event(Event::Paste(paste)))) + } +} + +pub(crate) fn parse_utf8_char(buffer: &[u8]) -> io::Result> { + match std::str::from_utf8(buffer) { + Ok(s) => { + let ch = s.chars().next().ok_or_else(could_not_parse_event_error)?; + + Ok(Some(ch)) + } + Err(_) => { + // from_utf8 failed, but we have to check if we need more bytes for code point + // and if all the bytes we have no are valid + + let required_bytes = match buffer[0] { + // https://en.wikipedia.org/wiki/UTF-8#Description + (0x00..=0x7F) => 1, // 0xxxxxxx + (0xC0..=0xDF) => 2, // 110xxxxx 10xxxxxx + (0xE0..=0xEF) => 3, // 1110xxxx 10xxxxxx 10xxxxxx + (0xF0..=0xF7) => 4, // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + (0x80..=0xBF) | (0xF8..=0xFF) => return Err(could_not_parse_event_error()), + }; + + // More than 1 byte, check them for 10xxxxxx pattern + if required_bytes > 1 && buffer.len() > 1 { + for byte in &buffer[1..] { + if byte & !0b0011_1111 != 0b1000_0000 { + return Err(could_not_parse_event_error()); + } + } + } + + if buffer.len() < required_bytes { + // All bytes looks good so far, but we need more of them + Ok(None) + } else { + Err(could_not_parse_event_error()) + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::event::{KeyEventState, KeyModifiers, MouseButton, MouseEvent}; + + use super::*; + + #[test] + fn test_esc_key() { + assert_eq!( + parse_event(b"\x1B", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyCode::Esc.into()))), + ); + } + + #[test] + fn test_possible_esc_sequence() { + assert_eq!(parse_event(b"\x1B", true).unwrap(), None,); + } + + #[test] + fn test_alt_key() { + assert_eq!( + parse_event(b"\x1Bc", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::ALT + )))), + ); + } + + #[test] + fn test_alt_shift() { + assert_eq!( + parse_event(b"\x1BH", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('H'), + KeyModifiers::ALT | KeyModifiers::SHIFT + )))), + ); + } + + #[test] + fn test_alt_ctrl() { + assert_eq!( + parse_event(b"\x1B\x14", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('t'), + KeyModifiers::ALT | KeyModifiers::CONTROL + )))), + ); + } + + #[test] + fn test_parse_event_subsequent_calls() { + // The main purpose of this test is to check if we're passing + // correct slice to other parse_ functions. + + // parse_csi_cursor_position + assert_eq!( + parse_event(b"\x1B[20;10R", false).unwrap(), + Some(InternalEvent::CursorPosition(9, 19)) + ); + + // parse_csi + assert_eq!( + parse_event(b"\x1B[D", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyCode::Left.into()))), + ); + + // parse_csi_modifier_key_code + assert_eq!( + parse_event(b"\x1B[2D", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Left, + KeyModifiers::SHIFT + )))) + ); + + // parse_csi_special_key_code + assert_eq!( + parse_event(b"\x1B[3~", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyCode::Delete.into()))), + ); + + // parse_csi_bracketed_paste + #[cfg(feature = "bracketed-paste")] + assert_eq!( + parse_event(b"\x1B[200~on and on and on\x1B[201~", false).unwrap(), + Some(InternalEvent::Event(Event::Paste( + "on and on and on".to_string() + ))), + ); + + // parse_csi_rxvt_mouse + assert_eq!( + parse_event(b"\x1B[32;30;40;M", false).unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 29, + row: 39, + modifiers: KeyModifiers::empty(), + }))) + ); + + // parse_csi_normal_mouse + assert_eq!( + parse_event(b"\x1B[M0\x60\x70", false).unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 63, + row: 79, + modifiers: KeyModifiers::CONTROL, + }))) + ); + + // parse_csi_sgr_mouse + assert_eq!( + parse_event(b"\x1B[<0;20;10;M", false).unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::empty(), + }))) + ); + + // parse_utf8_char + assert_eq!( + parse_event("Ž".as_bytes(), false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('Ž'), + KeyModifiers::SHIFT + )))), + ); + } + + #[test] + fn test_parse_event() { + assert_eq!( + parse_event(b"\t", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyCode::Tab.into()))), + ); + } + + #[test] + fn test_parse_csi_cursor_position() { + assert_eq!( + parse_csi_cursor_position(b"\x1B[20;10R").unwrap(), + Some(InternalEvent::CursorPosition(9, 19)) + ); + } + + #[test] + fn test_parse_csi() { + assert_eq!( + parse_csi(b"\x1B[D").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyCode::Left.into()))), + ); + } + + #[test] + fn test_parse_csi_modifier_key_code() { + assert_eq!( + parse_csi_modifier_key_code(b"\x1B[2D").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Left, + KeyModifiers::SHIFT + )))), + ); + } + + #[test] + fn test_parse_csi_special_key_code() { + assert_eq!( + parse_csi_special_key_code(b"\x1B[3~").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyCode::Delete.into()))), + ); + } + + #[test] + fn test_parse_csi_special_key_code_multiple_values_not_supported() { + assert_eq!( + parse_csi_special_key_code(b"\x1B[3;2~").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Delete, + KeyModifiers::SHIFT + )))), + ); + } + + #[cfg(feature = "bracketed-paste")] + #[test] + fn test_parse_csi_bracketed_paste() { + // + assert_eq!( + parse_event(b"\x1B[200~o", false).unwrap(), + None, + "A partial bracketed paste isn't parsed" + ); + assert_eq!( + parse_event(b"\x1B[200~o\x1B[2D", false).unwrap(), + None, + "A partial bracketed paste containing another escape code isn't parsed" + ); + assert_eq!( + parse_event(b"\x1B[200~o\x1B[2D\x1B[201~", false).unwrap(), + Some(InternalEvent::Event(Event::Paste("o\x1B[2D".to_string()))) + ); + } + + #[test] + fn test_parse_csi_focus() { + assert_eq!( + parse_csi(b"\x1B[O").unwrap(), + Some(InternalEvent::Event(Event::FocusLost)) + ); + } + + #[test] + fn test_parse_csi_rxvt_mouse() { + assert_eq!( + parse_csi_rxvt_mouse(b"\x1B[32;30;40;M").unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 29, + row: 39, + modifiers: KeyModifiers::empty(), + }))) + ); + } + + #[test] + fn test_parse_csi_normal_mouse() { + assert_eq!( + parse_csi_normal_mouse(b"\x1B[M0\x60\x70").unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 63, + row: 79, + modifiers: KeyModifiers::CONTROL, + }))) + ); + } + + #[test] + fn test_parse_csi_sgr_mouse() { + assert_eq!( + parse_csi_sgr_mouse(b"\x1B[<0;20;10;M").unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::empty(), + }))) + ); + assert_eq!( + parse_csi_sgr_mouse(b"\x1B[<0;20;10M").unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::empty(), + }))) + ); + assert_eq!( + parse_csi_sgr_mouse(b"\x1B[<0;20;10;m").unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::empty(), + }))) + ); + assert_eq!( + parse_csi_sgr_mouse(b"\x1B[<0;20;10m").unwrap(), + Some(InternalEvent::Event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::empty(), + }))) + ); + } + + #[test] + fn test_utf8() { + // https://www.php.net/manual/en/reference.pcre.pattern.modifiers.php#54805 + + // 'Valid ASCII' => "a", + assert_eq!(parse_utf8_char(b"a").unwrap(), Some('a'),); + + // 'Valid 2 Octet Sequence' => "\xc3\xb1", + assert_eq!(parse_utf8_char(&[0xC3, 0xB1]).unwrap(), Some('ñ'),); + + // 'Invalid 2 Octet Sequence' => "\xc3\x28", + assert!(parse_utf8_char(&[0xC3, 0x28]).is_err()); + + // 'Invalid Sequence Identifier' => "\xa0\xa1", + assert!(parse_utf8_char(&[0xA0, 0xA1]).is_err()); + + // 'Valid 3 Octet Sequence' => "\xe2\x82\xa1", + assert_eq!( + parse_utf8_char(&[0xE2, 0x81, 0xA1]).unwrap(), + Some('\u{2061}'), + ); + + // 'Invalid 3 Octet Sequence (in 2nd Octet)' => "\xe2\x28\xa1", + assert!(parse_utf8_char(&[0xE2, 0x28, 0xA1]).is_err()); + + // 'Invalid 3 Octet Sequence (in 3rd Octet)' => "\xe2\x82\x28", + assert!(parse_utf8_char(&[0xE2, 0x82, 0x28]).is_err()); + + // 'Valid 4 Octet Sequence' => "\xf0\x90\x8c\xbc", + assert_eq!( + parse_utf8_char(&[0xF0, 0x90, 0x8C, 0xBC]).unwrap(), + Some('𐌼'), + ); + + // 'Invalid 4 Octet Sequence (in 2nd Octet)' => "\xf0\x28\x8c\xbc", + assert!(parse_utf8_char(&[0xF0, 0x28, 0x8C, 0xBC]).is_err()); + + // 'Invalid 4 Octet Sequence (in 3rd Octet)' => "\xf0\x90\x28\xbc", + assert!(parse_utf8_char(&[0xF0, 0x90, 0x28, 0xBC]).is_err()); + + // 'Invalid 4 Octet Sequence (in 4th Octet)' => "\xf0\x28\x8c\x28", + assert!(parse_utf8_char(&[0xF0, 0x28, 0x8C, 0x28]).is_err()); + } + + #[test] + fn test_parse_char_event_lowercase() { + assert_eq!( + parse_event(b"c", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::empty() + )))), + ); + } + + #[test] + fn test_parse_char_event_uppercase() { + assert_eq!( + parse_event(b"C", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('C'), + KeyModifiers::SHIFT + )))), + ); + } + + #[test] + fn test_parse_basic_csi_u_encoded_key_code() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::empty() + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;2u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('A'), + KeyModifiers::SHIFT + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;7u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::ALT | KeyModifiers::CONTROL + )))), + ); + } + + #[test] + fn test_parse_basic_csi_u_encoded_key_code_special_keys() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[13u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::empty() + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[27u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::empty() + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57358u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::CapsLock, + KeyModifiers::empty() + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57376u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::F(13), + KeyModifiers::empty() + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57428u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Media(MediaKeyCode::Play), + KeyModifiers::empty() + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57441u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Modifier(ModifierKeyCode::LeftShift), + KeyModifiers::SHIFT, + )))), + ); + } + + #[test] + fn test_parse_csi_u_encoded_keypad_code() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57399u").unwrap(), + Some(InternalEvent::Event(Event::Key( + KeyEvent::new_with_kind_and_state( + KeyCode::Char('0'), + KeyModifiers::empty(), + KeyEventKind::Press, + KeyEventState::KEYPAD, + ) + ))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57419u").unwrap(), + Some(InternalEvent::Event(Event::Key( + KeyEvent::new_with_kind_and_state( + KeyCode::Up, + KeyModifiers::empty(), + KeyEventKind::Press, + KeyEventState::KEYPAD, + ) + ))), + ); + } + + #[test] + fn test_parse_csi_u_encoded_key_code_with_types() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;1u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('a'), + KeyModifiers::empty(), + KeyEventKind::Press, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;1:1u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('a'), + KeyModifiers::empty(), + KeyEventKind::Press, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;5:1u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('a'), + KeyModifiers::CONTROL, + KeyEventKind::Press, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;1:2u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('a'), + KeyModifiers::empty(), + KeyEventKind::Repeat, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;1:3u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('a'), + KeyModifiers::empty(), + KeyEventKind::Release, + )))), + ); + } + + #[test] + fn test_parse_csi_u_encoded_key_code_has_modifier_on_modifier_press() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57449u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Modifier(ModifierKeyCode::RightAlt), + KeyModifiers::ALT, + KeyEventKind::Press, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57449;3:3u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Modifier(ModifierKeyCode::RightAlt), + KeyModifiers::ALT, + KeyEventKind::Release, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57450u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Modifier(ModifierKeyCode::RightSuper), + KeyModifiers::SUPER, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57451u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Modifier(ModifierKeyCode::RightHyper), + KeyModifiers::HYPER, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[57452u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Modifier(ModifierKeyCode::RightMeta), + KeyModifiers::META, + )))), + ); + } + + #[test] + fn test_parse_csi_u_encoded_key_code_with_extra_modifiers() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;9u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::SUPER + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;17u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::HYPER, + )))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;33u").unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::META, + )))), + ); + } + + #[test] + fn test_parse_csi_u_encoded_key_code_with_extra_state() { + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[97;65u").unwrap(), + Some(InternalEvent::Event(Event::Key( + KeyEvent::new_with_kind_and_state( + KeyCode::Char('a'), + KeyModifiers::empty(), + KeyEventKind::Press, + KeyEventState::CAPS_LOCK, + ) + ))), + ); + assert_eq!( + parse_csi_u_encoded_key_code(b"\x1B[49;129u").unwrap(), + Some(InternalEvent::Event(Event::Key( + KeyEvent::new_with_kind_and_state( + KeyCode::Char('1'), + KeyModifiers::empty(), + KeyEventKind::Press, + KeyEventState::NUM_LOCK, + ) + ))), + ); + } + + #[test] + fn test_parse_csi_u_with_shifted_keycode() { + assert_eq!( + // A-S-9 is equivalent to A-( + parse_event(b"\x1B[57:40;4u", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('('), + KeyModifiers::ALT, + )))), + ); + assert_eq!( + // A-S-minus is equivalent to A-_ + parse_event(b"\x1B[45:95;4u", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new( + KeyCode::Char('_'), + KeyModifiers::ALT, + )))), + ); + } + + #[test] + fn test_parse_csi_special_key_code_with_types() { + assert_eq!( + parse_event(b"\x1B[;1:3B", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Release, + )))), + ); + assert_eq!( + parse_event(b"\x1B[1;1:3B", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Release, + )))), + ); + } + + #[test] + fn test_parse_csi_numbered_escape_code_with_types() { + assert_eq!( + parse_event(b"\x1B[5;1:3~", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::PageUp, + KeyModifiers::empty(), + KeyEventKind::Release, + )))), + ); + assert_eq!( + parse_event(b"\x1B[6;5:3~", false).unwrap(), + Some(InternalEvent::Event(Event::Key(KeyEvent::new_with_kind( + KeyCode::PageDown, + KeyModifiers::CONTROL, + KeyEventKind::Release, + )))), + ); + } +} diff --git a/patches/crossterm-0.29.0/src/event/sys/unix/waker.rs b/patches/crossterm-0.29.0/src/event/sys/unix/waker.rs new file mode 100644 index 0000000000..1c55f3b801 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/unix/waker.rs @@ -0,0 +1,11 @@ +#[cfg(feature = "use-dev-tty")] +pub(crate) mod tty; + +#[cfg(not(feature = "use-dev-tty"))] +pub(crate) mod mio; + +#[cfg(feature = "use-dev-tty")] +pub(crate) use self::tty::Waker; + +#[cfg(not(feature = "use-dev-tty"))] +pub(crate) use self::mio::Waker; diff --git a/patches/crossterm-0.29.0/src/event/sys/unix/waker/mio.rs b/patches/crossterm-0.29.0/src/event/sys/unix/waker/mio.rs new file mode 100644 index 0000000000..025db73d27 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/unix/waker/mio.rs @@ -0,0 +1,34 @@ +use std::sync::{Arc, Mutex}; + +use ::mio::{Registry, Token}; + +/// Allows to wake up the `mio::Poll::poll()` method. +/// This type wraps `mio::Waker`, for more information see its documentation. +#[derive(Clone, Debug)] +pub(crate) struct Waker { + inner: Arc>, +} + +impl Waker { + /// Create a new `Waker`. + pub(crate) fn new(registry: &Registry, waker_token: Token) -> std::io::Result { + Ok(Self { + inner: Arc::new(Mutex::new(mio::Waker::new(registry, waker_token)?)), + }) + } + + /// Wake up the [`Poll`] associated with this `Waker`. + /// + /// Readiness is set to `Ready::readable()`. + pub(crate) fn wake(&self) -> std::io::Result<()> { + self.inner.lock().unwrap().wake() + } + + /// Resets the state so the same waker can be reused. + /// + /// This function is not impl + #[allow(dead_code, clippy::clippy::unnecessary_wraps)] + pub(crate) fn reset(&self) -> std::io::Result<()> { + Ok(()) + } +} diff --git a/patches/crossterm-0.29.0/src/event/sys/unix/waker/tty.rs b/patches/crossterm-0.29.0/src/event/sys/unix/waker/tty.rs new file mode 100644 index 0000000000..8fda30b64f --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/unix/waker/tty.rs @@ -0,0 +1,28 @@ +use std::{ + io::{self, Write}, + os::unix::net::UnixStream, + sync::{Arc, Mutex}, +}; + +/// Allows to wake up the EventSource::try_read() method. +#[derive(Clone, Debug)] +pub(crate) struct Waker { + inner: Arc>, +} + +impl Waker { + /// Create a new `Waker`. + pub(crate) fn new(writer: UnixStream) -> Self { + Self { + inner: Arc::new(Mutex::new(writer)), + } + } + + /// Wake up the [`Poll`] associated with this `Waker`. + /// + /// Readiness is set to `Ready::readable()`. + pub(crate) fn wake(&self) -> io::Result<()> { + self.inner.lock().unwrap().write_all(&[0])?; + Ok(()) + } +} diff --git a/patches/crossterm-0.29.0/src/event/sys/windows.rs b/patches/crossterm-0.29.0/src/event/sys/windows.rs new file mode 100644 index 0000000000..d405ae8218 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/windows.rs @@ -0,0 +1,48 @@ +//! This is a WINDOWS specific implementation for input related action. + +use std::convert::TryFrom; +use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crossterm_winapi::{ConsoleMode, Handle}; + +pub(crate) mod parse; +pub(crate) mod poll; +#[cfg(feature = "event-stream")] +pub(crate) mod waker; + +const ENABLE_MOUSE_MODE: u32 = 0x0010 | 0x0080 | 0x0008; + +/// This is a either `u64::MAX` if it's uninitialized or a valid `u32` that stores the original +/// console mode if it's initialized. +static ORIGINAL_CONSOLE_MODE: AtomicU64 = AtomicU64::new(u64::MAX); + +/// Initializes the default console color. It will will be skipped if it has already been initialized. +fn init_original_console_mode(original_mode: u32) { + let _ = ORIGINAL_CONSOLE_MODE.compare_exchange( + u64::MAX, + u64::from(original_mode), + Ordering::Relaxed, + Ordering::Relaxed, + ); +} + +/// Returns the original console color, make sure to call `init_console_color` before calling this function. Otherwise this function will panic. +fn original_console_mode() -> std::io::Result { + u32::try_from(ORIGINAL_CONSOLE_MODE.load(Ordering::Relaxed)) + .map_err(|_| io::Error::new(io::ErrorKind::Other, "Initial console modes not set")) +} + +pub(crate) fn enable_mouse_capture() -> std::io::Result<()> { + let mode = ConsoleMode::from(Handle::current_in_handle()?); + init_original_console_mode(mode.mode()?); + mode.set_mode(ENABLE_MOUSE_MODE)?; + + Ok(()) +} + +pub(crate) fn disable_mouse_capture() -> std::io::Result<()> { + let mode = ConsoleMode::from(Handle::current_in_handle()?); + mode.set_mode(original_console_mode()?)?; + Ok(()) +} diff --git a/patches/crossterm-0.29.0/src/event/sys/windows/parse.rs b/patches/crossterm-0.29.0/src/event/sys/windows/parse.rs new file mode 100644 index 0000000000..24a3d75000 --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/windows/parse.rs @@ -0,0 +1,385 @@ +use crossterm_winapi::{ControlKeyState, EventFlags, KeyEventRecord, ScreenBuffer}; +use winapi::um::{ + wincon::{ + CAPSLOCK_ON, LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, + SHIFT_PRESSED, + }, + winuser::{ + GetForegroundWindow, GetKeyboardLayout, GetWindowThreadProcessId, ToUnicodeEx, VK_BACK, + VK_CONTROL, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F24, VK_HOME, VK_INSERT, + VK_LEFT, VK_MENU, VK_NEXT, VK_NUMPAD0, VK_NUMPAD9, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SHIFT, + VK_TAB, VK_UP, + }, +}; + +use crate::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; + +#[derive(Default)] +pub struct MouseButtonsPressed { + pub(crate) left: bool, + pub(crate) right: bool, + pub(crate) middle: bool, +} + +pub(crate) fn handle_mouse_event( + mouse_event: crossterm_winapi::MouseEvent, + buttons_pressed: &MouseButtonsPressed, +) -> Option { + if let Ok(Some(event)) = parse_mouse_event_record(&mouse_event, buttons_pressed) { + return Some(Event::Mouse(event)); + } + + None +} + +enum WindowsKeyEvent { + KeyEvent(KeyEvent), + Surrogate(u16), +} + +pub(crate) fn handle_key_event( + key_event: KeyEventRecord, + surrogate_buffer: &mut Option, +) -> Option { + let windows_key_event = parse_key_event_record(&key_event)?; + match windows_key_event { + WindowsKeyEvent::KeyEvent(key_event) => { + // Discard any buffered surrogate value if another valid key event comes before the + // next surrogate value. + *surrogate_buffer = None; + Some(Event::Key(key_event)) + } + WindowsKeyEvent::Surrogate(new_surrogate) => { + let ch = handle_surrogate(surrogate_buffer, new_surrogate)?; + let modifiers = KeyModifiers::from(&key_event.control_key_state); + let key_event = KeyEvent::new(KeyCode::Char(ch), modifiers); + Some(Event::Key(key_event)) + } + } +} + +fn handle_surrogate(surrogate_buffer: &mut Option, new_surrogate: u16) -> Option { + match *surrogate_buffer { + Some(buffered_surrogate) => { + *surrogate_buffer = None; + std::char::decode_utf16([buffered_surrogate, new_surrogate]) + .next() + .unwrap() + .ok() + } + None => { + *surrogate_buffer = Some(new_surrogate); + None + } + } +} + +impl From<&ControlKeyState> for KeyModifiers { + fn from(state: &ControlKeyState) -> Self { + let shift = state.has_state(SHIFT_PRESSED); + let alt = state.has_state(LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED); + let control = state.has_state(LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED); + + let mut modifier = KeyModifiers::empty(); + + if shift { + modifier |= KeyModifiers::SHIFT; + } + if control { + modifier |= KeyModifiers::CONTROL; + } + if alt { + modifier |= KeyModifiers::ALT; + } + + modifier + } +} + +enum CharCase { + LowerCase, + UpperCase, +} + +fn try_ensure_char_case(ch: char, desired_case: CharCase) -> char { + match desired_case { + CharCase::LowerCase if ch.is_uppercase() => { + let mut iter = ch.to_lowercase(); + // Unwrap is safe; iterator yields one or more chars. + let ch_lower = iter.next().unwrap(); + if iter.next().is_none() { + ch_lower + } else { + ch + } + } + CharCase::UpperCase if ch.is_lowercase() => { + let mut iter = ch.to_uppercase(); + // Unwrap is safe; iterator yields one or more chars. + let ch_upper = iter.next().unwrap(); + if iter.next().is_none() { + ch_upper + } else { + ch + } + } + _ => ch, + } +} + +// Attempts to return the character for a key event accounting for the user's keyboard layout. +// The returned character (if any) is capitalized (if applicable) based on shift and capslock state. +// Returns None if the key doesn't map to a character or if it is a dead key. +// We use the *currently* active keyboard layout (if it can be determined). This layout may not +// correspond to the keyboard layout that was active when the user typed their input, since console +// applications get their input asynchronously from the terminal. By the time a console application +// can process a key input, the user may have changed the active layout. In this case, the character +// returned might not correspond to what the user expects, but there is no way for a console +// application to know what the keyboard layout actually was for a key event, so this is our best +// effort. If a console application processes input in a timely fashion, then it is unlikely that a +// user has time to change their keyboard layout before a key event is processed. +fn get_char_for_key(key_event: &KeyEventRecord) -> Option { + let virtual_key_code = key_event.virtual_key_code as u32; + let virtual_scan_code = key_event.virtual_scan_code as u32; + let key_state = [0u8; 256]; + let mut utf16_buf = [0u16, 16]; + let dont_change_kernel_keyboard_state = 0x4; + + // Best-effort attempt at determining the currently active keyboard layout. + // At the time of writing, this works for a console application running in Windows Terminal, but + // doesn't work under a Conhost terminal. For Conhost, the window handle returned by + // GetForegroundWindow() does not appear to actually be the foreground window which has the + // keyboard layout associated with it (or perhaps it is, but also has special protection that + // doesn't allow us to query it). + // When this determination fails, the returned keyboard layout handle will be null, which is an + // acceptable input for ToUnicodeEx, as that argument is optional. In this case ToUnicodeEx + // appears to use the keyboard layout associated with the current thread, which will be the + // layout that was inherited when the console application started (or possibly when the current + // thread was spawned). This is then unfortunately not updated when the user changes their + // keyboard layout in the terminal, but it's what we get. + let active_keyboard_layout = unsafe { + let foreground_window = GetForegroundWindow(); + let foreground_thread = GetWindowThreadProcessId(foreground_window, std::ptr::null_mut()); + GetKeyboardLayout(foreground_thread) + }; + + let ret = unsafe { + ToUnicodeEx( + virtual_key_code, + virtual_scan_code, + key_state.as_ptr(), + utf16_buf.as_mut_ptr(), + utf16_buf.len() as i32, + dont_change_kernel_keyboard_state, + active_keyboard_layout, + ) + }; + + // -1 indicates a dead key. + // 0 indicates no character for this key. + if ret < 1 { + return None; + } + + let mut ch_iter = std::char::decode_utf16(utf16_buf.into_iter().take(ret as usize)); + let mut ch = ch_iter.next()?.ok()?; + if ch_iter.next().is_some() { + // Key doesn't map to a single char. + return None; + } + + let is_shift_pressed = key_event.control_key_state.has_state(SHIFT_PRESSED); + let is_capslock_on = key_event.control_key_state.has_state(CAPSLOCK_ON); + let desired_case = if is_shift_pressed ^ is_capslock_on { + CharCase::UpperCase + } else { + CharCase::LowerCase + }; + ch = try_ensure_char_case(ch, desired_case); + Some(ch) +} + +fn parse_key_event_record(key_event: &KeyEventRecord) -> Option { + let modifiers = KeyModifiers::from(&key_event.control_key_state); + let virtual_key_code = key_event.virtual_key_code as i32; + + // We normally ignore all key release events, but we will make an exception for an Alt key + // release if it carries a u_char value, as this indicates an Alt code. + let is_alt_code = virtual_key_code == VK_MENU && !key_event.key_down && key_event.u_char != 0; + if is_alt_code { + let utf16 = key_event.u_char; + match utf16 { + surrogate @ 0xD800..=0xDFFF => { + return Some(WindowsKeyEvent::Surrogate(surrogate)); + } + unicode_scalar_value => { + // Unwrap is safe: We tested for surrogate values above and those are the only + // u16 values that are invalid when directly interpreted as unicode scalar + // values. + let ch = std::char::from_u32(unicode_scalar_value as u32).unwrap(); + let key_code = KeyCode::Char(ch); + let kind = if key_event.key_down { + KeyEventKind::Press + } else { + KeyEventKind::Release + }; + let key_event = KeyEvent::new_with_kind(key_code, modifiers, kind); + return Some(WindowsKeyEvent::KeyEvent(key_event)); + } + } + } + + // Don't generate events for numpad key presses when they're producing Alt codes. + let is_numpad_numeric_key = (VK_NUMPAD0..=VK_NUMPAD9).contains(&virtual_key_code); + let is_only_alt_modifier = modifiers.contains(KeyModifiers::ALT) + && !modifiers.contains(KeyModifiers::SHIFT | KeyModifiers::CONTROL); + if is_only_alt_modifier && is_numpad_numeric_key { + return None; + } + + let parse_result = match virtual_key_code { + VK_SHIFT | VK_CONTROL | VK_MENU => None, + VK_BACK => Some(KeyCode::Backspace), + VK_ESCAPE => Some(KeyCode::Esc), + VK_RETURN => Some(KeyCode::Enter), + VK_F1..=VK_F24 => Some(KeyCode::F((key_event.virtual_key_code - 111) as u8)), + VK_LEFT => Some(KeyCode::Left), + VK_UP => Some(KeyCode::Up), + VK_RIGHT => Some(KeyCode::Right), + VK_DOWN => Some(KeyCode::Down), + VK_PRIOR => Some(KeyCode::PageUp), + VK_NEXT => Some(KeyCode::PageDown), + VK_HOME => Some(KeyCode::Home), + VK_END => Some(KeyCode::End), + VK_DELETE => Some(KeyCode::Delete), + VK_INSERT => Some(KeyCode::Insert), + VK_TAB if modifiers.contains(KeyModifiers::SHIFT) => Some(KeyCode::BackTab), + VK_TAB => Some(KeyCode::Tab), + _ => { + let utf16 = key_event.u_char; + match utf16 { + 0x00..=0x1f => { + // Some key combinations generate either no u_char value or generate control + // codes. To deliver back a KeyCode::Char(...) event we want to know which + // character the key normally maps to on the user's keyboard layout. + // The keys that intentionally generate control codes (ESC, ENTER, TAB, etc.) + // are handled by their virtual key codes above. + get_char_for_key(key_event).map(KeyCode::Char) + } + surrogate @ 0xD800..=0xDFFF => { + // Codewhale patch: Windows Terminal sends a key-down and a key-up record + // for each UTF-16 half of a pasted astral character. Pairing a half with + // its own key-up decodes to nothing and drops the character, so only the + // key-down halves take part in surrogate pairing. + if !key_event.key_down { + return None; + } + return Some(WindowsKeyEvent::Surrogate(surrogate)); + } + unicode_scalar_value => { + // Unwrap is safe: We tested for surrogate values above and those are the only + // u16 values that are invalid when directly interpreted as unicode scalar + // values. + let ch = std::char::from_u32(unicode_scalar_value as u32).unwrap(); + Some(KeyCode::Char(ch)) + } + } + } + }; + + if let Some(key_code) = parse_result { + let kind = if key_event.key_down { + KeyEventKind::Press + } else { + KeyEventKind::Release + }; + let key_event = KeyEvent::new_with_kind(key_code, modifiers, kind); + return Some(WindowsKeyEvent::KeyEvent(key_event)); + } + + None +} + +// The 'y' position of a mouse event or resize event is not relative to the window but absolute to screen buffer. +// This means that when the mouse cursor is at the top left it will be x: 0, y: 2295 (e.g. y = number of cells conting from the absolute buffer height) instead of relative x: 0, y: 0 to the window. +pub fn parse_relative_y(y: i16) -> std::io::Result { + let window_size = ScreenBuffer::current()?.info()?.terminal_window(); + Ok(y - window_size.top) +} + +fn parse_mouse_event_record( + event: &crossterm_winapi::MouseEvent, + buttons_pressed: &MouseButtonsPressed, +) -> std::io::Result> { + let modifiers = KeyModifiers::from(&event.control_key_state); + + let xpos = event.mouse_position.x as u16; + let ypos = parse_relative_y(event.mouse_position.y)? as u16; + + let button_state = event.button_state; + + let kind = match event.event_flags { + EventFlags::PressOrRelease | EventFlags::DoubleClick => { + if button_state.left_button() && !buttons_pressed.left { + Some(MouseEventKind::Down(MouseButton::Left)) + } else if !button_state.left_button() && buttons_pressed.left { + Some(MouseEventKind::Up(MouseButton::Left)) + } else if button_state.right_button() && !buttons_pressed.right { + Some(MouseEventKind::Down(MouseButton::Right)) + } else if !button_state.right_button() && buttons_pressed.right { + Some(MouseEventKind::Up(MouseButton::Right)) + } else if button_state.middle_button() && !buttons_pressed.middle { + Some(MouseEventKind::Down(MouseButton::Middle)) + } else if !button_state.middle_button() && buttons_pressed.middle { + Some(MouseEventKind::Up(MouseButton::Middle)) + } else { + None + } + } + EventFlags::MouseMoved => { + let button = if button_state.right_button() { + MouseButton::Right + } else if button_state.middle_button() { + MouseButton::Middle + } else { + MouseButton::Left + }; + if button_state.release_button() { + Some(MouseEventKind::Moved) + } else { + Some(MouseEventKind::Drag(button)) + } + } + EventFlags::MouseWheeled => { + // Vertical scroll + // from https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str + // if `button_state` is negative then the wheel was rotated backward, toward the user. + if button_state.scroll_down() { + Some(MouseEventKind::ScrollDown) + } else if button_state.scroll_up() { + Some(MouseEventKind::ScrollUp) + } else { + None + } + } + EventFlags::MouseHwheeled => { + if button_state.scroll_left() { + Some(MouseEventKind::ScrollLeft) + } else if button_state.scroll_right() { + Some(MouseEventKind::ScrollRight) + } else { + None + } + } + _ => None, + }; + + Ok(kind.map(|kind| MouseEvent { + kind, + column: xpos, + row: ypos, + modifiers, + })) +} diff --git a/patches/crossterm-0.29.0/src/event/sys/windows/poll.rs b/patches/crossterm-0.29.0/src/event/sys/windows/poll.rs new file mode 100644 index 0000000000..84d9bf76bb --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/windows/poll.rs @@ -0,0 +1,86 @@ +use std::io; +use std::time::Duration; + +use crossterm_winapi::Handle; +use winapi::{ + shared::winerror::WAIT_TIMEOUT, + um::{ + synchapi::WaitForMultipleObjects, + winbase::{INFINITE, WAIT_ABANDONED_0, WAIT_FAILED, WAIT_OBJECT_0}, + }, +}; + +#[cfg(feature = "event-stream")] +pub(crate) use super::waker::Waker; + +#[derive(Debug)] +pub(crate) struct WinApiPoll { + #[cfg(feature = "event-stream")] + waker: Waker, +} + +impl WinApiPoll { + #[cfg(not(feature = "event-stream"))] + pub(crate) fn new() -> WinApiPoll { + WinApiPoll {} + } + + #[cfg(feature = "event-stream")] + pub(crate) fn new() -> std::io::Result { + Ok(WinApiPoll { + waker: Waker::new()?, + }) + } +} + +impl WinApiPoll { + pub fn poll(&mut self, timeout: Option) -> std::io::Result> { + let dw_millis = if let Some(duration) = timeout { + duration.as_millis() as u32 + } else { + INFINITE + }; + + let console_handle = Handle::current_in_handle()?; + + #[cfg(feature = "event-stream")] + let semaphore = self.waker.semaphore(); + #[cfg(feature = "event-stream")] + let handles = &[*console_handle, **semaphore.handle()]; + #[cfg(not(feature = "event-stream"))] + let handles = &[*console_handle]; + + let output = + unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, dw_millis) }; + + match output { + output if output == WAIT_OBJECT_0 => { + // input handle triggered + Ok(Some(true)) + } + #[cfg(feature = "event-stream")] + output if output == WAIT_OBJECT_0 + 1 => { + // semaphore handle triggered + let _ = self.waker.reset(); + Err(io::Error::new( + io::ErrorKind::Interrupted, + "Poll operation was woken up by `Waker::wake`", + )) + } + WAIT_TIMEOUT | WAIT_ABANDONED_0 => { + // timeout elapsed + Ok(None) + } + WAIT_FAILED => Err(io::Error::last_os_error()), + _ => Err(io::Error::new( + io::ErrorKind::Other, + "WaitForMultipleObjects returned unexpected result.", + )), + } + } + + #[cfg(feature = "event-stream")] + pub fn waker(&self) -> Waker { + self.waker.clone() + } +} diff --git a/patches/crossterm-0.29.0/src/event/sys/windows/waker.rs b/patches/crossterm-0.29.0/src/event/sys/windows/waker.rs new file mode 100644 index 0000000000..9ec582bf4b --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/sys/windows/waker.rs @@ -0,0 +1,40 @@ +use std::sync::{Arc, Mutex}; + +use crossterm_winapi::Semaphore; + +/// Allows to wake up the `WinApiPoll::poll()` method. +#[derive(Clone, Debug)] +pub(crate) struct Waker { + inner: Arc>, +} + +impl Waker { + /// Creates a new waker. + /// + /// `Waker` is based on the `Semaphore`. You have to use the semaphore + /// handle along with the `WaitForMultipleObjects`. + pub(crate) fn new() -> std::io::Result { + let inner = Semaphore::new()?; + + Ok(Self { + inner: Arc::new(Mutex::new(inner)), + }) + } + + /// Wakes the `WaitForMultipleObjects`. + pub(crate) fn wake(&self) -> std::io::Result<()> { + self.inner.lock().unwrap().release()?; + Ok(()) + } + + /// Replaces the current semaphore with a new one allowing us to reuse the same `Waker`. + pub(crate) fn reset(&self) -> std::io::Result<()> { + *self.inner.lock().unwrap() = Semaphore::new()?; + Ok(()) + } + + /// Returns the semaphore associated with the waker. + pub(crate) fn semaphore(&self) -> Semaphore { + self.inner.lock().unwrap().clone() + } +} diff --git a/patches/crossterm-0.29.0/src/event/timeout.rs b/patches/crossterm-0.29.0/src/event/timeout.rs new file mode 100644 index 0000000000..f266d28dcc --- /dev/null +++ b/patches/crossterm-0.29.0/src/event/timeout.rs @@ -0,0 +1,92 @@ +use std::time::{Duration, Instant}; + +/// Keeps track of the elapsed time since the moment the polling started. +#[derive(Debug, Clone)] +pub struct PollTimeout { + timeout: Option, + start: Instant, +} + +impl PollTimeout { + /// Constructs a new `PollTimeout` with the given optional `Duration`. + pub fn new(timeout: Option) -> PollTimeout { + PollTimeout { + timeout, + start: Instant::now(), + } + } + + /// Returns whether the timeout has elapsed. + /// + /// It always returns `false` if the initial timeout was set to `None`. + pub fn elapsed(&self) -> bool { + self.timeout + .map(|timeout| self.start.elapsed() >= timeout) + .unwrap_or(false) + } + + /// Returns the timeout leftover (initial timeout duration - elapsed duration). + pub fn leftover(&self) -> Option { + self.timeout.map(|timeout| { + let elapsed = self.start.elapsed(); + + if elapsed >= timeout { + Duration::from_secs(0) + } else { + timeout - elapsed + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::PollTimeout; + + #[test] + pub fn test_timeout_without_duration_does_not_have_leftover() { + let timeout = PollTimeout::new(None); + assert_eq!(timeout.leftover(), None) + } + + #[test] + pub fn test_timeout_without_duration_never_elapses() { + let timeout = PollTimeout::new(None); + assert!(!timeout.elapsed()); + } + + #[test] + pub fn test_timeout_elapses() { + const TIMEOUT_MILLIS: u64 = 100; + + let timeout = PollTimeout { + timeout: Some(Duration::from_millis(TIMEOUT_MILLIS)), + start: Instant::now() - Duration::from_millis(2 * TIMEOUT_MILLIS), + }; + + assert!(timeout.elapsed()); + } + + #[test] + pub fn test_elapsed_timeout_has_zero_leftover() { + const TIMEOUT_MILLIS: u64 = 100; + + let timeout = PollTimeout { + timeout: Some(Duration::from_millis(TIMEOUT_MILLIS)), + start: Instant::now() - Duration::from_millis(2 * TIMEOUT_MILLIS), + }; + + assert!(timeout.elapsed()); + assert_eq!(timeout.leftover(), Some(Duration::from_millis(0))); + } + + #[test] + pub fn test_not_elapsed_timeout_has_positive_leftover() { + let timeout = PollTimeout::new(Some(Duration::from_secs(60))); + + assert!(!timeout.elapsed()); + assert!(timeout.leftover().unwrap() > Duration::from_secs(0)); + } +} diff --git a/patches/crossterm-0.29.0/src/lib.rs b/patches/crossterm-0.29.0/src/lib.rs new file mode 100644 index 0000000000..50e00df439 --- /dev/null +++ b/patches/crossterm-0.29.0/src/lib.rs @@ -0,0 +1,266 @@ +// Codewhale patch: a path-patched crate loses Cargo's cap-lints, and CI builds with +// `-D warnings` on a floating stable toolchain. Keep upstream lints from failing our build. +#![allow(warnings)] +#![deny(unused_imports, unused_must_use)] + +//! # Cross-platform Terminal Manipulation Library +//! +//! Crossterm is a pure-rust, terminal manipulation library that makes it possible to write cross-platform text-based interfaces. +//! +//! This crate supports all UNIX and Windows terminals down to Windows 7 (not all terminals are tested +//! see [Tested Terminals](https://github.com/crossterm-rs/crossterm#tested-terminals) +//! for more info). +//! +//! ## Command API +//! +//! The command API makes the use of `crossterm` much easier and offers more control over when and how a +//! command is executed. A command is just an action you can perform on the terminal e.g. cursor movement. +//! +//! The command API offers: +//! +//! * Better Performance. +//! * Complete control over when to flush. +//! * Complete control over where the ANSI escape commands are executed to. +//! * Way easier and nicer API. +//! +//! There are two ways to use the API command: +//! +//! * Functions can execute commands on types that implement Write. Functions are easier to use and debug. +//! There is a disadvantage, and that is that there is a boilerplate code involved. +//! * Macros are generally seen as more difficult and aren't always well supported by editors but offer an API with less boilerplate code. If you are +//! not afraid of macros, this is a recommendation. +//! +//! Linux and Windows 10 systems support ANSI escape codes. Those ANSI escape codes are strings or rather a +//! byte sequence. When we `write` and `flush` those to the terminal we can perform some action. +//! For older windows systems a WinAPI call is made. +//! +//! ### Supported Commands +//! +//! - Module [`cursor`](cursor/index.html) +//! - Visibility - [`Show`](cursor/struct.Show.html), [`Hide`](cursor/struct.Hide.html) +//! - Appearance - [`EnableBlinking`](cursor/struct.EnableBlinking.html), +//! [`DisableBlinking`](cursor/struct.DisableBlinking.html), +//! [`SetCursorStyle`](cursor/enum.SetCursorStyle.html) +//! - Position - +//! [`SavePosition`](cursor/struct.SavePosition.html), [`RestorePosition`](cursor/struct.RestorePosition.html), +//! [`MoveUp`](cursor/struct.MoveUp.html), [`MoveDown`](cursor/struct.MoveDown.html), +//! [`MoveLeft`](cursor/struct.MoveLeft.html), [`MoveRight`](cursor/struct.MoveRight.html), +//! [`MoveTo`](cursor/struct.MoveTo.html), [`MoveToColumn`](cursor/struct.MoveToColumn.html),[`MoveToRow`](cursor/struct.MoveToRow.html), +//! [`MoveToNextLine`](cursor/struct.MoveToNextLine.html), [`MoveToPreviousLine`](cursor/struct.MoveToPreviousLine.html) +//! - Module [`event`](event/index.html) +//! - Keyboard events - +//! [`PushKeyboardEnhancementFlags`](event/struct.PushKeyboardEnhancementFlags.html), +//! [`PopKeyboardEnhancementFlags`](event/struct.PopKeyboardEnhancementFlags.html) +//! - Mouse events - [`EnableMouseCapture`](event/struct.EnableMouseCapture.html), +//! [`DisableMouseCapture`](event/struct.DisableMouseCapture.html) +//! - Module [`style`](style/index.html) +//! - Colors - [`SetForegroundColor`](style/struct.SetForegroundColor.html), +//! [`SetBackgroundColor`](style/struct.SetBackgroundColor.html), +//! [`ResetColor`](style/struct.ResetColor.html), [`SetColors`](style/struct.SetColors.html) +//! - Attributes - [`SetAttribute`](style/struct.SetAttribute.html), [`SetAttributes`](style/struct.SetAttributes.html), +//! [`PrintStyledContent`](style/struct.PrintStyledContent.html) +//! - Module [`terminal`](terminal/index.html) +//! - Scrolling - [`ScrollUp`](terminal/struct.ScrollUp.html), +//! [`ScrollDown`](terminal/struct.ScrollDown.html) +//! - Miscellaneous - [`Clear`](terminal/struct.Clear.html), +//! [`SetSize`](terminal/struct.SetSize.html), +//! [`SetTitle`](terminal/struct.SetTitle.html), +//! [`DisableLineWrap`](terminal/struct.DisableLineWrap.html), +//! [`EnableLineWrap`](terminal/struct.EnableLineWrap.html) +//! - Alternate screen - [`EnterAlternateScreen`](terminal/struct.EnterAlternateScreen.html), +//! [`LeaveAlternateScreen`](terminal/struct.LeaveAlternateScreen.html) +//! - Module [`clipboard`](clipboard/index.html) (requires +//! [`feature = "osc52"`](#optional-features)) +//! - Clipboard - [`CopyToClipboard`](clipboard/struct.CopyToClipboard.html) +//! +//! ### Command Execution +//! +//! There are two different ways to execute commands: +//! +//! * [Lazy Execution](#lazy-execution) +//! * [Direct Execution](#direct-execution) +//! +//! #### Lazy Execution +//! +//! Flushing bytes to the terminal buffer is a heavy system call. If we perform a lot of actions with the terminal, +//! we want to do this periodically - like with a TUI editor - so that we can flush more data to the terminal buffer +//! at the same time. +//! +//! Crossterm offers the possibility to do this with `queue`. +//! With `queue` you can queue commands, and when you call [Write::flush][flush] these commands will be executed. +//! +//! You can pass a custom buffer implementing [std::io::Write][write] to this `queue` operation. +//! The commands will be executed on that buffer. +//! The most common buffer is [std::io::stdout][stdout] however, [std::io::stderr][stderr] is used sometimes as well. +//! +//! ##### Examples +//! +//! A simple demonstration that shows the command API in action with cursor commands. +//! +//! Functions: +//! +//! ```no_run +//! use std::io::{Write, stdout}; +//! use crossterm::{QueueableCommand, cursor}; +//! +//! let mut stdout = stdout(); +//! stdout.queue(cursor::MoveTo(5,5)); +//! +//! // some other code ... +//! +//! stdout.flush(); +//! ``` +//! +//! The [queue](./trait.QueueableCommand.html) function returns itself, therefore you can use this to queue another +//! command. Like `stdout.queue(Goto(5,5)).queue(Clear(ClearType::All))`. +//! +//! Macros: +//! +//! ```no_run +//! use std::io::{Write, stdout}; +//! use crossterm::{queue, QueueableCommand, cursor}; +//! +//! let mut stdout = stdout(); +//! queue!(stdout, cursor::MoveTo(5, 5)); +//! +//! // some other code ... +//! +//! // move operation is performed only if we flush the buffer. +//! stdout.flush(); +//! ``` +//! +//! You can pass more than one command into the [queue](./macro.queue.html) macro like +//! `queue!(stdout, MoveTo(5, 5), Clear(ClearType::All))` and +//! they will be executed in the given order from left to right. +//! +//! #### Direct Execution +//! +//! For many applications it is not at all important to be efficient with 'flush' operations. +//! For this use case there is the `execute` operation. +//! This operation executes the command immediately, and calls the `flush` under water. +//! +//! You can pass a custom buffer implementing [std::io::Write][write] to this `execute` operation. +//! The commands will be executed on that buffer. +//! The most common buffer is [std::io::stdout][stdout] however, [std::io::stderr][stderr] is used sometimes as well. +//! +//! ##### Examples +//! +//! Functions: +//! +//! ```no_run +//! use std::io::{Write, stdout}; +//! use crossterm::{ExecutableCommand, cursor}; +//! +//! let mut stdout = stdout(); +//! stdout.execute(cursor::MoveTo(5,5)); +//! ``` +//! The [execute](./trait.ExecutableCommand.html) function returns itself, therefore you can use this to queue +//! another command. Like `stdout.execute(Goto(5,5))?.execute(Clear(ClearType::All))`. +//! +//! Macros: +//! +//! ```no_run +//! use std::io::{stdout, Write}; +//! use crossterm::{execute, ExecutableCommand, cursor}; +//! +//! let mut stdout = stdout(); +//! execute!(stdout, cursor::MoveTo(5, 5)); +//! ``` +//! You can pass more than one command into the [execute](./macro.execute.html) macro like +//! `execute!(stdout, MoveTo(5, 5), Clear(ClearType::All))` and they will be executed in the given order from +//! left to right. +//! +//! ## Examples +//! +//! Print a rectangle colored with magenta and use both direct execution and lazy execution. +//! +//! Functions: +//! +//! ```no_run +//! use std::io::{self, Write}; +//! use crossterm::{ +//! ExecutableCommand, QueueableCommand, +//! terminal, cursor, style::{self, Stylize} +//! }; +//! +//! fn main() -> io::Result<()> { +//! let mut stdout = io::stdout(); +//! +//! stdout.execute(terminal::Clear(terminal::ClearType::All))?; +//! +//! for y in 0..40 { +//! for x in 0..150 { +//! if (y == 0 || y == 40 - 1) || (x == 0 || x == 150 - 1) { +//! // in this loop we are more efficient by not flushing the buffer. +//! stdout +//! .queue(cursor::MoveTo(x,y))? +//! .queue(style::PrintStyledContent( "█".magenta()))?; +//! } +//! } +//! } +//! stdout.flush()?; +//! Ok(()) +//! } +//! ``` +//! +//! Macros: +//! +//! ```no_run +//! use std::io::{self, Write}; +//! use crossterm::{ +//! execute, queue, +//! style::{self, Stylize}, cursor, terminal +//! }; +//! +//! fn main() -> io::Result<()> { +//! let mut stdout = io::stdout(); +//! +//! execute!(stdout, terminal::Clear(terminal::ClearType::All))?; +//! +//! for y in 0..40 { +//! for x in 0..150 { +//! if (y == 0 || y == 40 - 1) || (x == 0 || x == 150 - 1) { +//! // in this loop we are more efficient by not flushing the buffer. +//! queue!(stdout, cursor::MoveTo(x,y), style::PrintStyledContent( "█".magenta()))?; +//! } +//! } +//! } +//! stdout.flush()?; +//! Ok(()) +//! } +//!``` +//! ## Feature Flags +#![doc = document_features::document_features!()] +//! +//! [write]: https://doc.rust-lang.org/std/io/trait.Write.html +//! [stdout]: https://doc.rust-lang.org/std/io/fn.stdout.html +//! [stderr]: https://doc.rust-lang.org/std/io/fn.stderr.html +//! [flush]: https://doc.rust-lang.org/std/io/trait.Write.html#tymethod.flush + +pub use crate::command::{Command, ExecutableCommand, QueueableCommand, SynchronizedUpdate}; + +/// A module to work with the terminal cursor +pub mod cursor; +/// A module to read events. +#[cfg(feature = "events")] +pub mod event; +/// A module to apply attributes and colors on your text. +pub mod style; +/// A module to work with the terminal. +pub mod terminal; + +/// A module to query if the current instance is a tty. +pub mod tty; + +/// A module for clipboard interaction +#[cfg(feature = "osc52")] +pub mod clipboard; + +#[cfg(windows)] +/// A module that exposes one function to check if the current terminal supports ANSI sequences. +pub mod ansi_support; +mod command; +pub(crate) mod macros; + +#[cfg(all(windows, not(feature = "windows")))] +compile_error!("Compiling on Windows with \"windows\" feature disabled. Feature \"windows\" should only be disabled when project will never be compiled on Windows."); diff --git a/patches/crossterm-0.29.0/src/macros.rs b/patches/crossterm-0.29.0/src/macros.rs new file mode 100644 index 0000000000..47ded98594 --- /dev/null +++ b/patches/crossterm-0.29.0/src/macros.rs @@ -0,0 +1,378 @@ +/// Concatenate string literals while prepending a ANSI control sequence introducer (`"\x1b["`) +#[macro_export] +#[doc(hidden)] +macro_rules! csi { + ($( $l:expr ),*) => { concat!("\x1B[", $( $l ),*) }; +} + +/// Concatenate string literals while prepending a xterm Operating System Commands (OSC) +/// introducer (`"\x1b]"`) and appending a BEL (`"\x07"`). +#[macro_export] +#[doc(hidden)] +macro_rules! osc { + ($( $l:expr ),*) => { concat!("\x1B]", $( $l ),*, "\x1B\\") }; +} + +/// Queues one or more command(s) for further execution. +/// +/// Queued commands must be flushed to the underlying device to be executed. +/// This generally happens in the following cases: +/// +/// * When `flush` is called manually on the given type implementing `io::Write`. +/// * The terminal will `flush` automatically if the buffer is full. +/// * Each line is flushed in case of `stdout`, because it is line buffered. +/// +/// # Arguments +/// +/// - [std::io::Writer](std::io::Write) +/// +/// ANSI escape codes are written on the given 'writer', after which they are flushed. +/// +/// - [Command](./trait.Command.html) +/// +/// One or more commands +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Write, stdout}; +/// use crossterm::{queue, style::Print}; +/// +/// let mut stdout = stdout(); +/// +/// // `Print` will executed executed when `flush` is called. +/// queue!(stdout, Print("foo".to_string())); +/// +/// // some other code (no execution happening here) ... +/// +/// // when calling `flush` on `stdout`, all commands will be written to the stdout and therefore executed. +/// stdout.flush(); +/// +/// // ==== Output ==== +/// // foo +/// ``` +/// +/// Have a look over at the [Command API](./index.html#command-api) for more details. +/// +/// # Notes +/// +/// In case of Windows versions lower than 10, a direct WinAPI call will be made. +/// The reason for this is that Windows versions lower than 10 do not support ANSI codes, +/// and can therefore not be written to the given `writer`. +/// Therefore, there is no difference between [execute](macro.execute.html) +/// and [queue](macro.queue.html) for those old Windows versions. +/// +#[macro_export] +macro_rules! queue { + ($writer:expr $(, $command:expr)* $(,)?) => {{ + use ::std::io::Write; + + // This allows the macro to take both mut impl Write and &mut impl Write. + Ok($writer.by_ref()) + $(.and_then(|writer| $crate::QueueableCommand::queue(writer, $command)))* + .map(|_| ()) + }} +} + +/// Executes one or more command(s). +/// +/// # Arguments +/// +/// - [std::io::Writer](std::io::Write) +/// +/// ANSI escape codes are written on the given 'writer', after which they are flushed. +/// +/// - [Command](./trait.Command.html) +/// +/// One or more commands +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Write, stdout}; +/// use crossterm::{execute, style::Print}; +/// +/// // will be executed directly +/// execute!(stdout(), Print("sum:\n".to_string())); +/// +/// // will be executed directly +/// execute!(stdout(), Print("1 + 1 = ".to_string()), Print((1+1).to_string())); +/// +/// // ==== Output ==== +/// // sum: +/// // 1 + 1 = 2 +/// ``` +/// +/// Have a look over at the [Command API](./index.html#command-api) for more details. +/// +/// # Notes +/// +/// * In the case of UNIX and Windows 10, ANSI codes are written to the given 'writer'. +/// * In case of Windows versions lower than 10, a direct WinAPI call will be made. +/// The reason for this is that Windows versions lower than 10 do not support ANSI codes, +/// and can therefore not be written to the given `writer`. +/// Therefore, there is no difference between [execute](macro.execute.html) +/// and [queue](macro.queue.html) for those old Windows versions. +#[macro_export] +macro_rules! execute { + ($writer:expr $(, $command:expr)* $(,)? ) => {{ + use ::std::io::Write; + + // Queue each command, then flush + $crate::queue!($writer $(, $command)*) + .and_then(|()| { + ::std::io::Write::flush($writer.by_ref()) + }) + }} +} + +#[doc(hidden)] +#[macro_export] +macro_rules! impl_display { + (for $($t:ty),+) => { + $(impl ::std::fmt::Display for $t { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + $crate::command::execute_fmt(f, self) + } + })* + } +} + +#[doc(hidden)] +#[macro_export] +macro_rules! impl_from { + ($from:path, $to:expr) => { + impl From<$from> for ErrorKind { + fn from(e: $from) -> Self { + $to(e) + } + } + }; +} + +#[cfg(test)] +mod tests { + use std::io; + use std::str; + + // Helper for execute tests to confirm flush + #[derive(Default, Debug, Clone)] + struct FakeWrite { + buffer: String, + flushed: bool, + } + + impl io::Write for FakeWrite { + fn write(&mut self, content: &[u8]) -> io::Result { + let content = str::from_utf8(content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + self.buffer.push_str(content); + self.flushed = false; + Ok(content.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flushed = true; + Ok(()) + } + } + + #[cfg(not(windows))] + mod unix { + use std::fmt; + + use super::FakeWrite; + use crate::command::Command; + + pub struct FakeCommand; + + impl Command for FakeCommand { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("cmd") + } + } + + #[test] + fn test_queue_one() { + let mut result = FakeWrite::default(); + queue!(&mut result, FakeCommand).unwrap(); + assert_eq!(&result.buffer, "cmd"); + assert!(!result.flushed); + } + + #[test] + fn test_queue_many() { + let mut result = FakeWrite::default(); + queue!(&mut result, FakeCommand, FakeCommand).unwrap(); + assert_eq!(&result.buffer, "cmdcmd"); + assert!(!result.flushed); + } + + #[test] + fn test_queue_trailing_comma() { + let mut result = FakeWrite::default(); + queue!(&mut result, FakeCommand, FakeCommand,).unwrap(); + assert_eq!(&result.buffer, "cmdcmd"); + assert!(!result.flushed); + } + + #[test] + fn test_execute_one() { + let mut result = FakeWrite::default(); + execute!(&mut result, FakeCommand).unwrap(); + assert_eq!(&result.buffer, "cmd"); + assert!(result.flushed); + } + + #[test] + fn test_execute_many() { + let mut result = FakeWrite::default(); + execute!(&mut result, FakeCommand, FakeCommand).unwrap(); + assert_eq!(&result.buffer, "cmdcmd"); + assert!(result.flushed); + } + + #[test] + fn test_execute_trailing_comma() { + let mut result = FakeWrite::default(); + execute!(&mut result, FakeCommand, FakeCommand,).unwrap(); + assert_eq!(&result.buffer, "cmdcmd"); + assert!(result.flushed); + } + } + + #[cfg(windows)] + mod windows { + use std::fmt; + + use std::cell::RefCell; + + use super::FakeWrite; + use crate::command::Command; + + // We need to test two different APIs: WinAPI and the write api. We + // don't know until runtime which we're supporting (via + // Command::is_ansi_code_supported), so we have to test them both. The + // CI environment hopefully includes both versions of windows. + + // WindowsEventStream is a place for execute_winapi to push strings, + // when called. + type WindowsEventStream = Vec<&'static str>; + + struct FakeCommand<'a> { + // Need to use a refcell because we want execute_winapi to be able + // push to the vector, but execute_winapi take &self. + stream: RefCell<&'a mut WindowsEventStream>, + value: &'static str, + } + + impl<'a> FakeCommand<'a> { + fn new(stream: &'a mut WindowsEventStream, value: &'static str) -> Self { + Self { + value, + stream: RefCell::new(stream), + } + } + } + + impl<'a> Command for FakeCommand<'a> { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(self.value) + } + + fn execute_winapi(&self) -> std::io::Result<()> { + self.stream.borrow_mut().push(self.value); + Ok(()) + } + } + + // Helper function for running tests against either WinAPI or an + // io::Write. + // + // This function will execute the `test` function, which should + // queue some commands against the given FakeWrite and + // WindowsEventStream. It will then test that the correct data sink + // was populated. It does not currently check is_ansi_code_supported; + // for now it simply checks that one of the two streams was correctly + // populated. + // + // If the stream was populated, it tests that the two arrays are equal. + // If the writer was populated, it tests that the contents of the + // write buffer are equal to the concatenation of `stream_result`. + fn test_harness( + stream_result: &[&'static str], + test: impl FnOnce(&mut FakeWrite, &mut WindowsEventStream) -> std::io::Result<()>, + ) { + let mut stream = WindowsEventStream::default(); + let mut writer = FakeWrite::default(); + + if let Err(err) = test(&mut writer, &mut stream) { + panic!("Error returned from test function: {:?}", err); + } + + // We need this for type inference, for whatever reason. + const EMPTY_RESULT: [&str; 0] = []; + + // TODO: confirm that the correct sink was used, based on + // is_ansi_code_supported + match (writer.buffer.is_empty(), stream.is_empty()) { + (true, true) if stream_result == EMPTY_RESULT => {} + (true, true) => panic!( + "Neither the event stream nor the writer were populated. Expected {:?}", + stream_result + ), + + // writer is populated + (false, true) => { + // Concat the stream result to find the string result + let result: String = stream_result.iter().copied().collect(); + assert_eq!(result, writer.buffer); + assert_eq!(&stream, &EMPTY_RESULT); + } + + // stream is populated + (true, false) => { + assert_eq!(stream, stream_result); + assert_eq!(writer.buffer, ""); + } + + // Both are populated + (false, false) => panic!( + "Both the writer and the event stream were written to.\n\ + Only one should be used, based on is_ansi_code_supported.\n\ + stream: {stream:?}\n\ + writer: {writer:?}", + stream = stream, + writer = writer, + ), + } + } + + #[test] + fn test_queue_one() { + test_harness(&["cmd1"], |writer, stream| { + queue!(writer, FakeCommand::new(stream, "cmd1")) + }) + } + + #[test] + fn test_queue_some() { + test_harness(&["cmd1", "cmd2"], |writer, stream| { + queue!( + writer, + FakeCommand::new(stream, "cmd1"), + FakeCommand::new(stream, "cmd2"), + ) + }) + } + + #[test] + fn test_many_queues() { + test_harness(&["cmd1", "cmd2", "cmd3"], |writer, stream| { + queue!(writer, FakeCommand::new(stream, "cmd1"))?; + queue!(writer, FakeCommand::new(stream, "cmd2"))?; + queue!(writer, FakeCommand::new(stream, "cmd3")) + }) + } + } +} diff --git a/patches/crossterm-0.29.0/src/style.rs b/patches/crossterm-0.29.0/src/style.rs new file mode 100644 index 0000000000..034b54ad69 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style.rs @@ -0,0 +1,621 @@ +//! # Style +//! +//! The `style` module provides a functionality to apply attributes and colors on your text. +//! +//! This documentation does not contain a lot of examples. The reason is that it's fairly +//! obvious how to use this crate. Although, we do provide +//! [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) repository +//! to demonstrate the capabilities. +//! +//! ## Platform-specific Notes +//! +//! Not all features are supported on all terminals/platforms. You should always consult +//! platform-specific notes of the following types: +//! +//! * [Color](enum.Color.html#platform-specific-notes) +//! * [Attribute](enum.Attribute.html#platform-specific-notes) +//! +//! ## Examples +//! +//! A few examples of how to use the style module. +//! +//! ### Colors +//! +//! How to change the terminal text color. +//! +//! Command API: +//! +//! Using the Command API to color text. +//! +//! ```no_run +//! use std::io::{self, Write}; +//! use crossterm::execute; +//! use crossterm::style::{Print, SetForegroundColor, SetBackgroundColor, ResetColor, Color, Attribute}; +//! +//! fn main() -> io::Result<()> { +//! execute!( +//! io::stdout(), +//! // Blue foreground +//! SetForegroundColor(Color::Blue), +//! // Red background +//! SetBackgroundColor(Color::Red), +//! // Print text +//! Print("Blue text on Red.".to_string()), +//! // Reset to default colors +//! ResetColor +//! ) +//! } +//! ``` +//! +//! Functions: +//! +//! Using functions from [`Stylize`](crate::style::Stylize) on a `String` or `&'static str` to color +//! it. +//! +//! ```no_run +//! use crossterm::style::Stylize; +//! +//! println!("{}", "Red foreground color & blue background.".red().on_blue()); +//! ``` +//! +//! ### Attributes +//! +//! How to apply terminal attributes to text. +//! +//! Command API: +//! +//! Using the Command API to set attributes. +//! +//! ```no_run +//! use std::io::{self, Write}; +//! +//! use crossterm::execute; +//! use crossterm::style::{Attribute, Print, SetAttribute}; +//! +//! fn main() -> io::Result<()> { +//! execute!( +//! io::stdout(), +//! // Set to bold +//! SetAttribute(Attribute::Bold), +//! Print("Bold text here.".to_string()), +//! // Reset all attributes +//! SetAttribute(Attribute::Reset) +//! ) +//! } +//! ``` +//! +//! Functions: +//! +//! Using [`Stylize`](crate::style::Stylize) functions on a `String` or `&'static str` to set +//! attributes to it. +//! +//! ```no_run +//! use crossterm::style::Stylize; +//! +//! println!("{}", "Bold".bold()); +//! println!("{}", "Underlined".underlined()); +//! println!("{}", "Negative".negative()); +//! ``` +//! +//! Displayable: +//! +//! [`Attribute`](enum.Attribute.html) implements [Display](https://doc.rust-lang.org/beta/std/fmt/trait.Display.html) and therefore it can be formatted like: +//! +//! ```no_run +//! use crossterm::style::Attribute; +//! +//! println!( +//! "{} Underlined {} No Underline", +//! Attribute::Underlined, +//! Attribute::NoUnderline +//! ); +//! ``` + +use std::{ + env, + fmt::{self, Display}, +}; + +use crate::command::execute_fmt; +use crate::{csi, impl_display, Command}; + +pub use self::{ + attributes::Attributes, + content_style::ContentStyle, + styled_content::StyledContent, + stylize::Stylize, + types::{Attribute, Color, Colored, Colors}, +}; + +mod attributes; +mod content_style; +mod styled_content; +mod stylize; +mod sys; +mod types; + +/// Creates a `StyledContent`. +/// +/// This could be used to style any type that implements `Display` with colors and text attributes. +/// +/// See [`StyledContent`](struct.StyledContent.html) for more info. +/// +/// # Examples +/// +/// ```no_run +/// use crossterm::style::{style, Stylize, Color}; +/// +/// let styled_content = style("Blue colored text on yellow background") +/// .with(Color::Blue) +/// .on(Color::Yellow); +/// +/// println!("{}", styled_content); +/// ``` +pub fn style(val: D) -> StyledContent { + ContentStyle::new().apply(val) +} + +/// Returns available color count. +/// +/// # Notes +/// +/// This does not always provide a good result. +pub fn available_color_count() -> u16 { + #[cfg(windows)] + { + // Check if we're running in a pseudo TTY, which supports true color. + // Fall back to env vars otherwise for other terminals on Windows. + if crate::ansi_support::supports_ansi() { + return u16::MAX; + } + } + + const DEFAULT: u16 = 8; + env::var("COLORTERM") + .or_else(|_| env::var("TERM")) + .map_or(DEFAULT, |x| match x { + _ if x.contains("24bit") || x.contains("truecolor") => u16::MAX, + _ if x.contains("256") => 256, + _ => DEFAULT, + }) +} + +/// Forces colored output on or off globally, overriding NO_COLOR. +/// +/// # Notes +/// +/// crossterm supports NO_COLOR () to disabled colored output. +/// +/// This API allows applications to override that behavior and force colorized output +/// even if NO_COLOR is set. +pub fn force_color_output(enabled: bool) { + Colored::set_ansi_color_disabled(!enabled) +} + +/// A command that sets the the foreground color. +/// +/// See [`Color`](enum.Color.html) for more info. +/// +/// [`SetColors`](struct.SetColors.html) can also be used to set both the foreground and background +/// color in one command. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetForegroundColor(pub Color); + +impl Command for SetForegroundColor { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}m"), Colored::ForegroundColor(self.0)) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::windows::set_foreground_color(self.0) + } +} + +/// A command that sets the the background color. +/// +/// See [`Color`](enum.Color.html) for more info. +/// +/// [`SetColors`](struct.SetColors.html) can also be used to set both the foreground and background +/// color with one command. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetBackgroundColor(pub Color); + +impl Command for SetBackgroundColor { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}m"), Colored::BackgroundColor(self.0)) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::windows::set_background_color(self.0) + } +} + +/// A command that sets the the underline color. +/// +/// See [`Color`](enum.Color.html) for more info. +/// +/// [`SetColors`](struct.SetColors.html) can also be used to set both the foreground and background +/// color with one command. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetUnderlineColor(pub Color); + +impl Command for SetUnderlineColor { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}m"), Colored::UnderlineColor(self.0)) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "SetUnderlineColor not supported by winapi.", + )) + } +} + +/// A command that optionally sets the foreground and/or background color. +/// +/// For example: +/// ```no_run +/// use std::io::{stdout, Write}; +/// +/// use crossterm::execute; +/// use crossterm::style::{Color::{Green, Black}, Colors, Print, SetColors}; +/// +/// execute!( +/// stdout(), +/// SetColors(Colors::new(Green, Black)), +/// Print("Hello, world!".to_string()), +/// ).unwrap(); +/// ``` +/// +/// See [`Colors`](struct.Colors.html) for more info. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetColors(pub Colors); + +impl Command for SetColors { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + // Writing both foreground and background colors in one command resulted in about 20% more + // FPS (20 to 24 fps) on a fullscreen (171x51) app that writes every cell with a different + // foreground and background color, compared to separately using the SetForegroundColor and + // SetBackgroundColor commands (iTerm2, M2 Macbook Pro). `Esc[38;5;mEsc[48;5;m` (16 + // chars) vs `Esc[38;5;;48;5;m` (14 chars) + match (self.0.foreground, self.0.background) { + (Some(fg), Some(bg)) => { + write!( + f, + csi!("{};{}m"), + Colored::ForegroundColor(fg), + Colored::BackgroundColor(bg) + ) + } + (Some(fg), None) => write!(f, csi!("{}m"), Colored::ForegroundColor(fg)), + (None, Some(bg)) => write!(f, csi!("{}m"), Colored::BackgroundColor(bg)), + (None, None) => Ok(()), + } + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + if let Some(color) = self.0.foreground { + sys::windows::set_foreground_color(color)?; + } + if let Some(color) = self.0.background { + sys::windows::set_background_color(color)?; + } + Ok(()) + } +} + +/// A command that sets an attribute. +/// +/// See [`Attribute`](enum.Attribute.html) for more info. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetAttribute(pub Attribute); + +impl Command for SetAttribute { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("{}m"), self.0.sgr()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + // attributes are not supported by WinAPI. + Ok(()) + } +} + +/// A command that sets several attributes. +/// +/// See [`Attributes`](struct.Attributes.html) for more info. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetAttributes(pub Attributes); + +impl Command for SetAttributes { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + for attr in Attribute::iterator() { + if self.0.has(attr) { + SetAttribute(attr).write_ansi(f)?; + } + } + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + // attributes are not supported by WinAPI. + Ok(()) + } +} + +/// A command that sets a style (colors and attributes). +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetStyle(pub ContentStyle); + +impl Command for SetStyle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + if let Some(bg) = self.0.background_color { + execute_fmt(f, SetBackgroundColor(bg)).map_err(|_| fmt::Error)?; + } + if let Some(fg) = self.0.foreground_color { + execute_fmt(f, SetForegroundColor(fg)).map_err(|_| fmt::Error)?; + } + if let Some(ul) = self.0.underline_color { + execute_fmt(f, SetUnderlineColor(ul)).map_err(|_| fmt::Error)?; + } + if !self.0.attributes.is_empty() { + execute_fmt(f, SetAttributes(self.0.attributes)).map_err(|_| fmt::Error)?; + } + + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + panic!("tried to execute SetStyle command using WinAPI, use ANSI instead"); + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// A command that prints styled content. +/// +/// See [`StyledContent`](struct.StyledContent.html) for more info. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Copy, Clone)] +pub struct PrintStyledContent(pub StyledContent); + +impl Command for PrintStyledContent { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + let style = self.0.style(); + + let mut reset_background = false; + let mut reset_foreground = false; + let mut reset = false; + + if let Some(bg) = style.background_color { + execute_fmt(f, SetBackgroundColor(bg)).map_err(|_| fmt::Error)?; + reset_background = true; + } + if let Some(fg) = style.foreground_color { + execute_fmt(f, SetForegroundColor(fg)).map_err(|_| fmt::Error)?; + reset_foreground = true; + } + if let Some(ul) = style.underline_color { + execute_fmt(f, SetUnderlineColor(ul)).map_err(|_| fmt::Error)?; + reset_foreground = true; + } + + if !style.attributes.is_empty() { + execute_fmt(f, SetAttributes(style.attributes)).map_err(|_| fmt::Error)?; + reset = true; + } + + write!(f, "{}", self.0.content())?; + + if reset { + // NOTE: This will reset colors even though self has no colors, hence produce unexpected + // resets. + // TODO: reset the set attributes only. + execute_fmt(f, ResetColor).map_err(|_| fmt::Error)?; + } else { + // NOTE: Since the above bug, we do not need to reset colors when we reset attributes. + if reset_background { + execute_fmt(f, SetBackgroundColor(Color::Reset)).map_err(|_| fmt::Error)?; + } + if reset_foreground { + execute_fmt(f, SetForegroundColor(Color::Reset)).map_err(|_| fmt::Error)?; + } + } + + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A command that resets the colors back to default. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResetColor; + +impl Command for ResetColor { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("0m")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + sys::windows::reset() + } +} + +/// A command that prints the given displayable type. +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Print(pub T); + +impl Command for Print { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "{}", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + panic!("tried to execute Print command using WinAPI, use ANSI instead"); + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +impl Display for Print { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl_display!(for SetForegroundColor); +impl_display!(for SetBackgroundColor); +impl_display!(for SetColors); +impl_display!(for SetAttribute); +impl_display!(for PrintStyledContent); +impl_display!(for PrintStyledContent<&'static str>); +impl_display!(for ResetColor); + +/// Utility function for ANSI parsing in Color and Colored. +/// Gets the next element of `iter` and tries to parse it as a `u8`. +fn parse_next_u8<'a>(iter: &mut impl Iterator) -> Option { + iter.next().and_then(|s| s.parse().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // On Windows many env var tests will fail so we need to conditionally check for ANSI support. + // This allows other terminals on Windows to still assert env var support. + macro_rules! skip_windows_ansi_supported { + () => { + #[cfg(windows)] + { + if crate::ansi_support::supports_ansi() { + return; + } + } + }; + } + + #[cfg_attr(windows, test)] + #[cfg(windows)] + fn windows_always_truecolor() { + // This should always be true on supported Windows 10+, + // but downlevel Windows clients and other terminals may fail `cargo test` otherwise. + if crate::ansi_support::supports_ansi() { + assert_eq!(u16::MAX, available_color_count()); + }; + } + + #[test] + fn colorterm_overrides_term() { + skip_windows_ansi_supported!(); + temp_env::with_vars( + [ + ("COLORTERM", Some("truecolor")), + ("TERM", Some("xterm-256color")), + ], + || { + assert_eq!(u16::MAX, available_color_count()); + }, + ); + } + + #[test] + fn term_24bits() { + skip_windows_ansi_supported!(); + temp_env::with_vars( + [("COLORTERM", None), ("TERM", Some("xterm-24bits"))], + || { + assert_eq!(u16::MAX, available_color_count()); + }, + ); + } + + #[test] + fn term_256color() { + skip_windows_ansi_supported!(); + temp_env::with_vars( + [("COLORTERM", None), ("TERM", Some("xterm-256color"))], + || { + assert_eq!(256u16, available_color_count()); + }, + ); + } + + #[test] + fn default_color_count() { + skip_windows_ansi_supported!(); + temp_env::with_vars([("COLORTERM", None::<&str>), ("TERM", None)], || { + assert_eq!(8, available_color_count()); + }); + } + + #[test] + fn unsupported_term_colorterm_values() { + skip_windows_ansi_supported!(); + temp_env::with_vars( + [ + ("COLORTERM", Some("gibberish")), + ("TERM", Some("gibberish")), + ], + || { + assert_eq!(8u16, available_color_count()); + }, + ); + } +} diff --git a/patches/crossterm-0.29.0/src/style/attributes.rs b/patches/crossterm-0.29.0/src/style/attributes.rs new file mode 100644 index 0000000000..83dea3edc5 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/attributes.rs @@ -0,0 +1,150 @@ +use std::ops::{BitAnd, BitOr, BitXor}; + +use crate::style::Attribute; + +/// a bitset for all possible attributes +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Attributes(u32); + +impl From for Attributes { + fn from(attribute: Attribute) -> Self { + Self(attribute.bytes()) + } +} + +impl From<&[Attribute]> for Attributes { + fn from(arr: &[Attribute]) -> Self { + let mut attributes = Attributes::default(); + for &attr in arr { + attributes.set(attr); + } + attributes + } +} + +impl BitAnd for Attributes { + type Output = Self; + fn bitand(self, rhs: Attribute) -> Self { + Self(self.0 & rhs.bytes()) + } +} +impl BitAnd for Attributes { + type Output = Self; + fn bitand(self, rhs: Self) -> Self { + Self(self.0 & rhs.0) + } +} + +impl BitOr for Attributes { + type Output = Self; + fn bitor(self, rhs: Attribute) -> Self { + Self(self.0 | rhs.bytes()) + } +} +impl BitOr for Attributes { + type Output = Self; + fn bitor(self, rhs: Self) -> Self { + Self(self.0 | rhs.0) + } +} + +impl BitXor for Attributes { + type Output = Self; + fn bitxor(self, rhs: Attribute) -> Self { + Self(self.0 ^ rhs.bytes()) + } +} +impl BitXor for Attributes { + type Output = Self; + fn bitxor(self, rhs: Self) -> Self { + Self(self.0 ^ rhs.0) + } +} + +impl Attributes { + /// Returns the empty bitset. + #[inline(always)] + pub const fn none() -> Self { + Self(0) + } + + /// Returns a copy of the bitset with the given attribute set. + /// If it's already set, this returns the bitset unmodified. + #[inline(always)] + pub const fn with(self, attribute: Attribute) -> Self { + Self(self.0 | attribute.bytes()) + } + + /// Returns a copy of the bitset with the given attribute unset. + /// If it's not set, this returns the bitset unmodified. + #[inline(always)] + pub const fn without(self, attribute: Attribute) -> Self { + Self(self.0 & !attribute.bytes()) + } + + /// Sets the attribute. + /// If it's already set, this does nothing. + #[inline(always)] + pub fn set(&mut self, attribute: Attribute) { + self.0 |= attribute.bytes(); + } + + /// Unsets the attribute. + /// If it's not set, this changes nothing. + #[inline(always)] + pub fn unset(&mut self, attribute: Attribute) { + self.0 &= !attribute.bytes(); + } + + /// Sets the attribute if it's unset, unset it + /// if it is set. + #[inline(always)] + pub fn toggle(&mut self, attribute: Attribute) { + self.0 ^= attribute.bytes(); + } + + /// Returns whether the attribute is set. + #[inline(always)] + pub const fn has(self, attribute: Attribute) -> bool { + self.0 & attribute.bytes() != 0 + } + + /// Sets all the passed attributes. Removes none. + #[inline(always)] + pub fn extend(&mut self, attributes: Attributes) { + self.0 |= attributes.0; + } + + /// Returns whether there is no attribute set. + #[inline(always)] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +#[cfg(test)] +mod tests { + use super::{Attribute, Attributes}; + + #[test] + fn test_attributes() { + let mut attributes: Attributes = Attribute::Bold.into(); + assert!(attributes.has(Attribute::Bold)); + attributes.set(Attribute::Italic); + assert!(attributes.has(Attribute::Italic)); + attributes.unset(Attribute::Italic); + assert!(!attributes.has(Attribute::Italic)); + attributes.toggle(Attribute::Bold); + assert!(attributes.is_empty()); + } + + #[test] + fn test_attributes_const() { + const ATTRIBUTES: Attributes = Attributes::none() + .with(Attribute::Bold) + .with(Attribute::Italic) + .without(Attribute::Bold); + assert!(!ATTRIBUTES.has(Attribute::Bold)); + assert!(ATTRIBUTES.has(Attribute::Italic)); + } +} diff --git a/patches/crossterm-0.29.0/src/style/content_style.rs b/patches/crossterm-0.29.0/src/style/content_style.rs new file mode 100644 index 0000000000..6e99bb6665 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/content_style.rs @@ -0,0 +1,43 @@ +//! This module contains the `content style` that can be applied to an `styled content`. + +use std::fmt::Display; + +use crate::style::{Attributes, Color, StyledContent}; + +/// The style that can be put on content. +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub struct ContentStyle { + /// The foreground color. + pub foreground_color: Option, + /// The background color. + pub background_color: Option, + /// The underline color. + pub underline_color: Option, + /// List of attributes. + pub attributes: Attributes, +} + +impl ContentStyle { + /// Creates a `StyledContent` by applying the style to the given `val`. + #[inline] + pub fn apply(self, val: D) -> StyledContent { + StyledContent::new(self, val) + } + + /// Creates a new `ContentStyle`. + #[inline] + pub fn new() -> ContentStyle { + ContentStyle::default() + } +} + +impl AsRef for ContentStyle { + fn as_ref(&self) -> &Self { + self + } +} +impl AsMut for ContentStyle { + fn as_mut(&mut self) -> &mut Self { + self + } +} diff --git a/patches/crossterm-0.29.0/src/style/styled_content.rs b/patches/crossterm-0.29.0/src/style/styled_content.rs new file mode 100644 index 0000000000..39ebe0dbef --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/styled_content.rs @@ -0,0 +1,77 @@ +//! This module contains the logic to style some content. + +use std::fmt::{self, Display, Formatter}; + +use super::{ContentStyle, PrintStyledContent}; + +/// The style with the content to be styled. +/// +/// # Examples +/// +/// ```rust +/// use crossterm::style::{style, Color, Attribute, Stylize}; +/// +/// let styled = "Hello there" +/// .with(Color::Yellow) +/// .on(Color::Blue) +/// .attribute(Attribute::Bold); +/// +/// println!("{}", styled); +/// ``` +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct StyledContent { + /// The style (colors, content attributes). + style: ContentStyle, + /// A content to apply the style on. + content: D, +} + +impl StyledContent { + /// Creates a new `StyledContent`. + #[inline] + pub fn new(style: ContentStyle, content: D) -> StyledContent { + StyledContent { style, content } + } + + /// Returns the content. + #[inline] + pub fn content(&self) -> &D { + &self.content + } + + /// Returns the style. + #[inline] + pub fn style(&self) -> &ContentStyle { + &self.style + } + + /// Returns a mutable reference to the style, so that it can be further + /// manipulated + #[inline] + pub fn style_mut(&mut self) -> &mut ContentStyle { + &mut self.style + } +} + +impl AsRef for StyledContent { + fn as_ref(&self) -> &ContentStyle { + &self.style + } +} +impl AsMut for StyledContent { + fn as_mut(&mut self) -> &mut ContentStyle { + &mut self.style + } +} + +impl Display for StyledContent { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + crate::command::execute_fmt( + f, + PrintStyledContent(StyledContent { + style: self.style, + content: &self.content, + }), + ) + } +} diff --git a/patches/crossterm-0.29.0/src/style/stylize.rs b/patches/crossterm-0.29.0/src/style/stylize.rs new file mode 100644 index 0000000000..fcb12da5c4 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/stylize.rs @@ -0,0 +1,200 @@ +use std::fmt::Display; + +use super::{style, Attribute, Color, ContentStyle, StyledContent}; + +macro_rules! stylize_method { + ($method_name:ident Attribute::$attribute:ident) => { + calculated_docs! { + #[doc = concat!( + "Applies the [`", + stringify!($attribute), + "`](Attribute::", + stringify!($attribute), + ") attribute to the text.", + )] + fn $method_name(self) -> Self::Styled { + self.attribute(Attribute::$attribute) + } + } + }; + ($method_name_fg:ident, $method_name_bg:ident, $method_name_ul:ident Color::$color:ident) => { + calculated_docs! { + #[doc = concat!( + "Sets the foreground color to [`", + stringify!($color), + "`](Color::", + stringify!($color), + ")." + )] + fn $method_name_fg(self) -> Self::Styled { + self.with(Color::$color) + } + + #[doc = concat!( + "Sets the background color to [`", + stringify!($color), + "`](Color::", + stringify!($color), + ")." + )] + fn $method_name_bg(self) -> Self::Styled { + self.on(Color::$color) + } + + #[doc = concat!( + "Sets the underline color to [`", + stringify!($color), + "`](Color::", + stringify!($color), + ")." + )] + fn $method_name_ul(self) -> Self::Styled { + self.underline(Color::$color) + } + } + }; +} + +/// Provides a set of methods to set attributes and colors. +/// +/// # Examples +/// +/// ```no_run +/// use crossterm::style::Stylize; +/// +/// println!("{}", "Bold text".bold()); +/// println!("{}", "Underlined text".underlined()); +/// println!("{}", "Negative text".negative()); +/// println!("{}", "Red on blue".red().on_blue()); +/// ``` +pub trait Stylize: Sized { + /// This type with styles applied. + type Styled: AsRef + AsMut; + + /// Styles this type. + fn stylize(self) -> Self::Styled; + + /// Sets the foreground color. + fn with(self, color: Color) -> Self::Styled { + let mut styled = self.stylize(); + styled.as_mut().foreground_color = Some(color); + styled + } + + /// Sets the background color. + fn on(self, color: Color) -> Self::Styled { + let mut styled = self.stylize(); + styled.as_mut().background_color = Some(color); + styled + } + + /// Sets the underline color. + fn underline(self, color: Color) -> Self::Styled { + let mut styled = self.stylize(); + styled.as_mut().underline_color = Some(color); + styled + } + + /// Styles the content with the attribute. + fn attribute(self, attr: Attribute) -> Self::Styled { + let mut styled = self.stylize(); + styled.as_mut().attributes.set(attr); + styled + } + + stylize_method!(reset Attribute::Reset); + stylize_method!(bold Attribute::Bold); + stylize_method!(underlined Attribute::Underlined); + stylize_method!(reverse Attribute::Reverse); + stylize_method!(dim Attribute::Dim); + stylize_method!(italic Attribute::Italic); + stylize_method!(negative Attribute::Reverse); + stylize_method!(slow_blink Attribute::SlowBlink); + stylize_method!(rapid_blink Attribute::RapidBlink); + stylize_method!(hidden Attribute::Hidden); + stylize_method!(crossed_out Attribute::CrossedOut); + + stylize_method!(black, on_black, underline_black Color::Black); + stylize_method!(dark_grey, on_dark_grey, underline_dark_grey Color::DarkGrey); + stylize_method!(red, on_red, underline_red Color::Red); + stylize_method!(dark_red, on_dark_red, underline_dark_red Color::DarkRed); + stylize_method!(green, on_green, underline_green Color::Green); + stylize_method!(dark_green, on_dark_green, underline_dark_green Color::DarkGreen); + stylize_method!(yellow, on_yellow, underline_yellow Color::Yellow); + stylize_method!(dark_yellow, on_dark_yellow, underline_dark_yellow Color::DarkYellow); + stylize_method!(blue, on_blue, underline_blue Color::Blue); + stylize_method!(dark_blue, on_dark_blue, underline_dark_blue Color::DarkBlue); + stylize_method!(magenta, on_magenta, underline_magenta Color::Magenta); + stylize_method!(dark_magenta, on_dark_magenta, underline_dark_magenta Color::DarkMagenta); + stylize_method!(cyan, on_cyan, underline_cyan Color::Cyan); + stylize_method!(dark_cyan, on_dark_cyan, underline_dark_cyan Color::DarkCyan); + stylize_method!(white, on_white, underline_white Color::White); + stylize_method!(grey, on_grey, underline_grey Color::Grey); +} + +macro_rules! impl_stylize_for_display { + ($($t:ty),*) => { $( + impl Stylize for $t { + type Styled = StyledContent; + #[inline] + fn stylize(self) -> Self::Styled { + style(self) + } + } + )* } +} +impl_stylize_for_display!(String, char, &str); + +impl Stylize for ContentStyle { + type Styled = Self; + #[inline] + fn stylize(self) -> Self::Styled { + self + } +} +impl Stylize for StyledContent { + type Styled = StyledContent; + fn stylize(self) -> Self::Styled { + self + } +} + +// Workaround for https://github.com/rust-lang/rust/issues/78835 +macro_rules! calculated_docs { + ($(#[doc = $doc:expr] $item:item)*) => { $(#[doc = $doc] $item)* }; +} +// Remove once https://github.com/rust-lang/rust-clippy/issues/7106 stabilizes. +#[allow(clippy::single_component_path_imports)] +#[allow(clippy::useless_attribute)] +use calculated_docs; + +#[cfg(test)] +mod tests { + use super::super::{Attribute, Color, ContentStyle, Stylize}; + + #[test] + fn set_fg_bg_add_attr() { + let style = ContentStyle::new() + .with(Color::Blue) + .on(Color::Red) + .attribute(Attribute::Bold); + + assert_eq!(style.foreground_color, Some(Color::Blue)); + assert_eq!(style.background_color, Some(Color::Red)); + assert!(style.attributes.has(Attribute::Bold)); + + let mut styled_content = style.apply("test"); + + styled_content = styled_content + .with(Color::Green) + .on(Color::Magenta) + .attribute(Attribute::NoItalic); + + let style = styled_content.style(); + + assert_eq!(style.foreground_color, Some(Color::Green)); + assert_eq!(style.background_color, Some(Color::Magenta)); + assert!(style.attributes.has(Attribute::Bold)); + assert!(style.attributes.has(Attribute::NoItalic)); + } +} diff --git a/patches/crossterm-0.29.0/src/style/sys.rs b/patches/crossterm-0.29.0/src/style/sys.rs new file mode 100644 index 0000000000..5a5427620e --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/sys.rs @@ -0,0 +1,2 @@ +#[cfg(windows)] +pub(crate) mod windows; diff --git a/patches/crossterm-0.29.0/src/style/sys/windows.rs b/patches/crossterm-0.29.0/src/style/sys/windows.rs new file mode 100644 index 0000000000..f5e48b77db --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/sys/windows.rs @@ -0,0 +1,204 @@ +use std::convert::TryFrom; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crossterm_winapi::{Console, Handle, HandleType, ScreenBuffer}; +use winapi::um::wincon; + +use super::super::{Color, Colored}; + +const FG_GREEN: u16 = wincon::FOREGROUND_GREEN; +const FG_RED: u16 = wincon::FOREGROUND_RED; +const FG_BLUE: u16 = wincon::FOREGROUND_BLUE; +const FG_INTENSITY: u16 = wincon::FOREGROUND_INTENSITY; + +const BG_GREEN: u16 = wincon::BACKGROUND_GREEN; +const BG_RED: u16 = wincon::BACKGROUND_RED; +const BG_BLUE: u16 = wincon::BACKGROUND_BLUE; +const BG_INTENSITY: u16 = wincon::BACKGROUND_INTENSITY; + +pub(crate) fn set_foreground_color(fg_color: Color) -> std::io::Result<()> { + init_console_color()?; + + let color_value: u16 = Colored::ForegroundColor(fg_color).into(); + + let screen_buffer = ScreenBuffer::current()?; + let csbi = screen_buffer.info()?; + + // Notice that the color values are stored in wAttribute. + // So we need to use bitwise operators to check if the values exists or to get current console colors. + let attrs = csbi.attributes(); + let bg_color = attrs & 0x0070; + let mut color = color_value | bg_color; + + // background intensity is a separate value in attrs, + // we need to check if this was applied to the current bg color. + if (attrs & wincon::BACKGROUND_INTENSITY) != 0 { + color |= wincon::BACKGROUND_INTENSITY; + } + + Console::from(screen_buffer.handle().clone()).set_text_attribute(color)?; + Ok(()) +} + +pub(crate) fn set_background_color(bg_color: Color) -> std::io::Result<()> { + init_console_color()?; + + let color_value: u16 = Colored::BackgroundColor(bg_color).into(); + + let screen_buffer = ScreenBuffer::current()?; + let csbi = screen_buffer.info()?; + + // Notice that the color values are stored in wAttribute. + // So we need to use bitwise operators to check if the values exists or to get current console colors. + let attrs = csbi.attributes(); + let fg_color = attrs & 0x0007; + let mut color = fg_color | color_value; + + // Foreground intensity is a separate value in attrs, + // So we need to check if this was applied to the current fg color. + if (attrs & wincon::FOREGROUND_INTENSITY) != 0 { + color |= wincon::FOREGROUND_INTENSITY; + } + + Console::from(screen_buffer.handle().clone()).set_text_attribute(color)?; + Ok(()) +} + +pub(crate) fn reset() -> std::io::Result<()> { + if let Ok(original_color) = u16::try_from(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed)) { + Console::from(Handle::new(HandleType::CurrentOutputHandle)?) + .set_text_attribute(original_color)?; + } + + Ok(()) +} + +/// Initializes the default console color. It will will be skipped if it has already been initialized. +pub(crate) fn init_console_color() -> std::io::Result<()> { + if ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed) == u32::MAX { + let screen_buffer = ScreenBuffer::current()?; + let attr = screen_buffer.info()?.attributes(); + ORIGINAL_CONSOLE_COLOR.store(u32::from(attr), Ordering::Relaxed); + } + + Ok(()) +} + +/// Returns the original console color, make sure to call `init_console_color` before calling this function. Otherwise this function will panic. +pub(crate) fn original_console_color() -> u16 { + u16::try_from(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed)) + // safe unwrap, initial console color was set with `init_console_color` in `WinApiColor::new()` + .expect("Initial console color not set") +} + +// This is either a valid u16 in which case it stores the original console color or it is u32::MAX +// in which case it is uninitialized. +static ORIGINAL_CONSOLE_COLOR: AtomicU32 = AtomicU32::new(u32::MAX); + +impl From for u16 { + /// Returns the WinAPI color value (u16) from the `Colored` struct. + fn from(colored: Colored) -> Self { + match colored { + Colored::ForegroundColor(color) => { + match color { + Color::Black => 0, + Color::DarkGrey => FG_INTENSITY, + Color::Red => FG_INTENSITY | FG_RED, + Color::DarkRed => FG_RED, + Color::Green => FG_INTENSITY | FG_GREEN, + Color::DarkGreen => FG_GREEN, + Color::Yellow => FG_INTENSITY | FG_GREEN | FG_RED, + Color::DarkYellow => FG_GREEN | FG_RED, + Color::Blue => FG_INTENSITY | FG_BLUE, + Color::DarkBlue => FG_BLUE, + Color::Magenta => FG_INTENSITY | FG_RED | FG_BLUE, + Color::DarkMagenta => FG_RED | FG_BLUE, + Color::Cyan => FG_INTENSITY | FG_GREEN | FG_BLUE, + Color::DarkCyan => FG_GREEN | FG_BLUE, + Color::White => FG_INTENSITY | FG_RED | FG_GREEN | FG_BLUE, + Color::Grey => FG_RED | FG_GREEN | FG_BLUE, + + Color::Reset => { + // safe unwrap, initial console color was set with `init_console_color`. + let original_color = original_console_color(); + + const REMOVE_BG_MASK: u16 = BG_INTENSITY | BG_RED | BG_GREEN | BG_BLUE; + // remove all background values from the original color, we don't want to reset those. + + original_color & !REMOVE_BG_MASK + } + + /* WinAPI will be used for systems that do not support ANSI, those are windows version less then 10. RGB and 255 (AnsiBValue) colors are not supported in that case.*/ + Color::Rgb { .. } => 0, + Color::AnsiValue(_val) => 0, + } + } + Colored::BackgroundColor(color) => { + match color { + Color::Black => 0, + Color::DarkGrey => BG_INTENSITY, + Color::Red => BG_INTENSITY | BG_RED, + Color::DarkRed => BG_RED, + Color::Green => BG_INTENSITY | BG_GREEN, + Color::DarkGreen => BG_GREEN, + Color::Yellow => BG_INTENSITY | BG_GREEN | BG_RED, + Color::DarkYellow => BG_GREEN | BG_RED, + Color::Blue => BG_INTENSITY | BG_BLUE, + Color::DarkBlue => BG_BLUE, + Color::Magenta => BG_INTENSITY | BG_RED | BG_BLUE, + Color::DarkMagenta => BG_RED | BG_BLUE, + Color::Cyan => BG_INTENSITY | BG_GREEN | BG_BLUE, + Color::DarkCyan => BG_GREEN | BG_BLUE, + Color::White => BG_INTENSITY | BG_RED | BG_GREEN | BG_BLUE, + Color::Grey => BG_RED | BG_GREEN | BG_BLUE, + + Color::Reset => { + let original_color = original_console_color(); + + const REMOVE_FG_MASK: u16 = FG_INTENSITY | FG_RED | FG_GREEN | FG_BLUE; + // remove all foreground values from the original color, we don't want to reset those. + + original_color & !REMOVE_FG_MASK + } + /* WinAPI will be used for systems that do not support ANSI, those are windows version less then 10. RGB and 255 (AnsiBValue) colors are not supported in that case.*/ + Color::Rgb { .. } => 0, + Color::AnsiValue(_val) => 0, + } + } + Colored::UnderlineColor(_) => 0, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use crate::style::sys::windows::set_foreground_color; + + use super::{ + Color, Colored, BG_INTENSITY, BG_RED, FG_INTENSITY, FG_RED, ORIGINAL_CONSOLE_COLOR, + }; + + #[test] + fn test_parse_fg_color() { + let colored = Colored::ForegroundColor(Color::Red); + assert_eq!(Into::::into(colored), FG_INTENSITY | FG_RED); + } + + #[test] + fn test_parse_bg_color() { + let colored = Colored::BackgroundColor(Color::Red); + assert_eq!(Into::::into(colored), BG_INTENSITY | BG_RED); + } + + #[test] + fn test_original_console_color_is_set() { + assert_eq!(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed), u32::MAX); + + // will call `init_console_color` + set_foreground_color(Color::Blue).unwrap(); + + assert_ne!(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed), u32::MAX); + } +} diff --git a/patches/crossterm-0.29.0/src/style/types.rs b/patches/crossterm-0.29.0/src/style/types.rs new file mode 100644 index 0000000000..7cd7d6eec7 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/types.rs @@ -0,0 +1,6 @@ +pub use self::{attribute::Attribute, color::Color, colored::Colored, colors::Colors}; + +mod attribute; +mod color; +mod colored; +mod colors; diff --git a/patches/crossterm-0.29.0/src/style/types/attribute.rs b/patches/crossterm-0.29.0/src/style/types/attribute.rs new file mode 100644 index 0000000000..d2f57f607a --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/types/attribute.rs @@ -0,0 +1,183 @@ +use std::fmt::Display; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use super::super::SetAttribute; + +// This macro generates the Attribute enum, its iterator +// function, and the static array containing the sgr code +// of each attribute +macro_rules! Attribute { + ( + $( + $(#[$inner:ident $($args:tt)*])* + $name:ident = $sgr:expr, + )* + ) => { + /// Represents an attribute. + /// + /// # Platform-specific Notes + /// + /// * Only UNIX and Windows 10 terminals do support text attributes. + /// * Keep in mind that not all terminals support all attributes. + /// * Crossterm implements almost all attributes listed in the + /// [SGR parameters](https://en.wikipedia.org/wiki/ANSI_escape_code#Select_Graphic_Rendition_parameters). + /// + /// | Attribute | Windows | UNIX | Notes | + /// | :-- | :--: | :--: | :-- | + /// | `Reset` | ✓ | ✓ | | + /// | `Bold` | ✓ | ✓ | | + /// | `Dim` | ✓ | ✓ | | + /// | `Italic` | ? | ? | Not widely supported, sometimes treated as inverse. | + /// | `Underlined` | ✓ | ✓ | | + /// | `SlowBlink` | ? | ? | Not widely supported, sometimes treated as inverse. | + /// | `RapidBlink` | ? | ? | Not widely supported. MS-DOS ANSI.SYS; 150+ per minute. | + /// | `Reverse` | ✓ | ✓ | | + /// | `Hidden` | ✓ | ✓ | Also known as Conceal. | + /// | `Fraktur` | ✗ | ✓ | Legible characters, but marked for deletion. | + /// | `DefaultForegroundColor` | ? | ? | Implementation specific (according to standard). | + /// | `DefaultBackgroundColor` | ? | ? | Implementation specific (according to standard). | + /// | `Framed` | ? | ? | Not widely supported. | + /// | `Encircled` | ? | ? | This should turn on the encircled attribute. | + /// | `OverLined` | ? | ? | This should draw a line at the top of the text. | + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```no_run + /// use crossterm::style::Attribute; + /// + /// println!( + /// "{} Underlined {} No Underline", + /// Attribute::Underlined, + /// Attribute::NoUnderline + /// ); + /// ``` + /// + /// Style existing text: + /// + /// ```no_run + /// use crossterm::style::Stylize; + /// + /// println!("{}", "Bold text".bold()); + /// println!("{}", "Underlined text".underlined()); + /// println!("{}", "Negative text".negative()); + /// ``` + #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] + #[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] + #[non_exhaustive] + pub enum Attribute { + $( + $(#[$inner $($args)*])* + $name, + )* + } + + pub static SGR: &'static[i16] = &[ + $($sgr,)* + ]; + + impl Attribute { + /// Iterates over all the variants of the Attribute enum. + pub fn iterator() -> impl Iterator { + use self::Attribute::*; + [ $($name,)* ].iter().copied() + } + } + } +} + +Attribute! { + /// Resets all the attributes. + Reset = 0, + /// Increases the text intensity. + Bold = 1, + /// Decreases the text intensity. + Dim = 2, + /// Emphasises the text. + Italic = 3, + /// Underlines the text. + Underlined = 4, + + // Other types of underlining + /// Double underlines the text. + DoubleUnderlined = 2, + /// Undercurls the text. + Undercurled = 3, + /// Underdots the text. + Underdotted = 4, + /// Underdashes the text. + Underdashed = 5, + + /// Makes the text blinking (< 150 per minute). + SlowBlink = 5, + /// Makes the text blinking (>= 150 per minute). + RapidBlink = 6, + /// Swaps foreground and background colors. + Reverse = 7, + /// Hides the text (also known as Conceal). + Hidden = 8, + /// Crosses the text. + CrossedOut = 9, + /// Sets the [Fraktur](https://en.wikipedia.org/wiki/Fraktur) typeface. + /// + /// Mostly used for [mathematical alphanumeric symbols](https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols). + Fraktur = 20, + /// Turns off the `Bold` attribute. - Inconsistent - Prefer to use NormalIntensity + NoBold = 21, + /// Switches the text back to normal intensity (no bold, italic). + NormalIntensity = 22, + /// Turns off the `Italic` attribute. + NoItalic = 23, + /// Turns off the `Underlined` attribute. + NoUnderline = 24, + /// Turns off the text blinking (`SlowBlink` or `RapidBlink`). + NoBlink = 25, + /// Turns off the `Reverse` attribute. + NoReverse = 27, + /// Turns off the `Hidden` attribute. + NoHidden = 28, + /// Turns off the `CrossedOut` attribute. + NotCrossedOut = 29, + /// Makes the text framed. + Framed = 51, + /// Makes the text encircled. + Encircled = 52, + /// Draws a line at the top of the text. + OverLined = 53, + /// Turns off the `Frame` and `Encircled` attributes. + NotFramedOrEncircled = 54, + /// Turns off the `OverLined` attribute. + NotOverLined = 55, +} + +impl Display for Attribute { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", SetAttribute(*self))?; + Ok(()) + } +} + +impl Attribute { + /// Returns a u32 with one bit set, which is the + /// signature of this attribute in the Attributes + /// bitset. + /// + /// The +1 enables storing Reset (whose index is 0) + /// in the bitset Attributes. + #[inline(always)] + pub const fn bytes(self) -> u32 { + 1 << ((self as u32) + 1) + } + /// Returns the SGR attribute value. + /// + /// See + pub fn sgr(self) -> String { + if (self as usize) > 4 && (self as usize) < 9 { + return "4:".to_string() + SGR[self as usize].to_string().as_str(); + } + SGR[self as usize].to_string() + } +} diff --git a/patches/crossterm-0.29.0/src/style/types/color.rs b/patches/crossterm-0.29.0/src/style/types/color.rs new file mode 100644 index 0000000000..00d50a6564 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/types/color.rs @@ -0,0 +1,512 @@ +use std::{ + convert::{AsRef, TryFrom}, + str::FromStr, +}; + +#[cfg(feature = "serde")] +use std::fmt; + +use crate::style::parse_next_u8; + +/// Represents a color. +/// +/// # Platform-specific Notes +/// +/// The following list of 16 base colors are available for almost all terminals (Windows 7 and 8 included). +/// +/// | Light | Dark | +/// | :--------- | :------------ | +/// | `DarkGrey` | `Black` | +/// | `Red` | `DarkRed` | +/// | `Green` | `DarkGreen` | +/// | `Yellow` | `DarkYellow` | +/// | `Blue` | `DarkBlue` | +/// | `Magenta` | `DarkMagenta` | +/// | `Cyan` | `DarkCyan` | +/// | `White` | `Grey` | +/// +/// Most UNIX terminals and Windows 10 consoles support additional colors. +/// See [`Color::Rgb`] or [`Color::AnsiValue`] for more info. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub enum Color { + /// Resets the terminal color. + Reset, + + /// Black color. + Black, + + /// Dark grey color. + DarkGrey, + + /// Light red color. + Red, + + /// Dark red color. + DarkRed, + + /// Light green color. + Green, + + /// Dark green color. + DarkGreen, + + /// Light yellow color. + Yellow, + + /// Dark yellow color. + DarkYellow, + + /// Light blue color. + Blue, + + /// Dark blue color. + DarkBlue, + + /// Light magenta color. + Magenta, + + /// Dark magenta color. + DarkMagenta, + + /// Light cyan color. + Cyan, + + /// Dark cyan color. + DarkCyan, + + /// White color. + White, + + /// Grey color. + Grey, + + /// An RGB color. See [RGB color model](https://en.wikipedia.org/wiki/RGB_color_model) for more info. + /// + /// Most UNIX terminals and Windows 10 supported only. + /// See [Platform-specific notes](enum.Color.html#platform-specific-notes) for more info. + Rgb { r: u8, g: u8, b: u8 }, + + /// An ANSI color. See [256 colors - cheat sheet](https://jonasjacek.github.io/colors/) for more info. + /// + /// Most UNIX terminals and Windows 10 supported only. + /// See [Platform-specific notes](enum.Color.html#platform-specific-notes) for more info. + AnsiValue(u8), +} + +impl Color { + /// Parses an ANSI color sequence. + /// + /// # Examples + /// + /// ``` + /// use crossterm::style::Color; + /// + /// assert_eq!(Color::parse_ansi("5;0"), Some(Color::Black)); + /// assert_eq!(Color::parse_ansi("5;26"), Some(Color::AnsiValue(26))); + /// assert_eq!(Color::parse_ansi("2;50;60;70"), Some(Color::Rgb { r: 50, g: 60, b: 70 })); + /// assert_eq!(Color::parse_ansi("invalid color"), None); + /// ``` + /// + /// Currently, 3/4 bit color values aren't supported so return `None`. + /// + /// See also: [`Colored::parse_ansi`](crate::style::Colored::parse_ansi). + pub fn parse_ansi(ansi: &str) -> Option { + Self::parse_ansi_iter(&mut ansi.split(';')) + } + + /// The logic for parse_ansi, takes an iterator of the sequences terms (the numbers between the + /// ';'). It's a separate function so it can be used by both Color::parse_ansi and + /// colored::parse_ansi. + /// Tested in Colored tests. + pub(crate) fn parse_ansi_iter<'a>(values: &mut impl Iterator) -> Option { + let color = match parse_next_u8(values)? { + // 8 bit colors: `5;` + 5 => { + let n = parse_next_u8(values)?; + + use Color::*; + [ + Black, // 0 + DarkRed, // 1 + DarkGreen, // 2 + DarkYellow, // 3 + DarkBlue, // 4 + DarkMagenta, // 5 + DarkCyan, // 6 + Grey, // 7 + DarkGrey, // 8 + Red, // 9 + Green, // 10 + Yellow, // 11 + Blue, // 12 + Magenta, // 13 + Cyan, // 14 + White, // 15 + ] + .get(n as usize) + .copied() + .unwrap_or(Color::AnsiValue(n)) + } + + // 24 bit colors: `2;;;` + 2 => Color::Rgb { + r: parse_next_u8(values)?, + g: parse_next_u8(values)?, + b: parse_next_u8(values)?, + }, + + _ => return None, + }; + // If there's another value, it's unexpected so return None. + if values.next().is_some() { + return None; + } + Some(color) + } +} + +impl TryFrom<&str> for Color { + type Error = (); + + /// Try to create a `Color` from the string representation. This returns an error if the string does not match. + fn try_from(src: &str) -> Result { + let src = src.to_lowercase(); + + match src.as_ref() { + "reset" => Ok(Color::Reset), + "black" => Ok(Color::Black), + "dark_grey" => Ok(Color::DarkGrey), + "red" => Ok(Color::Red), + "dark_red" => Ok(Color::DarkRed), + "green" => Ok(Color::Green), + "dark_green" => Ok(Color::DarkGreen), + "yellow" => Ok(Color::Yellow), + "dark_yellow" => Ok(Color::DarkYellow), + "blue" => Ok(Color::Blue), + "dark_blue" => Ok(Color::DarkBlue), + "magenta" => Ok(Color::Magenta), + "dark_magenta" => Ok(Color::DarkMagenta), + "cyan" => Ok(Color::Cyan), + "dark_cyan" => Ok(Color::DarkCyan), + "white" => Ok(Color::White), + "grey" => Ok(Color::Grey), + _ => Err(()), + } + } +} + +impl FromStr for Color { + type Err = (); + + /// Creates a `Color` from the string representation. + /// + /// # Notes + /// + /// * Returns `Color::White` in case of an unknown color. + /// * Does not return `Err` and you can safely unwrap. + fn from_str(src: &str) -> Result { + Ok(Color::try_from(src).unwrap_or(Color::White)) + } +} + +impl From<(u8, u8, u8)> for Color { + /// Creates a 'Color' from the tuple representation. + fn from(val: (u8, u8, u8)) -> Self { + let (r, g, b) = val; + Self::Rgb { r, g, b } + } +} + +#[cfg(feature = "serde")] +impl serde::ser::Serialize for Color { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let str = match *self { + Color::Reset => "reset", + Color::Black => "black", + Color::DarkGrey => "dark_grey", + Color::Red => "red", + Color::DarkRed => "dark_red", + Color::Green => "green", + Color::DarkGreen => "dark_green", + Color::Yellow => "yellow", + Color::DarkYellow => "dark_yellow", + Color::Blue => "blue", + Color::DarkBlue => "dark_blue", + Color::Magenta => "magenta", + Color::DarkMagenta => "dark_magenta", + Color::Cyan => "cyan", + Color::DarkCyan => "dark_cyan", + Color::White => "white", + Color::Grey => "grey", + _ => "", + }; + + if str.is_empty() { + match *self { + Color::AnsiValue(value) => serializer.serialize_str(&format!("ansi_({})", value)), + Color::Rgb { r, g, b } => { + serializer.serialize_str(&format!("rgb_({},{},{})", r, g, b)) + } + _ => Err(serde::ser::Error::custom("Could not serialize enum type")), + } + } else { + serializer.serialize_str(str) + } + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::de::Deserialize<'de> for Color { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct ColorVisitor; + impl serde::de::Visitor<'_> for ColorVisitor { + type Value = Color; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "`reset`, `black`, `blue`, `dark_blue`, `cyan`, `dark_cyan`, `green`, `dark_green`, `grey`, `dark_grey`, `magenta`, `dark_magenta`, `red`, `dark_red`, `white`, `yellow`, `dark_yellow`, `ansi_(value)`, or `rgb_(r,g,b)` or `#rgbhex`", + ) + } + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if let Ok(c) = Color::try_from(value) { + Ok(c) + } else { + if value.contains("ansi") { + // strip away `ansi_(..)' and get the inner value between parenthesis. + let results = value.replace("ansi_(", "").replace(")", ""); + + let ansi_val = results.parse::(); + + if let Ok(ansi) = ansi_val { + return Ok(Color::AnsiValue(ansi)); + } + } else if value.contains("rgb") { + // strip away `rgb_(..)' and get the inner values between parenthesis. + let results = value + .replace("rgb_(", "") + .replace(")", "") + .split(',') + .map(|x| x.to_string()) + .collect::>(); + + if results.len() == 3 { + let r = results[0].parse::(); + let g = results[1].parse::(); + let b = results[2].parse::(); + + if let (Ok(r), Ok(g), Ok(b)) = (r, g, b) { + return Ok(Color::Rgb { r, g, b }); + } + } + } else if let Some(hex) = value.strip_prefix('#') { + if hex.is_ascii() && hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16); + let g = u8::from_str_radix(&hex[2..4], 16); + let b = u8::from_str_radix(&hex[4..6], 16); + + if let (Ok(r), Ok(g), Ok(b)) = (r, g, b) { + return Ok(Color::Rgb { r, g, b }); + } + } + } + + Err(E::invalid_value(serde::de::Unexpected::Str(value), &self)) + } + } + } + + deserializer.deserialize_str(ColorVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::Color; + + #[test] + fn test_known_color_conversion() { + assert_eq!("reset".parse(), Ok(Color::Reset)); + assert_eq!("grey".parse(), Ok(Color::Grey)); + assert_eq!("dark_grey".parse(), Ok(Color::DarkGrey)); + assert_eq!("red".parse(), Ok(Color::Red)); + assert_eq!("dark_red".parse(), Ok(Color::DarkRed)); + assert_eq!("green".parse(), Ok(Color::Green)); + assert_eq!("dark_green".parse(), Ok(Color::DarkGreen)); + assert_eq!("yellow".parse(), Ok(Color::Yellow)); + assert_eq!("dark_yellow".parse(), Ok(Color::DarkYellow)); + assert_eq!("blue".parse(), Ok(Color::Blue)); + assert_eq!("dark_blue".parse(), Ok(Color::DarkBlue)); + assert_eq!("magenta".parse(), Ok(Color::Magenta)); + assert_eq!("dark_magenta".parse(), Ok(Color::DarkMagenta)); + assert_eq!("cyan".parse(), Ok(Color::Cyan)); + assert_eq!("dark_cyan".parse(), Ok(Color::DarkCyan)); + assert_eq!("white".parse(), Ok(Color::White)); + assert_eq!("black".parse(), Ok(Color::Black)); + } + + #[test] + fn test_unknown_color_conversion_yields_white() { + assert_eq!("foo".parse(), Ok(Color::White)); + } + + #[test] + fn test_know_rgb_color_conversion() { + assert_eq!(Color::from((0, 0, 0)), Color::Rgb { r: 0, g: 0, b: 0 }); + assert_eq!( + Color::from((255, 255, 255)), + Color::Rgb { + r: 255, + g: 255, + b: 255 + } + ); + } +} + +#[cfg(test)] +#[cfg(feature = "serde")] +mod serde_tests { + use super::Color; + use serde_json; + + #[test] + fn test_deserial_known_color_conversion() { + assert_eq!( + serde_json::from_str::("\"Reset\"").unwrap(), + Color::Reset + ); + assert_eq!( + serde_json::from_str::("\"reset\"").unwrap(), + Color::Reset + ); + assert_eq!( + serde_json::from_str::("\"Red\"").unwrap(), + Color::Red + ); + assert_eq!( + serde_json::from_str::("\"red\"").unwrap(), + Color::Red + ); + assert_eq!( + serde_json::from_str::("\"dark_red\"").unwrap(), + Color::DarkRed + ); + assert_eq!( + serde_json::from_str::("\"grey\"").unwrap(), + Color::Grey + ); + assert_eq!( + serde_json::from_str::("\"dark_grey\"").unwrap(), + Color::DarkGrey + ); + assert_eq!( + serde_json::from_str::("\"green\"").unwrap(), + Color::Green + ); + assert_eq!( + serde_json::from_str::("\"dark_green\"").unwrap(), + Color::DarkGreen + ); + assert_eq!( + serde_json::from_str::("\"yellow\"").unwrap(), + Color::Yellow + ); + assert_eq!( + serde_json::from_str::("\"dark_yellow\"").unwrap(), + Color::DarkYellow + ); + assert_eq!( + serde_json::from_str::("\"blue\"").unwrap(), + Color::Blue + ); + assert_eq!( + serde_json::from_str::("\"dark_blue\"").unwrap(), + Color::DarkBlue + ); + assert_eq!( + serde_json::from_str::("\"magenta\"").unwrap(), + Color::Magenta + ); + assert_eq!( + serde_json::from_str::("\"dark_magenta\"").unwrap(), + Color::DarkMagenta + ); + assert_eq!( + serde_json::from_str::("\"cyan\"").unwrap(), + Color::Cyan + ); + assert_eq!( + serde_json::from_str::("\"dark_cyan\"").unwrap(), + Color::DarkCyan + ); + assert_eq!( + serde_json::from_str::("\"white\"").unwrap(), + Color::White + ); + assert_eq!( + serde_json::from_str::("\"black\"").unwrap(), + Color::Black + ); + } + + #[test] + fn test_deserial_unknown_color_conversion() { + assert!(serde_json::from_str::("\"unknown\"").is_err()); + } + + #[test] + fn test_deserial_ansi_value() { + assert_eq!( + serde_json::from_str::("\"ansi_(255)\"").unwrap(), + Color::AnsiValue(255) + ); + } + + #[test] + fn test_deserial_unvalid_ansi_value() { + assert!(serde_json::from_str::("\"ansi_(256)\"").is_err()); + assert!(serde_json::from_str::("\"ansi_(-1)\"").is_err()); + } + + #[test] + fn test_deserial_rgb() { + assert_eq!( + serde_json::from_str::("\"rgb_(255,255,255)\"").unwrap(), + Color::from((255, 255, 255)) + ); + } + + #[test] + fn test_deserial_unvalid_rgb() { + assert!(serde_json::from_str::("\"rgb_(255,255,255,255)\"").is_err()); + assert!(serde_json::from_str::("\"rgb_(256,255,255)\"").is_err()); + } + + #[test] + fn test_deserial_rgb_hex() { + assert_eq!( + serde_json::from_str::("\"#ffffff\"").unwrap(), + Color::from((255, 255, 255)) + ); + assert_eq!( + serde_json::from_str::("\"#FFFFFF\"").unwrap(), + Color::from((255, 255, 255)) + ); + } + + #[test] + fn test_deserial_unvalid_rgb_hex() { + assert!(serde_json::from_str::("\"#FFFFFFFF\"").is_err()); + assert!(serde_json::from_str::("\"#FFGFFF\"").is_err()); + // Ferris is 4 bytes so this will be considered the correct length. + assert!(serde_json::from_str::("\"#ff🦀\"").is_err()); + } +} diff --git a/patches/crossterm-0.29.0/src/style/types/colored.rs b/patches/crossterm-0.29.0/src/style/types/colored.rs new file mode 100644 index 0000000000..47f3566689 --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/types/colored.rs @@ -0,0 +1,320 @@ +use parking_lot::Once; +use std::fmt::{self, Formatter}; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::style::{parse_next_u8, Color}; + +/// Represents a foreground or background color. +/// +/// This can be converted to a [Colors](struct.Colors.html) by calling `into()` and applied +/// using the [SetColors](struct.SetColors.html) command. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub enum Colored { + /// A foreground color. + ForegroundColor(Color), + /// A background color. + BackgroundColor(Color), + /// An underline color. + /// Important: doesn't work on windows 10 or lower. + UnderlineColor(Color), +} + +static ANSI_COLOR_DISABLED: AtomicBool = AtomicBool::new(false); +static INITIALIZER: Once = Once::new(); + +impl Colored { + /// Parse an ANSI foreground or background color. + /// This is the string that would appear within an `ESC [ m` escape sequence, as found in + /// various configuration files. + /// + /// # Examples + /// + /// ``` + /// use crossterm::style::{Colored::{self, ForegroundColor, BackgroundColor}, Color}; + /// + /// assert_eq!(Colored::parse_ansi("38;5;0"), Some(ForegroundColor(Color::Black))); + /// assert_eq!(Colored::parse_ansi("38;5;26"), Some(ForegroundColor(Color::AnsiValue(26)))); + /// assert_eq!(Colored::parse_ansi("48;2;50;60;70"), Some(BackgroundColor(Color::Rgb { r: 50, g: 60, b: 70 }))); + /// assert_eq!(Colored::parse_ansi("49"), Some(BackgroundColor(Color::Reset))); + /// assert_eq!(Colored::parse_ansi("invalid color"), None); + /// ``` + /// + /// Currently, 3/4 bit color values aren't supported so return `None`. + /// + /// See also: [`Color::parse_ansi`]. + pub fn parse_ansi(ansi: &str) -> Option { + use Colored::{BackgroundColor, ForegroundColor, UnderlineColor}; + + let values = &mut ansi.split(';'); + + let output = match parse_next_u8(values)? { + 38 => return Color::parse_ansi_iter(values).map(ForegroundColor), + 48 => return Color::parse_ansi_iter(values).map(BackgroundColor), + 58 => return Color::parse_ansi_iter(values).map(UnderlineColor), + + 39 => ForegroundColor(Color::Reset), + 49 => BackgroundColor(Color::Reset), + 59 => UnderlineColor(Color::Reset), + + _ => return None, + }; + + if values.next().is_some() { + return None; + } + + Some(output) + } + + /// Checks whether ansi color sequences are disabled by setting of NO_COLOR + /// in environment as per + pub fn ansi_color_disabled() -> bool { + !std::env::var("NO_COLOR") + .unwrap_or("".to_string()) + .is_empty() + } + + pub fn ansi_color_disabled_memoized() -> bool { + INITIALIZER.call_once(|| { + ANSI_COLOR_DISABLED.store(Self::ansi_color_disabled(), Ordering::SeqCst); + }); + + ANSI_COLOR_DISABLED.load(Ordering::SeqCst) + } + + pub fn set_ansi_color_disabled(val: bool) { + // Force the one-time initializer to run. + _ = Self::ansi_color_disabled_memoized(); + ANSI_COLOR_DISABLED.store(val, Ordering::SeqCst); + } +} + +impl fmt::Display for Colored { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let color; + + if Self::ansi_color_disabled_memoized() { + return Ok(()); + } + + match *self { + Colored::ForegroundColor(new_color) => { + if new_color == Color::Reset { + return f.write_str("39"); + } else { + f.write_str("38;")?; + color = new_color; + } + } + Colored::BackgroundColor(new_color) => { + if new_color == Color::Reset { + return f.write_str("49"); + } else { + f.write_str("48;")?; + color = new_color; + } + } + Colored::UnderlineColor(new_color) => { + if new_color == Color::Reset { + return f.write_str("59"); + } else { + f.write_str("58;")?; + color = new_color; + } + } + } + + match color { + Color::Black => f.write_str("5;0"), + Color::DarkGrey => f.write_str("5;8"), + Color::Red => f.write_str("5;9"), + Color::DarkRed => f.write_str("5;1"), + Color::Green => f.write_str("5;10"), + Color::DarkGreen => f.write_str("5;2"), + Color::Yellow => f.write_str("5;11"), + Color::DarkYellow => f.write_str("5;3"), + Color::Blue => f.write_str("5;12"), + Color::DarkBlue => f.write_str("5;4"), + Color::Magenta => f.write_str("5;13"), + Color::DarkMagenta => f.write_str("5;5"), + Color::Cyan => f.write_str("5;14"), + Color::DarkCyan => f.write_str("5;6"), + Color::White => f.write_str("5;15"), + Color::Grey => f.write_str("5;7"), + Color::Rgb { r, g, b } => write!(f, "2;{r};{g};{b}"), + Color::AnsiValue(val) => write!(f, "5;{val}"), + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use crate::style::{Color, Colored}; + + fn check_format_color(colored: Colored, expected: &str) { + Colored::set_ansi_color_disabled(true); + assert_eq!(colored.to_string(), ""); + Colored::set_ansi_color_disabled(false); + assert_eq!(colored.to_string(), expected); + } + + #[test] + fn test_format_fg_color() { + let colored = Colored::ForegroundColor(Color::Red); + check_format_color(colored, "38;5;9"); + } + + #[test] + fn test_format_bg_color() { + let colored = Colored::BackgroundColor(Color::Red); + check_format_color(colored, "48;5;9"); + } + + #[test] + fn test_format_reset_fg_color() { + let colored = Colored::ForegroundColor(Color::Reset); + check_format_color(colored, "39"); + } + + #[test] + fn test_format_reset_bg_color() { + let colored = Colored::BackgroundColor(Color::Reset); + check_format_color(colored, "49"); + } + + #[test] + fn test_format_fg_rgb_color() { + let colored = Colored::BackgroundColor(Color::Rgb { r: 1, g: 2, b: 3 }); + check_format_color(colored, "48;2;1;2;3"); + } + + #[test] + fn test_format_fg_ansi_color() { + let colored = Colored::ForegroundColor(Color::AnsiValue(255)); + check_format_color(colored, "38;5;255"); + } + + #[test] + fn test_parse_ansi_fg() { + test_parse_ansi(Colored::ForegroundColor) + } + + #[test] + fn test_parse_ansi_bg() { + test_parse_ansi(Colored::ForegroundColor) + } + + /// Used for test_parse_ansi_fg and test_parse_ansi_bg + fn test_parse_ansi(bg_or_fg: impl Fn(Color) -> Colored) { + /// Formats a re-parses `color` to check the result. + macro_rules! test { + ($color:expr) => { + let colored = bg_or_fg($color); + assert_eq!(Colored::parse_ansi(&format!("{}", colored)), Some(colored)); + }; + } + + use Color::*; + + test!(Reset); + test!(Black); + test!(DarkGrey); + test!(Red); + test!(DarkRed); + test!(Green); + test!(DarkGreen); + test!(Yellow); + test!(DarkYellow); + test!(Blue); + test!(DarkBlue); + test!(Magenta); + test!(DarkMagenta); + test!(Cyan); + test!(DarkCyan); + test!(White); + test!(Grey); + + // n in 0..=15 will give us the color values above back. + for n in 16..=255 { + test!(AnsiValue(n)); + } + + for r in 0..=255 { + for g in [0, 2, 18, 19, 60, 100, 200, 250, 254, 255].iter().copied() { + for b in [0, 12, 16, 99, 100, 161, 200, 255].iter().copied() { + test!(Rgb { r, g, b }); + } + } + } + } + + #[test] + fn test_parse_invalid_ansi_color() { + /// Checks that trying to parse `s` yields None. + fn test(s: &str) { + assert_eq!(Colored::parse_ansi(s), None); + } + test(""); + test(";"); + test(";;"); + test(";;"); + test("0"); + test("1"); + test("12"); + test("100"); + test("100048949345"); + test("39;"); + test("49;"); + test("39;2"); + test("49;2"); + test("38"); + test("38;"); + test("38;0"); + test("38;5"); + test("38;5;0;"); + test("38;5;0;2"); + test("38;5;80;"); + test("38;5;80;2"); + test("38;5;257"); + test("38;2"); + test("38;2;"); + test("38;2;0"); + test("38;2;0;2"); + test("38;2;0;2;257"); + test("38;2;0;2;25;"); + test("38;2;0;2;25;3"); + test("48"); + test("48;"); + test("48;0"); + test("48;5"); + test("48;5;0;"); + test("48;5;0;2"); + test("48;5;80;"); + test("48;5;80;2"); + test("48;5;257"); + test("48;2"); + test("48;2;"); + test("48;2;0"); + test("48;2;0;2"); + test("48;2;0;2;257"); + test("48;2;0;2;25;"); + test("48;2;0;2;25;3"); + } + + #[test] + fn test_no_color() { + std::env::set_var("NO_COLOR", "1"); + assert!(Colored::ansi_color_disabled()); + std::env::set_var("NO_COLOR", "XXX"); + assert!(Colored::ansi_color_disabled()); + std::env::set_var("NO_COLOR", ""); + assert!(!Colored::ansi_color_disabled()); + std::env::remove_var("NO_COLOR"); + assert!(!Colored::ansi_color_disabled()); + } +} diff --git a/patches/crossterm-0.29.0/src/style/types/colors.rs b/patches/crossterm-0.29.0/src/style/types/colors.rs new file mode 100644 index 0000000000..0b4afbf0cc --- /dev/null +++ b/patches/crossterm-0.29.0/src/style/types/colors.rs @@ -0,0 +1,234 @@ +use crate::style::{Color, Colored}; + +/// Represents, optionally, a foreground and/or a background color. +/// +/// It can be applied using the `SetColors` command. +/// +/// It can also be created from a [Colored](enum.Colored.html) value or a tuple of +/// `(Color, Color)` in the order `(foreground, background)`. +/// +/// The [then](#method.then) method can be used to combine `Colors` values. +/// +/// For example: +/// ```no_run +/// use crossterm::style::{Color, Colors, Colored}; +/// +/// // An example color, loaded from a config, file in ANSI format. +/// let config_color = "38;2;23;147;209"; +/// +/// // Default to green text on a black background. +/// let default_colors = Colors::new(Color::Green, Color::Black); +/// // Load a colored value from a config and override the default colors +/// let colors = match Colored::parse_ansi(config_color) { +/// Some(colored) => default_colors.then(&colored.into()), +/// None => default_colors, +/// }; +/// ``` +/// +/// See [Color](enum.Color.html). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Colors { + pub foreground: Option, + pub background: Option, +} + +impl Colors { + /// Returns a new `Color` which, when applied, has the same effect as applying `self` and *then* + /// `other`. + pub fn then(&self, other: &Colors) -> Colors { + Colors { + foreground: other.foreground.or(self.foreground), + background: other.background.or(self.background), + } + } +} + +impl Colors { + pub fn new(foreground: Color, background: Color) -> Colors { + Colors { + foreground: Some(foreground), + background: Some(background), + } + } +} + +impl From for Colors { + fn from(colored: Colored) -> Colors { + match colored { + Colored::ForegroundColor(color) => Colors { + foreground: Some(color), + background: None, + }, + Colored::BackgroundColor(color) => Colors { + foreground: None, + background: Some(color), + }, + Colored::UnderlineColor(color) => Colors { + foreground: None, + background: Some(color), + }, + } + } +} + +#[cfg(test)] +mod tests { + use crate::style::{Color, Colors}; + + #[test] + fn test_colors_then() { + use Color::*; + + assert_eq!( + Colors { + foreground: None, + background: None, + } + .then(&Colors { + foreground: None, + background: None, + }), + Colors { + foreground: None, + background: None, + } + ); + + assert_eq!( + Colors { + foreground: None, + background: None, + } + .then(&Colors { + foreground: Some(Black), + background: None, + }), + Colors { + foreground: Some(Black), + background: None, + } + ); + + assert_eq!( + Colors { + foreground: None, + background: None, + } + .then(&Colors { + foreground: None, + background: Some(Grey), + }), + Colors { + foreground: None, + background: Some(Grey), + } + ); + + assert_eq!( + Colors { + foreground: None, + background: None, + } + .then(&Colors::new(White, Grey)), + Colors::new(White, Grey), + ); + + assert_eq!( + Colors { + foreground: None, + background: Some(Blue), + } + .then(&Colors::new(White, Grey)), + Colors::new(White, Grey), + ); + + assert_eq!( + Colors { + foreground: Some(Blue), + background: None, + } + .then(&Colors::new(White, Grey)), + Colors::new(White, Grey), + ); + + assert_eq!( + Colors::new(Blue, Green).then(&Colors::new(White, Grey)), + Colors::new(White, Grey), + ); + + assert_eq!( + Colors { + foreground: Some(Blue), + background: Some(Green), + } + .then(&Colors { + foreground: None, + background: Some(Grey), + }), + Colors { + foreground: Some(Blue), + background: Some(Grey), + } + ); + + assert_eq!( + Colors { + foreground: Some(Blue), + background: Some(Green), + } + .then(&Colors { + foreground: Some(White), + background: None, + }), + Colors { + foreground: Some(White), + background: Some(Green), + } + ); + + assert_eq!( + Colors { + foreground: Some(Blue), + background: Some(Green), + } + .then(&Colors { + foreground: None, + background: None, + }), + Colors { + foreground: Some(Blue), + background: Some(Green), + } + ); + + assert_eq!( + Colors { + foreground: None, + background: Some(Green), + } + .then(&Colors { + foreground: None, + background: None, + }), + Colors { + foreground: None, + background: Some(Green), + } + ); + + assert_eq!( + Colors { + foreground: Some(Blue), + background: None, + } + .then(&Colors { + foreground: None, + background: None, + }), + Colors { + foreground: Some(Blue), + background: None, + } + ); + } +} diff --git a/patches/crossterm-0.29.0/src/terminal.rs b/patches/crossterm-0.29.0/src/terminal.rs new file mode 100644 index 0000000000..3aebc1f8ef --- /dev/null +++ b/patches/crossterm-0.29.0/src/terminal.rs @@ -0,0 +1,568 @@ +//! # Terminal +//! +//! The `terminal` module provides functionality to work with the terminal. +//! +//! This documentation does not contain a lot of examples. The reason is that it's fairly +//! obvious how to use this crate. Although, we do provide +//! [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) repository +//! to demonstrate the capabilities. +//! +//! Most terminal actions can be performed with commands. +//! Please have a look at [command documentation](../index.html#command-api) for a more detailed documentation. +//! +//! ## Screen Buffer +//! +//! A screen buffer is a two-dimensional array of character +//! and color data which is displayed in a terminal screen. +//! +//! The terminal has several of those buffers and is able to switch between them. +//! The default screen in which you work is called the 'main screen'. +//! The other screens are called the 'alternative screen'. +//! +//! It is important to understand that crossterm does not yet support creating screens, +//! or switch between more than two buffers, and only offers the ability to change +//! between the 'alternate' and 'main screen'. +//! +//! ### Alternate Screen +//! +//! By default, you will be working on the main screen. +//! There is also another screen called the 'alternative' screen. +//! This screen is slightly different from the main screen. +//! For example, it has the exact dimensions of the terminal window, +//! without any scroll-back area. +//! +//! Crossterm offers the possibility to switch to the 'alternative' screen, +//! make some modifications, and move back to the 'main' screen again. +//! The main screen will stay intact and will have the original data as we performed all +//! operations on the alternative screen. +//! +//! An good example of this is Vim. +//! When it is launched from bash, a whole new buffer is used to modify a file. +//! Then, when the modification is finished, it closes again and continues on the main screen. +//! +//! ### Raw Mode +//! +//! By default, the terminal functions in a certain way. +//! For example, it will move the cursor to the beginning of the next line when the input hits the end of a line. +//! Or that the backspace is interpreted for character removal. +//! +//! Sometimes these default modes are irrelevant, +//! and in this case, we can turn them off. +//! This is what happens when you enable raw modes. +//! +//! Those modes will be set when enabling raw modes: +//! +//! - Input will not be forwarded to screen +//! - Input will not be processed on enter press +//! - Input will not be line buffered (input sent byte-by-byte to input buffer) +//! - Special keys like backspace and CTRL+C will not be processed by terminal driver +//! - New line character will not be processed therefore `println!` can't be used, use `write!` instead +//! +//! Raw mode can be enabled/disabled with the [enable_raw_mode](terminal::enable_raw_mode) and [disable_raw_mode](terminal::disable_raw_mode) functions. +//! +//! ## Examples +//! +//! ```no_run +//! use std::io::{self, Write}; +//! use crossterm::{execute, terminal::{ScrollUp, SetSize, size}}; +//! +//! fn main() -> io::Result<()> { +//! let (cols, rows) = size()?; +//! // Resize terminal and scroll up. +//! execute!( +//! io::stdout(), +//! SetSize(10, 10), +//! ScrollUp(5) +//! )?; +//! +//! // Be a good citizen, cleanup +//! execute!(io::stdout(), SetSize(cols, rows))?; +//! Ok(()) +//! } +//! ``` +//! +//! For manual execution control check out [crossterm::queue](../macro.queue.html). + +use std::{fmt, io}; + +#[cfg(windows)] +use crossterm_winapi::{ConsoleMode, Handle, ScreenBuffer}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(windows)] +use winapi::um::wincon::ENABLE_WRAP_AT_EOL_OUTPUT; + +#[doc(no_inline)] +use crate::Command; +use crate::{csi, impl_display}; + +pub(crate) mod sys; + +#[cfg(feature = "events")] +pub use sys::supports_keyboard_enhancement; + +/// Tells whether the raw mode is enabled. +/// +/// Please have a look at the [raw mode](./index.html#raw-mode) section. +pub fn is_raw_mode_enabled() -> io::Result { + #[cfg(unix)] + { + Ok(sys::is_raw_mode_enabled()) + } + + #[cfg(windows)] + { + sys::is_raw_mode_enabled() + } +} + +/// Enables raw mode. +/// +/// Please have a look at the [raw mode](./index.html#raw-mode) section. +pub fn enable_raw_mode() -> io::Result<()> { + sys::enable_raw_mode() +} + +/// Disables raw mode. +/// +/// Please have a look at the [raw mode](./index.html#raw-mode) section. +pub fn disable_raw_mode() -> io::Result<()> { + sys::disable_raw_mode() +} + +/// Returns the terminal size `(columns, rows)`. +/// +/// The top left cell is represented `(1, 1)`. +pub fn size() -> io::Result<(u16, u16)> { + sys::size() +} + +#[derive(Debug)] +pub struct WindowSize { + pub rows: u16, + pub columns: u16, + pub width: u16, + pub height: u16, +} + +/// Returns the terminal size `[WindowSize]`. +/// +/// The width and height in pixels may not be reliably implemented or default to 0. +/// For unix, documents them as "unused". +/// For windows it is not implemented. +pub fn window_size() -> io::Result { + sys::window_size() +} + +/// Disables line wrapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisableLineWrap; + +impl Command for DisableLineWrap { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?7l")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + let screen_buffer = ScreenBuffer::current()?; + let console_mode = ConsoleMode::from(screen_buffer.handle().clone()); + let new_mode = console_mode.mode()? & !ENABLE_WRAP_AT_EOL_OUTPUT; + console_mode.set_mode(new_mode)?; + Ok(()) + } +} + +/// Enable line wrapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnableLineWrap; + +impl Command for EnableLineWrap { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?7h")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + let screen_buffer = ScreenBuffer::current()?; + let console_mode = ConsoleMode::from(screen_buffer.handle().clone()); + let new_mode = console_mode.mode()? | ENABLE_WRAP_AT_EOL_OUTPUT; + console_mode.set_mode(new_mode)?; + Ok(()) + } +} + +/// A command that switches to alternate screen. +/// +/// # Notes +/// +/// * Commands must be executed/queued for execution otherwise they do nothing. +/// * Use [LeaveAlternateScreen](./struct.LeaveAlternateScreen.html) command to leave the entered alternate screen. +/// +/// # Examples +/// +/// ```no_run +/// use std::io::{self, Write}; +/// use crossterm::{execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen}}; +/// +/// fn main() -> io::Result<()> { +/// execute!(io::stdout(), EnterAlternateScreen)?; +/// +/// // Do anything on the alternate screen +/// +/// execute!(io::stdout(), LeaveAlternateScreen) +/// } +/// ``` +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnterAlternateScreen; + +impl Command for EnterAlternateScreen { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?1049h")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + let alternate_screen = ScreenBuffer::create()?; + alternate_screen.show()?; + Ok(()) + } +} + +/// A command that switches back to the main screen. +/// +/// # Notes +/// +/// * Commands must be executed/queued for execution otherwise they do nothing. +/// * Use [EnterAlternateScreen](./struct.EnterAlternateScreen.html) to enter the alternate screen. +/// +/// # Examples +/// +/// ```no_run +/// use std::io::{self, Write}; +/// use crossterm::{execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen}}; +/// +/// fn main() -> io::Result<()> { +/// execute!(io::stdout(), EnterAlternateScreen)?; +/// +/// // Do anything on the alternate screen +/// +/// execute!(io::stdout(), LeaveAlternateScreen) +/// } +/// ``` +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeaveAlternateScreen; + +impl Command for LeaveAlternateScreen { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?1049l")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + let screen_buffer = ScreenBuffer::from(Handle::current_out_handle()?); + screen_buffer.show()?; + Ok(()) + } +} + +/// Different ways to clear the terminal buffer. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub enum ClearType { + /// All cells. + All, + /// All plus history + Purge, + /// All cells from the cursor position downwards. + FromCursorDown, + /// All cells from the cursor position upwards. + FromCursorUp, + /// All cells at the cursor row. + CurrentLine, + /// All cells from the cursor position until the new line. + UntilNewLine, +} + +/// A command that scrolls the terminal screen a given number of rows up. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollUp(pub u16); + +impl Command for ScrollUp { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + if self.0 != 0 { + write!(f, csi!("{}S"), self.0)?; + } + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + sys::scroll_up(self.0) + } +} + +/// A command that scrolls the terminal screen a given number of rows down. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollDown(pub u16); + +impl Command for ScrollDown { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + if self.0 != 0 { + write!(f, csi!("{}T"), self.0)?; + } + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + sys::scroll_down(self.0) + } +} + +/// A command that clears the terminal screen buffer. +/// +/// See the [`ClearType`](enum.ClearType.html) enum. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Clear(pub ClearType); + +impl Command for Clear { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(match self.0 { + ClearType::All => csi!("2J"), + ClearType::Purge => csi!("3J"), + ClearType::FromCursorDown => csi!("J"), + ClearType::FromCursorUp => csi!("1J"), + ClearType::CurrentLine => csi!("2K"), + ClearType::UntilNewLine => csi!("K"), + }) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + sys::clear(self.0) + } +} + +/// A command that sets the terminal buffer size `(columns, rows)`. +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetSize(pub u16, pub u16); + +impl Command for SetSize { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, csi!("8;{};{}t"), self.1, self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + sys::set_size(self.0, self.1) + } +} + +/// A command that sets the terminal title +/// +/// # Notes +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetTitle(pub T); + +impl Command for SetTitle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1B]0;{}\x07", &self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + sys::set_window_title(&self.0) + } +} + +/// A command that instructs the terminal emulator to begin a synchronized frame. +/// +/// # Notes +/// +/// * Commands must be executed/queued for execution otherwise they do nothing. +/// * Use [EndSynchronizedUpdate](./struct.EndSynchronizedUpdate.html) command to leave the entered alternate screen. +/// +/// When rendering the screen of the terminal, the Emulator usually iterates through each visible grid cell and +/// renders its current state. With applications updating the screen at a higher frequency this can cause tearing. +/// +/// This mode attempts to mitigate that. +/// +/// When the synchronization mode is enabled following render calls will keep rendering the last rendered state. +/// The terminal Emulator keeps processing incoming text and sequences. When the synchronized update mode is disabled +/// again the renderer may fetch the latest screen buffer state again, effectively avoiding the tearing effect +/// by unintentionally rendering in the middle a of an application screen update. +/// +/// # Examples +/// +/// ```no_run +/// use std::io::{self, Write}; +/// use crossterm::{execute, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}}; +/// +/// fn main() -> io::Result<()> { +/// execute!(io::stdout(), BeginSynchronizedUpdate)?; +/// +/// // Anything performed here will not be rendered until EndSynchronizedUpdate is called. +/// +/// execute!(io::stdout(), EndSynchronizedUpdate)?; +/// Ok(()) +/// } +/// ``` +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BeginSynchronizedUpdate; + +impl Command for BeginSynchronizedUpdate { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?2026h")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } + + #[cfg(windows)] + #[inline] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// A command that instructs the terminal to end a synchronized frame. +/// +/// # Notes +/// +/// * Commands must be executed/queued for execution otherwise they do nothing. +/// * Use [BeginSynchronizedUpdate](./struct.BeginSynchronizedUpdate.html) to enter the alternate screen. +/// +/// When rendering the screen of the terminal, the Emulator usually iterates through each visible grid cell and +/// renders its current state. With applications updating the screen a at higher frequency this can cause tearing. +/// +/// This mode attempts to mitigate that. +/// +/// When the synchronization mode is enabled following render calls will keep rendering the last rendered state. +/// The terminal Emulator keeps processing incoming text and sequences. When the synchronized update mode is disabled +/// again the renderer may fetch the latest screen buffer state again, effectively avoiding the tearing effect +/// by unintentionally rendering in the middle a of an application screen update. +/// +/// # Examples +/// +/// ```no_run +/// use std::io::{self, Write}; +/// use crossterm::{execute, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}}; +/// +/// fn main() -> io::Result<()> { +/// execute!(io::stdout(), BeginSynchronizedUpdate)?; +/// +/// // Anything performed here will not be rendered until EndSynchronizedUpdate is called. +/// +/// execute!(io::stdout(), EndSynchronizedUpdate)?; +/// Ok(()) +/// } +/// ``` +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EndSynchronizedUpdate; + +impl Command for EndSynchronizedUpdate { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(csi!("?2026l")) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } + + #[cfg(windows)] + #[inline] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +impl_display!(for ScrollUp); +impl_display!(for ScrollDown); +impl_display!(for SetSize); +impl_display!(for Clear); + +#[cfg(test)] +mod tests { + use std::{io::stdout, thread, time}; + + use crate::execute; + + use super::*; + + // Test is disabled, because it's failing on Travis CI + #[test] + #[ignore] + fn test_resize_ansi() { + let (width, height) = size().unwrap(); + + execute!(stdout(), SetSize(35, 35)).unwrap(); + + // see issue: https://github.com/eminence/terminal-size/issues/11 + thread::sleep(time::Duration::from_millis(30)); + + assert_eq!((35, 35), size().unwrap()); + + // reset to previous size + execute!(stdout(), SetSize(width, height)).unwrap(); + + // see issue: https://github.com/eminence/terminal-size/issues/11 + thread::sleep(time::Duration::from_millis(30)); + + assert_eq!((width, height), size().unwrap()); + } + + #[test] + fn test_raw_mode() { + // check we start from normal mode (may fail on some test harnesses) + assert!(!is_raw_mode_enabled().unwrap()); + + // enable the raw mode + if enable_raw_mode().is_err() { + // Enabling raw mode doesn't work on the ci + // So we just ignore it + return; + } + + // check it worked (on unix it doesn't really check the underlying + // tty but rather check that the code is consistent) + assert!(is_raw_mode_enabled().unwrap()); + + // enable it again, this should not change anything + enable_raw_mode().unwrap(); + + // check we're still in raw mode + assert!(is_raw_mode_enabled().unwrap()); + + // now let's disable it + disable_raw_mode().unwrap(); + + // check we're back to normal mode + assert!(!is_raw_mode_enabled().unwrap()); + } +} diff --git a/patches/crossterm-0.29.0/src/terminal/sys.rs b/patches/crossterm-0.29.0/src/terminal/sys.rs new file mode 100644 index 0000000000..9dde47d0e9 --- /dev/null +++ b/patches/crossterm-0.29.0/src/terminal/sys.rs @@ -0,0 +1,27 @@ +//! This module provides platform related functions. + +#[cfg(unix)] +#[cfg(feature = "events")] +pub use self::unix::supports_keyboard_enhancement; +#[cfg(unix)] +pub(crate) use self::unix::{ + disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, size, window_size, +}; +#[cfg(windows)] +#[cfg(feature = "events")] +pub use self::windows::supports_keyboard_enhancement; +#[cfg(all(windows, test))] +pub(crate) use self::windows::temp_screen_buffer; +#[cfg(windows)] +pub(crate) use self::windows::{ + clear, disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, scroll_down, scroll_up, + set_size, set_window_title, size, window_size, +}; + +#[cfg(windows)] +mod windows; + +#[cfg(unix)] +pub mod file_descriptor; +#[cfg(unix)] +mod unix; diff --git a/patches/crossterm-0.29.0/src/terminal/sys/file_descriptor.rs b/patches/crossterm-0.29.0/src/terminal/sys/file_descriptor.rs new file mode 100644 index 0000000000..baff266c67 --- /dev/null +++ b/patches/crossterm-0.29.0/src/terminal/sys/file_descriptor.rs @@ -0,0 +1,154 @@ +use std::io; + +#[cfg(feature = "libc")] +use libc::size_t; +#[cfg(not(feature = "libc"))] +use rustix::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; +#[cfg(feature = "libc")] +use std::{ + fs, + marker::PhantomData, + os::unix::{ + io::{IntoRawFd, RawFd}, + prelude::AsRawFd, + }, +}; + +/// A file descriptor wrapper. +/// +/// It allows to retrieve raw file descriptor, write to the file descriptor and +/// mainly it closes the file descriptor once dropped. +#[derive(Debug)] +#[cfg(feature = "libc")] +pub struct FileDesc<'a> { + fd: RawFd, + close_on_drop: bool, + phantom: PhantomData<&'a ()>, +} + +#[cfg(not(feature = "libc"))] +pub enum FileDesc<'a> { + Owned(OwnedFd), + Borrowed(BorrowedFd<'a>), +} + +#[cfg(feature = "libc")] +impl FileDesc<'_> { + /// Constructs a new `FileDesc` with the given `RawFd`. + /// + /// # Arguments + /// + /// * `fd` - raw file descriptor + /// * `close_on_drop` - specify if the raw file descriptor should be closed once the `FileDesc` is dropped + pub fn new(fd: RawFd, close_on_drop: bool) -> FileDesc<'static> { + FileDesc { + fd, + close_on_drop, + phantom: PhantomData, + } + } + + pub fn read(&self, buffer: &mut [u8]) -> io::Result { + let result = unsafe { + libc::read( + self.fd, + buffer.as_mut_ptr() as *mut libc::c_void, + buffer.len() as size_t, + ) + }; + + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(result as usize) + } + } + + /// Returns the underlying file descriptor. + pub fn raw_fd(&self) -> RawFd { + self.fd + } +} + +#[cfg(not(feature = "libc"))] +impl FileDesc<'_> { + pub fn read(&self, buffer: &mut [u8]) -> io::Result { + let fd = match self { + FileDesc::Owned(fd) => fd.as_fd(), + FileDesc::Borrowed(fd) => fd.as_fd(), + }; + let result = rustix::io::read(fd, buffer)?; + Ok(result) + } + + pub fn raw_fd(&self) -> RawFd { + match self { + FileDesc::Owned(fd) => fd.as_raw_fd(), + FileDesc::Borrowed(fd) => fd.as_raw_fd(), + } + } +} + +#[cfg(feature = "libc")] +impl Drop for FileDesc<'_> { + fn drop(&mut self) { + if self.close_on_drop { + // Note that errors are ignored when closing a file descriptor. The + // reason for this is that if an error occurs we don't actually know if + // the file descriptor was closed or not, and if we retried (for + // something like EINTR), we might close another valid file descriptor + // opened after we closed ours. + let _ = unsafe { libc::close(self.fd) }; + } + } +} + +impl AsRawFd for FileDesc<'_> { + fn as_raw_fd(&self) -> RawFd { + self.raw_fd() + } +} + +#[cfg(not(feature = "libc"))] +impl AsFd for FileDesc<'_> { + fn as_fd(&self) -> BorrowedFd<'_> { + match self { + FileDesc::Owned(fd) => fd.as_fd(), + FileDesc::Borrowed(fd) => fd.as_fd(), + } + } +} + +#[cfg(feature = "libc")] +/// Creates a file descriptor pointing to the standard input or `/dev/tty`. +pub fn tty_fd() -> io::Result> { + let (fd, close_on_drop) = if unsafe { libc::isatty(libc::STDIN_FILENO) == 1 } { + (libc::STDIN_FILENO, false) + } else { + ( + fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty")? + .into_raw_fd(), + true, + ) + }; + + Ok(FileDesc::new(fd, close_on_drop)) +} + +#[cfg(not(feature = "libc"))] +/// Creates a file descriptor pointing to the standard input or `/dev/tty`. +pub fn tty_fd() -> io::Result> { + use std::fs::File; + + let stdin = rustix::stdio::stdin(); + let fd = if rustix::termios::isatty(stdin) { + FileDesc::Borrowed(stdin) + } else { + let dev_tty = File::options().read(true).write(true).open("/dev/tty")?; + FileDesc::Owned(dev_tty.into()) + }; + Ok(fd) +} diff --git a/patches/crossterm-0.29.0/src/terminal/sys/unix.rs b/patches/crossterm-0.29.0/src/terminal/sys/unix.rs new file mode 100644 index 0000000000..6a2a979fd0 --- /dev/null +++ b/patches/crossterm-0.29.0/src/terminal/sys/unix.rs @@ -0,0 +1,326 @@ +//! UNIX related logic for terminal manipulation. + +#[cfg(feature = "events")] +use crate::event::KeyboardEnhancementFlags; +use crate::terminal::{ + sys::file_descriptor::{tty_fd, FileDesc}, + WindowSize, +}; +#[cfg(feature = "libc")] +use libc::{ + cfmakeraw, ioctl, tcgetattr, tcsetattr, termios as Termios, winsize, STDOUT_FILENO, TCSANOW, + TIOCGWINSZ, +}; +use parking_lot::Mutex; +#[cfg(not(feature = "libc"))] +use rustix::{ + fd::AsFd, + termios::{Termios, Winsize}, +}; + +use std::{fs::File, io, process}; +#[cfg(feature = "libc")] +use std::{ + mem, + os::unix::io::{IntoRawFd, RawFd}, +}; + +// Some(Termios) -> we're in the raw mode and this is the previous mode +// None -> we're not in the raw mode +static TERMINAL_MODE_PRIOR_RAW_MODE: Mutex> = parking_lot::const_mutex(None); + +pub(crate) fn is_raw_mode_enabled() -> bool { + TERMINAL_MODE_PRIOR_RAW_MODE.lock().is_some() +} + +#[cfg(feature = "libc")] +impl From for WindowSize { + fn from(size: winsize) -> WindowSize { + WindowSize { + columns: size.ws_col, + rows: size.ws_row, + width: size.ws_xpixel, + height: size.ws_ypixel, + } + } +} +#[cfg(not(feature = "libc"))] +impl From for WindowSize { + fn from(size: Winsize) -> WindowSize { + WindowSize { + columns: size.ws_col, + rows: size.ws_row, + width: size.ws_xpixel, + height: size.ws_ypixel, + } + } +} + +#[allow(clippy::useless_conversion)] +#[cfg(feature = "libc")] +pub(crate) fn window_size() -> io::Result { + // http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc + let mut size = winsize { + ws_row: 0, + ws_col: 0, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + let file = File::open("/dev/tty").map(|file| (FileDesc::new(file.into_raw_fd(), true))); + let fd = if let Ok(file) = &file { + file.raw_fd() + } else { + // Fallback to libc::STDOUT_FILENO if /dev/tty is missing + STDOUT_FILENO + }; + + if wrap_with_result(unsafe { ioctl(fd, TIOCGWINSZ.into(), &mut size) }).is_ok() { + return Ok(size.into()); + } + + Err(std::io::Error::last_os_error().into()) +} + +#[cfg(not(feature = "libc"))] +pub(crate) fn window_size() -> io::Result { + let file = File::open("/dev/tty").map(|file| FileDesc::Owned(file.into())); + let fd = if let Ok(file) = &file { + file.as_fd() + } else { + // Fallback to libc::STDOUT_FILENO if /dev/tty is missing + rustix::stdio::stdout() + }; + let size = rustix::termios::tcgetwinsize(fd)?; + Ok(size.into()) +} + +#[allow(clippy::useless_conversion)] +pub(crate) fn size() -> io::Result<(u16, u16)> { + if let Ok(window_size) = window_size() { + return Ok((window_size.columns, window_size.rows)); + } + + tput_size().ok_or_else(|| std::io::Error::last_os_error().into()) +} + +#[cfg(feature = "libc")] +pub(crate) fn enable_raw_mode() -> io::Result<()> { + let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock(); + if original_mode.is_some() { + return Ok(()); + } + + let tty = tty_fd()?; + let fd = tty.raw_fd(); + let mut ios = get_terminal_attr(fd)?; + let original_mode_ios = ios; + raw_terminal_attr(&mut ios); + set_terminal_attr(fd, &ios)?; + // Keep it last - set the original mode only if we were able to switch to the raw mode + *original_mode = Some(original_mode_ios); + Ok(()) +} + +#[cfg(not(feature = "libc"))] +pub(crate) fn enable_raw_mode() -> io::Result<()> { + let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock(); + if original_mode.is_some() { + return Ok(()); + } + + let tty = tty_fd()?; + let mut ios = get_terminal_attr(&tty)?; + let original_mode_ios = ios.clone(); + ios.make_raw(); + set_terminal_attr(&tty, &ios)?; + // Keep it last - set the original mode only if we were able to switch to the raw mode + *original_mode = Some(original_mode_ios); + Ok(()) +} + +/// Reset the raw mode. +/// +/// More precisely, reset the whole termios mode to what it was before the first call +/// to [enable_raw_mode]. If you don't mess with termios outside of crossterm, it's +/// effectively disabling the raw mode and doing nothing else. +#[cfg(feature = "libc")] +pub(crate) fn disable_raw_mode() -> io::Result<()> { + let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock(); + if let Some(original_mode_ios) = original_mode.as_ref() { + let tty = tty_fd()?; + set_terminal_attr(tty.raw_fd(), original_mode_ios)?; + // Keep it last - remove the original mode only if we were able to switch back + *original_mode = None; + } + Ok(()) +} + +#[cfg(not(feature = "libc"))] +pub(crate) fn disable_raw_mode() -> io::Result<()> { + let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock(); + if let Some(original_mode_ios) = original_mode.as_ref() { + let tty = tty_fd()?; + set_terminal_attr(&tty, original_mode_ios)?; + // Keep it last - remove the original mode only if we were able to switch back + *original_mode = None; + } + Ok(()) +} + +#[cfg(not(feature = "libc"))] +fn get_terminal_attr(fd: impl AsFd) -> io::Result { + let result = rustix::termios::tcgetattr(fd)?; + Ok(result) +} + +#[cfg(not(feature = "libc"))] +fn set_terminal_attr(fd: impl AsFd, termios: &Termios) -> io::Result<()> { + rustix::termios::tcsetattr(fd, rustix::termios::OptionalActions::Now, termios)?; + Ok(()) +} + +/// Queries the terminal's support for progressive keyboard enhancement. +/// +/// On unix systems, this function will block and possibly time out while +/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called. +#[cfg(feature = "events")] +pub fn supports_keyboard_enhancement() -> io::Result { + query_keyboard_enhancement_flags().map(|flags| flags.is_some()) +} + +/// Queries the terminal's currently active keyboard enhancement flags. +/// +/// On unix systems, this function will block and possibly time out while +/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called. +#[cfg(feature = "events")] +pub fn query_keyboard_enhancement_flags() -> io::Result> { + if is_raw_mode_enabled() { + query_keyboard_enhancement_flags_raw() + } else { + query_keyboard_enhancement_flags_nonraw() + } +} + +#[cfg(feature = "events")] +fn query_keyboard_enhancement_flags_nonraw() -> io::Result> { + enable_raw_mode()?; + let flags = query_keyboard_enhancement_flags_raw(); + disable_raw_mode()?; + flags +} + +#[cfg(feature = "events")] +fn query_keyboard_enhancement_flags_raw() -> io::Result> { + use crate::event::{ + filter::{KeyboardEnhancementFlagsFilter, PrimaryDeviceAttributesFilter}, + poll_internal, read_internal, InternalEvent, + }; + use std::io::Write; + use std::time::Duration; + + // This is the recommended method for testing support for the keyboard enhancement protocol. + // We send a query for the flags supported by the terminal and then the primary device attributes + // query. If we receive the primary device attributes response but not the keyboard enhancement + // flags, none of the flags are supported. + // + // See + + // ESC [ ? u Query progressive keyboard enhancement flags (kitty protocol). + // ESC [ c Query primary device attributes. + const QUERY: &[u8] = b"\x1B[?u\x1B[c"; + + let result = File::open("/dev/tty").and_then(|mut file| { + file.write_all(QUERY)?; + file.flush() + }); + if result.is_err() { + let mut stdout = io::stdout(); + stdout.write_all(QUERY)?; + stdout.flush()?; + } + + loop { + match poll_internal( + Some(Duration::from_millis(2000)), + &KeyboardEnhancementFlagsFilter, + ) { + Ok(true) => { + match read_internal(&KeyboardEnhancementFlagsFilter) { + Ok(InternalEvent::KeyboardEnhancementFlags(current_flags)) => { + // Flush the PrimaryDeviceAttributes out of the event queue. + read_internal(&PrimaryDeviceAttributesFilter).ok(); + return Ok(Some(current_flags)); + } + _ => return Ok(None), + } + } + Ok(false) => { + return Err(io::Error::new( + io::ErrorKind::Other, + "The keyboard enhancement status could not be read within a normal duration", + )); + } + Err(_) => {} + } + } +} + +/// execute tput with the given argument and parse +/// the output as a u16. +/// +/// The arg should be "cols" or "lines" +fn tput_value(arg: &str) -> Option { + let output = process::Command::new("tput").arg(arg).output().ok()?; + let value = output + .stdout + .into_iter() + .filter_map(|b| char::from(b).to_digit(10)) + .fold(0, |v, n| v * 10 + n as u16); + + if value > 0 { + Some(value) + } else { + None + } +} + +/// Returns the size of the screen as determined by tput. +/// +/// This alternate way of computing the size is useful +/// when in a subshell. +fn tput_size() -> Option<(u16, u16)> { + match (tput_value("cols"), tput_value("lines")) { + (Some(w), Some(h)) => Some((w, h)), + _ => None, + } +} + +#[cfg(feature = "libc")] +// Transform the given mode into an raw mode (non-canonical) mode. +fn raw_terminal_attr(termios: &mut Termios) { + unsafe { cfmakeraw(termios) } +} + +#[cfg(feature = "libc")] +fn get_terminal_attr(fd: RawFd) -> io::Result { + unsafe { + let mut termios = mem::zeroed(); + wrap_with_result(tcgetattr(fd, &mut termios))?; + Ok(termios) + } +} + +#[cfg(feature = "libc")] +fn set_terminal_attr(fd: RawFd, termios: &Termios) -> io::Result<()> { + wrap_with_result(unsafe { tcsetattr(fd, TCSANOW, termios) }) +} + +#[cfg(feature = "libc")] +fn wrap_with_result(result: i32) -> io::Result<()> { + if result == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} diff --git a/patches/crossterm-0.29.0/src/terminal/sys/windows.rs b/patches/crossterm-0.29.0/src/terminal/sys/windows.rs new file mode 100644 index 0000000000..cd71fab8c2 --- /dev/null +++ b/patches/crossterm-0.29.0/src/terminal/sys/windows.rs @@ -0,0 +1,471 @@ +//! WinAPI related logic for terminal manipulation. + +use std::fmt::{self, Write}; +use std::io::{self}; + +use crossterm_winapi::{Console, ConsoleMode, Coord, Handle, ScreenBuffer, Size}; +use winapi::{ + shared::minwindef::DWORD, + um::wincon::{SetConsoleTitleW, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT}, +}; + +use crate::{ + cursor, + terminal::{ClearType, WindowSize}, +}; + +/// bits which can't be set in raw mode +const NOT_RAW_MODE_MASK: DWORD = ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT; + +pub(crate) fn is_raw_mode_enabled() -> std::io::Result { + let console_mode = ConsoleMode::from(Handle::current_in_handle()?); + + let dw_mode = console_mode.mode()?; + + Ok( + // check none of the "not raw" bits is set + dw_mode & NOT_RAW_MODE_MASK == 0, + ) +} + +pub(crate) fn enable_raw_mode() -> std::io::Result<()> { + let console_mode = ConsoleMode::from(Handle::current_in_handle()?); + + let dw_mode = console_mode.mode()?; + + let new_mode = dw_mode & !NOT_RAW_MODE_MASK; + + console_mode.set_mode(new_mode)?; + + Ok(()) +} + +pub(crate) fn disable_raw_mode() -> std::io::Result<()> { + let console_mode = ConsoleMode::from(Handle::current_in_handle()?); + + let dw_mode = console_mode.mode()?; + + let new_mode = dw_mode | NOT_RAW_MODE_MASK; + + console_mode.set_mode(new_mode)?; + + Ok(()) +} + +pub(crate) fn size() -> io::Result<(u16, u16)> { + let terminal_size = ScreenBuffer::current()?.info()?.terminal_size(); + // windows starts counting at 0, unix at 1, add one to replicated unix behaviour. + Ok(( + (terminal_size.width + 1) as u16, + (terminal_size.height + 1) as u16, + )) +} + +pub(crate) fn window_size() -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Window pixel size not implemented for the Windows API.", + )) +} + +/// Queries the terminal's support for progressive keyboard enhancement. +/// +/// This always returns `Ok(false)` on Windows. +#[cfg(feature = "events")] +pub fn supports_keyboard_enhancement() -> std::io::Result { + Ok(false) +} + +pub(crate) fn clear(clear_type: ClearType) -> std::io::Result<()> { + let screen_buffer = ScreenBuffer::current()?; + let csbi = screen_buffer.info()?; + + let pos = csbi.cursor_pos(); + let buffer_size = csbi.buffer_size(); + let current_attribute = csbi.attributes(); + + match clear_type { + ClearType::All => { + clear_entire_screen(buffer_size, current_attribute)?; + } + ClearType::FromCursorDown => clear_after_cursor(pos, buffer_size, current_attribute)?, + ClearType::FromCursorUp => clear_before_cursor(pos, buffer_size, current_attribute)?, + ClearType::CurrentLine => clear_current_line(pos, buffer_size, current_attribute)?, + ClearType::UntilNewLine => clear_until_line(pos, buffer_size, current_attribute)?, + _ => { + clear_entire_screen(buffer_size, current_attribute)?; + } //TODO: make purge flush the entire screen buffer not just the visible window. + }; + Ok(()) +} + +pub(crate) fn scroll_up(row_count: u16) -> std::io::Result<()> { + let csbi = ScreenBuffer::current()?; + let mut window = csbi.info()?.terminal_window(); + + // check whether the window is too close to the screen buffer top + let count = row_count as i16; + if window.top >= count { + window.top -= count; // move top down + window.bottom -= count; // move bottom down + + Console::output()?.set_console_info(true, window)?; + } + Ok(()) +} + +pub(crate) fn scroll_down(row_count: u16) -> std::io::Result<()> { + let screen_buffer = ScreenBuffer::current()?; + let csbi = screen_buffer.info()?; + let mut window = csbi.terminal_window(); + let buffer_size = csbi.buffer_size(); + + // check whether the window is too close to the screen buffer top + let count = row_count as i16; + if window.bottom < buffer_size.height - count { + window.top += count; // move top down + window.bottom += count; // move bottom down + + Console::output()?.set_console_info(true, window)?; + } + Ok(()) +} + +pub(crate) fn set_size(width: u16, height: u16) -> std::io::Result<()> { + if width <= 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal width must be at least 1", + )); + } + + if height <= 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal height must be at least 1", + )); + } + + // get the position of the current console window + let screen_buffer = ScreenBuffer::current()?; + let console = Console::from(screen_buffer.handle().clone()); + let csbi = screen_buffer.info()?; + + let current_size = csbi.buffer_size(); + let window = csbi.terminal_window(); + + let mut new_size = Size::new(current_size.width, current_size.height); + + // If the buffer is smaller than this new window size, resize the + // buffer to be large enough. Include window position. + let mut resize_buffer = false; + + let width = width as i16; + if current_size.width < window.left + width { + if window.left >= i16::MAX - width { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal width too large", + )); + } + + new_size.width = window.left + width; + resize_buffer = true; + } + let height = height as i16; + if current_size.height < window.top + height { + if window.top >= i16::MAX - height { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal height too large", + )); + } + + new_size.height = window.top + height; + resize_buffer = true; + } + + if resize_buffer { + screen_buffer.set_size(new_size.width - 1, new_size.height - 1)?; + } + + let mut window = window; + + // preserve the position, but change the size. + window.bottom = window.top + height - 1; + window.right = window.left + width - 1; + console.set_console_info(true, window)?; + + // if we resized the buffer, un-resize it. + if resize_buffer { + screen_buffer.set_size(current_size.width - 1, current_size.height - 1)?; + } + + let bounds = console.largest_window_size()?; + + if width > bounds.x { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("terminal width {width} too large"), + )); + } + if height > bounds.y { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("terminal height {height} too large"), + )); + } + + Ok(()) +} + +pub(crate) fn set_window_title(title: impl fmt::Display) -> std::io::Result<()> { + struct Utf16Encoder(Vec); + impl Write for Utf16Encoder { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.0.extend(s.encode_utf16()); + Ok(()) + } + } + + let mut title_utf16 = Utf16Encoder(Vec::new()); + write!(title_utf16, "{title}").expect("formatting failed"); + title_utf16.0.push(0); + let title = title_utf16.0; + + let result = unsafe { SetConsoleTitleW(title.as_ptr()) }; + if result != 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +fn clear_after_cursor( + location: Coord, + buffer_size: Size, + current_attribute: u16, +) -> std::io::Result<()> { + let (mut x, mut y) = (location.x, location.y); + + // if cursor position is at the outer right position + if x > buffer_size.width { + y += 1; + x = 0; + } + + // location where to start clearing + let start_location = Coord::new(x, y); + + // get sum cells before cursor + let cells_to_write = buffer_size.width as u32 * buffer_size.height as u32; + + clear_winapi(start_location, cells_to_write, current_attribute) +} + +fn clear_before_cursor( + location: Coord, + buffer_size: Size, + current_attribute: u16, +) -> std::io::Result<()> { + let (xpos, ypos) = (location.x, location.y); + + // one cell after cursor position + let x = 0; + // one at row of cursor position + let y = 0; + + // location where to start clearing + let start_location = Coord::new(x, y); + + // get sum cells before cursor + let cells_to_write = (buffer_size.width as u32 * ypos as u32) + (xpos as u32 + 1); + + // clear everything before cursor position + clear_winapi(start_location, cells_to_write, current_attribute) +} + +fn clear_entire_screen(buffer_size: Size, current_attribute: u16) -> std::io::Result<()> { + // get sum cells before cursor + let cells_to_write = buffer_size.width as u32 * buffer_size.height as u32; + + // location where to start clearing + let start_location = Coord::new(0, 0); + + // clear the entire screen + clear_winapi(start_location, cells_to_write, current_attribute)?; + + // put the cursor back at cell 0,0 + cursor::sys::move_to(0, 0)?; + Ok(()) +} + +fn clear_current_line( + location: Coord, + buffer_size: Size, + current_attribute: u16, +) -> std::io::Result<()> { + // location where to start clearing + let start_location = Coord::new(0, location.y); + + // get sum cells before cursor + let cells_to_write = buffer_size.width as u32; + + // clear the whole current line + clear_winapi(start_location, cells_to_write, current_attribute)?; + + // put the cursor back at cell 1 on current row + cursor::sys::move_to(0, location.y as u16)?; + Ok(()) +} + +fn clear_until_line( + location: Coord, + buffer_size: Size, + current_attribute: u16, +) -> std::io::Result<()> { + let (x, y) = (location.x, location.y); + + // location where to start clearing + let start_location = Coord::new(x, y); + + // get sum cells before cursor + let cells_to_write = (buffer_size.width - x) as u32; + + // clear until the current line + clear_winapi(start_location, cells_to_write, current_attribute)?; + + // put the cursor back at original cursor position before we did the clearing + cursor::sys::move_to(x as u16, y as u16)?; + Ok(()) +} + +fn clear_winapi( + start_location: Coord, + cells_to_write: u32, + current_attribute: u16, +) -> std::io::Result<()> { + let console = Console::from(Handle::current_out_handle()?); + console.fill_whit_character(start_location, cells_to_write, ' ')?; + console.fill_whit_attribute(start_location, cells_to_write, current_attribute)?; + Ok(()) +} + +#[cfg(test)] +// Create a new screen buffer to avoid changing the terminal the test +// is running within. +pub fn temp_screen_buffer() -> std::io::Result { + let alternate_screen = ScreenBuffer::create()?; + alternate_screen.show().unwrap(); + Ok(alternate_screen) +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + + use crossterm_winapi::ScreenBuffer; + use serial_test::serial; + use winapi::um::wincon::GetConsoleTitleW; + + use super::{scroll_down, scroll_up, set_size, set_window_title, size, temp_screen_buffer}; + + #[test] + #[serial] + fn test_resize_winapi_20_21() { + let _test_screen = temp_screen_buffer().unwrap(); + + let (width, height) = size().unwrap(); + + // The values 20 and 21 are arbitrary and different from each other + // just to see they're not crossed over. + set_size(20, 21).unwrap(); + assert_eq!((20, 21), size().unwrap()); + + // reset to previous size + set_size(width, height).unwrap(); + assert_eq!((width, height), size().unwrap()); + } + + // This is similar to test_resize_winapi_20_21() above. This verifies that + // another test of similar functionality runs independently (that a testing + // race condition has been addressed). + #[test] + #[serial] + #[ignore] + fn test_resize_winapi_30_31() { + let _test_screen = temp_screen_buffer().unwrap(); + + let (width, height) = size().unwrap(); + + set_size(30, 31).unwrap(); + assert_eq!((30, 31), size().unwrap()); + + // reset to previous size + set_size(width, height).unwrap(); + assert_eq!((width, height), size().unwrap()); + } + + // Test is disabled, because it's failing on Travis CI + #[test] + #[ignore] + fn test_scroll_down_winapi() { + let current_window = ScreenBuffer::current() + .unwrap() + .info() + .unwrap() + .terminal_window(); + + scroll_down(2).unwrap(); + + let new_window = ScreenBuffer::current() + .unwrap() + .info() + .unwrap() + .terminal_window(); + + assert_eq!(new_window.top, current_window.top + 2); + assert_eq!(new_window.bottom, current_window.bottom + 2); + } + + // Test is disabled, because it's failing on Travis CI + #[test] + #[ignore] + fn test_scroll_up_winapi() { + // move the terminal buffer down before moving it up + test_scroll_down_winapi(); + + let current_window = ScreenBuffer::current() + .unwrap() + .info() + .unwrap() + .terminal_window(); + + scroll_up(2).unwrap(); + + let new_window = ScreenBuffer::current() + .unwrap() + .info() + .unwrap() + .terminal_window(); + + assert_eq!(new_window.top, current_window.top - 2); + assert_eq!(new_window.bottom, current_window.bottom - 2); + } + + #[test] + #[serial] + fn test_set_title_winapi() { + let _test_screen = temp_screen_buffer().unwrap(); + + let test_title = "this is a crossterm test title"; + set_window_title(test_title).unwrap(); + + let mut raw = [0_u16; 128]; + let length = unsafe { GetConsoleTitleW(raw.as_mut_ptr(), raw.len() as u32) } as usize; + assert_ne!(0, length); + + let console_title = OsString::from_wide(&raw[..length]).into_string().unwrap(); + assert_eq!(test_title, &console_title[..]); + } +} diff --git a/patches/crossterm-0.29.0/src/tty.rs b/patches/crossterm-0.29.0/src/tty.rs new file mode 100644 index 0000000000..5a710b4abd --- /dev/null +++ b/patches/crossterm-0.29.0/src/tty.rs @@ -0,0 +1,54 @@ +//! Making it a little more convenient and safe to query whether +//! something is a terminal teletype or not. +//! This module defines the IsTty trait and the is_tty method to +//! return true if the item represents a terminal. + +#[cfg(unix)] +use std::os::unix::io::AsRawFd; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; + +#[cfg(windows)] +use winapi::um::consoleapi::GetConsoleMode; + +/// Adds the `is_tty` method to types that might represent a terminal +/// +/// ```rust +/// use std::io::stdout; +/// use crossterm::tty::IsTty; +/// +/// let is_tty: bool = stdout().is_tty(); +/// ``` +pub trait IsTty { + /// Returns true when an instance is a terminal teletype, otherwise false. + fn is_tty(&self) -> bool; +} + +/// On UNIX, the `isatty()` function returns true if a file +/// descriptor is a terminal. +#[cfg(all(unix, feature = "libc"))] +impl IsTty for S { + fn is_tty(&self) -> bool { + let fd = self.as_raw_fd(); + unsafe { libc::isatty(fd) == 1 } + } +} + +#[cfg(all(unix, not(feature = "libc")))] +impl IsTty for S { + fn is_tty(&self) -> bool { + let fd = self.as_raw_fd(); + rustix::termios::isatty(unsafe { std::os::unix::io::BorrowedFd::borrow_raw(fd) }) + } +} + +/// On windows, `GetConsoleMode` will return true if we are in a terminal. +/// Otherwise false. +#[cfg(windows)] +impl IsTty for S { + fn is_tty(&self) -> bool { + let mut mode = 0; + let ok = unsafe { GetConsoleMode(self.as_raw_handle() as *mut _, &mut mode) }; + ok == 1 + } +} diff --git a/scripts/release/windows-terminal-clipboard.mjs b/scripts/release/windows-terminal-clipboard.mjs new file mode 100644 index 0000000000..a16c6b1f69 --- /dev/null +++ b/scripts/release/windows-terminal-clipboard.mjs @@ -0,0 +1,151 @@ +// Actual Windows Terminal clipboard acceptance on disposable GitHub runners. +// Uses a synthetic home and loopback provider; no credentials or paid inference. +// This qualifies clipboard delivery in the runner's interactive session, not +// every Windows desktop, IME, remote-desktop client or terminal distribution. +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +assert.equal(process.platform, 'win32', 'requires a real Windows desktop'); +assert.equal(process.env.GITHUB_ACTIONS, 'true', 'disposable GitHub runner only'); +const [binaryArg, evidenceArg] = process.argv.slice(2); +assert.ok(binaryArg && evidenceArg, 'usage: node windows-terminal-clipboard.mjs BINARY EVIDENCE_DIR'); +const binary = path.resolve(binaryArg); +const evidence = path.resolve(evidenceArg); +await fs.mkdir(evidence, { recursive: true }); +const root = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP, 'cw-clipboard-')); +const qaHome = path.join(root, 'home'); +const workspace = path.join(root, 'workspace'); +const terminalRoot = path.join(root, 'terminal'); +const title = `Codewhale clipboard ${randomUUID()}`; +const model = 'gpt-4o-mini'; +const expected = Array.from({ length: 24 }, (_, n) => `cw-clipboard-${String(n + 1).padStart(2, '0')} 漢字 🐳 line ${n + 1}`).join('\n'); +const requests = []; +const receipt = { source: process.env.GITHUB_SHA, binary, result: 'failed', lineCount: 24, provider: 'loopback fixture', title }; +let desktop; +let desktopExit; +let desktopError = ''; +const server = http.createServer(async (req, res) => { + if (req.method === 'GET' && req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ object: 'list', data: [{ id: model, object: 'model', owned_by: 'fixture' }] })); + return; + } + if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { + res.writeHead(404); res.end(); return; + } + try { + const chunks = []; let size = 0; + for await (const chunk of req) { + size += chunk.length; + assert.ok(size <= 2 * 1024 * 1024, 'fixture request exceeds limit'); + chunks.push(chunk); + } + const body = JSON.parse(Buffer.concat(chunks)); + const users = (body.messages ?? []).filter(m => m.role === 'user').map(m => typeof m.content === 'string' + ? m.content : (m.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n')); + requests.push(users); + // Retain only synthetic user input; never dump headers or system prompts. + await fs.writeFile(path.join(evidence, 'provider-user-messages.json'), JSON.stringify(requests, null, 2)); + const response = { id: 'clipboard-fixture', object: 'chat.completion', created: 1, model, + choices: [{ index: 0, message: { role: 'assistant', content: 'cw-clipboard-ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }; + if (body.stream) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + res.write(`data: ${JSON.stringify({ ...response, object: 'chat.completion.chunk', choices: [{ index: 0, delta: { role: 'assistant', content: 'cw-clipboard-ok' }, finish_reason: null }] })}\n\n`); + res.write(`data: ${JSON.stringify({ ...response, object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`); + res.end('data: [DONE]\n\n'); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); + } + } catch (error) { desktopError ||= String(error); res.writeHead(500); res.end(); } +}); + +async function until(check, label, milliseconds = 60_000) { + const deadline = Date.now() + milliseconds; + while (Date.now() < deadline) { + const result = await check(); + if (result) return result; + assert.equal(desktop?.exitCode, null, `desktop stopped while waiting for ${label}: ${desktopError}`); + await delay(100); + } + throw new Error(`deadline waiting for ${label}: ${desktopError}`); +} +const exists = async name => fs.access(path.join(evidence, name)).then(() => true, () => false); +try { + receipt.binarySha256 = createHash('sha256').update(await fs.readFile(binary)).digest('hex'); + receipt.version = execFileSync(binary, ['--version'], { encoding: 'utf8' }).trim(); + for (const dir of [workspace, path.join(qaHome, '.codewhale'), path.join(qaHome, '.deepseek'), path.join(qaHome, 'AppData', 'Roaming'), path.join(qaHome, 'AppData', 'Local')]) await fs.mkdir(dir, { recursive: true }); + const config = 'telemetry = false\n[notifications]\nmethod = "off"\ncompletion_sound = "off"\n'; + for (const folder of ['.codewhale', '.deepseek']) await fs.writeFile(path.join(qaHome, folder, 'config.toml'), config); + await fs.writeFile(path.join(evidence, 'expected.txt'), expected.replaceAll('\n', '\r\n')); + // Official Microsoft ZIP, pinned by release and GitHub's asset SHA-256. + const terminalUrl = 'https://github.com/microsoft/terminal/releases/download/v1.25.2733.0/Microsoft.WindowsTerminal_1.25.2733.0_x64.zip'; + const terminalHash = 'bf3ef2012f6c44d8340a4c58125acc9498d19b580f9890dc043cdf831852e796'; + const download = await fetch(terminalUrl, { signal: AbortSignal.timeout(60_000) }); + assert.equal(download.status, 200, 'official Terminal download'); + const bytes = Buffer.from(await download.arrayBuffer()); + assert.equal(createHash('sha256').update(bytes).digest('hex'), terminalHash, 'Terminal asset digest'); + const zip = path.join(root, 'terminal.zip'); await fs.writeFile(zip, bytes); + await fs.mkdir(terminalRoot); + execFileSync('tar.exe', ['-xf', zip, '-C', terminalRoot], { timeout: 60_000 }); + const files = await fs.readdir(terminalRoot, { recursive: true }); + const executables = files.filter(f => path.basename(f).toLowerCase() === 'windowsterminal.exe'); + assert.equal(executables.length, 1, 'unique portable Terminal executable'); + const terminal = path.join(terminalRoot, executables[0]); + const terminalDir = path.dirname(terminal); + await fs.writeFile(path.join(terminalDir, '.portable'), ''); + await fs.mkdir(path.join(terminalDir, 'settings'), { recursive: true }); + const profile = '{9e4b713f-d69c-4206-85fb-98663b729f17}'; + await fs.writeFile(path.join(terminalDir, 'settings', 'settings.json'), JSON.stringify({ + defaultProfile: profile, initialCols: 110, initialRows: 38, confirmCloseAllTabs: false, + // The payload is trusted fixture text; this tests Codewhale's paste path, + // excluding Terminal's own optional multiline confirmation dialog. + multiLinePasteWarning: false, largePasteWarning: false, + profiles: { defaults: { font: { size: 12 } }, list: [{ guid: profile, name: 'Codewhale fixture', commandline: 'cmd.exe' }] }, + actions: [{ command: 'paste', keys: 'ctrl+shift+v' }], + }, null, 2)); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; + const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(PATH|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|PATHEXT|SYSTEMDRIVE|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|PROCESSOR_ARCHITECTURE|NUMBER_OF_PROCESSORS)$/i.test(k))); + Object.assign(env, { + HOME: qaHome, USERPROFILE: qaHome, APPDATA: path.join(qaHome, 'AppData', 'Roaming'), LOCALAPPDATA: path.join(qaHome, 'AppData', 'Local'), + XDG_CONFIG_HOME: path.join(qaHome, '.config'), XDG_DATA_HOME: path.join(qaHome, '.local', 'share'), XDG_CACHE_HOME: path.join(qaHome, '.cache'), + CODEWHALE_CONFIG_PATH: path.join(qaHome, '.codewhale', 'config.toml'), DEEPSEEK_CONFIG_PATH: path.join(qaHome, '.deepseek', 'config.toml'), + CODEWHALE_TELEMETRY: '0', CODEWHALE_NO_UPDATE_CHECK: '1', CODEWHALE_DISABLE_MODELS_DEV_FETCH: '1', CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE: '1', NO_ANIMATIONS: '1', + }); + receipt.terminal = { version: '1.25.2733.0', sha256: terminalHash, distribution: 'official portable x64' }; + const script = fileURLToPath(new URL('./windows-terminal-clipboard.ps1', import.meta.url)); + desktop = spawn('powershell.exe', ['-NoProfile', '-STA', '-File', script, terminal, binary, workspace, baseUrl, title, evidence], { env, windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] }); + desktop.stdout.on('data', data => { void fs.appendFile(path.join(evidence, 'desktop.log'), data); }); + desktop.stderr.on('data', data => { desktopError += data; void fs.appendFile(path.join(evidence, 'desktop-error.log'), data); }); + desktopExit = new Promise((resolve, reject) => { desktop.once('error', reject); desktop.once('exit', resolve); }); + await until(() => exists('pasted.json'), 'actual clipboard paste'); + assert.equal(requests.length, 0, 'pasting must not submit any partial turn'); + receipt.requestsBeforeEnter = 0; + await fs.writeFile(path.join(evidence, 'submit'), 'enter once'); + await until(() => exists('completed.json'), 'rendered fixture reply'); + await delay(1000); + assert.equal(requests.length, 1, 'one Enter must submit exactly one turn'); + const matching = requests[0].filter(text => text.includes('cw-clipboard-01')); + assert.equal(matching.length, 1, 'one user message contains the pasted input'); + const normalized = matching[0].replaceAll('\r\n', '\n'); + assert.ok(normalized.includes(expected), 'all24 Unicode lines arrive contiguously and unchanged'); + for (let line = 1; line <= 24; line++) assert.equal(normalized.split(`cw-clipboard-${String(line).padStart(2, '0')}`).length - 1, 1, `line${line} occurs once`); + assert.equal(await desktopExit, 0, desktopError); + assert.equal(desktopError, '', 'no desktop or provider fixture errors'); + receipt.requestsAfterEnter = 1; + receipt.result = 'passed'; +} catch (error) { + receipt.error = String(error); process.exitCode = 1; +} finally { + if (desktop && desktop.exitCode === null) { desktop.kill(); await desktopExit.catch(() => {}); } + server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); + await fs.writeFile(path.join(evidence, 'receipt.json'), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} diff --git a/scripts/release/windows-terminal-clipboard.ps1 b/scripts/release/windows-terminal-clipboard.ps1 new file mode 100644 index 0000000000..9ef25e00f6 --- /dev/null +++ b/scripts/release/windows-terminal-clipboard.ps1 @@ -0,0 +1,106 @@ +param( + [Parameter(Mandatory)][string]$Terminal, + [Parameter(Mandatory)][string]$Binary, + [Parameter(Mandatory)][string]$Workspace, + [Parameter(Mandatory)][string]$BaseUrl, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Evidence +) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Windows.Forms, System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ClipboardFixtureWindow { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int command); +} +'@ +$window = $null +$ownedPid = $null +function Read-Terminal { + $texts = $window.FindAll([System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::IsTextPatternAvailableProperty, $true)) + return (@($texts | ForEach-Object { + $_.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern).DocumentRange.GetText(-1) + }) -join "`n") +} +function Wait-For([scriptblock]$Check, [string]$Label) { + $deadline = [DateTime]::UtcNow.AddSeconds(45) + while ([DateTime]::UtcNow -lt $deadline) { + if (& $Check) { return } + Start-Sleep -Milliseconds 200 + } + throw "Deadline waiting for $Label" +} +function Capture([string]$Name) { + (Read-Terminal) | Set-Content -Encoding UTF8 (Join-Path $Evidence "$Name.txt") + $r = $window.Current.BoundingRectangle + if ($r.IsEmpty -or $r.Width -le 0 -or $r.Height -le 0) { throw 'Terminal has no visible window' } + $bitmap = [System.Drawing.Bitmap]::new([int]$r.Width, [int]$r.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen([int]$r.X, [int]$r.Y, 0, 0, $bitmap.Size) + $bitmap.Save((Join-Path $Evidence "$Name.png"), [System.Drawing.Imaging.ImageFormat]::Png) + } finally { $graphics.Dispose(); $bitmap.Dispose() } +} +function Focus-OwnedWindow { + $process = Get-Process -Id $ownedPid + if ($process.Path -ne $Terminal) { throw 'Terminal process identity changed' } + $handle = [IntPtr]$window.Current.NativeWindowHandle + [void][ClipboardFixtureWindow]::ShowWindow($handle, 9) + [void][ClipboardFixtureWindow]::SetForegroundWindow($handle) + Wait-For { [ClipboardFixtureWindow]::GetForegroundWindow() -eq $handle } 'owned Terminal focus' +} +try { + # All arguments are owned fixture paths; the fake key cannot reach a paid provider. + foreach ($arg in @($Terminal, $Binary, $Workspace, $BaseUrl, $Title)) { + if ($arg.Contains('"')) { throw 'Unexpected quote in fixture argument' } + } + $arguments = "-w new new-tab --title `"$Title`" --suppressApplicationTitle --startingDirectory `"$Workspace`" `"$Binary`" --workspace `"$Workspace`" --no-project-config --fresh --provider openai --model gpt-4o-mini --api-key clipboard-fixture-only --base-url `"$BaseUrl`"" + Start-Process -FilePath $Terminal -ArgumentList $arguments | Out-Null + Wait-For { + $windows = [System.Windows.Automation.AutomationElement]::RootElement.FindAll( + [System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition) + foreach ($candidate in $windows) { + if ($candidate.Current.Name.Contains($Title)) { + $process = Get-Process -Id $candidate.Current.ProcessId + if ($process.Path -eq $Terminal) { + $script:window = $candidate; $script:ownedPid = $process.Id; return $true + } + } + } + return $false + } 'owned Windows Terminal window' + Focus-OwnedWindow + Wait-For { (Read-Terminal).Contains('Type a message') } 'Codewhale composer' + Capture 'ready' + $payload = [System.IO.File]::ReadAllText((Join-Path $Evidence 'expected.txt'), [System.Text.Encoding]::UTF8) + [System.Windows.Forms.Clipboard]::SetText($payload) + if ([System.Windows.Forms.Clipboard]::GetText() -ne $payload) { throw 'Clipboard readback mismatch' } + Focus-OwnedWindow + [System.Windows.Forms.SendKeys]::SendWait('^+v') + # Let every pasted line arrive before asking the independent HTTP observer + # whether anything was submitted prematurely. + Start-Sleep -Milliseconds 1800 + Capture 'pasted' + @{ processId = $ownedPid; sessionId = (Get-Process -Id $ownedPid).SessionId; chars = $payload.Length; input = 'Windows clipboard + Ctrl+Shift+V' } | + ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $Evidence 'pasted.json') + Wait-For { Test-Path (Join-Path $Evidence 'submit') } 'independent no-submit assertion' + Focus-OwnedWindow + [System.Windows.Forms.SendKeys]::SendWait('{ENTER}') + Wait-For { (Read-Terminal).Contains('cw-clipboard-ok') } 'actual provider reply' + Capture 'completed' + @{ enterCount = 1; renderedReply = 'cw-clipboard-ok' } | ConvertTo-Json | + Set-Content -Encoding UTF8 (Join-Path $Evidence 'completed.json') +} catch { + if ($window) { try { Capture 'failure' } catch { Write-Warning $_ } } + throw +} finally { + # Retire only the portable Terminal instance proven to belong to this fixture. + if ($ownedPid) { + $process = Get-Process -Id $ownedPid -ErrorAction SilentlyContinue + if ($process -and $process.Path -eq $Terminal) { Stop-Process -Id $ownedPid -Force } + } +} diff --git a/telemetry-ingest/package-lock.json b/telemetry-ingest/package-lock.json index 43b1abef0b..c8c1f1172e 100644 --- a/telemetry-ingest/package-lock.json +++ b/telemetry-ingest/package-lock.json @@ -602,9 +602,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -621,13 +621,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -644,13 +644,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -658,7 +658,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -668,9 +668,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -685,9 +685,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -702,16 +702,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -722,16 +719,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -742,16 +736,13 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -762,16 +753,13 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -782,16 +770,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -802,16 +787,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -822,16 +804,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -842,16 +821,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -862,16 +838,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -884,20 +857,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -910,20 +880,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -936,20 +903,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -962,20 +926,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -988,20 +949,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1014,20 +972,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1040,20 +995,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1066,13 +1018,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -1087,9 +1039,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -1097,7 +1049,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1107,9 +1059,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1127,9 +1079,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1147,9 +1099,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1343,9 +1295,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1363,9 +1312,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1383,9 +1329,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1403,9 +1346,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1423,9 +1363,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1443,9 +1380,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2011,9 +1945,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2035,9 +1966,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2059,9 +1987,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2083,9 +2008,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2313,9 +2235,9 @@ } }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2330,31 +2252,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -2370,9 +2292,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/telemetry-ingest/package.json b/telemetry-ingest/package.json index d6e8a231b9..c6cf4de92b 100644 --- a/telemetry-ingest/package.json +++ b/telemetry-ingest/package.json @@ -20,7 +20,8 @@ "wrangler": "^4.107.0" }, "overrides": { - "sharp": "0.35.4", - "undici": "7.29.1" + "sharp": "0.35.5", + "undici": "7.29.1", + "source-map-js": "1.2.2" } } diff --git a/vendor/codewhale-ratatui/Cargo.toml b/vendor/codewhale-ratatui/Cargo.toml index f94c9b144e..c8f495dae4 100644 --- a/vendor/codewhale-ratatui/Cargo.toml +++ b/vendor/codewhale-ratatui/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.89" license = "MIT" description = "Reusable Ratatui components from Codewhale: native workbar, composer, views, themes, ocean and motion." -repository = "https://github.com/Hmbown/codewhale-ratatui" +repository = "https://github.com/codewhale-hq/codewhale-ratatui" keywords = ["ratatui", "tui", "terminal", "components", "codewhale"] categories = ["command-line-interface"] # Ship the library and runnable sources, not repository previews or local sessions. @@ -14,6 +14,10 @@ include = [ "/vendor/codewhale-design/tokens.rs", "/assets/whale-v2.scenes", "/assets/whale-motion/**", + "/assets/avatar-pack/**", + "/assets/whale-girl/avatar.json", + "/assets/whale-girl/player.rs", + "/assets/whale-girl/terminal.rgba", "/assets/tui-palettes.json", "/assets/tui-source.json", "/tests/fixtures/whale-motion/**", diff --git a/vendor/codewhale-ratatui/assets/avatar-pack/contract.rs b/vendor/codewhale-ratatui/assets/avatar-pack/contract.rs new file mode 100644 index 0000000000..3d7b6c2ec0 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/avatar-pack/contract.rs @@ -0,0 +1,305 @@ +//! Avatar pack v1: presentation data, never executable rendering or lifecycle. +//! Native, terminal and the extension host vendor this exact contract. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const ACTS: [&str; 17] = [ + "rest", "listen", "think", "busy", "read", "search", "write", "run", "browse", "talk", "pod", + "needs", "done", "hmm", "computer", "connect", "sleep", +]; +pub const MAX_PNG_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_PACK_BYTES: usize = 128 * 1024; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Pack { + pub version: u8, + pub id: String, + pub name: String, + pub atlases: Vec, + pub columns: u16, + pub rows: u16, + pub tile_width: u16, + pub tile_height: u16, + pub actions: BTreeMap, + pub states: BTreeMap, + #[serde(default)] + pub views: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Action { + pub frames: Vec, + pub durations_ms: Vec, + pub poster: u16, + #[serde(default)] + pub repeat: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Frame { + pub index: usize, +} + +pub fn slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') +} + +pub fn relative_asset(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && !value + .chars() + .any(|c| c.is_control() || matches!(c, '\\' | ':')) + && value + .split('/') + .all(|s| !s.is_empty() && s != "." && s != "..") +} + +impl Pack { + pub fn page(&self, frame: usize) -> usize { + frame / (usize::from(self.columns).max(1) * usize::from(self.rows).max(1)) + } + pub fn local_frame(&self, frame: usize) -> usize { + frame % (usize::from(self.columns).max(1) * usize::from(self.rows).max(1)) + } + + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PACK_BYTES { + return Err("Avatar manifest exceeds 128 KiB".into()); + } + let pack: Self = + serde_json::from_slice(bytes).map_err(|e| format!("Invalid avatar manifest: {e}"))?; + pack.validate()?; + Ok(pack) + } + + pub fn validate(&self) -> Result<(), String> { + let count = u32::from(self.columns) + .saturating_mul(u32::from(self.rows)) + .saturating_mul(self.atlases.len().try_into().unwrap_or(u32::MAX)); + if self.version != 1 + || !slug(&self.id) + || self.name.trim().is_empty() + || self.name.len() > 96 + || self.name.chars().any(char::is_control) + || self.atlases.is_empty() + || self.atlases.len() > 16 + || self + .atlases + .iter() + .any(|a| !relative_asset(a) || !a.ends_with(".png")) + || count == 0 + || count > 128 + || self.tile_width == 0 + || self.tile_height == 0 + || u32::from(self.tile_width) * u32::from(self.columns) > 2048 + || u32::from(self.tile_height) * u32::from(self.rows) > 2048 + || self.actions.is_empty() + || self.actions.len() > 64 + || self.views.len() > 8 + || !self.states.contains_key("rest") + { + return Err("Invalid avatar identity, atlas dimensions, or action limits".into()); + } + for (id, action) in &self.actions { + if !slug(id) + || action.frames.is_empty() + || action.frames.len() > 64 + || action.frames.len() != action.durations_ms.len() + || u32::from(action.poster) >= count + || action.frames.iter().any(|&f| u32::from(f) >= count) + || action + .durations_ms + .iter() + .any(|&d| !(80..=10_000).contains(&d)) + || action + .durations_ms + .iter() + .map(|&d| u32::from(d)) + .sum::() + > 30_000 + { + return Err(format!("Invalid avatar action {id}")); + } + } + for (state, action) in &self.states { + if !ACTS.contains(&state.as_str()) || !self.actions.contains_key(action) { + return Err(format!("Invalid avatar state binding {state}")); + } + } + for (view, action) in &self.views { + if !slug(view) || !self.actions.contains_key(action) { + return Err(format!("Invalid avatar view binding {view}")); + } + } + Ok(()) + } + + /// Check compressed size and dimensions before any client invokes a codec. + pub fn validate_png(&self, png: &[u8]) -> Result<(), String> { + self.validate()?; + if png.len() < 33 + || png.len() > MAX_PNG_BYTES + || &png[..8] != b"\x89PNG\r\n\x1a\n" + || png[8..12] != 13u32.to_be_bytes() + || &png[12..16] != b"IHDR" + { + return Err("Avatar atlas must be a bounded PNG".into()); + } + let width = u32::from_be_bytes(png[16..20].try_into().unwrap()); + let height = u32::from_be_bytes(png[20..24].try_into().unwrap()); + if width != u32::from(self.columns) * u32::from(self.tile_width) + || height != u32::from(self.rows) * u32::from(self.tile_height) + { + return Err("Avatar atlas dimensions do not match the manifest".into()); + } + Ok(()) + } + + /// One caller-supplied clock; reduced motion chooses the authored poster. + /// A custom action can be previewed by name without changing Engine state. + pub fn sample( + &self, + state: &str, + frame: f64, + reduced: bool, + view: Option<&str>, + action: Option<&str>, + ) -> Frame { + let named = action + .filter(|a| self.actions.contains_key(*a)) + .or_else(|| view.and_then(|v| self.views.get(v).map(String::as_str))) + .or_else(|| self.states.get(state).map(String::as_str)) + .or_else(|| self.states.get("rest").map(String::as_str)); + let Some(clip) = named.and_then(|name| self.actions.get(name)) else { + return Frame { index: 0 }; + }; + if reduced || !frame.is_finite() { + return Frame { + index: clip.poster as usize, + }; + } + let total: u64 = clip.durations_ms.iter().map(|&d| u64::from(d)).sum(); + if total == 0 { + return Frame { + index: clip.poster as usize, + }; + } + let elapsed = (frame.max(0.) / 30. * 1000.).min(u64::MAX as f64) as u64; + let mut elapsed = if clip.repeat { + elapsed % total + } else { + elapsed.min(total - 1) + }; + for (&index, &duration) in clip.frames.iter().zip(&clip.durations_ms) { + if elapsed < u64::from(duration) { + return Frame { + index: index as usize, + }; + } + elapsed -= u64::from(duration); + } + Frame { + index: clip.poster as usize, + } + } +} + +/// A reviewed Native registration projected by the Engine. No owner token, +/// host path, JavaScript, HTML, external URL or input handler crosses this API. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegisteredPack { + pub key: String, + pub handle: u64, + pub content_hash: String, + pub pack: Pack, +} + +impl RegisteredPack { + pub fn image_key(&self, page: usize) -> String { + format!("{}:{}:{}:{page}", self.key, self.content_hash, self.handle) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn example() -> Pack { + Pack::parse(br#"{"version":1,"id":"studio","name":"Studio avatar","atlases":["art/atlas.png"],"columns":2,"rows":1,"tileWidth":8,"tileHeight":8,"actions":{"wave":{"frames":[0,1],"durationsMs":[100,200],"poster":1,"repeat":true}},"states":{"rest":"wave","done":"wave"},"views":{"side":"wave"}}"#).unwrap() + } + #[test] + fn avatar_named_actions_share_clock_and_reduced_posters() { + let p = example(); + assert_eq!(p.sample("rest", 0., false, None, None).index, 0); + assert_eq!(p.sample("done", 3., false, None, None).index, 1); + assert_eq!(p.sample("unknown", 9., false, None, None).index, 0); + for time in [0., f64::INFINITY, f64::NAN, 5000.] { + assert_eq!(p.sample("rest", time, true, None, None).index, 1); + } + assert_eq!( + p.sample("rest", 0., false, Some("side"), Some("missing")) + .index, + 0 + ); + let mut p = p; + p.actions.get_mut("wave").unwrap().repeat = false; + assert_eq!(p.sample("rest", 10000., false, None, Some("wave")).index, 1); + } + #[test] + fn avatar_limits_and_references_are_validated_before_rendering() { + for path in [ + "/absolute.png", + "../outside.png", + "a/../b.png", + "https://a.png", + "a\\b.png", + "a//b.png", + "a/./b.png", + ] { + let mut p = example(); + p.atlases = vec![path.into()]; + assert!(p.validate().is_err(), "{path}"); + } + let mut p = example(); + p.actions.get_mut("wave").unwrap().frames[0] = 2; + assert!(p.validate().is_err()); + let mut p = example(); + p.actions.get_mut("wave").unwrap().durations_ms[0] = 0; + assert!(p.validate().is_err()); + let mut p = example(); + p.states.insert("made-up-owner-state".into(), "wave".into()); + assert!(p.validate().is_err()); + let mut p = example(); + p.tile_width = 2049; + assert!(p.validate().is_err()); + let mut p = example(); + p.columns = u16::MAX; + p.rows = u16::MAX; + p.atlases.push("another.png".into()); + assert!(p.validate().is_err()); + let mut p = example(); + p.actions.clear(); + assert_eq!(p.sample("rest", 3., false, None, None).index, 0); + } + #[test] + fn avatar_image_dimensions_are_checked_without_invoking_a_codec() { + let p = example(); + let mut header = vec![0u8; 33]; + header[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n"); + header[8..12].copy_from_slice(&13u32.to_be_bytes()); + header[12..16].copy_from_slice(b"IHDR"); + header[16..20].copy_from_slice(&16u32.to_be_bytes()); + header[20..24].copy_from_slice(&8u32.to_be_bytes()); + assert!(p.validate_png(&header).is_ok()); + header[20..24].copy_from_slice(&4096u32.to_be_bytes()); + assert!(p.validate_png(&header).is_err()); + assert!(p.validate_png(&[]).is_err()); + } +} diff --git a/vendor/codewhale-ratatui/assets/whale-girl/PROMPT.md b/vendor/codewhale-ratatui/assets/whale-girl/PROMPT.md new file mode 100644 index 0000000000..15512ba2f5 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/PROMPT.md @@ -0,0 +1,20 @@ +# Whale girl asset provenance + +Generated with the built-in image generation tool from the existing Codewhale +loyalty-pledge maid mascot in the October 4 merch attempts. The user requested +that her hair become whale tails at the ends, then rejected the first sheet's +hair construction and low resolution. + +The replacement uses exactly two continuous flowing hair sections, each ending +in a narrow neck and two flukes made from the same hair strands. Nine separately +authored 2x2 pages replace the rejected 6x6 sheet. Source cells are 627px rather +than 209px; packaging adds equal transparent padding to 672px without enlarging +the art. There are two keyframes per activity and one side/back view each. + +Exact prompts are in `sources/page-00.prompt.txt` through `page-08.prompt.txt`. +Each source and packaged PNG embeds its prompt. The source PNGs live in the +app repository; the Ratatui copies include packaged pages and prompt sidecars. +`manifest.json` records the resulting source/page/terminal hashes. + +Reference: `artifacts/merch-20261004/design/mascot/codewhale-loyalty-pledge-lowercase-w.png` +in the Codewhale workspace. No background, lettering or pledge text is included. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/avatar.json b/vendor/codewhale-ratatui/assets/whale-girl/avatar.json new file mode 100644 index 0000000000..7937058b0f --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/avatar.json @@ -0,0 +1,270 @@ +{ + "version": 1, + "id": "whale-girl", + "name": "Whale girl", + "columns": 2, + "rows": 2, + "tileWidth": 672, + "tileHeight": 672, + "actions": { + "rest": { + "frames": [ + 0, + 1 + ], + "durationsMs": [ + 3800, + 180 + ], + "poster": 0, + "repeat": true + }, + "listen": { + "frames": [ + 2, + 3 + ], + "durationsMs": [ + 3800, + 180 + ], + "poster": 2, + "repeat": true + }, + "think": { + "frames": [ + 4, + 5 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 4, + "repeat": true + }, + "busy": { + "frames": [ + 6, + 7 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 6, + "repeat": true + }, + "read": { + "frames": [ + 8, + 9 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 8, + "repeat": true + }, + "search": { + "frames": [ + 10, + 11 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 10, + "repeat": true + }, + "write": { + "frames": [ + 12, + 13 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 12, + "repeat": true + }, + "run": { + "frames": [ + 14, + 15 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 14, + "repeat": true + }, + "browse": { + "frames": [ + 16, + 17 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 16, + "repeat": true + }, + "talk": { + "frames": [ + 18, + 19 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 18, + "repeat": true + }, + "pod": { + "frames": [ + 20, + 21 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 20, + "repeat": true + }, + "needs": { + "frames": [ + 22, + 23 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 22, + "repeat": true + }, + "done": { + "frames": [ + 24, + 25 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 24, + "repeat": true + }, + "hmm": { + "frames": [ + 26, + 27 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 26, + "repeat": true + }, + "computer": { + "frames": [ + 28, + 29 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 28, + "repeat": true + }, + "connect": { + "frames": [ + 30, + 31 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 30, + "repeat": true + }, + "sleep": { + "frames": [ + 32, + 33 + ], + "durationsMs": [ + 1300, + 500 + ], + "poster": 32, + "repeat": true + }, + "side": { + "frames": [ + 34 + ], + "durationsMs": [ + 1000 + ], + "poster": 34, + "repeat": true + }, + "back": { + "frames": [ + 35 + ], + "durationsMs": [ + 1000 + ], + "poster": 35, + "repeat": true + } + }, + "states": { + "rest": "rest", + "listen": "listen", + "think": "think", + "busy": "busy", + "read": "read", + "search": "search", + "write": "write", + "run": "run", + "browse": "browse", + "talk": "talk", + "pod": "pod", + "needs": "needs", + "done": "done", + "hmm": "hmm", + "computer": "computer", + "connect": "connect", + "sleep": "sleep" + }, + "views": { + "front": "rest", + "side": "side", + "back": "back" + }, + "atlases": [ + "page-00.png", + "page-01.png", + "page-02.png", + "page-03.png", + "page-04.png", + "page-05.png", + "page-06.png", + "page-07.png", + "page-08.png" + ] +} diff --git a/vendor/codewhale-ratatui/assets/whale-girl/manifest.json b/vendor/codewhale-ratatui/assets/whale-girl/manifest.json new file mode 100644 index 0000000000..90df7846cf --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/manifest.json @@ -0,0 +1,82 @@ +{ + "version": 1, + "columns": 2, + "rows": 2, + "tile": 672, + "terminalTile": 96, + "frames": 36, + "acts": [ + "rest", + "listen", + "think", + "busy", + "read", + "search", + "write", + "run", + "browse", + "talk", + "pod", + "needs", + "done", + "hmm", + "computer", + "connect", + "sleep" + ], + "views": { + "side": 34, + "back": 35 + }, + "pages": [ + { + "file": "page-00.png", + "sha256": "68f1e41507f3e0c18ea4f22449249530128945d261da7f27ecdd7926dd44b5ea", + "sourceSha256": "d72a5c0e92e128ec5ca0003dde89cdfb6a027620af998eb1696b39917771bfee" + }, + { + "file": "page-01.png", + "sha256": "0319f7d31e424aa5b9578d88c9be372f8e825ff3c32362cb77c37ed72e6baee0", + "sourceSha256": "c16256cd36afb01b9fd01fb8410133d240200903770a59fdb1ab721627ed41ec" + }, + { + "file": "page-02.png", + "sha256": "591adaa8ef84ffe3037d9687e59aff932061433ae01042540421702a357c5ee5", + "sourceSha256": "861905631fb9aeca6feb1ea7ca04ae02d17c1c247e9c4ac4d24253d172ce8a60" + }, + { + "file": "page-03.png", + "sha256": "673a8d0482d1c5cd8ff1d6e7aa2014469a4b42a82d51bd3ea29f70428871d828", + "sourceSha256": "89469d6fd93552e9a95f2e1647b00e4572295f10e699f288b08b69ece6cd20a4" + }, + { + "file": "page-04.png", + "sha256": "9ee00959e2948826544d215157597860661b99bfc1b1b18c8a98ef7ca59f4912", + "sourceSha256": "d43fdfa76bc4b8327864d657b257140a842553821dd1b479746c99893ffc5ba8" + }, + { + "file": "page-05.png", + "sha256": "f29f95593947fde25c77616bd907d609bc9448b98975ff191b5eafc48c0371e0", + "sourceSha256": "e815bf672c741ce127c329486de0b7681cc337c471b2d1fe0ea455f313bc7b58" + }, + { + "file": "page-06.png", + "sha256": "b9eaa5f00a1b3f3e35501ef7c35b5c27ecad68c802dbd4f5f50567d64be3e2ea", + "sourceSha256": "1a1474561b6ac3b1d21332cba23fb34db17d931748b6feae4987b65d805b84f3" + }, + { + "file": "page-07.png", + "sha256": "72f7a0d394c0985818e3034dca1583b8f3c3681efbe02eb217f88b68ca898fba", + "sourceSha256": "80ebbe716338268b7ac83ddfa89507559a5724481656ae7bff589c7466008d0f" + }, + { + "file": "page-08.png", + "sha256": "0cc4a1a44a13c9bcbfe3ae51a071c5e4d95fc20ead27b3d3e2cb2c53b86c68a0", + "sourceSha256": "235baa9fb9ec9803de48cb2457b5063a138f80178e909f002465c5392d3803a6" + } + ], + "terminalSha256": "850c496a7a88d5bf9bf732725d4a33bd2f5f82ff355af4e994ede8692f99002c", + "reference": "Codewhale loyalty-pledge merch mascot (October 4, 2026)", + "generator": "Built-in image generation; exact prompts in sources/*.prompt.txt", + "resolution": "Authored 627px source cells; padded to 672px without enlargement (old source cells were 209px)." +} diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-00.png b/vendor/codewhale-ratatui/assets/whale-girl/page-00.png new file mode 100644 index 0000000000..fd22bd6e76 Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-00.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-01.png b/vendor/codewhale-ratatui/assets/whale-girl/page-01.png new file mode 100644 index 0000000000..d2931cf24e Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-01.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-02.png b/vendor/codewhale-ratatui/assets/whale-girl/page-02.png new file mode 100644 index 0000000000..9d51d147de Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-02.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-03.png b/vendor/codewhale-ratatui/assets/whale-girl/page-03.png new file mode 100644 index 0000000000..2f6b730c5c Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-03.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-04.png b/vendor/codewhale-ratatui/assets/whale-girl/page-04.png new file mode 100644 index 0000000000..26e8fe01fe Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-04.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-05.png b/vendor/codewhale-ratatui/assets/whale-girl/page-05.png new file mode 100644 index 0000000000..95c4040755 Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-05.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-06.png b/vendor/codewhale-ratatui/assets/whale-girl/page-06.png new file mode 100644 index 0000000000..97b5ceb259 Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-06.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-07.png b/vendor/codewhale-ratatui/assets/whale-girl/page-07.png new file mode 100644 index 0000000000..66983d477a Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-07.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/page-08.png b/vendor/codewhale-ratatui/assets/whale-girl/page-08.png new file mode 100644 index 0000000000..d22dab2924 Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/page-08.png differ diff --git a/vendor/codewhale-ratatui/assets/whale-girl/player.rs b/vendor/codewhale-ratatui/assets/whale-girl/player.rs new file mode 100644 index 0000000000..275585df9e --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/player.rs @@ -0,0 +1,69 @@ +//! Built-in art adapter over the shared avatar-pack contract. +//! No separate action vocabulary or animation clock lives here. +use crate::avatar::{Frame, Pack}; +use std::sync::OnceLock; +pub const TERMINAL: &[u8] = include_bytes!("terminal.rgba"); +pub const TILE: usize = 96; +pub const FRAMES: usize = 36; +pub fn pack() -> &'static Pack { + static PACK: OnceLock = OnceLock::new(); + PACK.get_or_init(|| { + Pack::parse(include_bytes!("avatar.json")).expect("validated built-in avatar") + }) +} +pub fn sample( + act: &str, + frame: f64, + reduced: bool, + view: Option<&str>, + action: Option<&str>, +) -> Frame { + pack().sample(act, frame, reduced, view, action) +} +pub fn pixel(index: usize, x: usize, y: usize) -> [u8; 4] { + if index >= FRAMES || x >= TILE || y >= TILE { + return [0; 4]; + } + let at = ((index * TILE + y) * TILE + x) * 4; + TERMINAL[at..at + 4].try_into().expect("four channels") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn girl_has_distinct_frames_for_all_native_actions_and_authored_views() { + assert_eq!(TERMINAL.len(), FRAMES * TILE * TILE * 4); + for act in crate::avatar::ACTS { + let clip = &pack().actions[&pack().states[act]]; + assert_eq!(clip.frames.len(), 2, "{act} has two authored poses"); + let tile = |index: u16| { + &TERMINAL[index as usize * TILE * TILE * 4..(index as usize + 1) * TILE * TILE * 4] + }; + assert_ne!( + tile(clip.frames[0]), + tile(clip.frames[1]), + "{act} poses differ" + ); + for index in &clip.frames { + let pixels = tile(*index); + assert!( + pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200), + "{act} paints" + ); + assert!( + pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0), + "{act} is transparent" + ); + } + assert_eq!( + sample(act, 10000., true, None, None).index, + clip.poster as usize + ); + } + assert_ne!( + sample("rest", 0., false, Some("side"), None), + sample("rest", 0., false, Some("back"), None) + ); + } +} diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-00.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-00.prompt.txt new file mode 100644 index 0000000000..4bd7eeefcc --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-00.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left REST frame A: calm friendly smile, hands loosely together in front. Top right REST frame B: identical resting pose with eyes gently shut for a blink. Bottom left LISTEN frame A: attentive smile, one hand near her ear. Bottom right LISTEN frame B: same hand and pose, tiny head inclination and blink. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-01.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-01.prompt.txt new file mode 100644 index 0000000000..a0e1a749c9 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-01.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left THINK frame A: thoughtful, one hand under chin, eyes looking slightly up. Top right THINK frame B: same pose with a gentle blink and slight finger movement. Bottom left BUSY frame A: focused, holding a small navy tablet at waist with both hands. Bottom right BUSY frame B: same tablet and registered pose, one finger tapping it, concentrated eyes. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-02.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-02.prompt.txt new file mode 100644 index 0000000000..3a1d025431 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-02.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left READ frame A: holding an open small navy book at waist and looking down at it. Top right READ frame B: same pose and book, one page slightly lifted. Bottom left SEARCH frame A: a small simple magnifying glass held near shoulder, curious expression. Bottom right SEARCH frame B: same pose, magnifying glass tilted slightly, gentle blink. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-03.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-03.prompt.txt new file mode 100644 index 0000000000..1ecac095f9 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-03.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left WRITE frame A: writing with a little pen in a small navy notebook held at waist. Top right WRITE frame B: same pose, tiny pen movement and blink. Bottom left RUN frame A: determined pleased expression, one hand on a tiny laptop held at waist with a simple small play triangle on screen. Bottom right RUN frame B: same pose, a small approving nod and finger pressing the laptop key. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-04.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-04.prompt.txt new file mode 100644 index 0000000000..66941109cd --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-04.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left BROWSE frame A: holding a small tablet with simple web page blocks, looking inquisitive. Top right BROWSE frame B: same tablet, one finger pointing to a different block. Bottom left TALK frame A: friendly conversational open palm at chest height, small open mouth. Bottom right TALK frame B: same pose, mouth gently closed and hand slightly turned. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-05.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-05.prompt.txt new file mode 100644 index 0000000000..3fdfad2c18 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-05.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left POD frame A: welcoming open hands at waist as if coordinating a team, pleased expression. Top right POD frame B: same pose, tiny head tilt and one finger raised to guide. Bottom left NEEDS frame A: raised hand as if requesting attention, questioning expression, relaxed non-alarmed face. Bottom right NEEDS frame B: same pose, blink and hand turned slightly. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-06.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-06.prompt.txt new file mode 100644 index 0000000000..e01e99a878 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-06.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left DONE frame A: joyful smile and a cheerful thumbs-up at shoulder height. Top right DONE frame B: same pose, joyful blink and slightly lifted thumb. Bottom left HMM frame A: mildly puzzled expression, one hand on cheek, head slightly tilted. Bottom right HMM frame B: same pose and tilt, subtle thoughtful blink. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-07.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-07.prompt.txt new file mode 100644 index 0000000000..999bc32746 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-07.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left COMPUTER frame A: small open navy laptop in hands at waist, focused look at screen. Top right COMPUTER frame B: same pose, one hand gently typing. Bottom left CONNECT frame A: holding a small cable connector at chest height with hopeful smile. Bottom right CONNECT frame B: same pose, tiny approving nod and connector tilted slightly. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/sources/page-08.prompt.txt b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-08.prompt.txt new file mode 100644 index 0000000000..6f148800c1 --- /dev/null +++ b/vendor/codewhale-ratatui/assets/whale-girl/sources/page-08.prompt.txt @@ -0,0 +1,6 @@ +Use case: identity-preserve. Asset: production character animation atlas for Codewhale. Reference image is the exact character identity and hair construction, not a background. +Generate a crisp high resolution 2048 by 2048 square sprite atlas on true transparent alpha, exactly TWO columns and TWO rows of FOUR separate full-body poses, one centered in each equal 1024x1024 cell. No text, no cell lines, no scenery, no background, no shadows or glow. Each character 880px high and same scale, head aligned identically within its cell, ample transparent margin on all sides. Full shoes, head curl and entire hair tips must fit each cell with no clipping. Consistent front camera except explicitly named side/back views. Same smooth high-detail blue-haired chibi maid from reference: large blue eyes, navy-and-white frilled dress, white stockings, navy shoes and hair bows; preserve exact face, proportions, outfit and colors. +CRITICAL HAIR: exactly two broad continuous blue sections descend naturally from the head past the skirt. Each flowing lock narrows to a single neck then splits into a clean two-lobed whale-tail-shaped tip. Hair strands continue uninterrupted through the flukes. These are the ENDS OF HER HAIR, not attached fins, accessories or separate tails. No tangled loops, no knots, no free floating tail shapes, no new fish tails or body tails. This structure must be visible in every pose. +For each pair of action poses keep body/head/hair registration stable. Change only the named subtle gesture/expression, not scale or character design. The second pose is a small animation keyframe, not another design. +CELL POSES in reading order: +Top left SLEEP frame A: peaceful closed eyes, hands together against tilted cheek, standing full-body with relaxed knees. Top right SLEEP frame B: same sleeping stance, tiny softer head tilt. Bottom left SIDE VIEW: calm full body LEFT SIDE profile, hands together at waist, show head profile and both long continuous hair sections where visible, each ends in its own split whale-tail fluke. Bottom right BACK VIEW: exact character facing away, back of white frill headband, navy dress with white apron bow, all body and feet visible; clearly show TWO continuous blue hair sections descending from the head and splitting into whale tail tips at the bottom; never add a tail to her body. diff --git a/vendor/codewhale-ratatui/assets/whale-girl/terminal.rgba b/vendor/codewhale-ratatui/assets/whale-girl/terminal.rgba new file mode 100644 index 0000000000..1132ee314e Binary files /dev/null and b/vendor/codewhale-ratatui/assets/whale-girl/terminal.rgba differ diff --git a/vendor/codewhale-ratatui/src/avatar_sprite.rs b/vendor/codewhale-ratatui/src/avatar_sprite.rs new file mode 100644 index 0000000000..1e1621e8d2 --- /dev/null +++ b/vendor/codewhale-ratatui/src/avatar_sprite.rs @@ -0,0 +1,182 @@ +//! Bounded RGBA avatar tiles painted with terminal half-blocks. This renderer +//! neither loads plugins nor owns a timer. Hosts supply reviewed pixels and +//! sample an action with the same Director used by the rest of their UI. +use crate::{ + Paint, Role, Theme, + color::{self, ColorDepth}, +}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, +}; + +/// Row-major RGBA pixels within each tile, tiles in manifest order. +/// Hosts decode PNG once and concatenate its tiles; terminal authors can also +/// ship predecoded tiles. Construction validates the complete byte geometry. +#[derive(Clone, Copy)] +pub struct Sprite<'a> { + pixels: &'a [u8], + width: usize, + height: usize, + frame: usize, +} +impl<'a> Sprite<'a> { + pub fn new( + pack: &crate::avatar::Pack, + pixels: &'a [u8], + width: usize, + height: usize, + frame: usize, + ) -> Result { + pack.validate()?; + let count = usize::from(pack.columns) * usize::from(pack.rows) * pack.atlases.len(); + Self::tiles(pixels, width, height, frame, count) + } + pub fn page( + pack: &crate::avatar::Pack, + pixels: &'a [u8], + width: usize, + height: usize, + frame: usize, + ) -> Result { + pack.validate()?; + if pack.page(frame) >= pack.atlases.len() { + return Err("avatar frame exceeds page count".into()); + } + Self::tiles( + pixels, + width, + height, + pack.local_frame(frame), + usize::from(pack.columns) * usize::from(pack.rows), + ) + } + fn tiles( + pixels: &'a [u8], + width: usize, + height: usize, + frame: usize, + count: usize, + ) -> Result { + if width == 0 + || height == 0 + || width > 2048 + || height > 2048 + || frame >= count + || width + .checked_mul(height) + .and_then(|n| n.checked_mul(4)) + .and_then(|n| n.checked_mul(count)) + != Some(pixels.len()) + || pixels.len() > 16 * 1024 * 1024 + { + return Err("avatar pixels do not match bounded tile dimensions".into()); + } + Ok(Self { + pixels, + width, + height, + frame, + }) + } + fn pixel(&self, x: usize, y: usize) -> [u8; 4] { + let offset = ((self.frame * self.height + y) * self.width + x) * 4; + self.pixels[offset..offset + 4] + .try_into() + .expect("validated RGBA pixel") + } +} +impl Paint for Sprite<'_> { + fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { + let area = area.intersection(buf.area); + if area.is_empty() { + return; + } + // One cell is approximately twice as tall as wide. Fit without changing + // the artwork's aspect ratio and never allocate from terminal geometry. + let scale = (f64::from(area.width) / self.width as f64) + .min(f64::from(area.height) * 2. / self.height as f64); + let width = ((self.width as f64 * scale).floor() as u16) + .max(1) + .min(area.width); + let rows = ((self.height as f64 * scale / 2.).ceil() as u16) + .max(1) + .min(area.height); + let left = area.x + (area.width - width) / 2; + let top = area.y + (area.height - rows) / 2; + let colored = theme.caps().paints_tokens() && !theme.ascii(); + for y in 0..rows { + for x in 0..width { + let sx = (usize::from(x) * self.width / usize::from(width)).min(self.width - 1); + let sy = |half: usize| { + ((usize::from(y) * 2 + half) * self.height / (usize::from(rows) * 2)) + .min(self.height - 1) + }; + let a = self.pixel(sx, sy(0)); + let b = self.pixel(sx, sy(1)); + if a[3] < 40 && b[3] < 40 { + continue; + } + let cell = &mut buf[(left + x, top + y)]; + if !colored { + // A legible one-ink density rendering for NO_COLOR, unknown + // ground and ASCII-safe profiles; no forced palette escapes. + let alpha = u16::from(a[3]) + u16::from(b[3]); + let shade = (u16::from(a[0]) + + u16::from(a[1]) + + u16::from(a[2]) + + u16::from(b[0]) + + u16::from(b[1]) + + u16::from(b[2])) + / 6; + let symbol = if alpha < 160 { + "." + } else if shade > 220 { + ":" + } else if shade > 130 { + "+" + } else { + "#" + }; + cell.set_symbol(symbol).set_style(theme.fg(Role::Primary)); + continue; + } + let bg = color::resolvable_rgb(cell.bg); + let blend = |p: [u8; 4]| { + let rgb = if let Some((r, g, b)) = bg { + let mix = |fg: u8, bg: u8| { + ((u32::from(fg) * u32::from(p[3]) + + u32::from(bg) * (255 - u32::from(p[3]))) + / 255) as u8 + }; + (mix(p[0], r), mix(p[1], g), mix(p[2], b)) + } else { + (p[0], p[1], p[2]) + }; + if theme.caps().depth == ColorDepth::Ansi256 { + Color::Indexed(color::rgb_to_ansi256(rgb.0, rgb.1, rgb.2)) + } else { + Color::Rgb(rgb.0, rgb.1, rgb.2) + } + }; + let background = cell.bg; + match (a[3] >= 40, b[3] >= 40) { + (true, true) => { + cell.set_symbol("▀") + .set_style(Style::default().fg(blend(a)).bg(blend(b))); + } + (true, false) => { + cell.set_symbol("▀") + .set_style(Style::default().fg(blend(a)).bg(background)); + } + (false, true) => { + cell.set_symbol("▄") + .set_style(Style::default().fg(blend(b)).bg(background)); + } + _ => {} + } + } + } + } +} diff --git a/vendor/codewhale-ratatui/src/lib.rs b/vendor/codewhale-ratatui/src/lib.rs index 1ff4088995..132f49c9f7 100644 --- a/vendor/codewhale-ratatui/src/lib.rs +++ b/vendor/codewhale-ratatui/src/lib.rs @@ -36,10 +36,15 @@ pub mod theme; #[path = "../vendor/codewhale-design/tokens.rs"] pub mod tokens; +#[path = "../assets/avatar-pack/contract.rs"] +pub mod avatar; +pub mod avatar_sprite; mod components; pub mod gallery; pub mod testing; pub mod whale; +#[path = "../assets/whale-girl/player.rs"] +pub mod whale_girl; pub mod whale_motion; // Every component module is re-exported whole (see `components/mod.rs`), so diff --git a/web/app/[locale]/install/page.tsx b/web/app/[locale]/install/page.tsx index 3cb8b76f1e..8d30428679 100644 --- a/web/app/[locale]/install/page.tsx +++ b/web/app/[locale]/install/page.tsx @@ -4,7 +4,7 @@ import { Icon } from "@/components/icon"; import { InstallCodeBlock } from "@/components/install-code-block"; import { WhalePose } from "@/components/whale-pose"; import { INSTALL_COPY } from "@/lib/content/install"; -import { INSTALL_GUIDE } from "@/lib/install-guide.generated"; +import { INSTALL_GUIDES } from "@/lib/install-guide.generated"; import { getHome, pickText, pickTextLocale } from "@/lib/i18n/dictionaries"; import { buildPageMetadata } from "@/lib/page-meta"; @@ -20,14 +20,14 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s /** * /install — the quick install plate first (the two one-line installs the - * home page offers), then the verified guide from docs/INSTALL.md, which owns - * the page's heading and every other channel. The guide stays in English - * until its translation is verified; the notice says so. + * home page offers), then the reviewed checked-in guide for this language. + * Both guides retain the actual historical scope of their installation receipts. */ export default async function InstallPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const home = getHome(locale); const copyLocale = pickTextLocale(locale); + const { guide, lang, sourcePath } = INSTALL_GUIDES[copyLocale]; return ( <>
@@ -37,20 +37,16 @@ export default async function InstallPage({ params }: { params: Promise<{ locale

- + {pickText(INSTALL_COPY.source, locale)}

- {/* The notice speaks about the Chinese translation, so only the - Chinese page shows it. */} - {copyLocale === "zh" ? ( -

{pickText(INSTALL_COPY.translationNotice, locale)}

- ) : null} +

{pickText(INSTALL_COPY.translationNotice, locale)}

-
- {INSTALL_GUIDE.chunks.map((chunk, index) => chunk.kind === "code" ? ( +
+ {guide.chunks.map((chunk, index) => chunk.kind === "code" ? ( ) : ( // HTML is generated at build time with strict raw-HTML and URL guards. diff --git a/web/docs/DEPENDENCY_SECURITY.md b/web/docs/DEPENDENCY_SECURITY.md new file mode 100644 index 0000000000..8fff002cd9 --- /dev/null +++ b/web/docs/DEPENDENCY_SECURITY.md @@ -0,0 +1,28 @@ +# Website build dependency mitigation + +The full npm audit currently reports GHSA-vfj7-8cjw-p6xm in `braces` 3.0.3 +and its build-tool dependents. The upstream advisory has no patched release. +We retain the upstream package name, version and licence, and the complete raw +audit report. This is a local mitigation, not an upstream fixed version or a +claim of zero upstream findings. + +`npm ci` runs `scripts/patch-braces.mjs`. It verifies the reviewed original or +patched SHA-256 of all four affected source files before writing any, limits +the parser's structural stack and every AST walker to 64 levels, and refuses +an unknown version or changed source. The normal expansion, compilation, +escaping and length limits remain intact. No package is published or replaced. + +`npm run audit:dependencies` verifies all four installed patched hashes before +auditing the complete lock. Only this exact advisory and dependencies whose +entire advisory chain ends in it can be classified as mitigated. A second +nested copy, another advisory, an incomplete audit, or an unapplied/changed +patch fails the check. CI saves the raw report, including the mitigated +upstream findings. Installation with `--ignore-scripts` does not apply the +mitigation and cannot pass this gate. + +After an upstream fix becomes available, upgrade and verify it, then remove +the installation patch, its audit classification and tests in the same slice. +Never broaden the admitted advisory list to get a passing check. + +References: [upstream advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) +and [upstream issue and recommended depth guard](https://github.com/micromatch/braces/issues/70). diff --git a/web/lib/content/install.ts b/web/lib/content/install.ts index 85da746692..323bf76493 100644 --- a/web/lib/content/install.ts +++ b/web/lib/content/install.ts @@ -15,6 +15,6 @@ export const INSTALL_COPY = { metaTitle: { en: "Install · Codewhale", zh: "安装 · Codewhale" }, metaDescription: { en: "Install Codewhale on macOS, Linux, or Windows, connect a model, and run your first task. Covers package managers and source builds.", zh: "在 macOS、Linux 或 Windows 上安装 Codewhale,连接模型并运行第一项任务。也介绍包管理器和源码构建。" }, source: { en: "Read the verified guide on GitHub", zh: "查看 GitHub 上已验证的指南" }, - translationNotice: { en: "This verified guide is in English. The Chinese translation has not yet been updated for this revision.", zh: "本页显示已验证的英文指南。中文译文尚未更新至此修订版。" }, + translationNotice: { en: "Installation receipts retain the version actually tested; latest resolves to the published release.", zh: "本页采用经审阅的简体中文指南。安装回执保留实际测试版本;latest 以已发布版本为准。" }, configuration: { en: "Settings and configuration", zh: "设置与配置" }, } as const; diff --git a/web/lib/install-guide.test.ts b/web/lib/install-guide.test.ts index dd081ea4bc..8509bd8aca 100644 --- a/web/lib/install-guide.test.ts +++ b/web/lib/install-guide.test.ts @@ -1,15 +1,19 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { buildInstallGuide, installAnchorErrors, renderInstallGuideModule } from "../scripts/install-guide-lib.mjs"; -import { INSTALL_GUIDE } from "./install-guide.generated"; +import { CHINESE_INSTALL_GUIDE, INSTALL_GUIDE } from "./install-guide.generated"; describe("canonical installation guide", () => { - it("ships the entire current guide, including the untranslated-document notice", () => { + it("ships both current source guides with their historical release receipts", () => { const source = readFileSync(new URL("../../docs/INSTALL.md", import.meta.url), "utf8"); + const chineseSource = readFileSync(new URL("../../docs/zh_hans/INSTALL.md", import.meta.url), "utf8"); expect(readFileSync(new URL("./install-guide.generated.ts", import.meta.url), "utf8")) - .toBe(renderInstallGuideModule(source)); + .toBe(renderInstallGuideModule(source, chineseSource)); expect(INSTALL_GUIDE.chunks.map((chunk) => chunk.text).join("\n")) - .toContain("not yet updated for this revision"); + .toContain("0.10.0"); + expect(CHINESE_INSTALL_GUIDE.chunks.map((chunk) => chunk.text).join("\n")) + .toContain("使用当前源码参与测试与贡献"); + expect(CHINESE_INSTALL_GUIDE.sourceHash).not.toBe(INSTALL_GUIDE.sourceHash); }); it("keeps top-level command bytes intact and nested code inside its list", () => { @@ -31,6 +35,22 @@ describe("canonical installation guide", () => { .toThrow("Missing INSTALL.md anchors: gone"); }); + it("keeps Chinese anchors and relative source links in the Chinese document", () => { + const guide = buildInstallGuide('# 安装\n\n\n\n[镜像](CNB_MIRROR.md) [英文](../INSTALL.md) [源码](INSTALL.md#通过-cargo-安装)\n', { sourcePath: "zh_hans/INSTALL.md" }); + const html = guide.chunks.map((chunk) => chunk.text).join("\n"); + expect(guide.anchors).toEqual(["安装", "通过-cargo-安装"]); + expect(html).toContain('href="https://github.com/codewhale-hq/CodeWhale/blob/main/docs/zh_hans/CNB_MIRROR.md"'); + expect(html).toContain('href="https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md"'); + expect(html).toContain('href="#%E9%80%9A%E8%BF%87-cargo-%E5%AE%89%E8%A3%85"'); + }); + + it("rejects broken Chinese fragments and attributes on a Unicode anchor", () => { + expect(() => buildInstallGuide('# 安装\n\n[缺失](#不存在)', { sourcePath: "zh_hans/INSTALL.md" })) + .toThrow("Missing INSTALL.md anchors: 不存在"); + expect(() => buildInstallGuide('')) + .toThrow("Unsupported raw HTML"); + }); + it("gives scrollable tables distinct, escaped names from their headers", () => { const table = '| "Need" | Reason |\n| --- | --- |\n| curl | Download |\n'; const guide = buildInstallGuide(`# Setup\n\n${table}\n${table}`); diff --git a/web/lib/public-copy.test.ts b/web/lib/public-copy.test.ts index cc922f00fd..b7b1392780 100644 --- a/web/lib/public-copy.test.ts +++ b/web/lib/public-copy.test.ts @@ -99,7 +99,7 @@ describe("public website copy contracts", () => { expect(homepage).not.toContain("releases/tag/v${version}"); expect(homepage).not.toMatch(/Codewhale v0\.9\.1|\"v0\.9\.1 \u00b7/); // Installation examples come from the verified guide, never the candidate version. - expect(install).toContain("INSTALL_GUIDE.chunks"); + expect(install).toContain('from "@/lib/install-guide.generated"'); expect(install).not.toContain("facts.version"); expect(install).not.toContain('"v0.8.x"'); expect(install).not.toContain("cnbInstall(facts.version"); diff --git a/web/lib/public-surface-contract.test.ts b/web/lib/public-surface-contract.test.ts index 88d246866c..78339a342b 100644 --- a/web/lib/public-surface-contract.test.ts +++ b/web/lib/public-surface-contract.test.ts @@ -264,7 +264,7 @@ describe("public surface contracts", () => { expect(installDoc).toContain("One Cargo package is required"); expect(installDoc).toContain("`codewhale-cli` installs the `codewhale` command"); expect(installDoc).toContain("Cargo does\nnot create that alias"); - expect(installPage).toContain("INSTALL_GUIDE.chunks"); + expect(installPage).toContain('from "@/lib/install-guide.generated"'); expect(INSTALL_GUIDE.chunks.some((chunk) => chunk.kind === "code" && chunk.text.includes("cargo install --path crates/cli --locked"))).toBe(true); expect(installPage).not.toContain("codewhale-tui"); expect(npmReadme).toContain("installs `codewhale` plus the `codew` convenience name"); diff --git a/web/package-lock.json b/web/package-lock.json index f436fd46a6..615c7655ba 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "codewhale-web", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "next": "^16.3.6", "react": "^19.2.8", @@ -2689,9 +2690,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -2708,13 +2709,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -2731,13 +2732,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -2745,7 +2746,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2755,9 +2756,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -2772,9 +2773,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -2789,9 +2790,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -2806,9 +2807,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -2823,9 +2824,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -2840,9 +2841,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -2857,9 +2858,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -2874,9 +2875,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -2891,9 +2892,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -2908,9 +2909,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -2925,9 +2926,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -2944,13 +2945,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -2967,13 +2968,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -2990,13 +2991,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -3013,13 +3014,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -3036,13 +3037,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -3059,13 +3060,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -3082,13 +3083,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -3105,13 +3106,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -3126,9 +3127,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -3136,7 +3137,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -3146,9 +3147,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -3166,9 +3167,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -3186,9 +3187,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -12754,9 +12755,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -13463,9 +13464,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -13480,31 +13481,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { diff --git a/web/package.json b/web/package.json index d73ab3938a..b039b7ebdc 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,7 @@ "prebuild": "node scripts/derive-facts.mjs && node scripts/derive-changelog.mjs && node scripts/derive-install.mjs", "build": "next build", "start": "next start", - "test": "vitest run", + "test": "node --test scripts/dependency-security.test.mjs && vitest run", "check": "npm run check:facts && npm run check:latest-release && npm run prebuild && npm run check:docs && npm run check:tokens && npm run lint && tsc --noEmit && npm run build", "check:facts": "node scripts/check-facts.mjs && node scripts/check-cloud-facts.mjs", "sync:latest-release": "node scripts/sync-latest-release.mjs", @@ -27,7 +27,9 @@ "preview": "npm run build:cloudflare && opennextjs-cloudflare populateCache local --config .cloudflare/opennext.json && node node_modules/wrangler/bin/cf-wrangler.js dev", "predeploy": "node scripts/check-kv-id.mjs", "deploy": "npm run build:cloudflare && opennextjs-cloudflare populateCache remote --config .cloudflare/opennext.json && cf deploy --prebuilt", - "cf-typegen": "cf workers types" + "cf-typegen": "cf workers types", + "postinstall": "node scripts/patch-braces.mjs", + "audit:dependencies": "node scripts/audit-dependencies.mjs" }, "dependencies": { "next": "^16.3.6", @@ -58,8 +60,9 @@ "esbuild": "0.28.1", "nanoid": "^3.3.17", "postcss": "^8.5.18", - "sharp": "0.35.4", + "sharp": "0.35.5", "ws": "8.21.0", - "undici": "7.29.1" + "undici": "7.29.1", + "postcss-selector-parser": "7.1.6" } } diff --git a/web/scripts/audit-dependencies.mjs b/web/scripts/audit-dependencies.mjs new file mode 100644 index 0000000000..529a8e596e --- /dev/null +++ b/web/scripts/audit-dependencies.mjs @@ -0,0 +1,46 @@ +// Raw upstream findings remain in the report. Only the exact reviewed and +// installed braces mitigation admits its transitive build-tool findings. +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { patchBraces } from './patch-braces.mjs'; + +const advisory = 'https://github.com/advisories/GHSA-vfj7-8cjw-p6xm'; + +export function classifyAudit(report) { + if (report.error || !report.vulnerabilities || !report.metadata?.vulnerabilities) throw new Error('Incomplete dependency audit'); + const entries = report.vulnerabilities; + if (Object.keys(entries).length !== report.metadata.vulnerabilities.total) throw new Error('Dependency audit count mismatch'); + function mitigated(name, seen = new Set()) { + if (seen.has(name)) return false; + const entry = entries[name]; + if (!entry || !Array.isArray(entry.via) || !entry.via.length) return false; + const next = new Set([...seen, name]); + return entry.via.every(via => typeof via === 'string' + ? mitigated(via, next) + : name === 'braces' && via.name === 'braces' && via.url === advisory); + } + // A nested second copy would bypass the single reviewed installation patch. + const braces = entries.braces; + if (braces && (braces.nodes?.length !== 1 || braces.nodes[0] !== 'node_modules/braces')) throw new Error('Unreviewed nested braces dependency'); + const blocked = Object.keys(entries).filter(name => !mitigated(name)); + return { upstreamFindings: report.metadata.vulnerabilities.total, mitigated: Object.keys(entries).filter(name => !blocked.includes(name)), blocked }; +} + +function main() { + const proof = patchBraces({ checkOnly: true }); + if (!process.env.npm_execpath) throw new Error('Run through npm run audit:dependencies'); + const result = spawnSync(process.execPath, [process.env.npm_execpath, 'audit', '--json'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (result.error || result.signal || ![0, 1].includes(result.status)) throw new Error('Dependency audit failed to complete'); + const report = JSON.parse(result.stdout); + const at = process.argv.indexOf('--report'); + if (at >= 0) { + if (!process.argv[at + 1]) throw new Error('Missing audit report path'); + writeFileSync(process.argv[at + 1], JSON.stringify(report, null, 2) + '\n'); + } + const verdict = classifyAudit(report); + console.log(JSON.stringify({ ...verdict, reviewedMitigation: proof })); + if (verdict.blocked.length) process.exitCode = 1; +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/web/scripts/dependency-security.test.mjs b/web/scripts/dependency-security.test.mjs new file mode 100644 index 0000000000..f41b02b96b --- /dev/null +++ b/web/scripts/dependency-security.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { patchBraces } from './patch-braces.mjs'; +import { classifyAudit } from './audit-dependencies.mjs'; + +const require = createRequire(import.meta.url); +const root = dirname(require.resolve('braces/package.json')); +const braces = require('braces'); +const advisory = 'https://github.com/advisories/GHSA-vfj7-8cjw-p6xm'; + +test('reviewed installed patch preserves ordinary expansion, escaping and all walkers', () => { + assert.equal(patchBraces({ checkOnly: true }).files.length, 4); + assert.deepEqual(braces.expand('{a,{b,c}}'), ['a', 'b', 'c']); + assert.deepEqual(braces.expand('x{1..3}'), ['x1', 'x2', 'x3']); + assert.deepEqual(braces.expand('x\\{a,b\\}'), ['x{a,b}']); + assert.equal(braces.stringify(braces.parse('{a,b}')), '{a,b}'); + assert.equal(braces.compile('{a,b}'), '(a|b)'); + assert.throws(() => braces.parse('abc', { maxLength: 2 }), /max characters/); +}); + +test('parser bounds structural nesting without treating quotes or escaped braces as groups', () => { + const groups = '{'.repeat(64) + 'x' + '}'.repeat(64); + assert.throws(() => braces.parse(groups), /nesting exceeds 64 levels/); + assert.throws(() => braces.parse('('.repeat(64) + 'x' + ')'.repeat(64)), /nesting exceeds 64 levels/); + assert.doesNotThrow(() => braces.parse('"' + groups + '"')); + assert.doesNotThrow(() => braces.parse('\\{'.repeat(64))); +}); + +test('direct AST walker inputs have their own finite depth guard', () => { + const ast = braces.parse('{a,b}'); + const inner = ast.nodes.find(node => node.type === 'brace'); + let cursor = inner; + for (let i = 0; i < 66; i++) { + const child = { type: 'brace', parent: cursor, nodes: [] }; + cursor.nodes = [child]; + cursor = child; + } + cursor.nodes = [{ type: 'text', value: 'a' }]; + for (const walk of [braces.compile, braces.expand, braces.stringify]) { + assert.throws(() => walk(ast), /nesting exceeds 64 levels/); + } +}); + +test('patch verification is idempotent and refuses changed source or an unknown version', t => { + const copy = mkdtempSync(join(tmpdir(), 'cw-braces-')); + t.after(() => rmSync(copy, { recursive: true, force: true })); + cpSync(root, copy, { recursive: true }); + assert.deepEqual(patchBraces({ root: copy }), patchBraces({ root: copy, checkOnly: true })); + const file = join(copy, 'lib/compile.js'); + writeFileSync(file, readFileSync(file, 'utf8') + '\n'); + assert.throws(() => patchBraces({ root: copy }), /Unreviewed or unpatched/); + const pkg = JSON.parse(readFileSync(join(copy, 'package.json'), 'utf8')); + pkg.version = '3.0.4'; + writeFileSync(join(copy, 'package.json'), JSON.stringify(pkg)); + assert.throws(() => patchBraces({ root: copy }), /Unreviewed braces version/); +}); + +function report(extra = {}) { + const vulnerabilities = { + braces: { nodes: ['node_modules/braces'], via: [{ name: 'braces', url: advisory }] }, + micromatch: { via: ['braces'] }, + ...extra, + }; + return { vulnerabilities, metadata: { vulnerabilities: { total: Object.keys(vulnerabilities).length } } }; +} + +test('audit preserves the exact upstream findings while classifying their complete chains', () => { + const verdict = classifyAudit(report()); + assert.equal(verdict.upstreamFindings, 2); + assert.deepEqual(verdict.mitigated, ['braces', 'micromatch']); + assert.deepEqual(verdict.blocked, []); +}); + +test('another advisory, an incomplete chain or a cycle is blocked', () => { + assert.deepEqual(classifyAudit(report({ other: { via: [{ name: 'other', url: 'https://github.com/advisories/new' }] } })).blocked, ['other']); + assert.deepEqual(classifyAudit(report({ other: { via: ['missing'] } })).blocked, ['other']); + assert.deepEqual(classifyAudit(report({ alias: { via: [{ name: 'braces', url: advisory }], nodes: ['node_modules/alias'] } })).blocked, ['alias']); + assert.deepEqual(classifyAudit(report({ first: { via: ['second'] }, second: { via: ['first'] } })).blocked, ['first', 'second']); +}); + +test('missing audit data and an unpatched nested dependency cannot be admitted', () => { + assert.throws(() => classifyAudit({ error: { message: 'registry unavailable' } }), /Incomplete/); + const mismatch = report(); mismatch.metadata.vulnerabilities.total++; + assert.throws(() => classifyAudit(mismatch), /count mismatch/); + const nested = report(); nested.vulnerabilities.braces.nodes.push('node_modules/other/node_modules/braces'); + assert.throws(() => classifyAudit(nested), /Unreviewed nested/); +}); diff --git a/web/scripts/derive-install.mjs b/web/scripts/derive-install.mjs index 5e052cefaa..1d15ce8626 100644 --- a/web/scripts/derive-install.mjs +++ b/web/scripts/derive-install.mjs @@ -3,10 +3,11 @@ import { readFileSync, writeFileSync } from "node:fs"; import { renderInstallGuideModule } from "./install-guide-lib.mjs"; const source = readFileSync(new URL("../../docs/INSTALL.md", import.meta.url), "utf8"); +const chineseSource = readFileSync(new URL("../../docs/zh_hans/INSTALL.md", import.meta.url), "utf8"); const target = new URL("../lib/install-guide.generated.ts", import.meta.url); -const generated = renderInstallGuideModule(source); +const generated = renderInstallGuideModule(source, chineseSource); // The output is not tracked (web/.gitignore): prebuild, dev and the vitest // global setup write it, so it can never go stale against a merged main. // Rendering throws on a broken internal link, which is the gate. writeFileSync(target, generated); -console.log("[install-guide] rendered docs/INSTALL.md"); +console.log("[install-guide] rendered English and Simplified Chinese installation guides"); diff --git a/web/scripts/install-guide-lib.d.mts b/web/scripts/install-guide-lib.d.mts index e7eaea22bd..26b3216c6c 100644 --- a/web/scripts/install-guide-lib.d.mts +++ b/web/scripts/install-guide-lib.d.mts @@ -3,6 +3,6 @@ export type InstallGuide = { anchors: string[]; chunks: { kind: "html" | "code"; text: string }[]; }; -export function buildInstallGuide(source: string): InstallGuide; +export function buildInstallGuide(source: string, options?: { sourcePath?: string; tableLabel?: string }): InstallGuide; export function installAnchorErrors(text: string, anchors: readonly string[]): string[]; -export function renderInstallGuideModule(source: string): string; +export function renderInstallGuideModule(source: string, chineseSource?: string): string; diff --git a/web/scripts/install-guide-lib.mjs b/web/scripts/install-guide-lib.mjs index 3909df80db..34d0ba5899 100644 --- a/web/scripts/install-guide-lib.mjs +++ b/web/scripts/install-guide-lib.mjs @@ -4,18 +4,19 @@ import GithubSlugger from "github-slugger"; const DOCS_URL = "https://github.com/codewhale-hq/CodeWhale/blob/main/docs/"; -function docLink(href) { - const url = new URL(href, DOCS_URL); +function docLink(href, sourcePath) { + const documentUrl = new URL(sourcePath, DOCS_URL); + const url = new URL(href, documentUrl); if (!["https:", "http:", "mailto:"].includes(url.protocol)) { throw new Error(`Unsupported install-guide link protocol: ${url.protocol}`); } if (href.startsWith("#")) return href; - if (url.href.startsWith(`${DOCS_URL}INSTALL.md#`)) return url.hash; + if (url.href.startsWith(`${documentUrl.href}#`)) return url.hash; return url.href; } /** Render the checked-in guide at build time; fenced commands stay copyable. */ -export function buildInstallGuide(source) { +export function buildInstallGuide(source, { sourcePath = "INSTALL.md", tableLabel = "Installation table" } = {}) { const slugger = new GithubSlugger(); const anchors = []; const fragments = []; @@ -31,7 +32,7 @@ export function buildInstallGuide(source) { html({ text }) { // INSTALL.md uses explicit aliases for old incoming links. No other // raw HTML is needed; reject it rather than widening the HTML surface. - const anchor = text.match(/^$/); + const anchor = text.match(/^$/u); if (anchor) { anchors.push(anchor[1]); return text; @@ -40,14 +41,14 @@ export function buildInstallGuide(source) { throw new Error(`Unsupported raw HTML in install guide: ${text.slice(0, 80)}`); }, table(token) { - const label = `Installation table ${++tableNumber}: ${token.header.map((cell) => cell.text).join(" / ")}` + const label = `${tableLabel} ${++tableNumber}: ${token.header.map((cell) => cell.text).join(" / ")}` .replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">"); return `
${Renderer.prototype.table.call(this, token)}
\n`; }, }, walkTokens(token) { if (token.type === "link" || token.type === "image") { - token.href = docLink(token.href); + token.href = docLink(token.href, sourcePath); if (token.href.startsWith("#")) fragments.push(decodeURIComponent(token.href.slice(1))); } }, @@ -82,7 +83,9 @@ export function installAnchorErrors(text, anchors) { .filter((id) => !anchors.includes(id)); } -export function renderInstallGuideModule(source) { +export function renderInstallGuideModule(source, chineseSource) { const guide = buildInstallGuide(source); - return `// Generated from docs/INSTALL.md by scripts/derive-install.mjs. Do not edit.\n\nexport const INSTALL_GUIDE = ${JSON.stringify(guide, null, 2)} as const;\n`; + const chineseGuide = chineseSource === undefined ? "" : `\nexport const CHINESE_INSTALL_GUIDE = ${JSON.stringify(buildInstallGuide(chineseSource, { sourcePath: "zh_hans/INSTALL.md", tableLabel: "安装表" }), null, 2)} as const;\n`; + const localizedGuides = chineseSource === undefined ? "" : `\nexport const INSTALL_GUIDES = {\n en: { guide: INSTALL_GUIDE, lang: "en", sourcePath: "INSTALL.md" },\n zh: { guide: CHINESE_INSTALL_GUIDE, lang: "zh-Hans", sourcePath: "zh_hans/INSTALL.md" },\n} as const;\n`; + return `// Generated from checked-in installation guides by scripts/derive-install.mjs. Do not edit.\n\nexport const INSTALL_GUIDE = ${JSON.stringify(guide, null, 2)} as const;\n${chineseGuide}${localizedGuides}`; } diff --git a/web/scripts/patch-braces.mjs b/web/scripts/patch-braces.mjs new file mode 100644 index 0000000000..833a6c75b9 --- /dev/null +++ b/web/scripts/patch-braces.mjs @@ -0,0 +1,97 @@ +// Temporary mitigation for GHSA-vfj7-8cjw-p6xm; see ../docs/DEPENDENCY_SECURITY.md. +// Preserve upstream version/licence and verify both original and patched bytes. +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const require = createRequire(import.meta.url); +const digest = value => createHash('sha256').update(value).digest('hex'); +const reviewed = [ + { + "file": "lib/parse.js", + "original": "e572166565f15fa6ad9865ae49d678218e32aabfd1b3720f6d0d43d39800d310", + "patched": "eb06a3799e392ea84cabe898f5095a3cad34fd88c78b75bf596e6196738b14b4", + "edits": [ + { + "before": " stack.push(block);", + "after": " stack.push(block);\n if (stack.length > 64) throw new SyntaxError('Brace AST nesting exceeds 64 levels');", + "count": 2 + } + ] + }, + { + "file": "lib/compile.js", + "original": "dc98f22eee3d511785d92a00758d5f0d48efed5f5813bdecc2de430c529b5c9f", + "patched": "3674b9cdb5d23fbe3795ec3083b9cbdbf164f7d586a1f90c1a01de8dcb6b2609", + "edits": [ + { + "before": "const walk = (node, parent = {}) => {", + "after": "const walk = (node, parent = {}, depth = 0) => {\n if (depth > 64) throw new SyntaxError('Brace AST nesting exceeds 64 levels');", + "count": 1 + }, + { + "before": "walk(child, node)", + "after": "walk(child, node, depth + 1)", + "count": 1 + } + ] + }, + { + "file": "lib/expand.js", + "original": "41ccc196ebfa7b7781a634e721eb744e4e7bcb54cba427a7e3d6806a1b9e58f7", + "patched": "eb2f9dcd071a237d36c7da755ca2cadef3f95d2147b108b1c7fa3c0f1adfdb96", + "edits": [ + { + "before": "const walk = (node, parent = {}) => {", + "after": "const walk = (node, parent = {}, depth = 0) => {\n if (depth > 64) throw new SyntaxError('Brace AST nesting exceeds 64 levels');", + "count": 1 + }, + { + "before": "walk(child, node)", + "after": "walk(child, node, depth + 1)", + "count": 1 + } + ] + }, + { + "file": "lib/stringify.js", + "original": "379f22d77bfa1478341ccd49c5e4267464aabcbba03558bab332aac23fc6f23a", + "patched": "64ec4fcc359c83ecbd5a3220b028d17f0b60a2645b95271f363b4d443173aba6", + "edits": [ + { + "before": "const stringify = (node, parent = {}) => {", + "after": "const stringify = (node, parent = {}, depth = 0) => {\n if (depth > 64) throw new SyntaxError('Brace AST nesting exceeds 64 levels');", + "count": 1 + }, + { + "before": "stringify(child)", + "after": "stringify(child, undefined, depth + 1)", + "count": 1 + } + ] + } +]; + +export function patchBraces({ root = dirname(require.resolve('braces/package.json')), checkOnly = false } = {}) { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + if (pkg.name !== 'braces' || pkg.version !== '3.0.3') throw new Error('Unreviewed braces version; retire or review the depth mitigation'); + const pending = reviewed.map(entry => { + const path = join(root, entry.file), source = readFileSync(path, 'utf8'), hash = digest(source); + if (hash === entry.patched) return { path, source, hash }; + if (checkOnly || hash !== entry.original) throw new Error('Unreviewed or unpatched braces file: ' + entry.file); + let patched = source; + for (const edit of entry.edits) { + if (patched.split(edit.before).length - 1 !== edit.count) throw new Error('braces patch anchor changed: ' + entry.file); + patched = patched.split(edit.before).join(edit.after); + } + if (digest(patched) !== entry.patched) throw new Error('braces patch digest mismatch: ' + entry.file); + return { path, source: patched, hash: entry.patched }; + }); + // Validate every file before touching any. Re-running after npm ci is idempotent. + if (!checkOnly) for (const file of pending) writeFileSync(file.path, file.source); + return { version: pkg.version, files: reviewed.map(entry => ({ file: entry.file, sha256: entry.patched })) }; +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + console.log(JSON.stringify({ mitigation: 'bounded AST depth', ...patchBraces() })); +}