diff --git a/extensions/bitwarden/CHANGELOG.md b/extensions/bitwarden/CHANGELOG.md
new file mode 100644
index 00000000..11dca60a
--- /dev/null
+++ b/extensions/bitwarden/CHANGELOG.md
@@ -0,0 +1,121 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.3.0] - 2026-05-13
+
+### Added
+
+- Bitwarden Send support — search, create, and receive Sends directly from the launcher
+- Search Sends command (view) — browse your Sends and copy links or text with a keystroke
+- Create Send command (view) — create new Sends to share text or files securely from the launcher
+- Receive Send command (no-view) — receive a Send from a URL in your clipboard, copying text or downloading files
+- Send editing, expiration date controls, and deletion from the Send detail view
+- Send access IDs are now encrypted before storage for security
+
+## [0.2.0] - 2026-05-11
+
+### Added
+
+- Search TOTP Codes command — browse accounts with TOTP 2FA enabled, view live verification codes with 30-second countdown timers, and copy codes with a keystroke
+- TOTP countdown progress bar on the item detail view showing remaining code validity
+- File attachment support — upload files when creating or editing items; download attachments to a configurable directory (new Download Directory preference)
+- Per-field copy and show/hide actions for custom fields in the item detail view
+- Auto-Lock Timeout preference — automatically lock the vault after a configurable period of inactivity (15 min to 24 h, or Never)
+
+### Fixed
+
+- Detail view actions now disabled during loading, preventing a stuck-loader bug when no session is active; shows a Loading indicator with only the Back action available
+- Security hardening — master password passed via environment variable instead of command-line arguments; sensitive payloads written through stdin; API credentials stored in system keyring
+- API credentials cleared from disk after every login, while the libsecret-stored session is preserved on logout
+- FilePicker control added to the edit form for selecting file attachments
+- Custom fields no longer duplicated in the markdown body — rendered only in the metadata sidebar
+
+### Changed
+
+- Custom field actions now appear before Open URL in the action panel
+
+## [0.1.3] - 2026-05-06
+
+### Added
+
+- Themed placeholder icons for each item type — white symbol on a coloured rounded rectangle matching Vicinae's native icon style (Login=Blue, Card=Green, Identity=Orange, SecureNote=Purple), with light/dark mode support
+- iOS-style rounded favicon corners, pre-rendered into the PNG bytes at fetch time
+
+### Fixed
+
+- Favicons are now stored on disk (`supportPath/favicons/`) with a 7-day TTL, surviving extension restarts with correct timestamps
+- Concurrent favicon fetches capped at 8 to prevent Google's favicon service timing out on large vaults
+- Search bar now disabled during gate states (loading, unlock, login) to prevent crashes when the List unmounts
+- Race condition removed: favicons are no longer cleared on Sync, so stale entries drop out when the vault item is deleted rather than on every refresh
+- Login favicon fallback now uses the themed Login placeholder icon instead of a bare key string
+
+### Changed
+
+- Favicons cached as base64 data URIs for direct rendering instead of file paths that required a separate disk read
+- Favicon cache prunes entries for domains no longer in the vault on each Sync
+
+## [0.1.2] - 2026-05-06
+
+### Added
+
+- Custom CA certificate path preference for self-hosted servers using a private CA — sets `NODE_EXTRA_CA_CERTS` in the `bw` process environment
+
+### Fixed
+
+- Favicon cache now persists timestamps so the 24-hour TTL survives extension restarts; previously all entries were reset to `Date.now()` on every module init
+- Favicon resolution handles bare domain URIs (e.g. `example.com` without a protocol) and tries all item URIs, not just the first one
+- Login failures are now surfaced as a dedicated error screen with a Retry button, instead of showing the Unlock form
+- Logout no longer throws when the CLI is already logged out — handles the "not logged in" response gracefully
+
+### Changed
+
+- Startup time reduced by running CLI checks (`bw status`, `secret-tool`, `bw --version`) in parallel via `Promise.allSettled`
+- Cached vault favicons and item list load synchronously on mount for instant display; sync runs in the background
+- `getErrorMessage` now filters Node.js deprecation warnings from `bw` stderr output
+- Logout now clears the cached vault in addition to the session
+- De-duplicated gate error rendering pattern into a shared `renderGate` function
+- Extracted shared test mock utilities to reduce test boilerplate
+
+## [0.1.1] - 2026-05-05
+
+### Changed
+
+- Session tokens are now stored in the system keyring via `libsecret-tools` instead of plaintext LocalStorage, providing encrypted at-rest storage
+- Removed Lock Vault action from the vault list — Log Out achieves the same behaviour
+
+### Added
+
+- Generate Password command (no-view) — copies a random password to clipboard using the configured generation preferences
+- Not-installed gate for `libsecret-tools` with OS-specific install instructions
+- Full custom field type support — field-type dropdown (Text/Hidden/Boolean) in edit forms, show/hide toggle for hidden fields in detail view, boolean fields displayed as Yes/No
+
+### Fixed
+
+- Negative `secret-tool` availability check no longer caches failures, so installing the package and re-opening the command works without restarting Vicinae
+- Use `secret-tool lookup` instead of unsupported `--version` flag for the install check
+- Stripped sensitive fields (passwords, card numbers, TOTP seeds, notes, custom fields) from the LocalStorage vault cache; only display metadata is persisted
+- Restored list-view copy actions (password, card number, security code, TOTP) that were lost after sensitive-field stripping — actions fetch fresh values from the CLI on demand and only appear when the field actually exists on the item
+
+## [0.1.0] - 2026-05-04
+
+Initial release.
+
+### Added
+
+- Search Vault command — browse items grouped by Folder, filter by name, and copy credentials (password, username, TOTP, etc.) with a keystroke
+- Create Item command — add new Login, Card, Identity, or Secure Note entries to the vault
+- Log Out command — clear stored Session and API key
+- Unlock gate with masked master password input and Session caching via LocalStorage
+- Automatic vault Sync after Unlock
+- Preference-based configuration for server region (US cloud, EU cloud, or self-hosted), API key (client ID + client secret), and password generation options
+- Item type-specific actions: copy password/username/TOTP/URL for Logins, copy number/code for Cards, copy name/email/phone for Identities, view notes for Secure Notes
+- Item Detail view with full field inspection and show/hide password toggle
+- Edit item with dynamic custom field support
+- Generate password action with configurable length and character sets
+- Delete item from vault list
+- Create new folder from the search view
+- Cached vault items and favicons for instant loading on subsequent opens
diff --git a/extensions/bitwarden/README.md b/extensions/bitwarden/README.md
new file mode 100644
index 00000000..6d95654c
--- /dev/null
+++ b/extensions/bitwarden/README.md
@@ -0,0 +1,139 @@
+
+
+Keyboard-driven access to your Bitwarden vault — right from the Vicinae launcher. Unlock once with your master password, then search for any item, copy credentials, grab a TOTP code, or create new entries, all without leaving the keyboard.
+
+## Prerequisites
+
+- **[Bitwarden CLI](https://bitwarden.com/download/)** (`bw`) must be installed and on your PATH.
+- **`libsecret-tools`** is needed for secure session storage in your system keyring:
+ - **Debian / Ubuntu**: `sudo apt install libsecret-tools`
+ - **Fedora**: `sudo dnf install libsecret`
+ - **Arch**: `sudo pacman -S libsecret`
+
+## Installation
+
+Install from the Vicinae Extensions Store (Pending), or build from source:
+
+```bash
+git clone https://github.com/edmogeor/vicinae-bitwarden.git
+cd vicinae-bitwarden
+npm install
+npm run build
+```
+
+## Configuration
+
+Set these preferences in the extension settings before you start. Generate your API key from the Bitwarden web vault under **Settings → Security → View API key**.
+
+### Connection
+
+| Preference | Type | Description |
+| --------------------- | --------- | ------------------------------------------------------------------------------ |
+| Server Region | dropdown | `bitwarden.com` (US), `bitwarden.eu` (EU), or `Self-hosted` |
+| Custom Server URL | textfield | Required when Server Region is `Self-hosted`. e.g. `https://vault.example.com` |
+| Custom CA Certificate | file | Path to a custom CA cert bundle for self-hosted servers with a private CA |
+| API Client ID | textfield | Your personal API key `client_id` |
+| API Client Secret | textfield | Your personal API key `client_secret` |
+
+### Security
+
+| Preference | Type | Description |
+| ----------------- | -------- | --------------------------------------------------------------------------------------------------------- |
+| Auto-Lock Timeout | dropdown | Lock the vault after inactivity. Options: Never, 15 min, 30 min, 1 h, 2 h, 6 h, 12 h, 24 h (default: 6 h) |
+
+### File Attachments
+
+| Preference | Type | Description |
+| ------------------ | --------- | ------------------------------------------------------------------------- |
+| Download Directory | textfield | Where attached files are saved. Defaults to `~/Downloads` when left empty |
+
+### Password Generation
+
+| Preference | Type | Description |
+| ----------------- | --------- | -------------------------------------------------------- |
+| Password Length | textfield | Characters per generated password (default: `20`) |
+| Include Uppercase | checkbox | Include A–Z (default: on) |
+| Include Lowercase | checkbox | Include a–z (default: on) |
+| Include Numbers | checkbox | Include 0–9 (default: on) |
+| Include Symbols | checkbox | Include special characters like `!@#$%^&*` (default: on) |
+
+## Commands
+
+### Search Vault
+
+Filter your vault by name (case-insensitive). Items are grouped by Folder so you can browse at a glance.
+
+**Item actions** available from the list:
+
+| Item type | Quick actions |
+| ----------- | --------------------------------------------------------- |
+| Login | Copy password, username, TOTP code; open URL; view detail |
+| Card | Copy number, security code; view detail |
+| Identity | Copy name, email, phone; view detail |
+| Secure Note | View note text |
+
+**Detail view** shows every field for the item, plus:
+
+- A TOTP countdown timer with a live verification code that refreshes every 30 seconds.
+- Per-field copy and show/hide toggles for each custom field.
+- File attachments — download them directly from the detail view.
+
+A **Sync Now** action pulls the latest vault state from the server.
+
+### Search TOTP Codes
+
+Browse every account that has TOTP two-factor authentication set up. Live verification codes are displayed next to each item with a 30-second countdown timer. Press a key to copy the code — no need to open the item first.
+
+### Create Item
+
+Add a new Login, Card, Identity, or Secure Note to your vault. The form adapts its fields to the item type you pick. You can also:
+
+- Attach files to any item you create or edit.
+- Add custom fields of type Text, Hidden, or Boolean.
+
+### Log Out
+
+Clears your API key session and removes the cached token from the system keyring. The next command invocation will prompt you for your master password.
+
+### Generate Password
+
+Creates a random password using your configured settings (length, character sets) and copies it straight to your clipboard. No vault access needed.
+
+### Search Sends
+
+List and search your Sends — text or file shares. Filter by name (case-insensitive). Each Send shows its type and countdowns for deletion and expiration. **Actions**: Copy Send Link, Copy Text (text Sends only), View Details, Edit, Delete. A Sync action pulls the latest from the server.
+
+### Create Send
+
+Create a new Text or File Send. Set a name, content, optional password, deletion and expiration dates, max access count, and privacy options. The Send link is copied to your clipboard on creation.
+
+### Receive Send
+
+Reads a Send URL from your clipboard and receives it — no vault session required. Text Sends are copied to your clipboard; File Sends download to your configured Download Directory. Password-protected Sends are surfaced with clear guidance.
+
+## Session Caching
+
+Once unlocked, your session token is stored securely in the system keyring via `secret-tool`. Future command invocations show your vault immediately — no need to re-enter your master password until the token expires.
+
+If you enabled **Auto-Lock Timeout**, the vault locks itself after the chosen period of inactivity so your data is never left exposed.
+
+## License
+
+[MIT](./LICENSE)
diff --git a/extensions/bitwarden/assets/extension_icon.png b/extensions/bitwarden/assets/extension_icon.png
new file mode 100644
index 00000000..6873acee
Binary files /dev/null and b/extensions/bitwarden/assets/extension_icon.png differ
diff --git a/extensions/bitwarden/package-lock.json b/extensions/bitwarden/package-lock.json
new file mode 100644
index 00000000..d31a0451
--- /dev/null
+++ b/extensions/bitwarden/package-lock.json
@@ -0,0 +1,6614 @@
+{
+ "name": "bitwarden",
+ "version": "0.1.3",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bitwarden",
+ "version": "0.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "@vicinae/api": "^0.19.9",
+ "better-sqlite3": "^12.9.0",
+ "pngjs": "^7.0.0"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@types/better-sqlite3": "^7.6.13",
+ "@types/node": "^20.0.0",
+ "@types/pngjs": "^6.0.5",
+ "@types/react": "^19.0.0",
+ "husky": "^9.1.7",
+ "jsdom": "^29.1.1",
+ "lint-staged": "^16.4.0",
+ "prettier": "^3.8.3",
+ "typescript": "^5.9.2",
+ "vitest": "^4.1.5"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
+ "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
+ "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
+ "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jgoz/esbuild-plugin-typecheck": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@jgoz/esbuild-plugin-typecheck/-/esbuild-plugin-typecheck-4.0.4.tgz",
+ "integrity": "sha512-ca38NAWnE/GchWjO5m7Wbny+yMOsYkoJOboQGheCjnnu5uDxqQWJSIegN+C+CWl8K/1naI/cMfTrAfDH1oRoVQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@jgoz/esbuild-plugin-livereload": ">=2.1.4",
+ "esbuild": ">=0.25.0",
+ "typescript": ">= 3.5"
+ },
+ "peerDependenciesMeta": {
+ "@jgoz/esbuild-plugin-livereload": {
+ "optional": true
+ }
+ }
+ },
+ "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/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oclif/core": {
+ "version": "4.11.0",
+ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.0.tgz",
+ "integrity": "sha512-nTkRMgxFlIKQIIYGvhO2JMsLSQ1aHPHblHfFgxgoBrGK8Ao/8wxc4eNOIv/+t8dMXliZd7mREVr6la4aXXXg5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^4.3.2",
+ "ansis": "^3.17.0",
+ "clean-stack": "^3.0.1",
+ "cli-spinners": "^2.9.2",
+ "debug": "^4.4.3",
+ "ejs": "^3.1.10",
+ "get-package-type": "^0.1.0",
+ "indent-string": "^4.0.0",
+ "is-wsl": "^2.2.0",
+ "lilconfig": "^3.1.3",
+ "minimatch": "^10.2.5",
+ "semver": "^7.7.3",
+ "string-width": "^4.2.3",
+ "supports-color": "^8",
+ "tinyglobby": "^0.2.14",
+ "widest-line": "^3.1.0",
+ "wordwrap": "^1.0.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@oclif/plugin-help": {
+ "version": "6.2.46",
+ "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.46.tgz",
+ "integrity": "sha512-KmuMFt/fURCVxor0rrRjEqs2nLN0Y3ixcixo/M5VjKcN920gbuw5T+AF23FBeyUDuW/Dg79YPcTWy/Rtz0Dg/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@oclif/core": "^4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@oclif/plugin-plugins": {
+ "version": "5.4.63",
+ "resolved": "https://registry.npmjs.org/@oclif/plugin-plugins/-/plugin-plugins-5.4.63.tgz",
+ "integrity": "sha512-PZ/NKMFwmlLu5iWR0KlcdqX7k8fHc1hZhFDgzCVrEPbMiEwRDGifKiDTwGcXiLsSpsKhL1BY11FEeA1jz/NRQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@oclif/core": "^4.11.0",
+ "ansis": "^3.17.0",
+ "debug": "^4.4.0",
+ "npm": "^11.13.0",
+ "npm-package-arg": "^11.0.3",
+ "npm-run-path": "^5.3.0",
+ "object-treeify": "^4.0.1",
+ "semver": "^7.7.4",
+ "validate-npm-package-name": "^5.0.1",
+ "which": "^4.0.0",
+ "yarn": "^1.22.22"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.127.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.39",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
+ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/pngjs": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
+ "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@vicinae/api": {
+ "version": "0.19.9",
+ "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.19.9.tgz",
+ "integrity": "sha512-7HSvOg6yorL3PAaVckQRvbVuihGvyE9ViSNFTGltXQQDv1jrGHpcs4ividK/evCHqs8iaYz3w2DFuF7uYJ/PQw==",
+ "license": "ISC",
+ "dependencies": {
+ "@jgoz/esbuild-plugin-typecheck": "^4.0.3",
+ "@oclif/core": "^4",
+ "@oclif/plugin-help": "^6",
+ "@oclif/plugin-plugins": "^5",
+ "@types/node": ">=18",
+ "@types/react": "19.0.10",
+ "chokidar": "^4.0.3",
+ "esbuild": "^0.25.2",
+ "react": "19.0.0",
+ "zod": "^4.0.17"
+ },
+ "bin": {
+ "vici": "bin/run.js"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18",
+ "@types/react": "19.0.10"
+ }
+ },
+ "node_modules/@vicinae/api/node_modules/@types/react": {
+ "version": "19.0.10",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
+ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@vicinae/api/node_modules/react": {
+ "version": "19.0.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
+ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
+ "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
+ "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
+ "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.5",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
+ "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
+ "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
+ "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/ansis": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz",
+ "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "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==",
+ "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/better-sqlite3": {
+ "version": "12.9.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
+ "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "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": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/clean-stack": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz",
+ "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+ "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
+ "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "slice-ansi": "^8.0.0",
+ "string-width": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate/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/cli-truncate/node_modules/string-width": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
+ "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate/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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "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==",
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/colorette": {
+ "version": "2.0.20",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "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-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "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/get-east-asian-width": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
+ "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^10.0.1"
+ },
+ "engines": {
+ "node": "^16.14.0 || >=18.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "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"
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+ "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.3.5",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
+ "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lint-staged": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
+ "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^14.0.3",
+ "listr2": "^9.0.5",
+ "picomatch": "^4.0.3",
+ "string-argv": "^0.3.2",
+ "tinyexec": "^1.0.4",
+ "yaml": "^2.8.2"
+ },
+ "bin": {
+ "lint-staged": "bin/lint-staged.js"
+ },
+ "engines": {
+ "node": ">=20.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/lint-staged"
+ }
+ },
+ "node_modules/listr2": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
+ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cli-truncate": "^5.0.0",
+ "colorette": "^2.0.20",
+ "eventemitter3": "^5.0.1",
+ "log-update": "^6.1.0",
+ "rfdc": "^1.4.1",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/listr2/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/listr2/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/listr2/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/listr2/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/listr2/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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/listr2/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/log-update": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
+ "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^7.0.0",
+ "cli-cursor": "^5.0.0",
+ "slice-ansi": "^7.1.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-update/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/log-update/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/log-update/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/log-update/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/log-update/node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-update/node_modules/slice-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
+ "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/log-update/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-update/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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/log-update/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "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==",
+ "license": "ISC"
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/mimic-function": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
+ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "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==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm": {
+ "version": "11.13.0",
+ "resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz",
+ "integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==",
+ "bundleDependencies": [
+ "@isaacs/string-locale-compare",
+ "@npmcli/arborist",
+ "@npmcli/config",
+ "@npmcli/fs",
+ "@npmcli/map-workspaces",
+ "@npmcli/metavuln-calculator",
+ "@npmcli/package-json",
+ "@npmcli/promise-spawn",
+ "@npmcli/redact",
+ "@npmcli/run-script",
+ "@sigstore/tuf",
+ "abbrev",
+ "archy",
+ "cacache",
+ "chalk",
+ "ci-info",
+ "fastest-levenshtein",
+ "fs-minipass",
+ "glob",
+ "graceful-fs",
+ "hosted-git-info",
+ "ini",
+ "init-package-json",
+ "is-cidr",
+ "json-parse-even-better-errors",
+ "libnpmaccess",
+ "libnpmdiff",
+ "libnpmexec",
+ "libnpmfund",
+ "libnpmorg",
+ "libnpmpack",
+ "libnpmpublish",
+ "libnpmsearch",
+ "libnpmteam",
+ "libnpmversion",
+ "make-fetch-happen",
+ "minimatch",
+ "minipass",
+ "minipass-pipeline",
+ "ms",
+ "node-gyp",
+ "nopt",
+ "npm-audit-report",
+ "npm-install-checks",
+ "npm-package-arg",
+ "npm-pick-manifest",
+ "npm-profile",
+ "npm-registry-fetch",
+ "npm-user-validate",
+ "p-map",
+ "pacote",
+ "parse-conflict-json",
+ "proc-log",
+ "qrcode-terminal",
+ "read",
+ "semver",
+ "spdx-expression-parse",
+ "ssri",
+ "supports-color",
+ "tar",
+ "text-table",
+ "tiny-relative-date",
+ "treeverse",
+ "validate-npm-package-name",
+ "which"
+ ],
+ "license": "Artistic-2.0",
+ "workspaces": [
+ "docs",
+ "smoke-tests",
+ "mock-globals",
+ "mock-registry",
+ "workspaces/*"
+ ],
+ "dependencies": {
+ "@isaacs/string-locale-compare": "^1.1.0",
+ "@npmcli/arborist": "^9.4.3",
+ "@npmcli/config": "^10.8.1",
+ "@npmcli/fs": "^5.0.0",
+ "@npmcli/map-workspaces": "^5.0.3",
+ "@npmcli/metavuln-calculator": "^9.0.3",
+ "@npmcli/package-json": "^7.0.5",
+ "@npmcli/promise-spawn": "^9.0.1",
+ "@npmcli/redact": "^4.0.0",
+ "@npmcli/run-script": "^10.0.4",
+ "@sigstore/tuf": "^4.0.2",
+ "abbrev": "^4.0.0",
+ "archy": "~1.0.0",
+ "cacache": "^20.0.4",
+ "chalk": "^5.6.2",
+ "ci-info": "^4.4.0",
+ "fastest-levenshtein": "^1.0.16",
+ "fs-minipass": "^3.0.3",
+ "glob": "^13.0.6",
+ "graceful-fs": "^4.2.11",
+ "hosted-git-info": "^9.0.2",
+ "ini": "^6.0.0",
+ "init-package-json": "^8.2.5",
+ "is-cidr": "^6.0.4",
+ "json-parse-even-better-errors": "^5.0.0",
+ "libnpmaccess": "^10.0.3",
+ "libnpmdiff": "^8.1.6",
+ "libnpmexec": "^10.2.6",
+ "libnpmfund": "^7.0.20",
+ "libnpmorg": "^8.0.1",
+ "libnpmpack": "^9.1.6",
+ "libnpmpublish": "^11.1.3",
+ "libnpmsearch": "^9.0.1",
+ "libnpmteam": "^8.0.2",
+ "libnpmversion": "^8.0.3",
+ "make-fetch-happen": "^15.0.5",
+ "minimatch": "^10.2.5",
+ "minipass": "^7.1.3",
+ "minipass-pipeline": "^1.2.4",
+ "ms": "^2.1.2",
+ "node-gyp": "^12.3.0",
+ "nopt": "^9.0.0",
+ "npm-audit-report": "^7.0.0",
+ "npm-install-checks": "^8.0.0",
+ "npm-package-arg": "^13.0.2",
+ "npm-pick-manifest": "^11.0.3",
+ "npm-profile": "^12.0.1",
+ "npm-registry-fetch": "^19.1.1",
+ "npm-user-validate": "^4.0.0",
+ "p-map": "^7.0.4",
+ "pacote": "^21.5.0",
+ "parse-conflict-json": "^5.0.1",
+ "proc-log": "^6.1.0",
+ "qrcode-terminal": "^0.12.0",
+ "read": "^5.0.1",
+ "semver": "^7.7.4",
+ "spdx-expression-parse": "^4.0.0",
+ "ssri": "^13.0.1",
+ "supports-color": "^10.2.2",
+ "tar": "^7.5.13",
+ "text-table": "~0.2.0",
+ "tiny-relative-date": "^2.0.2",
+ "treeverse": "^3.0.0",
+ "validate-npm-package-name": "^7.0.2",
+ "which": "^6.0.1"
+ },
+ "bin": {
+ "npm": "bin/npm-cli.js",
+ "npx": "bin/npx-cli.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm-package-arg": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz",
+ "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==",
+ "license": "ISC",
+ "dependencies": {
+ "hosted-git-info": "^7.0.0",
+ "proc-log": "^4.0.0",
+ "semver": "^7.3.5",
+ "validate-npm-package-name": "^5.0.0"
+ },
+ "engines": {
+ "node": "^16.14.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm/node_modules/@gar/promise-retry": {
+ "version": "1.0.3",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/@isaacs/string-locale-compare": {
+ "version": "1.1.0",
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/@npmcli/agent": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.1",
+ "lru-cache": "^11.2.1",
+ "socks-proxy-agent": "^8.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/arborist": {
+ "version": "9.4.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@isaacs/string-locale-compare": "^1.1.0",
+ "@npmcli/fs": "^5.0.0",
+ "@npmcli/installed-package-contents": "^4.0.0",
+ "@npmcli/map-workspaces": "^5.0.0",
+ "@npmcli/metavuln-calculator": "^9.0.2",
+ "@npmcli/name-from-folder": "^4.0.0",
+ "@npmcli/node-gyp": "^5.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/query": "^5.0.0",
+ "@npmcli/redact": "^4.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "bin-links": "^6.0.0",
+ "cacache": "^20.0.1",
+ "common-ancestor-path": "^2.0.0",
+ "hosted-git-info": "^9.0.0",
+ "json-stringify-nice": "^1.1.4",
+ "lru-cache": "^11.2.1",
+ "minimatch": "^10.0.3",
+ "nopt": "^9.0.0",
+ "npm-install-checks": "^8.0.0",
+ "npm-package-arg": "^13.0.0",
+ "npm-pick-manifest": "^11.0.1",
+ "npm-registry-fetch": "^19.0.0",
+ "pacote": "^21.0.2",
+ "parse-conflict-json": "^5.0.1",
+ "proc-log": "^6.0.0",
+ "proggy": "^4.0.0",
+ "promise-all-reject-late": "^1.0.0",
+ "promise-call-limit": "^3.0.1",
+ "semver": "^7.3.7",
+ "ssri": "^13.0.0",
+ "treeverse": "^3.0.0",
+ "walk-up-path": "^4.0.0"
+ },
+ "bin": {
+ "arborist": "bin/index.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/config": {
+ "version": "10.8.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/map-workspaces": "^5.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "ci-info": "^4.0.0",
+ "ini": "^6.0.0",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "walk-up-path": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/fs": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/git": {
+ "version": "7.0.2",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/promise-spawn": "^9.0.0",
+ "ini": "^6.0.0",
+ "lru-cache": "^11.2.1",
+ "npm-pick-manifest": "^11.0.1",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "which": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/installed-package-contents": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-bundled": "^5.0.0",
+ "npm-normalize-package-bin": "^5.0.0"
+ },
+ "bin": {
+ "installed-package-contents": "bin/index.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/map-workspaces": {
+ "version": "5.0.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/name-from-folder": "^4.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "glob": "^13.0.0",
+ "minimatch": "^10.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/metavuln-calculator": {
+ "version": "9.0.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "cacache": "^20.0.0",
+ "json-parse-even-better-errors": "^5.0.0",
+ "pacote": "^21.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/name-from-folder": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/node-gyp": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/package-json": {
+ "version": "7.0.5",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/git": "^7.0.0",
+ "glob": "^13.0.0",
+ "hosted-git-info": "^9.0.0",
+ "json-parse-even-better-errors": "^5.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.5.3",
+ "spdx-expression-parse": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/promise-spawn": {
+ "version": "9.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "which": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/query": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "postcss-selector-parser": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/redact": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/run-script": {
+ "version": "10.0.4",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/node-gyp": "^5.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/promise-spawn": "^9.0.0",
+ "node-gyp": "^12.1.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/bundle": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/protobuf-specs": "^0.5.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/core": {
+ "version": "3.2.0",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/protobuf-specs": {
+ "version": "0.5.1",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/sign": {
+ "version": "4.1.1",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.2",
+ "@sigstore/bundle": "^4.0.0",
+ "@sigstore/core": "^3.2.0",
+ "@sigstore/protobuf-specs": "^0.5.0",
+ "make-fetch-happen": "^15.0.4",
+ "proc-log": "^6.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/tuf": {
+ "version": "4.0.2",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/protobuf-specs": "^0.5.0",
+ "tuf-js": "^4.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/verify": {
+ "version": "3.1.0",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/bundle": "^4.0.0",
+ "@sigstore/core": "^3.1.0",
+ "@sigstore/protobuf-specs": "^0.5.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@tufjs/canonical-json": {
+ "version": "2.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^16.14.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/@tufjs/models": {
+ "version": "4.1.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tufjs/canonical-json": "2.0.0",
+ "minimatch": "^10.1.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/abbrev": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/agent-base": {
+ "version": "7.1.4",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/aproba": {
+ "version": "2.1.0",
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/archy": {
+ "version": "1.0.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/bin-links": {
+ "version": "6.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "cmd-shim": "^8.0.0",
+ "npm-normalize-package-bin": "^5.0.0",
+ "proc-log": "^6.0.0",
+ "read-cmd-shim": "^6.0.0",
+ "write-file-atomic": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/binary-extensions": {
+ "version": "3.1.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/cacache": {
+ "version": "20.0.4",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/fs": "^5.0.0",
+ "fs-minipass": "^3.0.0",
+ "glob": "^13.0.0",
+ "lru-cache": "^11.1.0",
+ "minipass": "^7.0.3",
+ "minipass-collect": "^2.0.1",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "p-map": "^7.0.2",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/chalk": {
+ "version": "5.6.2",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/npm/node_modules/chownr": {
+ "version": "3.0.0",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/npm/node_modules/ci-info": {
+ "version": "4.4.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/cidr-regex": {
+ "version": "5.0.4",
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/npm/node_modules/cmd-shim": {
+ "version": "8.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/common-ancestor-path": {
+ "version": "2.0.0",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/npm/node_modules/cssesc": {
+ "version": "3.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/npm/node_modules/debug": {
+ "version": "4.4.3",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/npm/node_modules/diff": {
+ "version": "8.0.4",
+ "inBundle": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/npm/node_modules/env-paths": {
+ "version": "2.2.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/npm/node_modules/exponential-backoff": {
+ "version": "3.1.3",
+ "inBundle": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/npm/node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
+ "node_modules/npm/node_modules/fs-minipass": {
+ "version": "3.0.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/glob": {
+ "version": "13.0.6",
+ "inBundle": 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/npm/node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/hosted-git-info": {
+ "version": "9.0.2",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^11.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "inBundle": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/npm/node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/npm/node_modules/ignore-walk": {
+ "version": "8.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minimatch": "^10.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/ini": {
+ "version": "6.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/init-package-json": {
+ "version": "8.2.5",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/package-json": "^7.0.0",
+ "npm-package-arg": "^13.0.0",
+ "promzard": "^3.0.1",
+ "read": "^5.0.1",
+ "semver": "^7.7.2",
+ "validate-npm-package-name": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/ip-address": {
+ "version": "10.1.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/npm/node_modules/is-cidr": {
+ "version": "6.0.4",
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "cidr-regex": "^5.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/npm/node_modules/isexe": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/npm/node_modules/json-parse-even-better-errors": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/json-stringify-nice": {
+ "version": "1.1.4",
+ "inBundle": true,
+ "license": "ISC",
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/jsonparse": {
+ "version": "1.3.1",
+ "engines": [
+ "node >= 0.2.0"
+ ],
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/just-diff": {
+ "version": "6.0.2",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/just-diff-apply": {
+ "version": "5.5.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/libnpmaccess": {
+ "version": "10.0.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-package-arg": "^13.0.0",
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmdiff": {
+ "version": "8.1.6",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/arborist": "^9.4.3",
+ "@npmcli/installed-package-contents": "^4.0.0",
+ "binary-extensions": "^3.0.0",
+ "diff": "^8.0.2",
+ "minimatch": "^10.0.3",
+ "npm-package-arg": "^13.0.0",
+ "pacote": "^21.0.2",
+ "tar": "^7.5.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmexec": {
+ "version": "10.2.6",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/arborist": "^9.4.3",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "ci-info": "^4.0.0",
+ "npm-package-arg": "^13.0.0",
+ "pacote": "^21.0.2",
+ "proc-log": "^6.0.0",
+ "read": "^5.0.1",
+ "semver": "^7.3.7",
+ "signal-exit": "^4.1.0",
+ "walk-up-path": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmfund": {
+ "version": "7.0.20",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/arborist": "^9.4.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmorg": {
+ "version": "8.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^2.0.0",
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmpack": {
+ "version": "9.1.6",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/arborist": "^9.4.3",
+ "@npmcli/run-script": "^10.0.0",
+ "npm-package-arg": "^13.0.0",
+ "pacote": "^21.0.2"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmpublish": {
+ "version": "11.1.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/package-json": "^7.0.0",
+ "ci-info": "^4.0.0",
+ "npm-package-arg": "^13.0.0",
+ "npm-registry-fetch": "^19.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.7",
+ "sigstore": "^4.0.0",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmsearch": {
+ "version": "9.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmteam": {
+ "version": "8.0.2",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^2.0.0",
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmversion": {
+ "version": "8.0.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/git": "^7.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "json-parse-even-better-errors": "^5.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.7"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/lru-cache": {
+ "version": "11.3.5",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/make-fetch-happen": {
+ "version": "15.0.5",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/agent": "^4.0.0",
+ "@npmcli/redact": "^4.0.0",
+ "cacache": "^20.0.1",
+ "http-cache-semantics": "^4.1.1",
+ "minipass": "^7.0.2",
+ "minipass-fetch": "^5.0.0",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^1.0.0",
+ "proc-log": "^6.0.0",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/minimatch": {
+ "version": "10.2.5",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/minipass": {
+ "version": "7.1.3",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-collect": {
+ "version": "2.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-fetch": {
+ "version": "5.0.2",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.0.3",
+ "minipass-sized": "^2.0.0",
+ "minizlib": "^3.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ },
+ "optionalDependencies": {
+ "iconv-lite": "^0.7.2"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-flush": {
+ "version": "1.0.6",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minipass": "^7.1.3"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": {
+ "version": "3.3.6",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/minipass-sized": {
+ "version": "2.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/minizlib": {
+ "version": "3.1.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/npm/node_modules/ms": {
+ "version": "2.1.3",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/mute-stream": {
+ "version": "3.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/negotiator": {
+ "version": "1.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/npm/node_modules/node-gyp": {
+ "version": "12.3.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "graceful-fs": "^4.2.6",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "tar": "^7.5.4",
+ "tinyglobby": "^0.2.12",
+ "undici": "^6.25.0",
+ "which": "^6.0.0"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/nopt": {
+ "version": "9.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^4.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-audit-report": {
+ "version": "7.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-bundled": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-normalize-package-bin": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-install-checks": {
+ "version": "8.0.0",
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "semver": "^7.1.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-normalize-package-bin": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-package-arg": {
+ "version": "13.0.2",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "hosted-git-info": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "validate-npm-package-name": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-packlist": {
+ "version": "10.0.4",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "ignore-walk": "^8.0.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-pick-manifest": {
+ "version": "11.0.3",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-install-checks": "^8.0.0",
+ "npm-normalize-package-bin": "^5.0.0",
+ "npm-package-arg": "^13.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-profile": {
+ "version": "12.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-registry-fetch": "^19.0.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-registry-fetch": {
+ "version": "19.1.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/redact": "^4.0.0",
+ "jsonparse": "^1.3.1",
+ "make-fetch-happen": "^15.0.0",
+ "minipass": "^7.0.2",
+ "minipass-fetch": "^5.0.0",
+ "minizlib": "^3.0.1",
+ "npm-package-arg": "^13.0.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-user-validate": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/p-map": {
+ "version": "7.0.4",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm/node_modules/pacote": {
+ "version": "21.5.0",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/git": "^7.0.0",
+ "@npmcli/installed-package-contents": "^4.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/promise-spawn": "^9.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "cacache": "^20.0.0",
+ "fs-minipass": "^3.0.0",
+ "minipass": "^7.0.2",
+ "npm-package-arg": "^13.0.0",
+ "npm-packlist": "^10.0.1",
+ "npm-pick-manifest": "^11.0.1",
+ "npm-registry-fetch": "^19.0.0",
+ "proc-log": "^6.0.0",
+ "sigstore": "^4.0.0",
+ "ssri": "^13.0.0",
+ "tar": "^7.4.3"
+ },
+ "bin": {
+ "pacote": "bin/index.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/parse-conflict-json": {
+ "version": "5.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "json-parse-even-better-errors": "^5.0.0",
+ "just-diff": "^6.0.0",
+ "just-diff-apply": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/path-scurry": {
+ "version": "2.0.2",
+ "inBundle": 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/npm/node_modules/postcss-selector-parser": {
+ "version": "7.1.1",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/npm/node_modules/proc-log": {
+ "version": "6.1.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/proggy": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/promise-all-reject-late": {
+ "version": "1.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/promise-call-limit": {
+ "version": "3.0.2",
+ "inBundle": true,
+ "license": "ISC",
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/promzard": {
+ "version": "3.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "read": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/qrcode-terminal": {
+ "version": "0.12.0",
+ "inBundle": true,
+ "bin": {
+ "qrcode-terminal": "bin/qrcode-terminal.js"
+ }
+ },
+ "node_modules/npm/node_modules/read": {
+ "version": "5.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "mute-stream": "^3.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/read-cmd-shim": {
+ "version": "6.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/npm/node_modules/semver": {
+ "version": "7.7.4",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm/node_modules/signal-exit": {
+ "version": "4.1.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/sigstore": {
+ "version": "4.1.0",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/bundle": "^4.0.0",
+ "@sigstore/core": "^3.1.0",
+ "@sigstore/protobuf-specs": "^0.5.0",
+ "@sigstore/sign": "^4.1.0",
+ "@sigstore/tuf": "^4.0.1",
+ "@sigstore/verify": "^3.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/socks": {
+ "version": "2.8.7",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.0.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/spdx-exceptions": {
+ "version": "2.5.0",
+ "inBundle": true,
+ "license": "CC-BY-3.0"
+ },
+ "node_modules/npm/node_modules/spdx-expression-parse": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/spdx-license-ids": {
+ "version": "3.0.23",
+ "inBundle": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/npm/node_modules/ssri": {
+ "version": "13.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/supports-color": {
+ "version": "10.2.2",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/npm/node_modules/tar": {
+ "version": "7.5.13",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/npm/node_modules/text-table": {
+ "version": "0.2.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/tiny-relative-date": {
+ "version": "2.0.2",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/npm/node_modules/treeverse": {
+ "version": "3.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/tuf-js": {
+ "version": "4.1.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tufjs/models": "4.1.0",
+ "debug": "^4.4.3",
+ "make-fetch-happen": "^15.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/undici": {
+ "version": "6.25.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/npm/node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/validate-npm-package-name": {
+ "version": "7.0.2",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/walk-up-path": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/which": {
+ "version": "6.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^4.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/write-file-atomic": {
+ "version": "7.0.1",
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/yallist": {
+ "version": "5.0.0",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/object-treeify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-4.0.1.tgz",
+ "integrity": "sha512-Y6tg5rHfsefSkfKujv2SwHulInROy/rCL5F4w0QOWxut8AnxYxf0YmNhTh95Zfyxpsudo66uqkux0ACFnyMSgQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "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==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.19.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "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.",
+ "license": "MIT",
+ "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/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/proc-log": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
+ "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==",
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "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/react": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/restore-cursor": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+ "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^7.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.127.0",
+ "@rolldown/pluginutils": "1.0.0-rc.17"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
+ }
+ },
+ "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==",
+ "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/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "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==",
+ "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/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "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": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/slice-ansi": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
+ "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "is-fullwidth-code-point": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/slice-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-argv": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
+ "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.19"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.30",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
+ "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.30"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.30",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
+ "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "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/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "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",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/validate-npm-package-name": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz",
+ "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
+ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/runner": "4.1.5",
+ "@vitest/snapshot": "4.1.5",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.5",
+ "@vitest/browser-preview": "4.1.5",
+ "@vitest/browser-webdriverio": "4.1.5",
+ "@vitest/coverage-istanbul": "4.1.5",
+ "@vitest/coverage-v8": "4.1.5",
+ "@vitest/ui": "4.1.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
+ "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/vitest/node_modules/vite": {
+ "version": "8.0.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.10",
+ "rolldown": "1.0.0-rc.17",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+ "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "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==",
+ "license": "ISC"
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yaml": {
+ "version": "2.8.4",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
+ "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yarn": {
+ "version": "1.22.22",
+ "resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz",
+ "integrity": "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==",
+ "hasInstallScript": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "yarn": "bin/yarn.js",
+ "yarnpkg": "bin/yarn.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
+ "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/extensions/bitwarden/package.json b/extensions/bitwarden/package.json
new file mode 100644
index 00000000..ed8c8474
--- /dev/null
+++ b/extensions/bitwarden/package.json
@@ -0,0 +1,247 @@
+{
+ "name": "bitwarden",
+ "title": "Bitwarden",
+ "description": "Keyboard-driven access to your Bitwarden vault. Search and copy passwords, usernames, and TOTP codes directly from the launcher.",
+ "author": "edmogeor",
+ "icon": "extension_icon.png",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/edmogeor/vicinae-bitwarden"
+ },
+ "categories": [
+ "Security"
+ ],
+ "version": "0.3.0",
+ "license": "MIT",
+ "preferences": [
+ {
+ "name": "serverRegion",
+ "title": "Server Region",
+ "description": "The Bitwarden instance your vault lives on",
+ "type": "dropdown",
+ "required": true,
+ "default": "bitwarden.com",
+ "data": [
+ {
+ "title": "bitwarden.com (US)",
+ "value": "bitwarden.com"
+ },
+ {
+ "title": "bitwarden.eu (EU)",
+ "value": "bitwarden.eu"
+ },
+ {
+ "title": "Self-hosted",
+ "value": "self-hosted"
+ }
+ ]
+ },
+ {
+ "name": "customServerUrl",
+ "title": "Custom Server URL",
+ "description": "Required when Server Region is set to Self-hosted. Example: https://vault.example.com",
+ "type": "textfield",
+ "required": false,
+ "placeholder": "https://vault.example.com"
+ },
+ {
+ "name": "customCertPath",
+ "title": "Custom CA Certificate Path",
+ "description": "Path to a custom CA certificate bundle for self-hosted servers using a private CA. Example: /etc/ssl/certs/my-ca.pem",
+ "type": "file",
+ "required": false
+ },
+ {
+ "name": "bitwardenApiClientId",
+ "title": "API Client ID",
+ "description": "Your personal API key client_id from the Bitwarden web vault (Settings → Security → View API key)",
+ "type": "textfield",
+ "required": true
+ },
+ {
+ "name": "bitwardenApiClientSecret",
+ "title": "API Client Secret",
+ "description": "Your personal API key client_secret from the Bitwarden web vault",
+ "type": "textfield",
+ "required": true
+ },
+ {
+ "name": "autoLockTimeout",
+ "title": "Auto-Lock Timeout",
+ "description": "Automatically lock the vault after this period of inactivity",
+ "type": "dropdown",
+ "required": false,
+ "default": "21600",
+ "data": [
+ {
+ "title": "Never",
+ "value": "0"
+ },
+ {
+ "title": "15 minutes",
+ "value": "900"
+ },
+ {
+ "title": "30 minutes",
+ "value": "1800"
+ },
+ {
+ "title": "1 hour",
+ "value": "3600"
+ },
+ {
+ "title": "2 hours",
+ "value": "7200"
+ },
+ {
+ "title": "6 hours",
+ "value": "21600"
+ },
+ {
+ "title": "12 hours",
+ "value": "43200"
+ },
+ {
+ "title": "24 hours",
+ "value": "86400"
+ }
+ ]
+ },
+ {
+ "name": "downloadDir",
+ "title": "Download Directory",
+ "description": "Directory where attached files will be saved. Defaults to ~/Downloads if empty.",
+ "type": "textfield",
+ "required": false,
+ "placeholder": "/home/user/Downloads"
+ },
+ {
+ "name": "passwordLength",
+ "title": "Password Length",
+ "description": "Number of characters for generated passwords",
+ "type": "textfield",
+ "required": false,
+ "default": "20",
+ "placeholder": "20"
+ },
+ {
+ "name": "passwordUppercase",
+ "title": "Add uppercase letters to generated passwords",
+ "description": "When enabled, generated passwords will include A-Z characters",
+ "type": "checkbox",
+ "required": false,
+ "default": true,
+ "label": "Include Uppercase"
+ },
+ {
+ "name": "passwordLowercase",
+ "title": "Add lowercase letters to generated passwords",
+ "description": "When enabled, generated passwords will include a-z characters",
+ "type": "checkbox",
+ "required": false,
+ "default": true,
+ "label": "Include Lowercase"
+ },
+ {
+ "name": "passwordNumbers",
+ "title": "Add digits to generated passwords",
+ "description": "When enabled, generated passwords will include 0-9 characters",
+ "type": "checkbox",
+ "required": false,
+ "default": true,
+ "label": "Include Numbers"
+ },
+ {
+ "name": "passwordSymbols",
+ "title": "Add special characters to generated passwords",
+ "description": "When enabled, generated passwords will include !@#$%^&* characters",
+ "type": "checkbox",
+ "required": false,
+ "default": true,
+ "label": "Include Symbols"
+ }
+ ],
+ "commands": [
+ {
+ "name": "search-vault",
+ "title": "Search Vault",
+ "description": "Search your Bitwarden vault and copy credentials",
+ "mode": "view"
+ },
+ {
+ "name": "create-item",
+ "title": "Create Item",
+ "description": "Add a new Login, Card, Identity, or Secure Note to your vault",
+ "mode": "view"
+ },
+ {
+ "name": "logout",
+ "title": "Log Out",
+ "description": "Log out of Bitwarden, clearing your stored API key session",
+ "mode": "no-view"
+ },
+ {
+ "name": "search-totp",
+ "title": "Search TOTP Codes",
+ "description": "Search accounts that have TOTP set up and copy verification codes",
+ "mode": "view"
+ },
+ {
+ "name": "generate-password",
+ "title": "Generate Password",
+ "description": "Generate a random password using your configured settings and copy it to clipboard",
+ "mode": "no-view"
+ },
+ {
+ "name": "search-sends",
+ "title": "Search Sends",
+ "description": "Search your Bitwarden Sends and copy links or text",
+ "mode": "view"
+ },
+ {
+ "name": "create-send",
+ "title": "Create Send",
+ "description": "Create a new Send to share text or files securely",
+ "mode": "view"
+ },
+ {
+ "name": "receive-send",
+ "title": "Receive Send",
+ "description": "Receive a Send from a URL in your clipboard — copies text or downloads files",
+ "mode": "no-view"
+ }
+ ],
+ "scripts": {
+ "build": "vici build",
+ "dev": "vici develop",
+ "lint": "vici lint",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "prepare": "husky"
+ },
+ "lint-staged": {
+ "*.{js,ts,tsx,jsx,json,md,yml,yaml}": [
+ "prettier --write"
+ ]
+ },
+ "dependencies": {
+ "@vicinae/api": "^0.19.9",
+ "better-sqlite3": "^12.9.0",
+ "pngjs": "^7.0.0"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@types/better-sqlite3": "^7.6.13",
+ "@types/node": "^20.0.0",
+ "@types/pngjs": "^6.0.5",
+ "@types/react": "^19.0.0",
+ "husky": "^9.1.7",
+ "jsdom": "^29.1.1",
+ "lint-staged": "^16.4.0",
+ "prettier": "^3.8.3",
+ "typescript": "^5.9.2",
+ "vitest": "^4.1.5"
+ }
+}
diff --git a/extensions/bitwarden/src/__tests__/__utils__/exec-mocks.ts b/extensions/bitwarden/src/__tests__/__utils__/exec-mocks.ts
new file mode 100644
index 00000000..e5312769
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/__utils__/exec-mocks.ts
@@ -0,0 +1,78 @@
+import { vi, expect } from 'vitest';
+
+export function mockExec(mf: ReturnType, stdout: string, stderr = ''): void {
+ mf.mockResolvedValueOnce({ stdout, stderr });
+}
+
+export function mockExecError(mf: ReturnType, message: string): void {
+ const err = new Error(message) as Error & { stderr: string; code: number };
+ err.stderr = message;
+ err.code = 1;
+ mf.mockRejectedValueOnce(err);
+}
+
+function createBaseChild(ms: ReturnType) {
+ const child = {
+ stdin: { write: vi.fn(), end: vi.fn(), on: vi.fn() },
+ on: vi.fn(),
+ };
+ child.stdin.on.mockImplementation((event: string, cb: () => void) => {
+ if (event === 'finish') cb();
+ return child;
+ });
+ ms.mockReturnValueOnce(child);
+ return child;
+}
+
+function makeSpawnChild(ms: ReturnType, exitCode: number) {
+ const child = createBaseChild(ms);
+ child.on.mockImplementation((event: string, cb: (code?: number) => void) => {
+ if (event === 'close') cb(exitCode);
+ return child;
+ });
+ return child;
+}
+
+export function mockSpawnSuccess(ms: ReturnType) {
+ return makeSpawnChild(ms, 0);
+}
+
+export function mockSpawnError(ms: ReturnType, code: number): void {
+ makeSpawnChild(ms, code);
+}
+
+export function createSpawnChild(ms: ReturnType) {
+ const child = createBaseChild(ms);
+ child.on.mockImplementation((event: string, cb: (...args: unknown[]) => void) => {
+ if (event === 'error') cb(new Error('spawn failed'));
+ return child;
+ });
+ return child;
+}
+
+export function expectEncodeAndExec(
+ ms: ReturnType,
+ session: string,
+ cmd: string,
+ args: string[],
+): void {
+ expect(ms).toHaveBeenCalledTimes(2);
+ expect(ms).toHaveBeenNthCalledWith(
+ 1,
+ 'bw',
+ ['encode'],
+ expect.objectContaining({
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: expect.objectContaining({ BW_SESSION: session }),
+ }),
+ );
+ expect(ms).toHaveBeenNthCalledWith(
+ 2,
+ 'bw',
+ [cmd, ...args],
+ expect.objectContaining({
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: expect.objectContaining({ BW_SESSION: session }),
+ }),
+ );
+}
diff --git a/extensions/bitwarden/src/__tests__/__utils__/test-data.ts b/extensions/bitwarden/src/__tests__/__utils__/test-data.ts
new file mode 100644
index 00000000..673a22ae
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/__utils__/test-data.ts
@@ -0,0 +1,48 @@
+import type { BwItem, BwFolder } from '../../bitwarden-types';
+import { ItemType } from '../../bitwarden-types';
+
+/** Build a full BwItem with sensible defaults — no casts needed in tests */
+export function makeItem(overrides: Partial = {}): BwItem {
+ return {
+ id: 'item-1',
+ organizationId: null,
+ folderId: null,
+ type: ItemType.Login,
+ name: 'Test Item',
+ notes: null,
+ favorite: false,
+ revisionDate: '',
+ creationDate: '',
+ deletedDate: null,
+ collectionIds: null,
+ ...overrides,
+ };
+}
+
+/** Build a BwFolder with sensible defaults */
+export function makeFolder(overrides: Partial = {}): BwFolder {
+ return {
+ id: 'f1',
+ name: 'Work',
+ ...overrides,
+ };
+}
+
+/** Build several items at once */
+export function makeItems(count: number, overrides?: (i: number) => Partial): BwItem[] {
+ return Array.from({ length: count }, (_, i) => {
+ const extra = overrides?.(i) ?? {};
+ return makeItem({ ...extra, id: `item-${i + 1}` });
+ });
+}
+
+/** Build several folders at once */
+export function makeFolders(
+ count: number,
+ overrides?: (i: number) => Partial,
+): BwFolder[] {
+ return Array.from({ length: count }, (_, i) => {
+ const extra = overrides?.(i) ?? {};
+ return makeFolder({ ...extra, id: `f${i + 1}` });
+ });
+}
diff --git a/extensions/bitwarden/src/__tests__/__utils__/vicinae-mocks.ts b/extensions/bitwarden/src/__tests__/__utils__/vicinae-mocks.ts
new file mode 100644
index 00000000..f038c619
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/__utils__/vicinae-mocks.ts
@@ -0,0 +1,47 @@
+import React from 'react';
+
+export function makeFormMock(
+ extras: Record>> = {},
+) {
+ return Object.assign(
+ function Form({ children, actions }: { children: React.ReactNode; actions?: React.ReactNode }) {
+ return React.createElement('form', { 'data-testid': 'form' }, children, actions);
+ },
+ {
+ PasswordField(props: { id: string; title: string; error?: string }) {
+ return React.createElement('input', {
+ type: 'password',
+ 'data-testid': props.id,
+ placeholder: props.title,
+ });
+ },
+ ...extras,
+ },
+ );
+}
+
+/**
+ * Shared mock factory for `vi.mock('@vicinae/api', ...)`.
+ * Pass your hoisted mockClipboardCopy and mockShowToast.
+ */
+export function createVicinaeApiMock(
+ copyFn: (...args: unknown[]) => void,
+ toastFn: (...args: unknown[]) => void,
+) {
+ return {
+ Clipboard: { copy: copyFn },
+ showToast: toastFn,
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+ };
+}
+
+function el(type: string, testId?: string) {
+ return (props: { children?: React.ReactNode; id?: string; [key: string]: unknown }) => {
+ const { children, ...rest } = props;
+ return React.createElement(type, { 'data-testid': testId ?? props.id, ...rest }, children);
+ };
+}
+
+export const DropdownItem = el('option');
+export const Dropdown = Object.assign(el('select'), { Item: DropdownItem });
+export { el };
diff --git a/extensions/bitwarden/src/__tests__/api-credential-store.test.ts b/extensions/bitwarden/src/__tests__/api-credential-store.test.ts
new file mode 100644
index 00000000..19ca3ea9
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/api-credential-store.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { mockExec, mockExecError, mockSpawnSuccess } from './__utils__/exec-mocks';
+
+const mockExecFile = vi.hoisted(() => vi.fn());
+const mockSpawn = vi.hoisted(() => vi.fn());
+const mockReadFileSync = vi.hoisted(() => vi.fn());
+const mockWriteFileSync = vi.hoisted(() => vi.fn());
+const mockJoin = vi.hoisted(() => vi.fn());
+
+// fallow-ignore-next-line code-duplication
+vi.mock('node:child_process', () => ({
+ default: { execFile: mockExecFile, spawn: mockSpawn },
+ execFile: mockExecFile,
+ spawn: mockSpawn,
+}));
+
+// fallow-ignore-next-line code-duplication
+vi.mock('node:util', () => ({
+ default: { promisify: (fn: unknown) => fn },
+ promisify: (fn: unknown) => fn,
+}));
+
+vi.mock('node:fs', () => ({
+ default: { readFileSync: mockReadFileSync, writeFileSync: mockWriteFileSync },
+ readFileSync: mockReadFileSync,
+ writeFileSync: mockWriteFileSync,
+}));
+
+vi.mock('node:path', () => ({
+ default: { join: mockJoin },
+ join: mockJoin,
+}));
+
+vi.mock('better-sqlite3', () => ({
+ default: function Database() {
+ return {
+ prepare: vi.fn().mockReturnValue({ run: vi.fn() }),
+ close: vi.fn(),
+ };
+ },
+}));
+
+let apiCredStore: typeof import('../api-credential-store');
+
+beforeEach(async () => {
+ vi.resetAllMocks();
+ vi.resetModules();
+ mockJoin.mockImplementation((...args: string[]) => args.join('/'));
+ process.env.HOME = '/home/testuser';
+ apiCredStore = await import('../api-credential-store');
+});
+
+describe('storeApiCredentials', () => {
+ it('stores credentials as JSON via secret-tool spawn', async () => {
+ mockSpawnSuccess(mockSpawn);
+
+ await apiCredStore.storeApiCredentials('my-client-id', 'my-client-secret');
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ 'secret-tool',
+ [
+ 'store',
+ '--label=Vicinae Bitwarden API Key',
+ 'service',
+ 'vicinae-bitwarden',
+ 'account',
+ 'api-creds',
+ ],
+ expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'] }),
+ );
+
+ const child = mockSpawn.mock.results[0].value;
+ const written = child.stdin.write.mock.calls[0][0];
+ const parsed = JSON.parse(written);
+ expect(parsed).toEqual({ clientId: 'my-client-id', clientSecret: 'my-client-secret' });
+ });
+});
+
+describe('getApiCredentials', () => {
+ it('returns parsed credentials from secret-tool lookup', async () => {
+ mockExec(mockExecFile, JSON.stringify({ clientId: 'id1', clientSecret: 'sec1' }) + '\n');
+
+ const result = await apiCredStore.getApiCredentials();
+ expect(result).toEqual({ clientId: 'id1', clientSecret: 'sec1' });
+ });
+
+ it('returns null when lookup fails', async () => {
+ mockExecError(mockExecFile, 'secret-tool: Cannot find item');
+
+ const result = await apiCredStore.getApiCredentials();
+ expect(result).toBeNull();
+ });
+
+ it('returns null when stdout is empty', async () => {
+ mockExec(mockExecFile, '\n');
+
+ const result = await apiCredStore.getApiCredentials();
+ expect(result).toBeNull();
+ });
+
+ it('passes correct args to secret-tool lookup', async () => {
+ mockExec(mockExecFile, JSON.stringify({ clientId: 'x', clientSecret: 'y' }) + '\n');
+
+ await apiCredStore.getApiCredentials();
+
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['lookup', 'service', 'vicinae-bitwarden', 'account', 'api-creds'],
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+});
+
+describe('clearApiCredentialsFromDisk', () => {
+ it('clears bitwardenApiClientId from settings.json', async () => {
+ mockReadFileSync.mockReturnValue(
+ '{"bitwardenApiClientId": "old-id", "serverRegion": "bitwarden.com"}',
+ );
+
+ await apiCredStore.clearApiCredentialsFromDisk();
+
+ expect(mockReadFileSync).toHaveBeenCalledWith(
+ '/home/testuser/.config/vicinae/settings.json',
+ 'utf-8',
+ );
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
+ '/home/testuser/.config/vicinae/settings.json',
+ '{"bitwardenApiClientId": "", "serverRegion": "bitwarden.com"}',
+ 'utf-8',
+ );
+ });
+
+ it('does not write settings.json when bitwardenApiClientId is already empty', async () => {
+ mockReadFileSync.mockReturnValue('{"bitwardenApiClientId": "", "other": true}');
+
+ await apiCredStore.clearApiCredentialsFromDisk();
+
+ expect(mockWriteFileSync).not.toHaveBeenCalled();
+ });
+
+ it('handles settings.json read failure gracefully', async () => {
+ mockReadFileSync.mockImplementation(() => {
+ throw new Error('ENOENT');
+ });
+
+ await expect(apiCredStore.clearApiCredentialsFromDisk()).resolves.toBeUndefined();
+ });
+
+ it('matches bitwardenApiClientId surrounded by other JSON fields', async () => {
+ mockReadFileSync.mockReturnValue(
+ '{\n "serverRegion": "bitwarden.com",\n "bitwardenApiClientId": "secret-id",\n "passwordLength": "20"\n}',
+ );
+
+ await apiCredStore.clearApiCredentialsFromDisk();
+
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
+ '/home/testuser/.config/vicinae/settings.json',
+ '{\n "serverRegion": "bitwarden.com",\n "bitwardenApiClientId": "",\n "passwordLength": "20"\n}',
+ 'utf-8',
+ );
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/bw-executor.test.ts b/extensions/bitwarden/src/__tests__/bw-executor.test.ts
new file mode 100644
index 00000000..70c65ecc
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/bw-executor.test.ts
@@ -0,0 +1,691 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import * as bw from '../bw-executor';
+import { mockExec, mockExecError, expectEncodeAndExec } from './__utils__/exec-mocks';
+
+const mockExecFile = vi.hoisted(() => vi.fn());
+const mockSpawn = vi.hoisted(() => vi.fn());
+const { mockPrefs } = vi.hoisted(() => ({
+ mockPrefs: {
+ serverRegion: 'bitwarden.com' as const,
+ customServerUrl: '',
+ customCertPath: '',
+ bitwardenApiClientId: '',
+ bitwardenApiClientSecret: '',
+ autoLockTimeout: '21600',
+ downloadDir: '',
+ passwordLength: '20',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ passwordNumbers: true,
+ passwordSymbols: true,
+ },
+}));
+
+// fallow-ignore-next-line code-duplication
+vi.mock('node:child_process', () => ({
+ default: { execFile: mockExecFile, spawn: mockSpawn },
+ execFile: mockExecFile,
+ spawn: mockSpawn,
+}));
+
+// fallow-ignore-next-line code-duplication
+vi.mock('node:util', () => ({
+ default: { promisify: (fn: unknown) => fn },
+ promisify: (fn: unknown) => fn,
+}));
+
+vi.mock('@vicinae/api', () => ({
+ getPreferenceValues: () => mockPrefs,
+}));
+
+function spawnMockChild(stdout: string, exitCode = 0) {
+ const child = {
+ stdout: { on: vi.fn() },
+ stderr: { on: vi.fn() },
+ stdin: { write: vi.fn(), end: vi.fn(), on: vi.fn() },
+ on: vi.fn(),
+ };
+ child.stdout.on.mockImplementation((event: string, cb: (d: Buffer) => void) => {
+ if (event === 'data') cb(Buffer.from(stdout));
+ return child;
+ });
+ child.stderr.on.mockImplementation((event: string, cb: (d: Buffer) => void) => {
+ if (event === 'data' && exitCode !== 0) cb(Buffer.from(stdout));
+ return child;
+ });
+ child.stdin.on.mockReturnValue(child);
+ child.on.mockImplementation((event: string, cb: (...args: unknown[]) => void) => {
+ if (event === 'close') cb(exitCode);
+ return child;
+ });
+ mockSpawn.mockReturnValueOnce(child);
+ return child;
+}
+
+const hasSession = (token: string) =>
+ expect.objectContaining({ env: expect.objectContaining({ BW_SESSION: token }) });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// checkInstalled
+// ---------------------------------------------------------------------------
+describe('checkInstalled', () => {
+ it('returns true when bw --version succeeds', async () => {
+ mockExec(mockExecFile, 'Bitwarden CLI v2024.1.0');
+ expect(await bw.checkInstalled()).toBe(true);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['--version'], expect.any(Object));
+ });
+
+ it('returns false when bw --version fails', async () => {
+ mockExecFile.mockRejectedValueOnce(new Error('not found'));
+ expect(await bw.checkInstalled()).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// login
+// ---------------------------------------------------------------------------
+describe('login', () => {
+ const params = {
+ clientId: 'user.123',
+ clientSecret: 'secret',
+ serverUrl: 'https://bitwarden.com',
+ };
+
+ it('calls bw config server then bw login --apikey with env vars', async () => {
+ mockExec(mockExecFile, '');
+ mockExec(mockExecFile, '');
+
+ await bw.login(params);
+
+ expect(mockExecFile).toHaveBeenCalledTimes(2);
+ expect(mockExecFile).toHaveBeenNthCalledWith(
+ 1,
+ 'bw',
+ ['config', 'server', 'https://bitwarden.com'],
+ expect.objectContaining({
+ env: expect.objectContaining({ BW_CLIENTID: 'user.123', BW_CLIENTSECRET: 'secret' }),
+ }),
+ );
+ expect(mockExecFile).toHaveBeenNthCalledWith(
+ 2,
+ 'bw',
+ ['login', '--apikey'],
+ expect.objectContaining({ env: expect.objectContaining({ BW_CLIENTID: 'user.123' }) }),
+ );
+ });
+
+ it('throws BwError when config server fails', async () => {
+ mockExecError(mockExecFile, 'Network error');
+
+ await expect(bw.login(params)).rejects.toThrow('Network error');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// unlock
+// ---------------------------------------------------------------------------
+describe('unlock', () => {
+ it('calls bw unlock --passwordenv BW_PASSWORD --raw and returns session', async () => {
+ mockExec(mockExecFile, 'session-token-abc\n');
+
+ const sessionToken = await bw.unlock('mypassword');
+ expect(sessionToken).toBe('session-token-abc');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'],
+ expect.objectContaining({
+ env: expect.objectContaining({ BW_PASSWORD: 'mypassword' }),
+ }),
+ );
+ });
+
+ it('throws BwError with INVALID_PASSWORD for invalid password', async () => {
+ mockExecError(mockExecFile, 'Invalid master password');
+
+ await expect(bw.unlock('wrong')).rejects.toMatchObject({
+ message: 'Invalid master password',
+ code: 'INVALID_PASSWORD',
+ });
+ });
+
+ it('throws generic BwError for other unlock errors', async () => {
+ mockExecError(mockExecFile, 'Network timeout');
+
+ await expect(bw.unlock('pass')).rejects.toMatchObject({
+ message: 'Network timeout',
+ code: 'CLI_ERROR',
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// sync
+// ---------------------------------------------------------------------------
+describe('sync', () => {
+ it('calls bw sync with BW_SESSION env var', async () => {
+ mockExec(mockExecFile, '');
+
+ await bw.sync('token-abc');
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['sync'], hasSession('token-abc'));
+ });
+
+ it('throws BwError on sync failure', async () => {
+ mockExecError(mockExecFile, 'Sync failed');
+
+ await expect(bw.sync('token')).rejects.toThrow('Sync failed');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listItems
+// ---------------------------------------------------------------------------
+describe('listItems', () => {
+ it('calls bw list items with BW_SESSION and parses JSON', async () => {
+ const items = [{ id: '1', name: 'GitHub', type: 1 }];
+ mockExec(mockExecFile, JSON.stringify(items));
+
+ const result = await bw.listItems('token-abc');
+ expect(result).toEqual(items);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['list', 'items'], hasSession('token-abc'));
+ });
+
+ it('throws BwError when JSON parsing fails', async () => {
+ mockExec(mockExecFile, 'not valid json {{{');
+
+ await expect(bw.listItems('token')).rejects.toMatchObject({
+ message: 'Failed to parse `bw` output as JSON',
+ code: 'PARSE_ERROR',
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listFolders
+// ---------------------------------------------------------------------------
+describe('listFolders', () => {
+ it('calls bw list folders with BW_SESSION and parses JSON', async () => {
+ const folders = [{ id: 'f1', name: 'Work' }];
+ mockExec(mockExecFile, JSON.stringify(folders));
+
+ const result = await bw.listFolders('token');
+ expect(result).toEqual(folders);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['list', 'folders'], hasSession('token'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getItem
+// ---------------------------------------------------------------------------
+describe('getItem', () => {
+ it('calls bw get item with BW_SESSION and returns parsed item', async () => {
+ const itemObj = { id: '1', name: 'GitHub', type: 1 };
+ mockExec(mockExecFile, JSON.stringify(itemObj));
+
+ const result = await bw.getItem('1', 'token');
+ expect(result).toEqual(itemObj);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['get', 'item', '1'], hasSession('token'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getTotp
+// ---------------------------------------------------------------------------
+describe('getTotp', () => {
+ it('calls bw get totp with BW_SESSION and returns trimmed code', async () => {
+ mockExec(mockExecFile, '123456\n');
+
+ const code = await bw.getTotp('1', 'token');
+ expect(code).toBe('123456');
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['get', 'totp', '1'], hasSession('token'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// createItem
+// ---------------------------------------------------------------------------
+describe('createItem', () => {
+ it('encodes payload and creates item via bw encode + bw create item', async () => {
+ const encoded = Buffer.from(JSON.stringify({ name: 'Test' })).toString('base64');
+ spawnMockChild(encoded);
+ spawnMockChild(
+ '{"id":"abc","name":"Test","type":1,"notes":null,"folderId":null,"favorite":false,"revisionDate":"","creationDate":"","deletedDate":null,"collectionIds":null}',
+ );
+
+ const payload = {
+ type: 1 as const,
+ name: 'Test',
+ notes: null,
+ folderId: null,
+ favorite: false,
+ };
+
+ const result = await bw.createItem(payload, 'token');
+ expect(result.id).toBe('abc');
+ expect(result.name).toBe('Test');
+
+ expectEncodeAndExec(mockSpawn, 'token', 'create', ['item']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// editItem
+// ---------------------------------------------------------------------------
+describe('editItem', () => {
+ it('encodes payload and edits item via bw encode + bw edit item', async () => {
+ const encoded = Buffer.from(JSON.stringify({ name: 'Updated' })).toString('base64');
+ spawnMockChild(encoded);
+ spawnMockChild('');
+
+ const payload = { name: 'Updated', notes: null };
+
+ await bw.editItem('item-123', payload, 'token');
+
+ expectEncodeAndExec(mockSpawn, 'token', 'edit', ['item', 'item-123']);
+ });
+
+ it('throws BwError on failure', async () => {
+ spawnMockChild('Edit failed', 1);
+
+ await expect(bw.editItem('item-123', { name: 'X' }, 'token')).rejects.toThrow('Edit failed');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// createFolder
+// ---------------------------------------------------------------------------
+describe('createFolder', () => {
+ it('encodes folder name and creates folder via bw encode + bw create folder', async () => {
+ const folderJson = JSON.stringify({ id: 'f1', name: 'Work' });
+ const encoded = Buffer.from(JSON.stringify({ name: 'Work' })).toString('base64');
+ spawnMockChild(encoded);
+ spawnMockChild(folderJson);
+
+ const result = await bw.createFolder('Work', 'token');
+ expect(result).toEqual({ id: 'f1', name: 'Work' });
+
+ expectEncodeAndExec(mockSpawn, 'token', 'create', ['folder']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// lock
+// ---------------------------------------------------------------------------
+describe('lock', () => {
+ it('calls bw lock with BW_SESSION', async () => {
+ mockExec(mockExecFile, '');
+
+ await bw.lock('token');
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['lock'], hasSession('token'));
+ });
+
+ it('does not throw on lock failure', async () => {
+ mockExecFile.mockRejectedValueOnce(new Error('already locked'));
+
+ await expect(bw.lock('token')).resolves.toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// status
+// ---------------------------------------------------------------------------
+describe('status', () => {
+ it('calls bw status and returns parsed result', async () => {
+ const statusObj = {
+ serverUrl: null,
+ lastSync: null,
+ userEmail: 'a@b.com',
+ userId: 'x',
+ status: 'unlocked' as const,
+ };
+ mockExec(mockExecFile, JSON.stringify(statusObj));
+
+ const result = await bw.status();
+ expect(result).toEqual(statusObj);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['status'], expect.any(Object));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// deleteItem
+// ---------------------------------------------------------------------------
+describe('deleteItem', () => {
+ it('calls bw delete item with BW_SESSION', async () => {
+ mockExec(mockExecFile, '');
+
+ await bw.deleteItem('item-1', 'token');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['delete', 'item', 'item-1'],
+ hasSession('token'),
+ );
+ });
+
+ it('throws BwError on delete failure', async () => {
+ mockExecError(mockExecFile, 'Item not found');
+
+ await expect(bw.deleteItem('missing', 'token')).rejects.toThrow('Item not found');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// generatePassword
+// ---------------------------------------------------------------------------
+describe('generatePassword', () => {
+ it('calls bw generate with all flags enabled', async () => {
+ mockExec(mockExecFile, 'aB3$xY9!pQ2&wE5!rT');
+
+ const result = await bw.generatePassword({
+ length: 20,
+ uppercase: true,
+ lowercase: true,
+ numbers: true,
+ symbols: true,
+ });
+ expect(result).toBe('aB3$xY9!pQ2&wE5!rT');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['generate', '-u', '-l', '-n', '-s', '--length', '20'],
+ expect.objectContaining({ timeout: 10000 }),
+ );
+ });
+
+ it('calls bw generate with subset of flags', async () => {
+ mockExec(mockExecFile, 'abc123');
+
+ await bw.generatePassword({
+ length: 12,
+ uppercase: false,
+ lowercase: true,
+ numbers: true,
+ symbols: false,
+ });
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['generate', '-l', '-n', '--length', '12'],
+ expect.objectContaining({ timeout: 10000 }),
+ );
+ });
+
+ it('throws BwError on failure', async () => {
+ mockExecError(mockExecFile, 'CLI error');
+
+ await expect(
+ bw.generatePassword({
+ length: 20,
+ uppercase: true,
+ lowercase: true,
+ numbers: true,
+ symbols: true,
+ }),
+ ).rejects.toThrow('CLI error');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getErrorMessage
+// ---------------------------------------------------------------------------
+describe('getErrorMessage', () => {
+ it('filters deprecation warnings from stderr', () => {
+ const err = new Error('Command failed') as { stderr: string } & Error;
+ err.stderr =
+ '[DEP001] DeprecationWarning: old api\nactual error\n[DEP002] DeprecationWarning: another';
+
+ expect(bw.getErrorMessage(err)).toBe('actual error');
+ });
+
+ it('returns error message when stderr is all deprecation lines', () => {
+ const err = new Error('Command failed') as { stderr: string } & Error;
+ err.stderr = '[DEP001] DeprecationWarning: x\n[DEP002] DeprecationWarning: y';
+
+ expect(bw.getErrorMessage(err)).toBe('Command failed');
+ });
+
+ it('returns error message when no stderr property', () => {
+ const err = new Error('Something broke');
+
+ expect(bw.getErrorMessage(err)).toBe('Something broke');
+ });
+
+ it('handles non-Error values', () => {
+ expect(bw.getErrorMessage('plain string')).toBe('plain string');
+ expect(bw.getErrorMessage(null)).toBe('null');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// logout
+// ---------------------------------------------------------------------------
+describe('logout', () => {
+ it('calls bw logout', async () => {
+ mockExec(mockExecFile, '');
+
+ await bw.logout();
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['logout'], expect.any(Object));
+ });
+
+ it('returns silently when already logged out', async () => {
+ mockExecError(mockExecFile, 'Not logged in.');
+
+ await expect(bw.logout()).resolves.toBeUndefined();
+ });
+
+ it('throws BwError on other logout failures', async () => {
+ mockExecError(mockExecFile, 'Network error');
+
+ await expect(bw.logout()).rejects.toThrow('Network error');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// NODE_EXTRA_CA_CERTS
+// ---------------------------------------------------------------------------
+describe('custom CA cert', () => {
+ const params = {
+ clientId: 'user.123',
+ clientSecret: 'secret',
+ serverUrl: 'https://bitwarden.com',
+ };
+
+ beforeEach(() => {
+ mockPrefs.customCertPath = '';
+ });
+
+ async function loginAndCheckFirstCall(expectedEnv: ReturnType) {
+ mockExec(mockExecFile, '');
+ mockExec(mockExecFile, '');
+ await bw.login(params);
+ expect(mockExecFile).toHaveBeenNthCalledWith(
+ 1,
+ 'bw',
+ ['config', 'server', 'https://bitwarden.com'],
+ expectedEnv,
+ );
+ }
+
+ it('does not set NODE_EXTRA_CA_CERTS when customCertPath is empty', async () => {
+ await loginAndCheckFirstCall(
+ expect.objectContaining({
+ env: expect.not.objectContaining({ NODE_EXTRA_CA_CERTS: expect.anything() }),
+ }),
+ );
+ });
+
+ it('sets NODE_EXTRA_CA_CERTS when customCertPath is configured', async () => {
+ mockPrefs.customCertPath = '/etc/ssl/certs/custom-ca.pem';
+ await loginAndCheckFirstCall(
+ expect.objectContaining({
+ env: expect.objectContaining({ NODE_EXTRA_CA_CERTS: '/etc/ssl/certs/custom-ca.pem' }),
+ }),
+ );
+ });
+
+ it('sets NODE_EXTRA_CA_CERTS in sessionEnv calls', async () => {
+ mockPrefs.customCertPath = '/etc/ssl/certs/custom-ca.pem';
+ mockExec(mockExecFile, '');
+
+ await bw.sync('token-abc');
+
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['sync'],
+ expect.objectContaining({
+ env: expect.objectContaining({ NODE_EXTRA_CA_CERTS: '/etc/ssl/certs/custom-ca.pem' }),
+ }),
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listSends
+// ---------------------------------------------------------------------------
+describe('listSends', () => {
+ it('calls bw send list with BW_SESSION and parses JSON', async () => {
+ const sends = [{ id: 's1', name: 'My Send', type: 0, accessId: 'abc' }];
+ mockExec(mockExecFile, JSON.stringify(sends));
+
+ const result = await bw.listSends('token-abc');
+ expect(result).toEqual(sends);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['send', 'list'], hasSession('token-abc'));
+ });
+
+ it('throws BwError on failure', async () => {
+ mockExecError(mockExecFile, 'CLI error');
+
+ await expect(bw.listSends('token')).rejects.toThrow('CLI error');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getSend
+// ---------------------------------------------------------------------------
+describe('getSend', () => {
+ it('calls bw send get with BW_SESSION and returns parsed send', async () => {
+ const sendObj = { id: 's1', name: 'My Send', type: 0, accessId: 'abc' };
+ mockExec(mockExecFile, JSON.stringify(sendObj));
+
+ const result = await bw.getSend('s1', 'token');
+ expect(result).toEqual(sendObj);
+ expect(mockExecFile).toHaveBeenCalledWith('bw', ['send', 'get', 's1'], hasSession('token'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// createSend
+// ---------------------------------------------------------------------------
+describe('createSend', () => {
+ it('encodes payload and creates send via bw encode + bw send create', async () => {
+ const encoded = Buffer.from(JSON.stringify({ name: 'Test Send' })).toString('base64');
+ spawnMockChild(encoded);
+ spawnMockChild(
+ '{"id":"s1","name":"Test Send","type":0,"accessId":"abc","notes":null,"deletionDate":"","creationDate":"","revisionDate":"","disabled":false,"hideEmail":false,"password":null,"maxAccessCount":null,"accessCount":0,"text":null,"file":null,"expirationDate":null}',
+ );
+
+ const payload = {
+ name: 'Test Send',
+ notes: null,
+ type: 0 as const,
+ text: null,
+ file: null,
+ password: null,
+ maxAccessCount: null,
+ deletionDate: null,
+ expirationDate: null,
+ disabled: false,
+ hideEmail: false,
+ };
+
+ const result = await bw.createSend(payload, 'token');
+ expect(result.id).toBe('s1');
+ expect(result.name).toBe('Test Send');
+
+ expectEncodeAndExec(mockSpawn, 'token', 'send', ['create']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// editSend
+// ---------------------------------------------------------------------------
+describe('editSend', () => {
+ it('encodes payload and edits send via bw encode + bw send edit', async () => {
+ const encoded = Buffer.from(JSON.stringify({ name: 'Updated' })).toString('base64');
+ spawnMockChild(encoded);
+ spawnMockChild('');
+
+ await bw.editSend('send-123', { name: 'Updated' }, 'token');
+
+ expectEncodeAndExec(mockSpawn, 'token', 'send', ['edit', 'send-123']);
+ });
+
+ it('throws BwError on failure', async () => {
+ spawnMockChild('Edit failed', 1);
+
+ await expect(bw.editSend('send-123', { name: 'X' }, 'token')).rejects.toThrow('Edit failed');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// deleteSend
+// ---------------------------------------------------------------------------
+describe('deleteSend', () => {
+ it('calls bw send delete with BW_SESSION', async () => {
+ mockExec(mockExecFile, '');
+
+ await bw.deleteSend('send-1', 'token');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['send', 'delete', 'send-1'],
+ hasSession('token'),
+ );
+ });
+
+ it('throws BwError on delete failure', async () => {
+ mockExecError(mockExecFile, 'Send not found');
+
+ await expect(bw.deleteSend('missing', 'token')).rejects.toThrow('Send not found');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// receiveSend
+// ---------------------------------------------------------------------------
+describe('receiveSend', () => {
+ it('calls bw send receive and returns text for text sends', async () => {
+ mockExec(mockExecFile, 'This is the send content\n');
+
+ const result = await bw.receiveSend('https://vault.bitwarden.com/#/send/abc');
+ expect(result.kind).toBe('text');
+ expect(result.text).toBe('This is the send content');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['send', 'receive', 'https://vault.bitwarden.com/#/send/abc'],
+ expect.objectContaining({ env: expect.any(Object) }),
+ );
+ });
+
+ it('returns file result when output directory is provided', async () => {
+ mockExec(mockExecFile, '/home/user/Downloads/file.pdf\n');
+
+ const result = await bw.receiveSend(
+ 'https://vault.bitwarden.com/#/send/abc',
+ undefined,
+ '/tmp/Downloads',
+ );
+ expect(result.kind).toBe('file');
+ expect(result.path).toBe('/home/user/Downloads/file.pdf');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'bw',
+ ['send', 'receive', 'https://vault.bitwarden.com/#/send/abc', '--output', '/tmp/Downloads'],
+ expect.objectContaining({ env: expect.any(Object) }),
+ );
+ });
+
+ it('throws BwError on failure', async () => {
+ mockExecError(mockExecFile, 'Send not found');
+
+ await expect(bw.receiveSend('https://example.com/bad')).rejects.toThrow('Send not found');
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/bw-not-installed.test.tsx b/extensions/bitwarden/src/__tests__/bw-not-installed.test.tsx
new file mode 100644
index 00000000..c803220b
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/bw-not-installed.test.tsx
@@ -0,0 +1,24 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('@vicinae/api', () => ({
+ Action: {
+ OpenInBrowser: vi.fn(({ title, url }: { title: string; url: string }) => null),
+ SubmitForm: vi.fn(() => null),
+ Style: { Destructive: 'destructive' },
+ },
+ ActionPanel: vi.fn(({ children }: { children: React.ReactNode }) => children),
+ Detail: vi.fn(({ markdown }: { markdown: string }) => markdown),
+ Icon: {},
+}));
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { BwNotInstalled } from '../bw-not-installed';
+
+describe('BwNotInstalled', () => {
+ it('renders install guide markdown', () => {
+ const { container } = render(React.createElement(BwNotInstalled));
+ expect(container.textContent).toContain('Bitwarden CLI Not Found');
+ expect(container.textContent).toContain('bitwarden.com/download');
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/create-item.test.tsx b/extensions/bitwarden/src/__tests__/create-item.test.tsx
new file mode 100644
index 00000000..a9024bf8
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/create-item.test.tsx
@@ -0,0 +1,271 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+
+// ---------------------------------------------------------------------------
+// All mock factory values must be hoisted for vi.mock
+// ---------------------------------------------------------------------------
+const {
+ mockBw,
+ mockUseSession,
+ mockPopToRoot,
+ mockShowToast,
+ MockForm,
+ MockActionSubmitForm,
+ MockActionPanel,
+ getFormSubmitHandler,
+} = vi.hoisted(() => {
+ const mockBw = {
+ checkInstalled: vi.fn().mockResolvedValue(true),
+ status: vi.fn().mockResolvedValue({ status: 'unlocked' }),
+ listFolders: vi.fn().mockResolvedValue([]),
+ createItem: vi.fn().mockResolvedValue({ id: 'new-id', name: 'Test' }),
+ login: vi.fn(),
+ unlock: vi.fn(),
+ sync: vi.fn(),
+ lock: vi.fn(),
+ listItems: vi.fn(),
+ getItem: vi.fn(),
+ getTotp: vi.fn(),
+ deleteItem: vi.fn(),
+ getErrorMessage: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+ };
+
+ const mockUseSession = {
+ session: 'test-session' as string | null,
+ unlock: vi.fn(),
+ clearSession: vi.fn(),
+ loginIfNeeded: vi.fn(),
+ isLoggingIn: false,
+ loginError: null as string | null,
+ };
+
+ const mockPopToRoot = vi.fn();
+ const mockShowToast = vi.fn();
+
+ let _handler: ((values: Record) => void) | null = null;
+
+ // fallow-ignore-next-line code-duplication
+ const el = (type: string, testId?: string) => {
+ return (props: { children?: React.ReactNode; [key: string]: unknown }) => {
+ const { children, ...rest } = props;
+ return React.createElement(type, { 'data-testid': testId ?? props.id, ...rest }, children);
+ };
+ };
+
+ // fallow-ignore-next-line code-duplication
+ const DropdownItem = el('option');
+ // fallow-ignore-next-line code-duplication
+ const Dropdown = Object.assign(el('select'), { Item: DropdownItem });
+
+ const FormInner = el('div');
+ const MockForm = Object.assign(
+ function FormWrapper(props: {
+ children: React.ReactNode;
+ actions?: React.ReactNode;
+ isLoading?: boolean;
+ }) {
+ return React.createElement(
+ 'form',
+ { 'data-testid': 'form' },
+ React.createElement(FormInner, null, props.children),
+ props.actions,
+ );
+ },
+ {
+ Dropdown,
+ TextField: el('input'),
+ PasswordField: el('input'),
+ TextArea: el('textarea'),
+ Description: el('span'),
+ Separator: () => React.createElement('hr', { 'data-testid': 'separator' }),
+ FilePicker: el('input'),
+ },
+ );
+
+ const MockActionSubmitForm = vi.fn(
+ ({
+ title,
+ onSubmit,
+ }: {
+ title: string;
+ onSubmit: (values: Record) => void;
+ }) => {
+ _handler = onSubmit;
+ return React.createElement('button', { type: 'submit', 'data-testid': 'submit-btn' }, title);
+ },
+ );
+
+ const MockActionPanel = vi.fn(({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'action-panel' }, children),
+ );
+
+ return {
+ mockBw,
+ mockUseSession,
+ mockPopToRoot,
+ mockShowToast,
+ MockForm,
+ MockActionSubmitForm,
+ MockActionPanel,
+ getFormSubmitHandler: () => _handler,
+ };
+});
+
+vi.mock('../bw-executor', () => mockBw);
+
+vi.mock('../secret-store', () => ({
+ checkSecretToolInstalled: vi.fn().mockResolvedValue(true),
+}));
+
+vi.mock('../use-session', () => ({
+ useSession: () => mockUseSession,
+}));
+
+vi.mock('../item-utils', () => ({
+ toCreatePayload: vi.fn((values: Record, type: number) => ({
+ type,
+ name: values.name ?? '',
+ notes: null,
+ folderId: null,
+ favorite: false,
+ })),
+ CARD_BRANDS: ['Visa', 'Mastercard', 'Amex', 'Discover', 'Other'],
+ readFormValues: vi.fn((values: Record) => {
+ const result: Record = {};
+ for (const [key, val] of Object.entries(values)) {
+ result[key] = String(val ?? '');
+ }
+ return result;
+ }),
+ uploadAttachments: vi.fn().mockResolvedValue(undefined),
+ showFailureToast: async (_err: unknown, title: string) =>
+ mockShowToast({ style: 'failure', title }),
+}));
+
+vi.mock('../bw-not-installed', () => ({
+ BwNotInstalled: () => React.createElement('div', { 'data-testid': 'bw-not-installed' }),
+ SecretToolNotInstalled: () =>
+ React.createElement('div', { 'data-testid': 'secret-tool-not-installed' }),
+}));
+
+vi.mock('@vicinae/api', () => ({
+ Action: Object.assign(
+ ({ title, onAction }: { title: string; onAction?: () => void }) =>
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': `action-${title?.replace(/\s+/g, '-').toLowerCase()}`,
+ onClick: onAction,
+ },
+ title,
+ ),
+ {
+ SubmitForm: MockActionSubmitForm,
+ OpenInBrowser: vi.fn(() => null),
+ Style: { Destructive: 'destructive' },
+ },
+ ),
+ ActionPanel: MockActionPanel,
+ Clipboard: { copy: vi.fn().mockResolvedValue(undefined) },
+ Form: MockForm,
+ Icon: { Key: 'icon-key' },
+ popToRoot: (...args: unknown[]) => mockPopToRoot(...args),
+ showToast: (...args: unknown[]) => mockShowToast(...args),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+ LocalStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
+ Image: {},
+}));
+
+// ---------------------------------------------------------------------------
+import CreateItem from '../create-item';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockBw.checkInstalled.mockResolvedValue(true);
+ mockBw.status.mockResolvedValue({ status: 'unlocked' });
+ mockBw.listFolders.mockResolvedValue([]);
+ mockBw.createItem.mockResolvedValue({ id: 'new-id', name: 'Test' });
+ mockUseSession.session = 'test-session';
+ mockUseSession.isLoggingIn = false;
+ mockUseSession.loginError = null;
+});
+
+// ---------------------------------------------------------------------------
+describe('CreateItem', () => {
+ it('renders the form with fields when session is available', async () => {
+ render(React.createElement(CreateItem));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('form')).toBeTruthy();
+ expect(screen.getByTestId('name')).toBeTruthy();
+ expect(screen.getByTestId('itemType')).toBeTruthy();
+ });
+ });
+
+ it('shows unlock form when session is null', async () => {
+ mockUseSession.session = null;
+ mockBw.status.mockResolvedValue({ status: 'locked' });
+
+ render(React.createElement(CreateItem));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('password')).toBeTruthy();
+ });
+ });
+
+ it('shows bw-not-installed when CLI not found', async () => {
+ mockBw.checkInstalled.mockResolvedValue(false);
+
+ render(React.createElement(CreateItem));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('bw-not-installed')).toBeTruthy();
+ });
+ });
+
+ it('calls createItem and popToRoot on successful submit', async () => {
+ render(React.createElement(CreateItem));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('submit-btn')).toBeTruthy();
+ });
+
+ getFormSubmitHandler()?.({ name: 'Test Item' });
+
+ await waitFor(() => {
+ expect(mockBw.createItem).toHaveBeenCalledOnce();
+ expect(mockPopToRoot).toHaveBeenCalledOnce();
+ });
+ });
+
+ it('shows failure toast when createItem fails', async () => {
+ mockBw.createItem.mockRejectedValueOnce(new Error('Validation error'));
+
+ render(React.createElement(CreateItem));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('submit-btn')).toBeTruthy();
+ });
+
+ getFormSubmitHandler()?.({ name: 'Bad Item' });
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'failure', title: 'Failed to create item' }),
+ );
+ });
+ });
+
+ it('shows login-specific fields: username, password, url, totp', async () => {
+ render(React.createElement(CreateItem));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('username')).toBeTruthy();
+ expect(screen.getByTestId('password')).toBeTruthy();
+ expect(screen.getByTestId('url')).toBeTruthy();
+ expect(screen.getByTestId('totp')).toBeTruthy();
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/custom-fields-section.test.tsx b/extensions/bitwarden/src/__tests__/custom-fields-section.test.tsx
new file mode 100644
index 00000000..b9dbad5a
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/custom-fields-section.test.tsx
@@ -0,0 +1,142 @@
+import { describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import CustomFieldsSection from '../custom-fields-section';
+import type { CustomField } from '../custom-fields-section';
+
+// fallow-ignore-next-line code-duplication
+const MockForm = vi.hoisted(() => {
+ const el = (type: string, testId?: string) => {
+ return (props: {
+ children?: React.ReactNode;
+ id?: string;
+ title?: string;
+ value?: string;
+ defaultValue?: string;
+ onChange?: (value: unknown) => void;
+ label?: string;
+ }) => {
+ const { children, ...rest } = props;
+ return React.createElement(type, { 'data-testid': testId ?? props.id, ...rest }, children);
+ };
+ };
+
+ // fallow-ignore-next-line code-duplication
+ const DropdownItem = el('option');
+ // fallow-ignore-next-line code-duplication
+ const Dropdown = Object.assign(el('select'), { Item: DropdownItem });
+
+ return Object.assign(el('div'), {
+ TextField: el('input'),
+ PasswordField: el('input'),
+ Checkbox: el('input'),
+ TextArea: el('textarea'),
+ Dropdown,
+ Description: ({ text }: { text: string }) =>
+ React.createElement('span', { 'data-testid': 'description' }, text),
+ Separator: () => React.createElement('hr', { 'data-testid': 'separator' }),
+ });
+});
+
+vi.mock('@vicinae/api', () => ({
+ Form: MockForm,
+}));
+
+function makeFields(overrides: Partial[] = []): CustomField[] {
+ return overrides.map((f, i) => ({
+ id: i,
+ name: '',
+ value: '',
+ type: 0,
+ ...f,
+ }));
+}
+
+describe('CustomFieldsSection', () => {
+ it('renders Notes textarea', () => {
+ render(
+ React.createElement(CustomFieldsSection, {
+ customFields: [],
+ setCustomFields: vi.fn(),
+ notes: 'some notes',
+ }),
+ );
+
+ expect(screen.getByTestId('notes')).toBeTruthy();
+ });
+
+ it('hides custom field headers when no fields exist', () => {
+ render(
+ React.createElement(CustomFieldsSection, {
+ customFields: [],
+ setCustomFields: vi.fn(),
+ }),
+ );
+
+ expect(screen.queryByTestId('separator')).toBeNull();
+ expect(screen.queryByTestId('description')).toBeNull();
+ });
+
+ it('renders separator and description when custom fields exist', () => {
+ render(
+ React.createElement(CustomFieldsSection, {
+ customFields: makeFields([{ name: 'API Key', value: 'abc', type: 0 }]),
+ setCustomFields: vi.fn(),
+ }),
+ );
+
+ expect(screen.getByTestId('separator')).toBeTruthy();
+ expect(screen.getByTestId('description').textContent).toBe('Custom Fields');
+ });
+
+ it('renders field name, type dropdown, and value for each custom field', () => {
+ render(
+ React.createElement(CustomFieldsSection, {
+ customFields: makeFields([
+ { name: 'API Key', value: 'abc123', type: 0 },
+ { name: 'PIN', value: '••••', type: 1 },
+ ]),
+ setCustomFields: vi.fn(),
+ }),
+ );
+
+ expect(screen.getByTestId('cf_name_0')).toBeTruthy();
+ expect(screen.getByTestId('cf_type_0')).toBeTruthy();
+ expect(screen.getByTestId('cf_value_0')).toBeTruthy();
+ expect(screen.getByTestId('cf_name_1')).toBeTruthy();
+ expect(screen.getByTestId('cf_type_1')).toBeTruthy();
+ expect(screen.getByTestId('cf_value_1')).toBeTruthy();
+ });
+
+ it('renders appropriate input per field type', () => {
+ render(
+ React.createElement(CustomFieldsSection, {
+ customFields: makeFields([
+ { name: 'API Key', value: 'abc', type: 0 },
+ { name: 'Secret', value: 'xyz', type: 1 },
+ { name: 'Flag', value: 'true', type: 2 },
+ ]),
+ setCustomFields: vi.fn(),
+ }),
+ );
+
+ expect(screen.getByTestId('cf_value_0').tagName).toBe('INPUT');
+ expect(screen.getByTestId('cf_value_1')).toBeTruthy();
+ expect(screen.getByTestId('cf_value_2')).toBeTruthy();
+ });
+
+ it('calls setCustomFields on field type change', () => {
+ const setCustomFields = vi.fn();
+
+ render(
+ React.createElement(CustomFieldsSection, {
+ customFields: makeFields([{ name: 'Flag', value: 'hello', type: 0 }]),
+ setCustomFields,
+ }),
+ );
+
+ fireEvent.change(screen.getByTestId('cf_type_0'), { target: { value: '2' } });
+
+ expect(setCustomFields).toHaveBeenCalled();
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/edit-item.test.tsx b/extensions/bitwarden/src/__tests__/edit-item.test.tsx
new file mode 100644
index 00000000..aa70b509
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/edit-item.test.tsx
@@ -0,0 +1,193 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+
+const { mockBw, mockItem, mockOnSaved } = vi.hoisted(() => {
+ const mockBw = {
+ getItem: vi.fn(),
+ listFolders: vi.fn().mockResolvedValue([]),
+ editItem: vi.fn().mockResolvedValue(undefined),
+ deleteItem: vi.fn(),
+ sync: vi.fn(),
+ unlock: vi.fn(),
+ login: vi.fn(),
+ listItems: vi.fn(),
+ lock: vi.fn(),
+ getTotp: vi.fn(),
+ };
+
+ const mockItem = {
+ id: 'item-1',
+ type: 1,
+ name: 'Test Login',
+ notes: 'my notes',
+ folderId: null,
+ login: {
+ username: 'alice',
+ password: 'secret',
+ totp: null,
+ uris: [{ uri: 'https://example.com', match: null }],
+ },
+ };
+
+ const mockOnSaved = vi.fn();
+
+ return { mockBw, mockItem, mockOnSaved };
+});
+
+vi.mock('@vicinae/api', () => ({
+ Action: Object.assign(
+ ({ title }: { title: string }) =>
+ React.createElement(
+ 'button',
+ { 'data-testid': `action-${title.replace(/\s+/g, '-').toLowerCase()}` },
+ title,
+ ),
+ {
+ SubmitForm: ({ title }: { title: string }) =>
+ React.createElement('button', { type: 'submit', 'data-testid': 'submit-btn' }, title),
+ OpenInBrowser: () => null,
+ Style: { Destructive: 'destructive' },
+ },
+ ),
+ ActionPanel: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'action-panel' }, children),
+ Alert: { ActionStyle: { Destructive: 'destructive' } },
+ confirmAlert: vi.fn(),
+ Form: Object.assign(
+ ({ children }: { children: React.ReactNode }) =>
+ React.createElement('form', { 'data-testid': 'form' }, children),
+ {
+ TextField: ({
+ id,
+ title,
+ defaultValue,
+ }: {
+ id: string;
+ title: string;
+ defaultValue?: string;
+ }) => React.createElement('input', { 'data-testid': id, placeholder: title, defaultValue }),
+ PasswordField: ({
+ id,
+ title,
+ defaultValue,
+ }: {
+ id: string;
+ title: string;
+ defaultValue?: string;
+ }) =>
+ React.createElement('input', {
+ type: 'password',
+ 'data-testid': id,
+ placeholder: title,
+ defaultValue,
+ }),
+ TextArea: ({
+ id,
+ title,
+ defaultValue,
+ }: {
+ id: string;
+ title: string;
+ defaultValue?: string;
+ }) =>
+ React.createElement('textarea', { 'data-testid': id, placeholder: title, defaultValue }),
+ Dropdown: Object.assign(
+ ({ id, title }: { id: string; title: string }) =>
+ React.createElement('select', { 'data-testid': id, title }),
+ {
+ Item: ({ value, title }: { value: string; title: string }) =>
+ React.createElement('option', { value, children: title }),
+ },
+ ),
+ Description: ({ text }: { text: string }) =>
+ React.createElement('span', { 'data-testid': 'description' }, text),
+ Separator: () => React.createElement('hr', { 'data-testid': 'separator' }),
+ FilePicker: ({ id }: { id: string }) =>
+ React.createElement('input', { 'data-testid': id ?? 'file-picker', type: 'file' }),
+ },
+ ),
+ Icon: {
+ Eye: 'icon-eye',
+ Pencil: 'icon-pencil',
+ Plus: 'icon-plus',
+ CheckCircle: 'icon-check',
+ Trash: 'icon-trash',
+ },
+ popToRoot: vi.fn(),
+ showToast: vi.fn(),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+}));
+
+vi.mock('../bw-executor', () => mockBw);
+
+vi.mock('../item-utils', () => ({
+ toCreatePayload: vi.fn((values: Record, type: number) => ({
+ type,
+ name: values.name ?? '',
+ notes: values.notes ?? null,
+ folderId: null,
+ favorite: false,
+ })),
+ itemTypeLabel: vi.fn(() => 'Login'),
+ CARD_BRANDS: ['Visa', 'Mastercard', 'Amex', 'Discover', 'Other'],
+ uploadAttachments: vi.fn().mockResolvedValue(undefined),
+}));
+
+import EditItem from '../edit-item';
+
+describe('EditItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockBw.getItem.mockResolvedValue(mockItem);
+ mockBw.listFolders.mockResolvedValue([]);
+ });
+
+ it('renders loading state initially', () => {
+ mockBw.getItem.mockReturnValue(new Promise(() => {})); // never resolves
+ render(
+ React.createElement(EditItem, {
+ item: mockItem as never,
+ session: 'token',
+ onSaved: mockOnSaved,
+ }),
+ );
+ expect(screen.getByTestId('description')).toBeTruthy();
+ });
+
+ it('renders the form with pre-populated fields after load', async () => {
+ render(
+ React.createElement(EditItem, {
+ item: mockItem as never,
+ session: 'token',
+ onSaved: mockOnSaved,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('name')).toBeTruthy();
+ });
+
+ const nameInput = screen.getByTestId('name') as HTMLInputElement;
+ expect(nameInput.defaultValue).toBe('Test Login');
+
+ const usernameInput = screen.getByTestId('username') as HTMLInputElement;
+ expect(usernameInput.defaultValue).toBe('alice');
+ });
+
+ it('renders the type label', async () => {
+ render(
+ React.createElement(EditItem, {
+ item: mockItem as never,
+ session: 'token',
+ onSaved: mockOnSaved,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('description')).toBeTruthy();
+ });
+
+ expect(screen.getByTestId('description').textContent).toContain('Login');
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/generate-password.test.ts b/extensions/bitwarden/src/__tests__/generate-password.test.ts
new file mode 100644
index 00000000..03ca2f23
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/generate-password.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const { mockBw, mockPrefs } = vi.hoisted(() => {
+ const mockBw = {
+ generatePassword: vi.fn(),
+ getErrorMessage: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+ };
+
+ const mockPrefs = {
+ serverRegion: 'bitwarden.com' as const,
+ customServerUrl: '',
+ customCertPath: '',
+ bitwardenApiClientId: 'x',
+ bitwardenApiClientSecret: 'x',
+ autoLockTimeout: '21600',
+ downloadDir: '',
+ passwordLength: '20',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ passwordNumbers: true,
+ passwordSymbols: true,
+ };
+
+ return { mockBw, mockPrefs };
+});
+
+// fallow-ignore-next-line code-duplication
+const mockClipboardCopy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const mockShowToast = vi.hoisted(() => vi.fn());
+
+vi.mock('../bw-executor', () => mockBw);
+
+vi.mock('../preferences', () => ({
+ getPreferences: () => mockPrefs,
+ getPasswordPrefs: (prefs: typeof mockPrefs) => ({
+ length: Number(prefs.passwordLength) || 20,
+ uppercase: prefs.passwordUppercase,
+ lowercase: prefs.passwordLowercase,
+ numbers: prefs.passwordNumbers,
+ symbols: prefs.passwordSymbols,
+ }),
+}));
+
+vi.mock('@vicinae/api', async () => {
+ const { createVicinaeApiMock } = await vi.importActual<
+ typeof import('./__utils__/vicinae-mocks')
+ >('./__utils__/vicinae-mocks');
+ return createVicinaeApiMock(mockClipboardCopy, mockShowToast);
+});
+
+import GeneratePassword from '../generate-password';
+
+describe('GeneratePassword', () => {
+ it('generates a password and copies to clipboard', async () => {
+ mockBw.generatePassword.mockResolvedValue('aB3$xY9!pQ2&wE5!rT');
+
+ await GeneratePassword();
+
+ expect(mockClipboardCopy).toHaveBeenCalledWith('aB3$xY9!pQ2&wE5!rT');
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'success', title: 'Password generated' }),
+ );
+ });
+
+ it('shows failure toast on generation error', async () => {
+ mockBw.generatePassword.mockRejectedValue(new Error('CLI error'));
+
+ await GeneratePassword();
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'failure', title: 'Generation failed' }),
+ );
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/item-detail-view.test.tsx b/extensions/bitwarden/src/__tests__/item-detail-view.test.tsx
new file mode 100644
index 00000000..2ff10d7d
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/item-detail-view.test.tsx
@@ -0,0 +1,275 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import ItemDetailView, { renderItemActionElements } from '../item-detail-view';
+import type { BwItem } from '../bitwarden-types';
+import type { ItemAction } from '../bw-executor';
+import { makeItem } from './__utils__/test-data';
+
+const { mockBw, mockPop } = vi.hoisted(() => {
+ const mockBw = {
+ getItem: vi.fn(),
+ getTotp: vi.fn(),
+ downloadAttachment: vi.fn(),
+ getErrorMessage: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+ };
+
+ const mockPop = vi.fn();
+
+ return { mockBw, mockPop };
+});
+
+const mockClipboardCopy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const mockShowToast = vi.hoisted(() => vi.fn());
+const mockPush = vi.hoisted(() => vi.fn());
+
+vi.mock('../bw-executor', () => ({
+ ...mockBw,
+ getErrorMessage: mockBw.getErrorMessage,
+}));
+
+vi.mock('../item-utils', () => ({
+ buildItemDetailMarkdown: (item: BwItem) => (item.notes ? item.notes : ''),
+ formatTotp: (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`,
+ itemActions: (item: BwItem): ItemAction[] => {
+ const actions: ItemAction[] = [];
+ if (item.login?.username) actions.push({ label: 'Copy Username', value: item.login.username });
+ if (item.login?.password) actions.push({ label: 'Copy Password', value: item.login.password });
+ if (item.login?.totp)
+ actions.push({ label: 'Copy Verification Code', value: '', fetchKind: 'totp' });
+ return actions;
+ },
+ itemTypeLabel: () => 'Login',
+ actionIcon: () => undefined,
+}));
+
+vi.mock('./edit-item', () => ({
+ default: () => React.createElement('div', { 'data-testid': 'edit-item' }),
+}));
+
+vi.mock('@vicinae/api', () => ({
+ Action: Object.assign(
+ ({ title, icon, onAction }: { title: string; icon?: string; onAction?: () => void }) =>
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': `action-${title.replace(/\s+/g, '-').toLowerCase()}`,
+ onClick: onAction,
+ },
+ title,
+ ),
+ {
+ CopyToClipboard: ({ title, content }: { title: string; content: string }) =>
+ React.createElement(
+ 'button',
+ { 'data-testid': `copy-${title.replace(/\s+/g, '-').toLowerCase()}`, title: content },
+ title,
+ ),
+ OpenInBrowser: ({ title, url }: { title: string; url: string }) =>
+ React.createElement('a', { 'data-testid': 'open-url', href: url }, title),
+ SubmitForm: () => null,
+ Style: { Destructive: 'destructive' },
+ },
+ ),
+ ActionPanel: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'action-panel' }, children),
+ Clipboard: { copy: (...args: unknown[]) => mockClipboardCopy(...args) },
+ Detail: Object.assign(
+ ({
+ markdown,
+ actions,
+ metadata,
+ }: {
+ markdown: string;
+ actions: React.ReactNode;
+ metadata: React.ReactNode;
+ }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'detail-view' },
+ React.createElement('div', { 'data-testid': 'markdown' }, markdown),
+ React.createElement('div', { 'data-testid': 'metadata-wrapper' }, metadata),
+ actions,
+ ),
+ {
+ Metadata: Object.assign(
+ ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'metadata' }, children),
+ {
+ Label: ({ title, text }: { title: string; text: string }) =>
+ React.createElement(
+ 'span',
+ { 'data-testid': `metadata-${title.replace(/\s+/g, '-').toLowerCase()}` },
+ `${title}: ${text}`,
+ ),
+ Separator: () => React.createElement('hr', { 'data-testid': 'metadata-separator' }),
+ },
+ ),
+ },
+ ),
+ Icon: {
+ ArrowLeft: 'arrow-left',
+ CopyClipboard: 'copy',
+ Eye: 'eye',
+ Globe01: 'globe',
+ Pencil: 'pencil',
+ SaveDocument: 'save',
+ },
+ showToast: (...args: unknown[]) => mockShowToast(...args),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+ useNavigation: () => ({ pop: mockPop, push: mockPush }),
+}));
+
+function loginItem(overrides: Partial = {}): BwItem {
+ return makeItem({
+ login: { username: 'user', password: 'pass', totp: 'JBSWY3DPEHPK3PXP' },
+ ...overrides,
+ });
+}
+
+function renderDetail(session: string | null = 'token', notes?: string) {
+ const item = loginItem(notes ? { notes } : {});
+ mockBw.getItem.mockResolvedValue(item);
+ render(
+ React.createElement(ItemDetailView, {
+ item: loginItem(),
+ session,
+ onCopyTotp: vi.fn(),
+ }),
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockPop.mockReset();
+ mockPush.mockReset();
+});
+
+// ---------------------------------------------------------------------------
+// renderItemActionElements
+// ---------------------------------------------------------------------------
+describe('renderItemActionElements', () => {
+ const simpleItems: ItemAction[] = [
+ { label: 'Copy Username', value: 'alice' },
+ { label: 'Copy Password', value: 'secret' },
+ ];
+
+ it('renders CopyToClipboard actions for simple values', () => {
+ const elements = renderItemActionElements(simpleItems, vi.fn(), 'item-1', null);
+ expect(elements).toHaveLength(2);
+ });
+
+ it('renders TOTP action that calls onCopyTotp', () => {
+ const onCopyTotp = vi.fn();
+ const totpItem: ItemAction = { label: 'Copy Verification Code', value: '', fetchKind: 'totp' };
+ const elements = renderItemActionElements([totpItem], onCopyTotp, 'item-1', 'session');
+ expect(elements).toHaveLength(1);
+ });
+
+ it('renders OpenInBrowser action', () => {
+ const urlItem: ItemAction = { label: 'Open URL', value: 'https://example.com' };
+ const elements = renderItemActionElements([urlItem], vi.fn(), 'item-1', null);
+ expect(elements).toHaveLength(1);
+ });
+
+ it('renders fetch-based actions that resolve with getItem', () => {
+ const fetchItem: ItemAction = { label: 'Copy Card Number', value: '', fetchKind: 'cardNumber' };
+ const elements = renderItemActionElements([fetchItem], vi.fn(), 'item-1', 'token');
+ expect(elements).toHaveLength(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// ItemDetailView component
+// ---------------------------------------------------------------------------
+describe('ItemDetailView', () => {
+ it('shows loading state with only Back action', async () => {
+ mockBw.getItem.mockReturnValue(new Promise(() => {}));
+
+ render(
+ React.createElement(ItemDetailView, {
+ item: loginItem(),
+ session: 'token',
+ onCopyTotp: vi.fn(),
+ }),
+ );
+
+ expect(screen.getByTestId('markdown').textContent).toBe('Loading...');
+ expect(screen.getByTestId('action-back')).toBeTruthy();
+ expect(screen.queryByTestId('action-edit-item')).toBeNull();
+ });
+
+ it('shows content immediately when session is null', async () => {
+ const item = loginItem({ notes: 'some note' });
+
+ render(
+ React.createElement(ItemDetailView, {
+ item,
+ session: null,
+ onCopyTotp: vi.fn(),
+ }),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('markdown').textContent).toBe('some note');
+ });
+ });
+
+ it('fetches item and shows content after loading', async () => {
+ renderDetail('token', 'My notes');
+
+ await waitFor(() => {
+ expect(mockBw.getItem).toHaveBeenCalledWith('item-1', 'token');
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('markdown').textContent).toBe('My notes');
+ });
+ });
+
+ it('falls back to partial item when getItem fails', async () => {
+ mockBw.getItem.mockRejectedValue(new Error('not found'));
+ render(
+ React.createElement(ItemDetailView, {
+ item: loginItem(),
+ session: 'token',
+ onCopyTotp: vi.fn(),
+ }),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('detail-view')).toBeTruthy();
+ });
+ expect(screen.getByTestId('markdown').textContent).not.toBe('Loading...');
+ });
+
+ it('shows full action panel after loading', async () => {
+ renderDetail();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('action-edit-item')).toBeTruthy();
+ });
+ });
+
+ it('fetches TOTP codes when item has totp', async () => {
+ mockBw.getTotp.mockResolvedValue('123456');
+ renderDetail();
+
+ await waitFor(() => {
+ expect(mockBw.getTotp).toHaveBeenCalledWith('item-1', 'token');
+ });
+ });
+
+ it('navigates to edit view', async () => {
+ renderDetail();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('action-edit-item')).toBeTruthy();
+ });
+
+ screen.getByTestId('action-edit-item').click();
+
+ expect(mockPush).toHaveBeenCalled();
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/item-utils.test.ts b/extensions/bitwarden/src/__tests__/item-utils.test.ts
new file mode 100644
index 00000000..0abc5e75
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/item-utils.test.ts
@@ -0,0 +1,956 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+const { mockGetItem, mockSetItem, mockRemoveItem } = vi.hoisted(() => ({
+ mockGetItem: vi.fn().mockResolvedValue(undefined),
+ mockSetItem: vi.fn().mockResolvedValue(undefined),
+ mockRemoveItem: vi.fn().mockResolvedValue(undefined),
+}));
+
+const { mockExistsSync, mockStatSync } = vi.hoisted(() => ({
+ mockExistsSync: vi.fn().mockReturnValue(false),
+ mockStatSync: vi.fn().mockReturnValue({ mtimeMs: 0 }),
+}));
+
+vi.mock('node:fs', () => {
+ const TEST_PNG = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 32,
+ ]);
+ const fsMock: Record = {
+ existsSync: (path: string) => mockExistsSync(path),
+ mkdirSync: vi.fn(),
+ readFileSync: () => TEST_PNG,
+ statSync: (path: string) => mockStatSync(path),
+ unlinkSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ };
+ fsMock.default = fsMock;
+ return fsMock;
+});
+
+vi.mock('node:path', () => {
+ const pathMock = {
+ join: (...args: string[]) => args.join('/'),
+ };
+ return { default: pathMock, ...pathMock };
+});
+
+vi.mock('node:crypto', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createHash: () => ({
+ update: () => ({ digest: () => 'not-the-globe-md5' }),
+ }),
+ };
+});
+
+vi.mock('@vicinae/api', () => ({
+ LocalStorage: {
+ getItem: (key: string) => mockGetItem(key),
+ setItem: (key: string, value: string) => mockSetItem(key, value),
+ removeItem: (key: string) => mockRemoveItem(key),
+ },
+ Image: { Mask: { Circle: 'circle', RoundedRectangle: 'roundedRectangle' } },
+ environment: {
+ supportPath: '/mock/support',
+ assetsPath: '/mock/assets',
+ raycastVersion: '1.0.0',
+ ownerOrAuthorName: 'test',
+ extensionName: 'test',
+ commandName: 'test',
+ commandMode: 'view' as const,
+ isDevelopment: true,
+ appearance: 'light' as const,
+ theme: 'light' as const,
+ textSize: 'medium' as const,
+ launchType: 'userInitiated' as const,
+ canAccess: () => false,
+ vicinaeVersion: { tag: '0.0.0', commit: 'abc' },
+ isRaycast: false,
+ },
+}));
+
+import { BwFolder, BwItem, ItemType } from '../bitwarden-types';
+import { CreateItemPayload, ItemAction } from '../bw-executor';
+import {
+ buildItemDetailMarkdown,
+ clearCachedVault,
+ filterItems,
+ itemActions,
+ groupByFolder,
+ itemIcon,
+ itemSubtitle,
+ itemTypeLabel,
+ loadCachedVault,
+ saveCachedVault,
+ toCreatePayload,
+} from '../item-utils';
+import { clearFaviconCache, loadFaviconCache, resolveFavicons } from '../favicons';
+
+// Expected data URI for the 24-byte test PNG used by createFetchMock and readFileSync mock
+const TEST_DATA_URI = 'data:image/png;base64,iVBORwAAAAAAAAAAAAAAAAAAACAAAAAg';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetItem.mockResolvedValue(undefined);
+});
+
+function makeItem(overrides: Partial = {}): BwItem {
+ return {
+ id: 'item-1',
+ organizationId: null,
+ folderId: null,
+ type: ItemType.Login,
+ name: 'Test Item',
+ notes: null,
+ favorite: false,
+ revisionDate: '2024-01-01T00:00:00Z',
+ creationDate: '2024-01-01T00:00:00Z',
+ deletedDate: null,
+ collectionIds: null,
+ ...overrides,
+ };
+}
+
+const folders = [
+ { id: 'f1', name: 'Work' },
+ { id: 'f2', name: 'Personal' },
+];
+
+// ---------------------------------------------------------------------------
+// filterItems
+// ---------------------------------------------------------------------------
+describe('filterItems', () => {
+ const items = [
+ makeItem({ id: '1', name: 'GitHub' }),
+ makeItem({ id: '2', name: 'gitlab' }),
+ makeItem({ id: '3', name: 'Bank Account' }),
+ makeItem({ id: '4', name: 'Email' }),
+ ];
+
+ it('returns all items when query is empty', () => {
+ expect(filterItems(items, '')).toHaveLength(4);
+ });
+
+ it('returns all items when query is whitespace only', () => {
+ expect(filterItems(items, ' ')).toHaveLength(4);
+ });
+
+ it('matches case-insensitive substring', () => {
+ const result = filterItems(items, 'git');
+ expect(result).toHaveLength(2);
+ expect(result.map((i) => i.id)).toEqual(['1', '2']);
+ });
+
+ it('returns empty array when nothing matches', () => {
+ expect(filterItems(items, 'notfound')).toHaveLength(0);
+ });
+
+ it('matches by full name', () => {
+ const result = filterItems(items, 'Bank Account');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('3');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// groupByFolder
+// ---------------------------------------------------------------------------
+describe('groupByFolder', () => {
+ it('groups items by folderId', () => {
+ const items = [
+ makeItem({ id: '1', folderId: 'f1', name: 'A' }),
+ makeItem({ id: '2', folderId: 'f1', name: 'B' }),
+ makeItem({ id: '3', folderId: 'f2', name: 'C' }),
+ ];
+
+ const grouped = groupByFolder(items, folders);
+ expect(grouped.size).toBe(2);
+ expect(grouped.get('f1')!.items).toHaveLength(2);
+ expect(grouped.get('f2')!.items).toHaveLength(1);
+ });
+
+ it('places items with null folderId under "Unfiled"', () => {
+ const items = [makeItem({ id: '1', folderId: null, name: 'A' })];
+
+ const grouped = groupByFolder(items, folders);
+ expect(grouped.size).toBe(1);
+ expect(grouped.get(null)!.folderName).toBe('Unfiled');
+ });
+
+ it('uses folder name from folder list', () => {
+ const items = [makeItem({ id: '1', folderId: 'f1', name: 'A' })];
+
+ const grouped = groupByFolder(items, folders);
+ expect(grouped.get('f1')!.folderName).toBe('Work');
+ });
+
+ it('falls back to "Unknown" for missing folder IDs', () => {
+ const items = [makeItem({ id: '1', folderId: 'unknown', name: 'A' })];
+
+ const grouped = groupByFolder(items, folders);
+ expect(grouped.get('unknown')!.folderName).toBe('Unknown');
+ });
+
+ it('returns empty map for empty item list', () => {
+ expect(groupByFolder([], folders).size).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// itemSubtitle
+// ---------------------------------------------------------------------------
+describe('itemSubtitle', () => {
+ it('returns username for Login items', () => {
+ const item = makeItem({
+ type: ItemType.Login,
+ login: { username: 'alice', password: 'secret', totp: null },
+ });
+ expect(itemSubtitle(item)).toBe('alice');
+ });
+
+ it('returns cardholder name for Card items', () => {
+ const item = makeItem({
+ type: ItemType.Card,
+ card: {
+ cardholderName: 'John Doe',
+ brand: null,
+ number: null,
+ expMonth: null,
+ expYear: null,
+ code: null,
+ },
+ });
+ expect(itemSubtitle(item)).toBe('John Doe');
+ });
+
+ it('returns brand + last4 for Card items without cardholder', () => {
+ const item = makeItem({
+ type: ItemType.Card,
+ card: {
+ cardholderName: null,
+ brand: 'Visa',
+ number: '4111111111111111',
+ expMonth: null,
+ expYear: null,
+ code: null,
+ },
+ });
+ expect(itemSubtitle(item)).toBe('Visa *1111');
+ });
+
+ it('returns full name for Identity items', () => {
+ const item = makeItem({
+ type: ItemType.Identity,
+ identity: {
+ firstName: 'Jane',
+ lastName: 'Smith',
+ title: null,
+ middleName: null,
+ email: null,
+ phone: null,
+ address1: null,
+ address2: null,
+ address3: null,
+ city: null,
+ state: null,
+ postalCode: null,
+ country: null,
+ company: null,
+ ssn: null,
+ username: null,
+ passportNumber: null,
+ licenseNumber: null,
+ },
+ });
+ expect(itemSubtitle(item)).toBe('Jane Smith');
+ });
+
+ it('returns undefined for Secure Note items', () => {
+ const item = makeItem({ type: ItemType.SecureNote, secureNote: { type: 0 } });
+ expect(itemSubtitle(item)).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// itemTypeLabel
+// ---------------------------------------------------------------------------
+describe('itemTypeLabel', () => {
+ it('returns Login for type 1', () => {
+ expect(itemTypeLabel(makeItem({ type: ItemType.Login }))).toBe('Login');
+ });
+ it('returns Card for type 3', () => {
+ expect(itemTypeLabel(makeItem({ type: ItemType.Card }))).toBe('Card');
+ });
+ it('returns Identity for type 4', () => {
+ expect(itemTypeLabel(makeItem({ type: ItemType.Identity }))).toBe('Identity');
+ });
+ it('returns Secure Note for type 2', () => {
+ expect(itemTypeLabel(makeItem({ type: ItemType.SecureNote }))).toBe('Secure Note');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// itemActions
+// ---------------------------------------------------------------------------
+describe('itemActions', () => {
+ it('returns username, password, TOTP, and URL actions for Login items', () => {
+ const item = makeItem({
+ type: ItemType.Login,
+ login: {
+ username: 'bob',
+ password: 'pass123',
+ totp: 'JBSWY3DPEHPK3PXP',
+ uris: [{ uri: 'https://example.com', match: null }],
+ },
+ });
+ const actions = itemActions(item);
+ const labels = actions.map((a) => a.label);
+ expect(labels).toContain('Copy Username');
+ expect(labels).toContain('Copy Password');
+ expect(labels).toContain('Copy Verification Code');
+ expect(labels).toContain('Open URL');
+ });
+
+ it('omits missing fields for Login items', () => {
+ const item = makeItem({
+ type: ItemType.Login,
+ login: { username: null, password: 'pass', totp: null },
+ });
+ const actions = itemActions(item);
+ const labels = actions.map((a) => a.label);
+ expect(labels).toContain('Copy Password');
+ expect(labels).not.toContain('Copy Username');
+ expect(labels).not.toContain('Copy Verification Code');
+ });
+
+ it('returns card number and code actions for Card items', () => {
+ const item = makeItem({
+ type: ItemType.Card,
+ card: {
+ cardholderName: null,
+ brand: null,
+ number: '4111111111111111',
+ expMonth: null,
+ expYear: null,
+ code: '123',
+ },
+ });
+ const actions = itemActions(item);
+ const labels = actions.map((a) => a.label);
+ expect(labels).toContain('Copy Card Number');
+ expect(labels).toContain('Copy Security Code');
+ });
+
+ it('returns name, email, phone actions for Identity items', () => {
+ const item = makeItem({
+ type: ItemType.Identity,
+ identity: {
+ firstName: 'Jane',
+ lastName: 'Doe',
+ email: 'jane@test.com',
+ phone: '555-1234',
+ title: null,
+ middleName: null,
+ address1: null,
+ address2: null,
+ address3: null,
+ city: null,
+ state: null,
+ postalCode: null,
+ country: null,
+ company: null,
+ ssn: null,
+ username: null,
+ passportNumber: null,
+ licenseNumber: null,
+ },
+ });
+ const actions = itemActions(item);
+ const labels = actions.map((a) => a.label);
+ expect(labels).toContain('Copy Name');
+ expect(labels).toContain('Copy Email');
+ expect(labels).toContain('Copy Phone');
+ });
+
+ it('returns empty actions for Secure Note items', () => {
+ const item = makeItem({ type: ItemType.SecureNote, secureNote: { type: 0 } });
+ expect(itemActions(item)).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// toCreatePayload
+// ---------------------------------------------------------------------------
+describe('toCreatePayload', () => {
+ it('serializes Login form values', () => {
+ const values = {
+ name: 'My Login',
+ username: 'alice',
+ password: 'secret',
+ url: 'https://example.com',
+ totp: 'JBSWY3DPEHPK3PXP',
+ notes: 'some note',
+ };
+ const payload = toCreatePayload(values, ItemType.Login);
+
+ expect(payload.type).toBe(ItemType.Login);
+ expect(payload.name).toBe('My Login');
+ expect(payload.notes).toBe('some note');
+ expect(payload.login).toBeDefined();
+ expect(payload.login!.username).toBe('alice');
+ expect(payload.login!.password).toBe('secret');
+ expect(payload.login!.totp).toBe('JBSWY3DPEHPK3PXP');
+ expect(payload.login!.uris).toEqual([{ uri: 'https://example.com', match: null }]);
+ expect(payload.folderId).toBeNull();
+ expect(payload.favorite).toBe(false);
+ });
+
+ it('serializes Login without URL when URL is empty', () => {
+ const payload = toCreatePayload(
+ { name: 'Login', username: 'a', password: 'b' },
+ ItemType.Login,
+ );
+ expect(payload.login!.uris).toBeUndefined();
+ });
+
+ it('serializes Card form values', () => {
+ const values = {
+ name: 'My Card',
+ cardholderName: 'John Doe',
+ brand: 'Visa',
+ number: '4111111111111111',
+ expMonth: '12',
+ expYear: '2025',
+ code: '123',
+ };
+ const payload = toCreatePayload(values, ItemType.Card);
+
+ expect(payload.type).toBe(ItemType.Card);
+ expect(payload.card).toBeDefined();
+ expect(payload.card!.cardholderName).toBe('John Doe');
+ expect(payload.card!.brand).toBe('Visa');
+ expect(payload.card!.number).toBe('4111111111111111');
+ });
+
+ it('serializes Identity form values', () => {
+ const values = {
+ name: 'My Identity',
+ title: 'Mr',
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john@test.com',
+ phone: '555-1234',
+ };
+ const payload = toCreatePayload(values, ItemType.Identity);
+
+ expect(payload.type).toBe(ItemType.Identity);
+ expect(payload.identity).toBeDefined();
+ expect(payload.identity!.firstName).toBe('John');
+ expect(payload.identity!.lastName).toBe('Doe');
+ expect(payload.identity!.email).toBe('john@test.com');
+ });
+
+ it('serializes Secure Note form values', () => {
+ const values = { name: 'My Note', notes: 'secret text' };
+ const payload = toCreatePayload(values, ItemType.SecureNote);
+
+ expect(payload.type).toBe(ItemType.SecureNote);
+ expect(payload.secureNote).toEqual({ type: 0 });
+ });
+
+ it('trims whitespace from string values', () => {
+ const values = {
+ name: ' My Login ',
+ username: ' alice ',
+ password: 'secret',
+ };
+ const payload = toCreatePayload(values, ItemType.Login);
+ expect(payload.name).toBe(' My Login ');
+ expect(payload.login!.username).toBe('alice');
+ });
+
+ it('converts empty strings to null for optional fields', () => {
+ const payload = toCreatePayload({ name: 'Item', notes: ' ' }, ItemType.Login);
+ expect(payload.notes).toBeNull();
+ });
+
+ it('includes custom fields when provided', () => {
+ const payload = toCreatePayload({ name: 'Item' }, ItemType.Login, null, [
+ { name: 'API Key', value: 'abc123', type: 0 },
+ { name: 'PIN', value: '9999', type: 0 },
+ ]);
+ expect(payload.fields).toEqual([
+ { name: 'API Key', value: 'abc123', type: 0 },
+ { name: 'PIN', value: '9999', type: 0 },
+ ]);
+ });
+
+ it('omits fields when empty array provided', () => {
+ const payload = toCreatePayload({ name: 'Item' }, ItemType.Login, null, []);
+ expect(payload.fields).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildItemDetailMarkdown
+// ---------------------------------------------------------------------------
+describe('buildItemDetailMarkdown', () => {
+ it('returns empty string when no notes', () => {
+ const md = buildItemDetailMarkdown(makeItem({ name: 'My Item' }));
+ expect(md).toBe('');
+ });
+
+ it('does not render custom fields in markdown (rendered in metadata sidebar)', () => {
+ const item = makeItem({
+ name: 'My Item',
+ fields: [
+ { name: 'API Key', value: 'abc123', type: 0, linkedId: null },
+ { name: 'Secret', value: 'xyz', type: 1, linkedId: null },
+ ],
+ });
+ const md = buildItemDetailMarkdown(item);
+ expect(md).not.toContain('API Key');
+ expect(md).not.toContain('Secret');
+ expect(md).toBe('');
+ });
+
+ it('shows notes when present', () => {
+ const item = makeItem({ notes: 'Some note text' });
+ const md = buildItemDetailMarkdown(item);
+ expect(md).toContain('Some note text');
+ });
+
+ it('shows Secure Note content as notes', () => {
+ const item = makeItem({
+ type: ItemType.SecureNote,
+ notes: 'My secret note',
+ secureNote: { type: 0 },
+ });
+ const md = buildItemDetailMarkdown(item);
+ expect(md).toContain('My secret note');
+ });
+
+ it('shows password when showPassword is true', () => {
+ // Password moved to metadata sidebar — markdown no longer contains it
+ const md = buildItemDetailMarkdown(makeItem({ name: 'My Item' }));
+ expect(md).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// itemIcon
+// ---------------------------------------------------------------------------
+
+function expectSvgBase64Icon(icon: { source: { light: string; dark: string } }) {
+ expect(icon.source.light).toMatch(/^data:image\/svg\+xml;base64,/);
+ expect(icon.source.dark).toMatch(/^data:image\/svg\+xml;base64,/);
+}
+
+describe('itemIcon', () => {
+ it('returns favicon Image object when real URL cached in map', () => {
+ const item = makeItem({
+ type: ItemType.Login,
+ login: {
+ username: null,
+ password: null,
+ totp: null,
+ uris: [{ uri: 'https://github.com/login', match: null }],
+ },
+ });
+ const icon = itemIcon(item, { 'github.com': 'https://github.com/favicon.ico' }) as {
+ source: string;
+ fallback: { light: string; dark: string };
+ };
+ expect(icon.source).toBe('https://github.com/favicon.ico');
+ expect(icon.fallback.light).toMatch(/^data:image\/svg\+xml;base64,/);
+ expect(icon.fallback.dark).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it('returns themed SVG placeholder for Login items without URL', () => {
+ const item = makeItem({
+ type: ItemType.Login,
+ login: { username: null, password: null, totp: null },
+ });
+ const icon = itemIcon(item) as { source: { light: string; dark: string } };
+ expectSvgBase64Icon(icon);
+ });
+
+ it('returns themed SVG placeholder for Card items', () => {
+ const icon = itemIcon(makeItem({ type: ItemType.Card })) as {
+ source: { light: string; dark: string };
+ };
+ expectSvgBase64Icon(icon);
+ });
+
+ it('returns themed SVG placeholder for Identity items', () => {
+ const icon = itemIcon(makeItem({ type: ItemType.Identity })) as {
+ source: { light: string; dark: string };
+ };
+ expectSvgBase64Icon(icon);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// loadCachedVault
+// ---------------------------------------------------------------------------
+describe('loadCachedVault', () => {
+ it('returns null when no cached data exists', async () => {
+ mockGetItem.mockResolvedValue(undefined);
+
+ const result = await loadCachedVault();
+ expect(result).toBeNull();
+ });
+
+ it('returns cached vault when fresh', async () => {
+ const cache = {
+ items: [{ id: '1', name: 'A', type: 1 }],
+ folders: [{ id: 'f1', name: 'Work' }],
+ timestamp: Date.now(),
+ };
+ mockGetItem.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await loadCachedVault();
+ expect(result).toEqual({ items: cache.items, folders: cache.folders });
+ });
+
+ it('returns null when cache is older than 24 hours', async () => {
+ const staleTimestamp = Date.now() - 25 * 60 * 60 * 1000;
+ const cache = { items: [], folders: [], timestamp: staleTimestamp };
+ mockGetItem.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await loadCachedVault();
+ expect(result).toBeNull();
+ });
+
+ it('returns null on JSON parse error', async () => {
+ mockGetItem.mockResolvedValue('not json {{{');
+
+ const result = await loadCachedVault();
+ expect(result).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// saveCachedVault
+// ---------------------------------------------------------------------------
+describe('saveCachedVault', () => {
+ it('stores items, folders, and timestamp to LocalStorage', async () => {
+ const items = [{ id: '1', name: 'A', type: 1 } as BwItem];
+ const folders = [{ id: 'f1', name: 'Work' }];
+
+ await saveCachedVault(items, folders);
+
+ expect(mockSetItem).toHaveBeenCalledTimes(1);
+ const [key, raw] = mockSetItem.mock.calls[0] as [string, string];
+ expect(key).toBe('vicinae-bitwarden-cache');
+ const parsed = JSON.parse(raw);
+ expect(parsed.folders).toEqual(folders);
+ expect(parsed.timestamp).toBeGreaterThan(0);
+ expect(parsed.items).toHaveLength(1);
+ expect(parsed.items[0]).toMatchObject({ id: '1', name: 'A', type: 1 });
+ });
+
+ it('strips sensitive fields from items before caching', async () => {
+ const items: BwItem[] = [
+ {
+ id: '1',
+ organizationId: null,
+ folderId: null,
+ type: 1,
+ name: 'GitHub',
+ notes: 'private note',
+ favorite: false,
+ revisionDate: '2024-01-01',
+ creationDate: '2024-01-01',
+ deletedDate: null,
+ collectionIds: null,
+ login: {
+ username: 'user',
+ password: 'secret123',
+ totp: 'JBSWY3DPEHPK3PXP',
+ uris: [{ uri: 'https://github.com', match: null }],
+ passwordRevisionDate: null,
+ },
+ fields: [{ name: 'API Key', value: 'sk-abc123', type: 0, linkedId: null }],
+ },
+ ];
+ const folders: BwFolder[] = [];
+
+ await saveCachedVault(items, folders);
+
+ const [, raw] = mockSetItem.mock.calls[0] as [string, string];
+ const parsed = JSON.parse(raw);
+ const cached = parsed.items[0];
+
+ // Kept
+ expect(cached.id).toBe('1');
+ expect(cached.name).toBe('GitHub');
+ expect(cached.type).toBe(1);
+ expect(cached.favorite).toBe(false);
+ expect(cached.login.username).toBe('user');
+ expect(cached.login.uris).toEqual([{ uri: 'https://github.com', match: null }]);
+
+ // Stripped (empty string sentinel means "exists but hidden")
+ expect(cached.notes).toBeNull();
+ expect(cached.login.password).toBe('');
+ expect(cached.login.totp).toBe('');
+ expect(cached.fields).toEqual([]);
+ });
+
+ it('strips card and identity fields', async () => {
+ const items: BwItem[] = [
+ {
+ id: '2',
+ organizationId: null,
+ folderId: null,
+ type: 3,
+ name: 'Visa Card',
+ notes: null,
+ favorite: false,
+ revisionDate: '',
+ creationDate: '',
+ deletedDate: null,
+ collectionIds: null,
+ card: {
+ cardholderName: 'John Doe',
+ brand: 'Visa',
+ number: '4111111111111111',
+ expMonth: '12',
+ expYear: '2025',
+ code: '123',
+ },
+ },
+ {
+ id: '3',
+ organizationId: null,
+ folderId: null,
+ type: 4,
+ name: 'John Doe',
+ notes: null,
+ favorite: false,
+ revisionDate: '',
+ creationDate: '',
+ deletedDate: null,
+ collectionIds: null,
+ identity: {
+ title: 'Mr',
+ firstName: 'John',
+ middleName: 'M',
+ lastName: 'Doe',
+ address1: '123 Main St',
+ address2: null,
+ address3: null,
+ city: 'Springfield',
+ state: 'IL',
+ postalCode: '62701',
+ country: 'US',
+ company: 'Acme',
+ email: 'john@example.com',
+ phone: '555-0100',
+ ssn: '123-45-6789',
+ username: 'jdoe',
+ passportNumber: 'AB123456',
+ licenseNumber: 'D1234567',
+ },
+ },
+ ];
+ const folders: BwFolder[] = [];
+
+ await saveCachedVault(items, folders);
+
+ const [, raw] = mockSetItem.mock.calls[0] as [string, string];
+ const parsed = JSON.parse(raw);
+
+ // Card: keep brand and holder, strip sensitive (empty string = exists but hidden)
+ const card = parsed.items[0].card;
+ expect(card.cardholderName).toBe('John Doe');
+ expect(card.brand).toBe('Visa');
+ expect(card.number).toBe('');
+ expect(card.code).toBe('');
+ expect(card.expMonth).toBeNull();
+ expect(card.expYear).toBeNull();
+
+ // Identity: keep names, strip everything else
+ const identity = parsed.items[1].identity;
+ expect(identity.firstName).toBe('John');
+ expect(identity.lastName).toBe('Doe');
+ expect(identity.middleName).toBeNull();
+ expect(identity.email).toBeNull();
+ expect(identity.phone).toBeNull();
+ expect(identity.ssn).toBeNull();
+ expect(identity.address1).toBeNull();
+ expect(identity.city).toBeNull();
+ expect(identity.passportNumber).toBeNull();
+ expect(identity.licenseNumber).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// clearCachedVault
+// ---------------------------------------------------------------------------
+describe('clearCachedVault', () => {
+ it('removes the vault cache key but leaves favicons in place', async () => {
+ await clearCachedVault();
+
+ expect(mockRemoveItem).toHaveBeenCalledWith('vicinae-bitwarden-cache');
+ expect(mockRemoveItem).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// loadFaviconCache
+// ---------------------------------------------------------------------------
+describe('loadFaviconCache', () => {
+ it('returns empty object when no favicon cache exists', async () => {
+ mockGetItem.mockResolvedValue(undefined);
+
+ const result = await loadFaviconCache();
+ expect(result).toEqual({});
+ });
+
+ it('returns parsed favicon map from LocalStorage', async () => {
+ const entries = {
+ 'github.com': { dataUri: 'https://favicon.url/github.com', timestamp: 1000 },
+ 'example.com': { dataUri: 'https://favicon.url/example.com', timestamp: 1000 },
+ };
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ const result = await loadFaviconCache();
+ expect(result).toEqual({
+ 'github.com': 'https://favicon.url/github.com',
+ 'example.com': 'https://favicon.url/example.com',
+ });
+ });
+
+ it('returns empty object on parse error', async () => {
+ mockGetItem.mockResolvedValue('bad json');
+
+ const result = await loadFaviconCache();
+ expect(result).toEqual({});
+ });
+});
+
+// ---------------------------------------------------------------------------
+// clearFaviconCache
+// ---------------------------------------------------------------------------
+describe('clearFaviconCache', () => {
+ it('clears the in-memory favicon cache', async () => {
+ const { fetchMock, r1 } = await resolveAndExpectFetched();
+
+ clearFaviconCache();
+
+ const r2 = await resolveFavicons(['github.com']);
+ expect(r2['github.com']).toBe(TEST_DATA_URI);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ vi.unstubAllGlobals();
+ });
+});
+
+function createFetchMock() {
+ return vi.fn().mockResolvedValue({
+ ok: true,
+ headers: { get: () => 'image/png' },
+ arrayBuffer: async () =>
+ new Uint8Array([
+ 0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 32,
+ ]).buffer,
+ status: 200,
+ });
+}
+
+function stubFaviconFetch() {
+ clearFaviconCache();
+ const fetchMock = createFetchMock();
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+async function resolveAndExpectFetched() {
+ const fetchMock = stubFaviconFetch();
+ const r1 = await resolveFavicons(['github.com']);
+ expect(r1['github.com']).toBe(TEST_DATA_URI);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ return { fetchMock, r1 };
+}
+
+// ---------------------------------------------------------------------------
+// resolveFavicons
+// ---------------------------------------------------------------------------
+describe('resolveFavicons', () => {
+ it('downloads and caches favicons as local files', async () => {
+ const { fetchMock, r1 } = await resolveAndExpectFetched();
+
+ // Simulate file now existing on disk
+ mockExistsSync.mockReturnValue(true);
+
+ // Second call uses in-memory cache (verifies file exists)
+ const r2 = await resolveFavicons(['github.com']);
+ expect(r2['github.com']).toBe(r1['github.com']);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ mockExistsSync.mockReturnValue(false);
+ vi.unstubAllGlobals();
+ });
+
+ it('uses file mtime when loading from disk cold', async () => {
+ const fetchMock = stubFaviconFetch();
+
+ // First call: download and cache
+ await resolveFavicons(['github.com']);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // Wipe in-memory cache to simulate restart
+ clearFaviconCache();
+
+ // File exists on disk and is fresh
+ const now = Date.now();
+ mockExistsSync.mockReturnValue(true);
+ mockStatSync.mockReturnValue({ mtimeMs: now });
+
+ const r2 = await resolveFavicons(['github.com']);
+ expect(r2['github.com']).toBe(TEST_DATA_URI);
+ expect(fetchMock).toHaveBeenCalledTimes(1); // No re-fetch
+
+ vi.unstubAllGlobals();
+ mockExistsSync.mockReturnValue(false);
+ });
+
+ it('handles HTTP errors gracefully', async () => {
+ clearFaviconCache();
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
+
+ const result = await resolveFavicons(['bad.com']);
+ expect(result['bad.com']).toBeUndefined();
+
+ vi.unstubAllGlobals();
+ });
+
+ it('deduplicates domains', async () => {
+ const fetchMock = stubFaviconFetch();
+
+ const result = await resolveFavicons(['a.com', 'a.com', 'b.com']);
+ expect(Object.keys(result).sort()).toEqual(['a.com', 'b.com']);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ vi.unstubAllGlobals();
+ });
+
+ it('prunes domains no longer in the request set', async () => {
+ clearFaviconCache();
+ vi.stubGlobal('fetch', createFetchMock());
+
+ await resolveFavicons(['a.com', 'b.com']);
+ await resolveFavicons(['a.com']);
+
+ const persistCalls = mockSetItem.mock.calls.filter(
+ (c) => c[0] === 'vicinae-bitwarden-favicons',
+ );
+ const lastPersisted = JSON.parse(persistCalls[persistCalls.length - 1][1]);
+ expect(lastPersisted).toHaveProperty('a.com');
+ expect(lastPersisted).not.toHaveProperty('b.com');
+
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/logout.test.ts b/extensions/bitwarden/src/__tests__/logout.test.ts
new file mode 100644
index 00000000..6ade6ede
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/logout.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+const { mockBw, mockDeleteSession, mockShowToast, mockClearCachedVault, mockClearCachedSends } =
+ vi.hoisted(() => ({
+ mockBw: {
+ logout: vi.fn().mockResolvedValue(undefined),
+ lock: vi.fn(),
+ sync: vi.fn(),
+ unlock: vi.fn(),
+ login: vi.fn(),
+ getErrorMessage: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+ },
+ mockDeleteSession: vi.fn().mockResolvedValue(undefined),
+ mockShowToast: vi.fn(),
+ mockClearCachedVault: vi.fn().mockResolvedValue(undefined),
+ mockClearCachedSends: vi.fn().mockResolvedValue(undefined),
+ }));
+
+vi.mock('../bw-executor', () => mockBw);
+
+vi.mock('../session-store', () => ({
+ deleteSession: mockDeleteSession,
+}));
+
+vi.mock('@vicinae/api', () => ({
+ showToast: mockShowToast,
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+}));
+
+vi.mock('../vault-cache', () => ({
+ clearCachedVault: mockClearCachedVault,
+ clearCachedSends: mockClearCachedSends,
+}));
+
+import Logout from '../logout';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('Logout', () => {
+ it('calls bw.logout, clears session, clears cached vault and sends, and shows success toast', async () => {
+ await Logout();
+
+ expect(mockBw.logout).toHaveBeenCalledOnce();
+ expect(mockDeleteSession).toHaveBeenCalledOnce();
+ expect(mockClearCachedVault).toHaveBeenCalledOnce();
+ expect(mockClearCachedSends).toHaveBeenCalledOnce();
+ expect(mockShowToast).toHaveBeenCalledWith({
+ style: 'success',
+ title: 'Logged out',
+ message: 'Your Bitwarden session has been cleared',
+ });
+ });
+
+ it('shows failure toast when bw.logout throws', async () => {
+ mockBw.logout.mockRejectedValueOnce(new Error('Network error'));
+
+ await Logout();
+
+ expect(mockShowToast).toHaveBeenCalledWith({
+ style: 'failure',
+ title: 'Logout failed',
+ message: 'Network error',
+ });
+ });
+
+ it('shows failure toast with non-Error rejections', async () => {
+ mockBw.logout.mockRejectedValueOnce('something broke');
+
+ await Logout();
+
+ expect(mockShowToast).toHaveBeenCalledWith({
+ style: 'failure',
+ title: 'Logout failed',
+ message: 'something broke',
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/preferences.test.ts b/extensions/bitwarden/src/__tests__/preferences.test.ts
new file mode 100644
index 00000000..bb37b7aa
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/preferences.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+
+vi.mock('@vicinae/api', () => ({
+ getPreferenceValues: vi.fn(),
+ LocalStorage: {},
+}));
+
+import { getServerUrl, getAutoLockSeconds, getDownloadDir, getPasswordPrefs } from '../preferences';
+
+function prefs(
+ overrides: Partial<{
+ serverRegion: 'bitwarden.com' | 'bitwarden.eu' | 'self-hosted';
+ customServerUrl: string;
+ customCertPath: string;
+ autoLockTimeout: string;
+ downloadDir: string;
+ passwordLength: string;
+ passwordUppercase: boolean;
+ passwordLowercase: boolean;
+ passwordNumbers: boolean;
+ passwordSymbols: boolean;
+ }> = {},
+) {
+ return {
+ serverRegion: 'bitwarden.com' as const,
+ customServerUrl: '',
+ customCertPath: '',
+ bitwardenApiClientId: 'x',
+ bitwardenApiClientSecret: 'x',
+ autoLockTimeout: '21600',
+ downloadDir: '',
+ passwordLength: '20',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ passwordNumbers: true,
+ passwordSymbols: true,
+ ...overrides,
+ };
+}
+
+describe('getServerUrl', () => {
+ it('returns https://bitwarden.com for US cloud region', () => {
+ expect(getServerUrl(prefs({ serverRegion: 'bitwarden.com' }))).toBe('https://bitwarden.com');
+ });
+
+ it('returns https://bitwarden.eu for EU cloud region', () => {
+ expect(getServerUrl(prefs({ serverRegion: 'bitwarden.eu' }))).toBe('https://bitwarden.eu');
+ });
+
+ it('returns custom server URL for self-hosted region', () => {
+ expect(
+ getServerUrl(
+ prefs({ serverRegion: 'self-hosted', customServerUrl: 'https://vault.example.com' }),
+ ),
+ ).toBe('https://vault.example.com');
+ });
+
+ it('strips trailing slashes from self-hosted URL', () => {
+ expect(
+ getServerUrl(
+ prefs({ serverRegion: 'self-hosted', customServerUrl: 'https://vault.example.com///' }),
+ ),
+ ).toBe('https://vault.example.com');
+ });
+
+ it('throws when self-hosted URL is empty', () => {
+ expect(() => getServerUrl(prefs({ serverRegion: 'self-hosted', customServerUrl: '' }))).toThrow(
+ 'Custom Server URL is required',
+ );
+ });
+
+ it('throws when self-hosted URL is whitespace only', () => {
+ expect(() =>
+ getServerUrl(prefs({ serverRegion: 'self-hosted', customServerUrl: ' ' })),
+ ).toThrow('Custom Server URL is required');
+ });
+});
+
+describe('getAutoLockSeconds', () => {
+ it('returns 0 for "Never" (value "0")', () => {
+ expect(getAutoLockSeconds(prefs({ autoLockTimeout: '0' }))).toBe(0);
+ });
+
+ it('returns 900 for 15 minutes', () => {
+ expect(getAutoLockSeconds(prefs({ autoLockTimeout: '900' }))).toBe(900);
+ });
+
+ it('returns 21600 for 6 hours (default)', () => {
+ expect(getAutoLockSeconds(prefs({ autoLockTimeout: '21600' }))).toBe(21600);
+ });
+
+ it('returns 0 for invalid values', () => {
+ expect(getAutoLockSeconds(prefs({ autoLockTimeout: 'invalid' }))).toBe(0);
+ });
+
+ it('returns 0 for negative values', () => {
+ expect(getAutoLockSeconds(prefs({ autoLockTimeout: '-500' }))).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getDownloadDir
+// ---------------------------------------------------------------------------
+describe('getDownloadDir', () => {
+ const originalHome = process.env.HOME;
+
+ afterEach(() => {
+ process.env.HOME = originalHome;
+ });
+
+ it('returns the configured download directory when set', () => {
+ process.env.HOME = '/home/user';
+ expect(getDownloadDir(prefs({ downloadDir: '/custom/downloads' }))).toBe('/custom/downloads');
+ });
+
+ it('strips trailing slashes from configured download directory', () => {
+ process.env.HOME = '/home/user';
+ expect(getDownloadDir(prefs({ downloadDir: '/custom/downloads///' }))).toBe(
+ '/custom/downloads',
+ );
+ });
+
+ it('falls back to HOME/Downloads when downloadDir is empty', () => {
+ process.env.HOME = '/home/user';
+ expect(getDownloadDir(prefs({ downloadDir: '' }))).toBe('/home/user/Downloads');
+ });
+
+ it('falls back to HOME/Downloads when downloadDir is whitespace only', () => {
+ process.env.HOME = '/home/user';
+ expect(getDownloadDir(prefs({ downloadDir: ' ' }))).toBe('/home/user/Downloads');
+ });
+
+ it('falls back to /tmp/Downloads when HOME is unset', () => {
+ delete process.env.HOME;
+ expect(getDownloadDir(prefs({ downloadDir: '' }))).toBe('/tmp/Downloads');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getPasswordPrefs
+// ---------------------------------------------------------------------------
+describe('getPasswordPrefs', () => {
+ it('extracts password preferences from full preferences object', () => {
+ const result = getPasswordPrefs(
+ prefs({
+ passwordLength: '20',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ passwordNumbers: true,
+ passwordSymbols: true,
+ }),
+ );
+ expect(result).toEqual({
+ length: 20,
+ uppercase: true,
+ lowercase: true,
+ numbers: true,
+ symbols: true,
+ });
+ });
+
+ it('clamps length to minimum of 5', () => {
+ const result = getPasswordPrefs(
+ prefs({ passwordLength: '1', passwordUppercase: true, passwordLowercase: true }),
+ );
+ expect(result.length).toBe(5);
+ });
+
+ it('clamps length to maximum of 128', () => {
+ const result = getPasswordPrefs(
+ prefs({ passwordLength: '999', passwordUppercase: true, passwordLowercase: true }),
+ );
+ expect(result.length).toBe(128);
+ });
+
+ it('defaults length to 20 when value is invalid', () => {
+ const result = getPasswordPrefs(
+ prefs({
+ passwordLength: 'not-a-number',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ }),
+ );
+ expect(result.length).toBe(20);
+ });
+
+ it('preserves boolean preferences correctly', () => {
+ const result = getPasswordPrefs(
+ prefs({
+ passwordLength: '16',
+ passwordUppercase: false,
+ passwordLowercase: true,
+ passwordNumbers: false,
+ passwordSymbols: false,
+ }),
+ );
+ expect(result).toEqual({
+ length: 16,
+ uppercase: false,
+ lowercase: true,
+ numbers: false,
+ symbols: false,
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/search-totp.test.tsx b/extensions/bitwarden/src/__tests__/search-totp.test.tsx
new file mode 100644
index 00000000..5c4a4720
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/search-totp.test.tsx
@@ -0,0 +1,242 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import SearchTotp from '../search-totp';
+import type { BwItem } from '../bitwarden-types';
+import { ItemType } from '../bitwarden-types';
+import { makeItem } from './__utils__/test-data';
+
+const mockBw = vi.hoisted(() => ({
+ getTotp: vi.fn(),
+ getErrorMessage: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+}));
+
+// fallow-ignore-next-line code-duplication
+const mockClipboardCopy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const mockShowToast = vi.hoisted(() => vi.fn());
+
+vi.mock('../bw-executor', () => ({
+ ...mockBw,
+ getErrorMessage: mockBw.getErrorMessage,
+}));
+
+vi.mock('../item-utils', () => ({
+ formatTotp: (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`,
+ itemIcon: () => ({ source: 'icon.png' }),
+ itemSubtitle: (item: BwItem) => item.login?.username ?? '',
+ filterItems: (items: BwItem[], query: string) =>
+ query ? items.filter((i) => i.name.toLowerCase().includes(query.toLowerCase())) : items,
+ groupByFolder: (items: BwItem[]) => {
+ const map = new Map();
+ if (items.length > 0) map.set('f1', { folderName: 'Work', items });
+ return map;
+ },
+}));
+
+let mockGateRender: React.ReactElement | null = null;
+let mockEmptyVault = false;
+
+vi.mock('../use-vault-search', () => ({
+ useVaultSearch: (preFilter?: (items: BwItem[]) => BwItem[]) => {
+ const allItems: BwItem[] = [
+ makeItem({
+ id: '1',
+ folderId: 'f1',
+ name: 'GitHub',
+ login: { username: 'gh-user', password: '', totp: 'JBSWY3DPEHPK3PXP' },
+ }),
+ makeItem({
+ id: '2',
+ folderId: 'f1',
+ name: 'Email',
+ login: { username: null, password: '', totp: null },
+ }),
+ makeItem({ id: '3', type: ItemType.SecureNote, name: 'My Note' }),
+ ];
+ const filtered = preFilter ? preFilter(allItems) : allItems;
+
+ const base = {
+ state: { kind: 'vault' as const, items: allItems, folders: [{ id: 'f1', name: 'Work' }] },
+ session: 'token',
+ searchText: '',
+ setSearchText: vi.fn() as (text: string) => void,
+ faviconMap: {} as Record,
+ handleSync: vi.fn(),
+ handleCopyTotp: vi.fn(),
+ gateRender: mockGateRender,
+ isLoading: false,
+ };
+
+ return {
+ ...base,
+ sortedSections: mockEmptyVault
+ ? []
+ : filtered.length > 0
+ ? [['f1', { folderName: 'Work', items: filtered }] as const]
+ : [],
+ };
+ },
+}));
+
+vi.mock('@vicinae/api', () => ({
+ Action: ({ title, onAction }: { title: string; onAction?: () => void }) =>
+ React.createElement(
+ 'button',
+ { 'data-testid': `action-${title.replace(/\s+/g, '-').toLowerCase()}`, onClick: onAction },
+ title,
+ ),
+ ActionPanel: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'action-panel' }, children),
+ Clipboard: { copy: (...args: unknown[]) => mockClipboardCopy(...args) },
+ Icon: { ArrowClockwise: 'sync', CopyClipboard: 'copy' },
+ List: Object.assign(
+ ({
+ children,
+ isLoading,
+ onSearchTextChange,
+ searchBarPlaceholder,
+ }: {
+ children: React.ReactNode;
+ isLoading?: boolean;
+ onSearchTextChange?: (text: string) => void;
+ searchBarPlaceholder?: string;
+ throttle?: boolean;
+ }) =>
+ React.createElement(
+ 'div',
+ {
+ 'data-testid': 'list',
+ 'data-isloading': isLoading,
+ 'data-placeholder': searchBarPlaceholder,
+ },
+ children,
+ ),
+ {
+ Section: ({ children, title }: { children: React.ReactNode; title: string }) =>
+ React.createElement('div', { 'data-testid': `section-${title}` }, children),
+ Item: Object.assign(
+ ({
+ title,
+ subtitle,
+ accessories,
+ icon,
+ actions,
+ }: {
+ title: string;
+ subtitle?: string;
+ accessories?: { text: string }[];
+ icon?: unknown;
+ actions: React.ReactNode;
+ }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': `item-${title}` },
+ React.createElement('span', { 'data-testid': `item-title-${title}` }, title),
+ subtitle
+ ? React.createElement('span', { 'data-testid': `item-subtitle-${title}` }, subtitle)
+ : null,
+ accessories?.map((a, i) =>
+ React.createElement(
+ 'span',
+ { key: i, 'data-testid': `item-accessory-${title}-${i}` },
+ a.text,
+ ),
+ ),
+ actions,
+ ),
+ {
+ Accessory: ({ text }: { text: string }) =>
+ React.createElement('span', { 'data-testid': 'accessory' }, text),
+ },
+ ),
+ EmptyView: ({ title, description }: { title: string; description?: string }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'empty-view' },
+ React.createElement('div', null, title),
+ description ? React.createElement('div', null, description) : null,
+ ),
+ },
+ ),
+ showToast: (...args: unknown[]) => mockShowToast(...args),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockGateRender = null;
+ mockEmptyVault = false;
+ mockBw.getTotp.mockResolvedValue('123456');
+});
+
+afterEach(() => {
+ mockGateRender = null;
+ mockEmptyVault = false;
+});
+
+// ---------------------------------------------------------------------------
+// SearchTotp
+// ---------------------------------------------------------------------------
+describe('SearchTotp', () => {
+ it('renders section and items for totp accounts', async () => {
+ render(React.createElement(SearchTotp));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('section-Work')).toBeTruthy();
+ expect(screen.getByTestId('item-GitHub')).toBeTruthy();
+ });
+
+ // GitHub has totp, should be shown
+ expect(screen.getByTestId('item-subtitle-GitHub').textContent).toBe('gh-user');
+
+ // Email has no totp, should not appear
+ expect(screen.queryByTestId('item-Email')).toBeNull();
+
+ // SecureNote should not appear
+ expect(screen.queryByTestId('item-My Note')).toBeNull();
+ });
+
+ it('shows empty view when no totp items exist', async () => {
+ mockEmptyVault = true;
+
+ render(React.createElement(SearchTotp));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('empty-view')).toBeTruthy();
+ });
+ });
+
+ it('shows Loading... accessory before TOTP codes arrive', async () => {
+ mockBw.getTotp.mockReturnValue(new Promise(() => {})); // never resolves
+
+ render(React.createElement(SearchTotp));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('item-GitHub')).toBeTruthy();
+ });
+
+ // Should show "Loading..." in accessories
+ const accessory = screen.getByTestId('item-accessory-GitHub-0');
+ expect(accessory.textContent).toBe('Loading...');
+ });
+
+ it('displays TOTP code and countdown when codes arrive', async () => {
+ mockBw.getTotp.mockResolvedValue('123456');
+
+ render(React.createElement(SearchTotp));
+
+ await waitFor(() => {
+ const accessory = screen.getByTestId('item-accessory-GitHub-0');
+ expect(accessory.textContent).toBe('123 456');
+ });
+ });
+
+ it('shows Copy TOTP and Sync Vault actions', async () => {
+ render(React.createElement(SearchTotp));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('action-copy-totp')).toBeTruthy();
+ expect(screen.getByTestId('action-sync-vault')).toBeTruthy();
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/search-vault.test.tsx b/extensions/bitwarden/src/__tests__/search-vault.test.tsx
new file mode 100644
index 00000000..c6281759
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/search-vault.test.tsx
@@ -0,0 +1,200 @@
+import { describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { makeFormMock } from './__utils__/vicinae-mocks';
+
+// ---------------------------------------------------------------------------
+// Hoisted mock values
+// ---------------------------------------------------------------------------
+const { mockUseSession } = vi.hoisted(() => {
+ const mockUseSession = {
+ session: null as string | null,
+ unlock: vi.fn(),
+ clearSession: vi.fn(),
+ loginIfNeeded: vi.fn(),
+ isLoggingIn: false,
+ loginError: null as string | null,
+ };
+ return { mockUseSession };
+});
+
+// ---------------------------------------------------------------------------
+// Module mocks (hoisted by vitest)
+// ---------------------------------------------------------------------------
+vi.mock('@vicinae/api', () => ({
+ Action: {
+ SubmitForm: 'Action.SubmitForm',
+ CopyToClipboard: 'Action.CopyToClipboard',
+ OpenInBrowser: 'Action.OpenInBrowser',
+ Style: { Destructive: 'destructive' },
+ },
+ ActionPanel: 'ActionPanel',
+ Clipboard: { copy: vi.fn() },
+ Icon: {
+ ArrowClockwise: 'icon-arrow',
+ Lock: 'icon-lock',
+ Eye: 'icon-eye',
+ CopyClipboard: 'icon-copy',
+ Globe01: 'icon-globe',
+ Key: 'icon-key',
+ Person: 'icon-person',
+ CreditCard: 'icon-cc',
+ Envelope: 'icon-envelope',
+ Phone: 'icon-phone',
+ Trash: 'icon-trash',
+ },
+ List: Object.assign(
+ function List({ children }: { children: React.ReactNode }) {
+ return React.createElement('div', { 'data-testid': 'list' }, children);
+ },
+ {
+ Item(props: { title: string }) {
+ return React.createElement('div', { 'data-testid': 'list-item' }, props.title);
+ },
+ Section(props: { children: React.ReactNode }) {
+ return React.createElement('div', { 'data-testid': 'list-section' }, props.children);
+ },
+ EmptyView(props: { title: string }) {
+ return React.createElement('div', { 'data-testid': 'list-empty' }, props.title);
+ },
+ },
+ ),
+ Form: makeFormMock(),
+ showToast: vi.fn(),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+ useNavigation: () => ({ push: vi.fn() }),
+}));
+
+vi.mock('../bw-executor', () => ({
+ checkInstalled: () => true,
+ status: () => ({ status: 'unlocked' }),
+ unlock: vi.fn(),
+ login: vi.fn(),
+ sync: vi.fn(),
+ listItems: () => [],
+ listFolders: () => [],
+ getItem: vi.fn(),
+ getTotp: vi.fn(),
+ createItem: vi.fn(),
+ deleteItem: vi.fn(),
+ logout: vi.fn(),
+ lock: vi.fn(),
+}));
+
+vi.mock('../vault-cache', () => ({
+ loadCachedVault: () => null,
+ saveCachedVault: vi.fn(),
+ clearCachedVault: vi.fn(),
+}));
+
+vi.mock('../item-utils', () => ({
+ buildItemDetailMarkdown: () => '',
+ filterItems: (items: unknown[]) => items,
+ itemActions: () => [],
+ groupByFolder: () => new Map(),
+ itemIcon: () => 'key',
+ itemSubtitle: () => undefined,
+ itemTypeLabel: () => 'Login',
+}));
+
+vi.mock('../use-session', () => ({
+ useSession: () => mockUseSession,
+}));
+
+vi.mock('../unlock-gate', () => ({
+ checkBwGate: (session: string | null) => (session ? { kind: 'ready' } : { kind: 'needs-unlock' }),
+ createUnlockCallbacks: (_setState: unknown, onUnlockReady: () => void) => ({
+ onUnlockStart: vi.fn(),
+ onUnlockReady,
+ onUnlockError: vi.fn(),
+ onLoginReady: vi.fn(),
+ onLoginError: vi.fn(),
+ }),
+ renderUnlockGate: (kind: string) => {
+ if (kind === 'bw-not-installed')
+ return React.createElement('div', { 'data-testid': 'bw-not-installed' }, 'BW Not Installed');
+ if (kind === 'secret-tool-not-installed')
+ return React.createElement(
+ 'div',
+ { 'data-testid': 'secret-tool-not-installed' },
+ 'Install libsecret',
+ );
+ if (kind === 'needs-unlock' || kind === 'unlocking') {
+ return React.createElement(
+ 'form',
+ { 'data-testid': 'unlock-form' },
+ React.createElement('h2', null, 'Unlock'),
+ );
+ }
+ return null;
+ },
+ renderGate: (state: { kind: string; error?: string }) => {
+ if (state.kind === 'bw-not-installed')
+ return React.createElement('div', { 'data-testid': 'bw-not-installed' }, 'BW Not Installed');
+ if (state.kind === 'secret-tool-not-installed')
+ return React.createElement(
+ 'div',
+ { 'data-testid': 'secret-tool-not-installed' },
+ 'Install libsecret',
+ );
+ if (state.kind === 'login-failed')
+ return React.createElement('div', { 'data-testid': 'login-failed' }, 'Login failed');
+ if (state.kind === 'needs-unlock' || state.kind === 'unlocking') {
+ return React.createElement(
+ 'form',
+ { 'data-testid': 'unlock-form' },
+ React.createElement('h2', null, 'Unlock'),
+ );
+ }
+ return null;
+ },
+ useUnlockGate: () => ({ handleLogin: vi.fn(), handleUnlock: vi.fn() }),
+}));
+
+vi.mock('../use-vault-sync', () => ({
+ useVaultSync: () => ({ syncVault: vi.fn(), handleSync: vi.fn(), isSyncing: false }),
+}));
+
+vi.mock('../favicons', () => ({
+ loadFaviconCache: () => ({}),
+ resolveFavicons: () => ({}),
+ clearFaviconCache: vi.fn(),
+}));
+
+vi.mock('../item-detail-view', () => ({
+ default: () => React.createElement('div', null, 'ItemDetailView'),
+ renderItemActionElements: () => [],
+}));
+
+vi.mock('../bw-not-installed', () => ({
+ BwNotInstalled: () =>
+ React.createElement('div', { 'data-testid': 'bw-not-installed-comp' }, 'Install BW'),
+ SecretToolNotInstalled: () =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'secret-tool-not-installed-comp' },
+ 'Install libsecret',
+ ),
+}));
+
+import SearchVault from '../search-vault';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+describe('SearchVault', () => {
+ it('renders the unlock form when no session and no cache', async () => {
+ render(React.createElement(SearchVault));
+ await waitFor(() => {
+ expect(screen.getByTestId('unlock-form')).toBeTruthy();
+ });
+ });
+
+ it('renders loading state once session resolves', async () => {
+ mockUseSession.session = 'loaded-session';
+ render(React.createElement(SearchVault));
+ await waitFor(() => {
+ expect(screen.getByTestId('list')).toBeTruthy();
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/secret-store.test.ts b/extensions/bitwarden/src/__tests__/secret-store.test.ts
new file mode 100644
index 00000000..1aee5c29
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/secret-store.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { mockExec, mockExecError, mockSpawnSuccess, mockSpawnError } from './__utils__/exec-mocks';
+
+const mockExecFile = vi.hoisted(() => vi.fn());
+const mockSpawn = vi.hoisted(() => vi.fn());
+
+vi.mock('node:child_process', () => ({
+ default: { execFile: mockExecFile, spawn: mockSpawn },
+ execFile: mockExecFile,
+ spawn: mockSpawn,
+}));
+
+vi.mock('node:util', () => ({
+ default: { promisify: (fn: unknown) => fn },
+ promisify: (fn: unknown) => fn,
+}));
+
+let secretStore: typeof import('../secret-store');
+
+beforeEach(async () => {
+ vi.resetAllMocks();
+ vi.resetModules();
+ secretStore = await import('../secret-store');
+});
+
+describe('secretLookup', () => {
+ it('returns trimmed stdout when secret-tool succeeds', async () => {
+ mockExec(mockExecFile, 'my-secret-value\n');
+ const result = await secretStore.secretLookup('test-account');
+ expect(result).toBe('my-secret-value');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['lookup', 'service', 'vicinae-bitwarden', 'account', 'test-account'],
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+
+ it('returns null when stdout is empty', async () => {
+ mockExec(mockExecFile, '\n');
+ const result = await secretStore.secretLookup('test-account');
+ expect(result).toBeNull();
+ });
+
+ it('returns null when secret-tool fails', async () => {
+ mockExecError(mockExecFile, 'Cannot find item');
+ const result = await secretStore.secretLookup('test-account');
+ expect(result).toBeNull();
+ });
+});
+
+describe('secretStore', () => {
+ it('stores data via secret-tool spawn', async () => {
+ mockSpawnSuccess(mockSpawn);
+
+ await secretStore.secretStore('test-account', '{"key":"val"}', 'My Label');
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['store', '--label=My Label', 'service', 'vicinae-bitwarden', 'account', 'test-account'],
+ expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'] }),
+ );
+ });
+
+ it('rejects when spawn exits with non-zero', async () => {
+ mockSpawnError(mockSpawn, 1);
+ await expect(secretStore.secretStore('test', 'data', 'Label')).rejects.toThrow(
+ 'secret-tool exited with code 1',
+ );
+ });
+});
+
+describe('secretClear', () => {
+ it('clears secret from keychain', async () => {
+ mockExec(mockExecFile, '');
+ await secretStore.secretClear('test-account');
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['clear', 'service', 'vicinae-bitwarden', 'account', 'test-account'],
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+
+ it('does not throw when clear fails', async () => {
+ mockExecError(mockExecFile, 'Cannot find item');
+ await expect(secretStore.secretClear('test-account')).resolves.toBeUndefined();
+ });
+});
+
+describe('checkSecretToolInstalled', () => {
+ it('returns true when lookup succeeds', async () => {
+ mockExec(mockExecFile, 'token\n');
+ const result = await secretStore.checkSecretToolInstalled();
+ expect(result).toBe(true);
+ });
+
+ it('returns false when binary not found', async () => {
+ const err = new Error('ENOENT') as Error & { code: string };
+ err.code = 'ENOENT';
+ mockExecFile.mockRejectedValueOnce(err);
+ const result = await secretStore.checkSecretToolInstalled();
+ expect(result).toBe(false);
+ });
+
+ it('returns true when lookup fails for other reasons', async () => {
+ mockExecError(mockExecFile, 'Cannot find item');
+ const result = await secretStore.checkSecretToolInstalled();
+ expect(result).toBe(true);
+ });
+
+ it('caches result after first call', async () => {
+ mockExec(mockExecFile, 'token\n');
+ await secretStore.checkSecretToolInstalled();
+ await secretStore.checkSecretToolInstalled();
+ expect(mockExecFile).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/send-utils.test.ts b/extensions/bitwarden/src/__tests__/send-utils.test.ts
new file mode 100644
index 00000000..b91a29ee
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/send-utils.test.ts
@@ -0,0 +1,208 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ buildDeletionCountdown,
+ daysUntilDeletion,
+ filterSends,
+ sendAccessUrl,
+ getSendActions,
+ sendSubtitle,
+ sendTypeLabel,
+ toSendPayload,
+} from '../send-utils';
+import { SendType } from '../send-types';
+import type { BwSend } from '../send-types';
+
+const mockPrefs = {
+ serverRegion: 'bitwarden.com' as const,
+ customServerUrl: '',
+ customCertPath: '',
+ bitwardenApiClientId: '',
+ bitwardenApiClientSecret: '',
+ autoLockTimeout: '21600',
+ downloadDir: '',
+ passwordLength: '20',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ passwordNumbers: true,
+ passwordSymbols: true,
+};
+
+vi.mock('@vicinae/api', () => ({
+ getPreferenceValues: () => mockPrefs,
+ Icon: {},
+}));
+
+function makeSend(overrides: Partial = {}): BwSend {
+ return {
+ id: 'send-1',
+ accessId: 'abc123',
+ name: 'Test Send',
+ notes: null,
+ type: SendType.Text,
+ password: null,
+ text: { text: 'hello world', hidden: false },
+ file: null,
+ maxAccessCount: null,
+ accessCount: 0,
+ deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ expirationDate: null,
+ creationDate: new Date().toISOString(),
+ revisionDate: new Date().toISOString(),
+ disabled: false,
+ hideEmail: false,
+ ...overrides,
+ };
+}
+
+describe('filterSends', () => {
+ const sends: BwSend[] = [
+ makeSend({ id: '1', name: 'Alpha' }),
+ makeSend({ id: '2', name: 'Beta' }),
+ makeSend({ id: '3', name: 'Alphabet Soup' }),
+ ];
+
+ it('returns all sends when query is empty', () => {
+ expect(filterSends(sends, '')).toHaveLength(3);
+ expect(filterSends(sends, ' ')).toHaveLength(3);
+ });
+
+ it('returns matching sends case-insensitively', () => {
+ expect(filterSends(sends, 'ALPHA')).toHaveLength(2);
+ expect(filterSends(sends, 'beta')).toHaveLength(1);
+ });
+
+ it('returns empty array when nothing matches', () => {
+ expect(filterSends(sends, 'gamma')).toHaveLength(0);
+ });
+});
+
+describe('sendTypeLabel', () => {
+ it('returns "Text" for Text sends', () => {
+ expect(sendTypeLabel(makeSend({ type: SendType.Text }))).toBe('Text');
+ });
+
+ it('returns "File" for File sends', () => {
+ expect(sendTypeLabel(makeSend({ type: SendType.File }))).toBe('File');
+ });
+});
+
+describe('sendSubtitle', () => {
+ it('returns file name for File sends', () => {
+ const send = makeSend({
+ type: SendType.File,
+ text: null,
+ file: { id: 'f1', fileName: 'report.pdf', size: 1024, sizeName: '1 KB' },
+ });
+ expect(sendSubtitle(send)).toBe('File: report.pdf');
+ });
+
+ it('returns text preview for Text sends', () => {
+ const send = makeSend({ type: SendType.Text, text: { text: 'hello world', hidden: false } });
+ expect(sendSubtitle(send)).toBe('hello world');
+ });
+
+ it('returns type label when send has no content', () => {
+ const send = makeSend({ type: SendType.Text, text: null });
+ expect(sendSubtitle(send)).toBe('Text');
+ });
+});
+
+describe('getSendActions', () => {
+ it('always includes Copy Send Link', () => {
+ const actions = getSendActions(makeSend());
+ expect(actions.some((a) => a.label === 'Copy Send Link')).toBe(true);
+ });
+
+ it('includes Copy Text for Text sends with content', () => {
+ const actions = getSendActions(
+ makeSend({ type: SendType.Text, text: { text: 'secret', hidden: false } }),
+ );
+ expect(actions.some((a) => a.label === 'Copy Text')).toBe(true);
+ });
+
+ it('does not include Copy Text for File sends', () => {
+ const send = makeSend({
+ type: SendType.File,
+ text: null,
+ file: { id: 'f1', fileName: 'x.pdf', size: 0, sizeName: '0 B' },
+ });
+ const actions = getSendActions(send);
+ expect(actions.some((a) => a.label === 'Copy Text')).toBe(false);
+ });
+});
+
+describe('sendAccessUrl', () => {
+ it('builds URL from server config', () => {
+ const url = sendAccessUrl(makeSend({ accessId: 'abc123' }));
+ expect(url).toBe('https://bitwarden.com/#/send/abc123');
+ });
+});
+
+describe('daysUntilDeletion', () => {
+ it('returns days until deletion date', () => {
+ const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
+ expect(daysUntilDeletion(makeSend({ deletionDate: future }))).toBe(3);
+ });
+
+ it('returns 0 for past deletion date', () => {
+ const past = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
+ expect(daysUntilDeletion(makeSend({ deletionDate: past }))).toBe(0);
+ });
+
+ it('returns null when no deletion date', () => {
+ const send = makeSend({ deletionDate: '' });
+ expect(daysUntilDeletion(send)).toBeNull();
+ });
+});
+
+describe('buildDeletionCountdown', () => {
+ it('returns formatted days', () => {
+ const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
+ expect(buildDeletionCountdown(makeSend({ deletionDate: future }))).toBe('3d');
+ });
+
+ it('returns "Today" for 0 days', () => {
+ const past = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
+ expect(buildDeletionCountdown(makeSend({ deletionDate: past }))).toBe('Today');
+ });
+});
+
+describe('toSendPayload', () => {
+ it('builds basic Text send payload', () => {
+ const payload = toSendPayload(
+ { name: 'My Send', textContent: 'hello', deletionDays: '3', hideText: 'true' },
+ SendType.Text,
+ );
+ expect(payload.name).toBe('My Send');
+ expect(payload.type).toBe(SendType.Text);
+ expect(payload.text?.text).toBe('hello');
+ expect(payload.text?.hidden).toBe(true);
+ expect(payload.file).toBeNull();
+ });
+
+ it('builds basic File send payload', () => {
+ const payload = toSendPayload(
+ { name: 'My File', fileName: 'doc.pdf', deletionDays: '5' },
+ SendType.File,
+ );
+ expect(payload.type).toBe(SendType.File);
+ expect(payload.file?.fileName).toBe('doc.pdf');
+ expect(payload.text).toBeNull();
+ });
+
+ it('sets deletionDate from deletionHours', () => {
+ const payload = toSendPayload({ name: 'Test', deletionHours: '168' }, SendType.Text);
+ const expectedDate = new Date(Date.now() + 168 * 60 * 60 * 1000).toISOString();
+ expect(payload.deletionDate?.slice(0, 10)).toBe(expectedDate.slice(0, 10));
+ });
+
+ it('handles optional maxAccessCount', () => {
+ const payload = toSendPayload({ name: 'Test', maxAccessCount: '10' }, SendType.Text);
+ expect(payload.maxAccessCount).toBe(10);
+ });
+
+ it('handles password', () => {
+ const payload = toSendPayload({ name: 'Test', password: 'secret' }, SendType.Text);
+ expect(payload.password).toBe('secret');
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/session-store.test.ts b/extensions/bitwarden/src/__tests__/session-store.test.ts
new file mode 100644
index 00000000..e16a9e53
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/session-store.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import {
+ mockExec,
+ mockExecError,
+ mockSpawnSuccess,
+ mockSpawnError,
+ createSpawnChild,
+} from './__utils__/exec-mocks';
+
+const mockExecFile = vi.hoisted(() => vi.fn());
+const mockSpawn = vi.hoisted(() => vi.fn());
+const { mockGetPreferences, mockGetAutoLockSeconds } = vi.hoisted(() => ({
+ mockGetPreferences: vi.fn(),
+ mockGetAutoLockSeconds: vi.fn(),
+}));
+
+// fallow-ignore-next-line code-duplication
+vi.mock('node:child_process', () => ({
+ default: { execFile: mockExecFile, spawn: mockSpawn },
+ execFile: mockExecFile,
+ spawn: mockSpawn,
+}));
+
+// fallow-ignore-next-line code-duplication
+vi.mock('node:util', () => ({
+ default: { promisify: (fn: unknown) => fn },
+ promisify: (fn: unknown) => fn,
+}));
+
+vi.mock('../preferences', () => ({
+ getPreferences: mockGetPreferences,
+ getAutoLockSeconds: mockGetAutoLockSeconds,
+}));
+
+let sessionStore: typeof import('../session-store');
+
+beforeEach(async () => {
+ vi.resetAllMocks();
+ vi.resetModules();
+ mockGetAutoLockSeconds.mockReturnValue(0);
+ mockGetPreferences.mockReturnValue({ autoLockTimeout: '0' });
+ sessionStore = await import('../session-store');
+});
+
+describe('checkSecretToolInstalled', () => {
+ it('returns true when secret-tool lookup succeeds', async () => {
+ mockExec(mockExecFile, 'session-token\n');
+ const result = await sessionStore.checkSecretToolInstalled();
+ expect(result).toBe(true);
+ });
+
+ it('returns false when secret-tool is not found (ENOENT)', async () => {
+ const err = new Error('spawn ENOENT') as Error & { code: string };
+ err.code = 'ENOENT';
+ mockExecFile.mockRejectedValueOnce(err);
+
+ const result = await sessionStore.checkSecretToolInstalled();
+ expect(result).toBe(false);
+ });
+
+ it('returns true when lookup fails for other reasons (key not found)', async () => {
+ mockExecError(mockExecFile, 'secret-tool: Cannot find item');
+
+ const result = await sessionStore.checkSecretToolInstalled();
+ expect(result).toBe(true);
+ });
+
+ it('caches result after first call', async () => {
+ mockExec(mockExecFile, 'token\n');
+
+ await sessionStore.checkSecretToolInstalled();
+ await sessionStore.checkSecretToolInstalled();
+
+ expect(mockExecFile).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('getSession', () => {
+ it('returns null when secret-tool lookup fails', async () => {
+ mockExecError(mockExecFile, 'secret-tool: Cannot find item');
+ const result = await sessionStore.getSession();
+ expect(result).toBeNull();
+ });
+
+ it('returns null when stdout is empty', async () => {
+ mockExec(mockExecFile, '\n');
+ const result = await sessionStore.getSession();
+ expect(result).toBeNull();
+ });
+
+ it('returns token from valid session payload (new format)', async () => {
+ const payload = JSON.stringify({ token: 'session-abc', timestamp: Date.now() });
+ mockExec(mockExecFile, payload + '\n');
+
+ const result = await sessionStore.getSession();
+ expect(result).toBe('session-abc');
+ });
+
+ it('returns null for expired session', async () => {
+ mockGetAutoLockSeconds.mockReturnValue(900);
+ const oldTimestamp = Date.now() - 1000 * 1000;
+ const payload = JSON.stringify({ token: 'expired-token', timestamp: oldTimestamp });
+ mockExec(mockExecFile, payload + '\n');
+
+ mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
+
+ const result = await sessionStore.getSession();
+ expect(result).toBeNull();
+ expect(mockExecFile).toHaveBeenCalledTimes(2);
+ expect(mockExecFile).toHaveBeenNthCalledWith(
+ 2,
+ 'secret-tool',
+ ['clear', 'service', 'vicinae-bitwarden', 'account', 'session'],
+ expect.any(Object),
+ );
+ });
+
+ it('does not expire when autoLockTimeout is 0', async () => {
+ mockGetAutoLockSeconds.mockReturnValue(0);
+ const oldTimestamp = Date.now() - 1000 * 1000;
+ const payload = JSON.stringify({ token: 'still-valid', timestamp: oldTimestamp });
+ mockExec(mockExecFile, payload + '\n');
+
+ const result = await sessionStore.getSession();
+ expect(result).toBe('still-valid');
+ });
+
+ it('returns null for unparseable data (old plain-text format)', async () => {
+ mockExec(mockExecFile, 'legacy-session-token\n');
+
+ const result = await sessionStore.getSession();
+ expect(result).toBeNull();
+ });
+
+ it('passes correct args to secret-tool lookup', async () => {
+ const payload = JSON.stringify({ token: 'tok', timestamp: Date.now() });
+ mockExec(mockExecFile, payload + '\n');
+
+ await sessionStore.getSession();
+
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['lookup', 'service', 'vicinae-bitwarden', 'account', 'session'],
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+});
+
+describe('setSession', () => {
+ it('stores session with current timestamp via secret-tool spawn', async () => {
+ const before = Date.now();
+ mockSpawnSuccess(mockSpawn);
+
+ await sessionStore.setSession('my-session-token');
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['store', '--label=Vicinae Bitwarden', 'service', 'vicinae-bitwarden', 'account', 'session'],
+ expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'] }),
+ );
+
+ const child = mockSpawn.mock.results[0].value;
+ const writtenData = child.stdin.write.mock.calls[0][0];
+ const parsed = JSON.parse(writtenData);
+ expect(parsed.token).toBe('my-session-token');
+ expect(parsed.timestamp).toBeGreaterThanOrEqual(before);
+ expect(parsed.timestamp).toBeLessThanOrEqual(Date.now());
+ });
+
+ it('rejects when spawn process exits with non-zero code', async () => {
+ mockSpawnError(mockSpawn, 1);
+
+ await expect(sessionStore.setSession('token')).rejects.toThrow(
+ 'secret-tool exited with code 1',
+ );
+ });
+
+ it('rejects when spawn emits error', async () => {
+ createSpawnChild(mockSpawn);
+
+ await expect(sessionStore.setSession('token')).rejects.toThrow('spawn failed');
+ });
+});
+
+describe('deleteSession', () => {
+ it('calls secret-tool clear with correct args', async () => {
+ mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
+
+ await sessionStore.deleteSession();
+
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'secret-tool',
+ ['clear', 'service', 'vicinae-bitwarden', 'account', 'session'],
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+
+ it('does not throw when clear fails', async () => {
+ mockExecError(mockExecFile, 'secret-tool: Cannot find item');
+
+ await expect(sessionStore.deleteSession()).resolves.toBeUndefined();
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/setup.ts b/extensions/bitwarden/src/__tests__/setup.ts
new file mode 100644
index 00000000..bb02c60c
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/extensions/bitwarden/src/__tests__/unlock-gate.test.tsx b/extensions/bitwarden/src/__tests__/unlock-gate.test.tsx
new file mode 100644
index 00000000..b13ab2bb
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/unlock-gate.test.tsx
@@ -0,0 +1,170 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { cleanup, render, screen } from '@testing-library/react';
+import { makeFormMock } from './__utils__/vicinae-mocks';
+
+// ---------------------------------------------------------------------------
+// Module mocks
+// ---------------------------------------------------------------------------
+const { MockAction } = vi.hoisted(() => {
+ const Action = Object.assign(
+ function Action({ title, onAction }: { title: string; onAction?: () => void }) {
+ return React.createElement('button', { 'data-testid': 'action', onClick: onAction }, title);
+ },
+ {
+ SubmitForm({ title }: { title: string }) {
+ return React.createElement('button', { type: 'submit' }, title);
+ },
+ },
+ );
+ return { MockAction: Action };
+});
+
+vi.mock('@vicinae/api', () => ({
+ Action: MockAction,
+ ActionPanel: 'ActionPanel',
+ Form: makeFormMock({
+ Description(props: { title?: string; text?: string }) {
+ return React.createElement(
+ 'div',
+ { 'data-testid': 'form-description' },
+ React.createElement('strong', null, props.title),
+ React.createElement('span', null, props.text),
+ );
+ },
+ }),
+ showToast: vi.fn(),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+}));
+
+vi.mock('../bw-not-installed', () => ({
+ BwNotInstalled: () =>
+ React.createElement('div', { 'data-testid': 'bw-not-installed' }, 'Install BW'),
+ SecretToolNotInstalled: () =>
+ React.createElement('div', { 'data-testid': 'secret-tool-not-installed' }, 'Install libsecret'),
+}));
+
+vi.mock('../bw-executor', () => ({
+ getErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
+}));
+
+vi.mock('../session-store', () => ({
+ checkSecretToolInstalled: vi.fn().mockResolvedValue(true),
+}));
+
+import { createUnlockCallbacks, renderUnlockGate } from '../unlock-gate';
+
+afterEach(() => {
+ cleanup();
+});
+
+// ---------------------------------------------------------------------------
+// createUnlockCallbacks
+// ---------------------------------------------------------------------------
+describe('createUnlockCallbacks', () => {
+ it('onLoginError sets state to login-failed', () => {
+ const setState = vi.fn();
+ const onUnlockReady = vi.fn();
+ const { onLoginError } = createUnlockCallbacks(setState, onUnlockReady);
+
+ onLoginError('Invalid API key');
+
+ expect(setState).toHaveBeenCalledWith({ kind: 'login-failed', error: 'Invalid API key' });
+ });
+
+ it('onLoginReady sets state to needs-unlock', () => {
+ const setState = vi.fn();
+ const onUnlockReady = vi.fn();
+ const { onLoginReady } = createUnlockCallbacks(setState, onUnlockReady);
+
+ onLoginReady();
+
+ expect(setState).toHaveBeenCalledWith({ kind: 'needs-unlock' });
+ });
+
+ it('onUnlockStart sets state to unlocking', () => {
+ const setState = vi.fn();
+ const onUnlockReady = vi.fn();
+ const { onUnlockStart } = createUnlockCallbacks(setState, onUnlockReady);
+
+ onUnlockStart();
+
+ expect(setState).toHaveBeenCalledWith({ kind: 'unlocking' });
+ });
+
+ it('onUnlockReady calls the ready callback directly', () => {
+ const setState = vi.fn();
+ const onUnlockReady = vi.fn();
+ const { onUnlockReady: cb } = createUnlockCallbacks(setState, onUnlockReady);
+
+ cb();
+
+ expect(onUnlockReady).toHaveBeenCalledOnce();
+ });
+
+ it('onUnlockError sets state to needs-unlock with error', () => {
+ const setState = vi.fn();
+ const onUnlockReady = vi.fn();
+ const { onUnlockError } = createUnlockCallbacks(setState, onUnlockReady);
+
+ onUnlockError('Invalid master password');
+
+ expect(setState).toHaveBeenCalledWith({
+ kind: 'needs-unlock',
+ error: 'Invalid master password',
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// renderUnlockGate
+// ---------------------------------------------------------------------------
+describe('renderUnlockGate', () => {
+ it('renders BwNotInstalled for bw-not-installed kind', () => {
+ const el = renderUnlockGate('bw-not-installed', undefined, vi.fn());
+ expect(el).toBeTruthy();
+ });
+
+ it('renders SecretToolNotInstalled for secret-tool-not-installed kind', () => {
+ const el = renderUnlockGate('secret-tool-not-installed', undefined, vi.fn());
+ expect(el).toBeTruthy();
+ });
+
+ it('renders unlock form for needs-unlock kind', () => {
+ render(renderUnlockGate('needs-unlock', undefined, vi.fn()));
+ expect(screen.getByTestId('password')).toBeTruthy();
+ });
+
+ it('renders unlock form with loading state for unlocking kind', () => {
+ render(renderUnlockGate('unlocking', undefined, vi.fn()));
+ expect(screen.getByTestId('password')).toBeTruthy();
+ });
+
+ it('renders login-failed form with error text', () => {
+ render(renderUnlockGate('login-failed', 'Invalid API key', vi.fn()));
+ expect(screen.getByText('Login failed')).toBeTruthy();
+ expect(screen.getByText('Invalid API key')).toBeTruthy();
+ });
+
+ it('renders login-failed with default message when error is undefined', () => {
+ render(renderUnlockGate('login-failed', undefined, vi.fn()));
+ expect(screen.getByText('Check your API key in extension preferences')).toBeTruthy();
+ });
+
+ it('renders Retry Login button when onRetryLogin is provided', () => {
+ const onRetry = vi.fn();
+ render(renderUnlockGate('login-failed', 'Auth error', vi.fn(), onRetry));
+ expect(screen.getByTestId('action')).toBeTruthy();
+ expect(screen.getByText('Retry Login')).toBeTruthy();
+ });
+
+ it('does not show Retry Login button when onRetryLogin is not provided', () => {
+ render(renderUnlockGate('login-failed', 'Auth error', vi.fn()));
+ expect(screen.queryByTestId('action')).toBeNull();
+ });
+
+ it('returns null for unknown kinds', () => {
+ const result = renderUnlockGate('vault', undefined, vi.fn());
+ expect(result).toBeNull();
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/use-session.test.ts b/extensions/bitwarden/src/__tests__/use-session.test.ts
new file mode 100644
index 00000000..ecb3bb68
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/use-session.test.ts
@@ -0,0 +1,342 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useSession } from '../use-session';
+
+const { execFileMock, spawnMock } = vi.hoisted(() => ({
+ execFileMock: vi.fn(),
+ spawnMock: vi.fn(),
+}));
+
+vi.mock('node:child_process', () => ({
+ default: { execFile: execFileMock, spawn: spawnMock },
+ execFile: execFileMock,
+ spawn: spawnMock,
+}));
+
+vi.mock('node:util', () => {
+ const identity = (fn: unknown) => fn;
+ return {
+ default: { promisify: identity },
+ promisify: identity,
+ };
+});
+
+vi.mock('node:fs', () => {
+ const mockWrite = vi.fn();
+ return {
+ default: { readFileSync: () => '', writeFileSync: mockWrite },
+ readFileSync: () => '',
+ writeFileSync: mockWrite,
+ };
+});
+
+vi.mock('better-sqlite3', () => {
+ const mockDb = () => ({
+ prepare: () => ({ run: vi.fn() }),
+ close: vi.fn(),
+ });
+ return { default: mockDb };
+});
+
+vi.mock('@vicinae/api', () => ({
+ getPreferenceValues: () => ({
+ serverRegion: 'bitwarden.com' as const,
+ customServerUrl: '',
+ customCertPath: '',
+ bitwardenApiClientId: 'test-client-id',
+ bitwardenApiClientSecret: 'test-client-secret',
+ autoLockTimeout: '21600',
+ downloadDir: '',
+ passwordLength: '20',
+ passwordUppercase: true,
+ passwordLowercase: true,
+ passwordNumbers: true,
+ passwordSymbols: true,
+ }),
+ showToast: vi.fn(),
+ Toast: { Style: { Success: 'success', Failure: 'failure', Animated: 'animated' } },
+}));
+
+vi.mock('../vault-cache', () => ({
+ clearCachedSends: vi.fn().mockResolvedValue(undefined),
+ clearCachedVault: vi.fn().mockResolvedValue(undefined),
+ loadCachedVault: vi.fn().mockResolvedValue(null),
+ saveCachedVault: vi.fn().mockResolvedValue(undefined),
+ loadCachedSends: vi.fn().mockResolvedValue(null),
+ saveCachedSends: vi.fn().mockResolvedValue(undefined),
+}));
+
+const sessionLookupArgs = ['lookup', 'service', 'vicinae-bitwarden', 'account', 'session'];
+const sessionStoreArgs = [
+ 'store',
+ '--label=Vicinae Bitwarden',
+ 'service',
+ 'vicinae-bitwarden',
+ 'account',
+ 'session',
+];
+const sessionClearArgs = ['clear', 'service', 'vicinae-bitwarden', 'account', 'session'];
+const apiCredsLookupArgs = ['lookup', 'service', 'vicinae-bitwarden', 'account', 'api-creds'];
+const apiCredsStoreArgs = [
+ 'store',
+ '--label=Vicinae Bitwarden API Key',
+ 'service',
+ 'vicinae-bitwarden',
+ 'account',
+ 'api-creds',
+];
+const bwUnlockArgs = ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'];
+const bwLockArgs = ['lock'];
+const bwConfigArgs = (url: string) => ['config', 'server', url];
+const bwLoginArgs = ['login', '--apikey'];
+
+function tokenPayload(token: string) {
+ return { stdout: `${JSON.stringify({ token, timestamp: Date.now() })}\n`, stderr: '' };
+}
+
+function apiCredsPayload(clientId: string, clientSecret: string) {
+ return { stdout: `${JSON.stringify({ clientId, clientSecret })}\n`, stderr: '' };
+}
+
+function execResolves(value: { stdout: string; stderr?: string }) {
+ execFileMock.mockResolvedValueOnce({ stdout: value.stdout, stderr: value.stderr ?? '' });
+}
+
+function execRejects(message: string) {
+ const err = new Error(message) as Error & { stderr: string; code: number };
+ err.stderr = message;
+ err.code = 1;
+ execFileMock.mockRejectedValueOnce(err);
+}
+
+function spawnSucceeds() {
+ const child = {
+ stdin: { write: vi.fn(), end: vi.fn(), on: vi.fn() },
+ on: vi.fn(),
+ };
+ child.stdin.on.mockImplementation((_event: string, _cb: () => void) => child.stdin);
+ child.on.mockImplementation((event: string, cb: (code?: number) => void) => {
+ if (event === 'close') queueMicrotask(() => cb(0));
+ return child;
+ });
+ spawnMock.mockReturnValueOnce(child);
+}
+
+const originalHome = process.env.HOME;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.HOME = '/home/test';
+});
+
+afterEach(() => {
+ process.env.HOME = originalHome;
+});
+
+describe('useSession', () => {
+ describe('initial mount', () => {
+ it('has null session when no cached session exists', async () => {
+ execRejects('Cannot find item');
+
+ const { result } = renderHook(() => useSession());
+
+ expect(result.current.session).toBeNull();
+ await waitFor(() => {
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'secret-tool',
+ sessionLookupArgs,
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+ });
+
+ it('loads cached session from session store', async () => {
+ execResolves(tokenPayload('cached-token'));
+
+ const { result } = renderHook(() => useSession());
+
+ await waitFor(() => {
+ expect(result.current.session).toBe('cached-token');
+ });
+ });
+ });
+
+ describe('unlock', () => {
+ it('calls bw.unlock, stores session, and updates state', async () => {
+ execRejects('Cannot find item');
+ execResolves({ stdout: 'new-session-token\n', stderr: '' });
+ spawnSucceeds();
+
+ const { result } = renderHook(() => useSession());
+
+ await act(async () => {
+ const token = await result.current.unlock('mypassword');
+ expect(token).toBe('new-session-token');
+ });
+
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'bw',
+ bwUnlockArgs,
+ expect.objectContaining({ env: expect.objectContaining({ BW_PASSWORD: 'mypassword' }) }),
+ );
+ expect(result.current.session).toBe('new-session-token');
+ });
+
+ it('propagates unlock errors', async () => {
+ execRejects('Cannot find item');
+ execRejects('Invalid master password');
+
+ const { result } = renderHook(() => useSession());
+
+ await expect(act(() => result.current.unlock('wrong'))).rejects.toThrow(
+ 'Invalid master password',
+ );
+
+ expect(result.current.session).toBeNull();
+ });
+ });
+
+ describe('clearSession', () => {
+ async function renderAndClear() {
+ const { result } = renderHook(() => useSession());
+
+ await waitFor(() => {
+ expect(result.current.session).toBe('active-token');
+ });
+
+ await act(async () => {
+ await result.current.clearSession();
+ });
+
+ return result;
+ }
+
+ it('calls bw.lock, deletes session, and sets session to null', async () => {
+ execResolves(tokenPayload('active-token'));
+ execResolves({ stdout: '', stderr: '' });
+ execResolves({ stdout: '', stderr: '' });
+
+ const result = await renderAndClear();
+
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'bw',
+ bwLockArgs,
+ expect.objectContaining({ env: expect.objectContaining({ BW_SESSION: 'active-token' }) }),
+ );
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'secret-tool',
+ sessionClearArgs,
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ expect(result.current.session).toBeNull();
+ });
+
+ it('clears session even when bw.lock fails', async () => {
+ execResolves(tokenPayload('active-token'));
+ execResolves({ stdout: '', stderr: '' });
+ execRejects('already locked');
+
+ const result = await renderAndClear();
+
+ expect(result.current.session).toBeNull();
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'secret-tool',
+ sessionClearArgs,
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+
+ it('deletes session even when session is null', async () => {
+ execRejects('Cannot find item');
+ execResolves({ stdout: '', stderr: '' });
+
+ const { result } = renderHook(() => useSession());
+
+ await act(async () => {
+ await result.current.clearSession();
+ });
+
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'secret-tool',
+ sessionClearArgs,
+ expect.any(Object),
+ );
+ expect(result.current.session).toBeNull();
+ });
+ });
+
+ describe('loginIfNeeded', () => {
+ async function renderAndLogin() {
+ const { result } = renderHook(() => useSession());
+ await act(async () => {
+ await result.current.loginIfNeeded();
+ });
+ return result;
+ }
+
+ function expectBwLoginCalled() {
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'bw',
+ bwLoginArgs,
+ expect.objectContaining({
+ env: expect.objectContaining({
+ BW_CLIENTID: 'test-client-id',
+ BW_CLIENTSECRET: 'test-client-secret',
+ }),
+ }),
+ );
+ }
+
+ function expectSpawnStoreCalled() {
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'secret-tool',
+ apiCredsStoreArgs,
+ expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'] }),
+ );
+ }
+
+ it('uses libsecret credentials when available and prefs unchanged', async () => {
+ execRejects('Cannot find item');
+ execResolves(apiCredsPayload('test-client-id', 'test-client-secret'));
+ execResolves({ stdout: '', stderr: '' });
+ execResolves({ stdout: '', stderr: '' });
+
+ await renderAndLogin();
+ expectBwLoginCalled();
+ });
+
+ it('uses preferences and migrates to libsecret when no libsecret creds exist', async () => {
+ execRejects('Cannot find item');
+ execRejects('Cannot find item');
+ execResolves({ stdout: '', stderr: '' });
+ execResolves({ stdout: '', stderr: '' });
+ spawnSucceeds();
+
+ await renderAndLogin();
+ expectSpawnStoreCalled();
+ });
+
+ it('detects credential rotation and re-migrates', async () => {
+ execRejects('Cannot find item');
+ execResolves(apiCredsPayload('old-rotated-id', 'old-rotated-secret'));
+ execResolves({ stdout: '', stderr: '' });
+ execResolves({ stdout: '', stderr: '' });
+ spawnSucceeds();
+
+ await renderAndLogin();
+ expectBwLoginCalled();
+ expectSpawnStoreCalled();
+ });
+
+ it('isLoggingIn is false after login completes', async () => {
+ execRejects('Cannot find item');
+ execRejects('Cannot find item');
+ execResolves({ stdout: '', stderr: '' });
+ execResolves({ stdout: '', stderr: '' });
+ spawnSucceeds();
+
+ const result = await renderAndLogin();
+ expect(result.current.isLoggingIn).toBe(false);
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/use-vault-search.test.ts b/extensions/bitwarden/src/__tests__/use-vault-search.test.ts
new file mode 100644
index 00000000..afacc465
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/use-vault-search.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import React, { useEffect } from 'react';
+import { useVaultSearch } from '../use-vault-search';
+import type { BwItem, BwFolder } from '../bitwarden-types';
+import type { UIState } from '../vault-lifecycle';
+
+const mockClipboardCopy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const mockShowToast = vi.hoisted(() => vi.fn());
+const mockGetTotp = vi.hoisted(() => vi.fn());
+const mockGetErrorMessage = vi.hoisted(() =>
+ vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+);
+
+vi.mock('../bw-executor', () => ({
+ getTotp: mockGetTotp,
+ getErrorMessage: mockGetErrorMessage,
+}));
+
+vi.mock('../item-utils', () => ({
+ filterItems: (items: BwItem[]) => items,
+ groupByFolder: (items: BwItem[]) => {
+ const map = new Map();
+ if (items.length > 0) {
+ map.set('f1', { folderName: 'Work', items });
+ }
+ return map;
+ },
+ showFailureToast: async (_err: unknown, title: string) =>
+ mockShowToast({ style: 'failure', title }),
+}));
+
+let mockSession: string | null = 'token';
+let mockIsSyncing = false;
+let mockGateRender: React.ReactElement | null = null;
+
+vi.mock('../use-session', () => ({
+ useSession: () => ({
+ session: mockSession,
+ unlock: vi.fn(),
+ clearSession: vi.fn(),
+ loginIfNeeded: vi.fn(),
+ loginError: null,
+ }),
+}));
+
+vi.mock('../use-vault-sync', () => ({
+ useVaultSync: () => ({
+ syncVault: vi.fn(),
+ handleSync: vi.fn(),
+ isSyncing: mockIsSyncing,
+ }),
+}));
+
+vi.mock('../vault-lifecycle', () => ({
+ useVaultLifecycle: vi.fn(),
+}));
+
+vi.mock('../unlock-gate', () => ({
+ createUnlockCallbacks: () => ({
+ onUnlockStart: vi.fn(),
+ onUnlockReady: vi.fn(),
+ onUnlockError: vi.fn(),
+ onLoginReady: vi.fn(),
+ onLoginError: vi.fn(),
+ }),
+ renderGate: () => mockGateRender,
+ useUnlockGate: () => ({
+ handleLogin: vi.fn(),
+ handleUnlock: vi.fn(),
+ }),
+}));
+
+vi.mock('@vicinae/api', async () => {
+ const { createVicinaeApiMock } = await vi.importActual<
+ typeof import('./__utils__/vicinae-mocks')
+ >('./__utils__/vicinae-mocks');
+ return createVicinaeApiMock(mockClipboardCopy, mockShowToast);
+});
+
+import { useVaultLifecycle as mockUseVaultLifecycle } from '../vault-lifecycle';
+import { makeItem, makeFolder } from './__utils__/test-data';
+
+function defaultLifecycle() {
+ const items: BwItem[] = [
+ makeItem({ login: { username: 'user', password: null, totp: null }, name: 'GitHub' }),
+ ];
+ const folders: BwFolder[] = [makeFolder()];
+
+ vi.mocked(mockUseVaultLifecycle).mockImplementation(
+ (params: {
+ setState: React.Dispatch>;
+ setVault: (items: BwItem[], folders: BwFolder[]) => void;
+ setFaviconMap: React.Dispatch>>;
+ }) => {
+ useEffect(() => {
+ params.setFaviconMap({});
+ params.setState({ kind: 'vault', items, folders });
+ params.setVault(items, folders);
+ }, []);
+ },
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockSession = 'token';
+ mockIsSyncing = false;
+ mockGateRender = null;
+ defaultLifecycle();
+});
+
+describe('useVaultSearch', () => {
+ describe('handleCopyTotp', () => {
+ it('copies TOTP and shows success toast', async () => {
+ mockGetTotp.mockResolvedValue('123456');
+
+ const { result } = renderHook(() => useVaultSearch());
+
+ await act(async () => {
+ await result.current.handleCopyTotp('item-1');
+ });
+
+ expect(mockGetTotp).toHaveBeenCalledWith('item-1', 'token');
+ expect(mockClipboardCopy).toHaveBeenCalledWith('123456');
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'success', title: 'Copied TOTP' }),
+ );
+ });
+
+ it('shows failure toast when getTotp fails', async () => {
+ mockGetTotp.mockRejectedValue(new Error('TOTP error'));
+
+ const { result } = renderHook(() => useVaultSearch());
+
+ await act(async () => {
+ await result.current.handleCopyTotp('item-1');
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'failure', title: 'Failed to get TOTP' }),
+ );
+ });
+ });
+
+ describe('isLoading', () => {
+ it('is true when state kind is checking-bw', () => {
+ vi.mocked(mockUseVaultLifecycle).mockImplementation(() => {});
+ const { result } = renderHook(() => useVaultSearch());
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('is true when syncing', async () => {
+ mockIsSyncing = true;
+ const { result } = renderHook(() => useVaultSearch());
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(true);
+ });
+ });
+
+ it('is false when vault is loaded and not syncing', async () => {
+ const { result } = renderHook(() => useVaultSearch());
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+ });
+
+ describe('preFilter', () => {
+ it('applies preFilter to vault items before search', async () => {
+ const preFilter = (items: BwItem[]) => items.filter((i) => i.login?.username === 'user');
+
+ const { result } = renderHook(() => useVaultSearch(preFilter));
+
+ await waitFor(() => {
+ expect(result.current.filtered).toHaveLength(1);
+ });
+ });
+
+ it('returns all items when no preFilter provided', async () => {
+ const { result } = renderHook(() => useVaultSearch());
+
+ await waitFor(() => {
+ expect(result.current.filtered).toHaveLength(1);
+ });
+ });
+ });
+
+ describe('sortedSections', () => {
+ it('returns sorted sections by folder name', async () => {
+ const { result } = renderHook(() => useVaultSearch());
+
+ await waitFor(() => {
+ expect(result.current.sortedSections).toHaveLength(1);
+ expect(result.current.sortedSections[0][0]).toBe('f1');
+ expect(result.current.sortedSections[0][1].folderName).toBe('Work');
+ });
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/use-vault-sync.test.ts b/extensions/bitwarden/src/__tests__/use-vault-sync.test.ts
new file mode 100644
index 00000000..369ee37e
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/use-vault-sync.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useVaultSync } from '../use-vault-sync';
+import { makeItems, makeFolders } from './__utils__/test-data';
+
+const { mockBw, mockSaveCachedVault } = vi.hoisted(() => {
+ const mockBw = {
+ sync: vi.fn(),
+ listItems: vi.fn(),
+ listFolders: vi.fn(),
+ getErrorMessage: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
+ };
+
+ const mockSaveCachedVault = vi.fn().mockResolvedValue(undefined);
+
+ return { mockBw, mockSaveCachedVault };
+});
+
+const mockShowToast = vi.hoisted(() => vi.fn());
+
+vi.mock('../bw-executor', () => ({
+ ...mockBw,
+ getErrorMessage: mockBw.getErrorMessage,
+}));
+
+vi.mock('../vault-cache', () => ({
+ saveCachedVault: mockSaveCachedVault,
+}));
+
+vi.mock('@vicinae/api', () => ({
+ showToast: (...args: unknown[]) => mockShowToast(...args),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+}));
+
+function setupHandleSync() {
+ mockBw.sync.mockResolvedValue(undefined);
+ mockBw.listItems.mockResolvedValue(makeItems(1));
+ mockBw.listFolders.mockResolvedValue(makeFolders(1));
+ const setVault = vi.fn();
+ const { result } = renderHook(() => useVaultSync('token', setVault));
+ return { result, setVault };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('useVaultSync', () => {
+ describe('syncVault', () => {
+ it('syncs, lists items/folders, caches, and sets vault', async () => {
+ const items = makeItems(1);
+ const folders = makeFolders(1);
+ mockBw.sync.mockResolvedValue(undefined);
+ mockBw.listItems.mockResolvedValue(items);
+ mockBw.listFolders.mockResolvedValue(folders);
+ const setVault = vi.fn();
+
+ const { result } = renderHook(() => useVaultSync('token', setVault));
+
+ await act(async () => {
+ await result.current.syncVault('token');
+ });
+
+ expect(mockBw.sync).toHaveBeenCalledWith('token');
+ expect(mockBw.listItems).toHaveBeenCalledWith('token');
+ expect(mockBw.listFolders).toHaveBeenCalledWith('token');
+ expect(mockSaveCachedVault).toHaveBeenCalledWith(items, folders);
+ expect(setVault).toHaveBeenCalledWith(items, folders);
+ });
+
+ it('throws when sync fails', async () => {
+ mockBw.sync.mockRejectedValue(new Error('sync error'));
+ const setVault = vi.fn();
+
+ const { result } = renderHook(() => useVaultSync('token', setVault));
+
+ await expect(act(() => result.current.syncVault('token'))).rejects.toThrow('sync error');
+ });
+ });
+
+ describe('handleSync', () => {
+ it('shows success toast on successful sync', async () => {
+ const { result } = setupHandleSync();
+
+ await act(async () => {
+ await result.current.handleSync();
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'success', title: 'Vault synced' }),
+ );
+ });
+
+ it('shows failure toast on sync error', async () => {
+ mockBw.sync.mockRejectedValue(new Error('network error'));
+ const setVault = vi.fn();
+ const { result } = renderHook(() => useVaultSync('token', setVault));
+
+ await act(async () => {
+ await result.current.handleSync();
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'failure', title: 'Sync failed' }),
+ );
+ });
+
+ it('resets isSyncing to false after completion', async () => {
+ const { result } = setupHandleSync();
+
+ expect(result.current.isSyncing).toBe(false);
+
+ await act(async () => {
+ await result.current.handleSync();
+ });
+
+ expect(result.current.isSyncing).toBe(false);
+ });
+ });
+});
diff --git a/extensions/bitwarden/src/__tests__/vault-lifecycle.test.ts b/extensions/bitwarden/src/__tests__/vault-lifecycle.test.ts
new file mode 100644
index 00000000..4a4f302e
--- /dev/null
+++ b/extensions/bitwarden/src/__tests__/vault-lifecycle.test.ts
@@ -0,0 +1,313 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useVaultLifecycle } from '../vault-lifecycle';
+import type { UIState } from '../vault-lifecycle';
+import type { BwItem, BwFolder } from '../bitwarden-types';
+import { makeItem, makeFolder, makeItems, makeFolders } from './__utils__/test-data';
+
+const { mockLoadFaviconCache, mockResolveFavicons, mockExtractHostname } = vi.hoisted(() => ({
+ mockLoadFaviconCache: vi.fn().mockResolvedValue({}),
+ mockResolveFavicons: vi.fn().mockResolvedValue({}),
+ mockExtractHostname: vi.fn().mockReturnValue(null),
+}));
+
+const { mockLoadCachedVault } = vi.hoisted(() => ({
+ mockLoadCachedVault: vi.fn().mockResolvedValue(null),
+}));
+
+const mockCheckBwGate = vi.hoisted(() => vi.fn());
+
+const mockShowToast = vi.hoisted(() => vi.fn());
+
+vi.mock('../favicons', () => ({
+ loadFaviconCache: mockLoadFaviconCache,
+ resolveFavicons: mockResolveFavicons,
+ extractHostname: mockExtractHostname,
+}));
+
+vi.mock('../vault-cache', () => ({
+ loadCachedVault: mockLoadCachedVault,
+}));
+
+vi.mock('../unlock-gate', () => ({
+ checkBwGate: mockCheckBwGate,
+}));
+
+vi.mock('@vicinae/api', () => ({
+ showToast: (...args: unknown[]) => mockShowToast(...args),
+ Toast: { Style: { Success: 'success', Failure: 'failure' } },
+}));
+
+type SetUIState = React.Dispatch>;
+type SetFaviconMap = React.Dispatch>>;
+
+function makeParams(
+ overrides: Partial<{
+ session: string | null;
+ state: UIState;
+ setState: SetUIState;
+ setVault: (items: BwItem[], folders: BwFolder[]) => void;
+ syncVault: (token: string) => Promise;
+ handleLogin: () => Promise;
+ clearSession: () => Promise;
+ setFaviconMap: SetFaviconMap;
+ }> = {},
+) {
+ return {
+ session: null,
+ state: { kind: 'checking-bw' } as const,
+ setState: vi.fn(),
+ setVault: vi.fn<(items: BwItem[], folders: BwFolder[]) => void>(),
+ syncVault: vi.fn<(token: string) => Promise>().mockResolvedValue(undefined),
+ handleLogin: vi.fn<() => Promise>().mockResolvedValue(undefined),
+ clearSession: vi.fn<() => Promise>().mockResolvedValue(undefined),
+ setFaviconMap: vi.fn(),
+ ...overrides,
+ };
+}
+
+const items = makeItems(1);
+const folders = makeFolders(1);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockCheckBwGate.mockReset();
+ mockCheckBwGate.mockResolvedValue({ kind: 'ready' });
+ mockLoadCachedVault.mockResolvedValue(null);
+ mockLoadFaviconCache.mockResolvedValue({});
+ mockResolveFavicons.mockResolvedValue({});
+ mockShowToast.mockClear();
+});
+
+describe('useVaultLifecycle', () => {
+ // -------------------------------------------------------------------------
+ // Initial mount (checking-bw → ready path)
+ // -------------------------------------------------------------------------
+ describe('initial mount: ready path', () => {
+ it('loads favicon cache on mount', async () => {
+ const setFaviconMap = vi.fn();
+ mockLoadFaviconCache.mockResolvedValue({ 'test.com': 'data:...' });
+
+ const params = makeParams({ session: 'token', setFaviconMap });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(mockLoadFaviconCache).toHaveBeenCalled();
+ expect(setFaviconMap).toHaveBeenCalledWith({ 'test.com': 'data:...' });
+ });
+ });
+
+ it('caches vault data when cached in storage', async () => {
+ const cached = { items, folders };
+ mockLoadCachedVault.mockResolvedValue(cached);
+ const setVault = vi.fn<(items: BwItem[], folders: BwFolder[]) => void>();
+
+ const params = makeParams({ session: 'token', setVault });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(setVault).toHaveBeenCalledWith(cached.items, cached.folders);
+ });
+ });
+
+ it('syncs vault after gate ready', async () => {
+ const syncVault = vi.fn<(token: string) => Promise>().mockResolvedValue(undefined);
+
+ const params = makeParams({ session: 'token', syncVault });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(syncVault).toHaveBeenCalledWith('token');
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'success', title: 'Vault synced' }),
+ );
+ });
+ });
+
+ it('falls back to cached vault on sync failure', async () => {
+ const cached = { items, folders };
+ mockLoadCachedVault.mockResolvedValue(cached);
+ const syncVault = vi
+ .fn<(token: string) => Promise>()
+ .mockRejectedValue(new Error('network'));
+ const setVault = vi.fn<(items: BwItem[], folders: BwFolder[]) => void>();
+ const clearSession = vi.fn<() => Promise>();
+
+ const params = makeParams({ session: 'token', syncVault, setVault, clearSession });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(setVault).toHaveBeenCalledWith(cached.items, cached.folders);
+ });
+ expect(clearSession).not.toHaveBeenCalled();
+ });
+
+ it('clears session and sets needs-unlock on sync failure with no cache', async () => {
+ mockLoadCachedVault.mockResolvedValue(null);
+ const syncVault = vi
+ .fn<(token: string) => Promise>()
+ .mockRejectedValue(new Error('expired'));
+ const clearSession = vi.fn<() => Promise>();
+ const setState = vi.fn();
+
+ const params = makeParams({ session: 'token', syncVault, clearSession, setState });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(clearSession).toHaveBeenCalled();
+ expect(setState).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: 'needs-unlock', error: 'Session expired' }),
+ );
+ });
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Initial mount: gate states
+ // -------------------------------------------------------------------------
+ describe('initial mount: gate states', () => {
+ it.each([
+ 'bw-not-installed' as const,
+ 'secret-tool-not-installed' as const,
+ 'logging-in' as const,
+ 'needs-unlock' as const,
+ ])('sets %s state', async (kind) => {
+ mockCheckBwGate.mockResolvedValue({ kind });
+ const setState = vi.fn();
+
+ const params = makeParams({ setState });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(setState).toHaveBeenCalledWith({ kind });
+ });
+ });
+
+ it('suppresses needs-unlock when cache exists (shows stale data)', async () => {
+ mockCheckBwGate.mockResolvedValue({ kind: 'needs-unlock' });
+ const cached = { items, folders };
+ mockLoadCachedVault.mockResolvedValue(cached);
+ const setState = vi.fn();
+ const setVault = vi.fn<(items: BwItem[], folders: BwFolder[]) => void>();
+
+ const params = makeParams({ setState, setVault });
+ renderHook(() => useVaultLifecycle(params));
+
+ await waitFor(() => {
+ expect(setVault).toHaveBeenCalledWith(cached.items, cached.folders);
+ });
+ expect(setState).not.toHaveBeenCalledWith({ kind: 'needs-unlock' });
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Session arrival transitions needs-unlock → loading
+ // -------------------------------------------------------------------------
+ it('transitions needs-unlock to loading when session arrives', () => {
+ mockCheckBwGate.mockResolvedValue({ kind: 'needs-unlock' });
+ mockLoadCachedVault.mockResolvedValue(null);
+ const setState = vi.fn();
+
+ const { rerender } = renderHook(
+ (state: UIState) => useVaultLifecycle(makeParams({ session: 'token', state, setState })),
+ { initialProps: { kind: 'checking-bw' } },
+ );
+
+ rerender({ kind: 'needs-unlock' });
+
+ expect(setState).toHaveBeenCalledWith({ kind: 'loading' });
+ });
+
+ it('does not transition non-needs-unlock states on session arrival', () => {
+ mockLoadCachedVault.mockResolvedValue({ items, folders });
+ const setState = vi.fn();
+
+ const { rerender } = renderHook(
+ (state: UIState) => useVaultLifecycle(makeParams({ session: 'token', state, setState })),
+ { initialProps: { kind: 'vault', items: [], folders: [] } },
+ );
+
+ rerender({ kind: 'vault', items, folders });
+
+ expect(setState).not.toHaveBeenCalled();
+ });
+
+ // -------------------------------------------------------------------------
+ // loading state → sync vault
+ // -------------------------------------------------------------------------
+ it('syncs vault when state transitions to loading with session', async () => {
+ mockLoadCachedVault.mockResolvedValue(null);
+ const syncVault = vi.fn<(token: string) => Promise>().mockResolvedValue(undefined);
+ const setVault = vi.fn<(items: BwItem[], folders: BwFolder[]) => void>();
+ const cached = { items, folders };
+ mockLoadCachedVault.mockImplementationOnce(async () => null).mockResolvedValueOnce(cached);
+
+ const { rerender } = renderHook(
+ (state: UIState) =>
+ useVaultLifecycle(makeParams({ session: 'token', state, setVault, syncVault })),
+ { initialProps: { kind: 'needs-unlock' } },
+ );
+
+ rerender({ kind: 'loading' });
+
+ await waitFor(() => {
+ expect(syncVault).toHaveBeenCalledWith('token');
+ });
+ });
+
+ it('shows failure toast and clears session on loading sync failure without cache', async () => {
+ mockLoadCachedVault.mockResolvedValue(null);
+ const syncVault = vi
+ .fn<(token: string) => Promise>()
+ .mockRejectedValue(new Error('network down'));
+ const setState = vi.fn();
+ const clearSession = vi.fn<() => Promise>();
+
+ const { rerender } = renderHook(
+ (state: UIState) =>
+ useVaultLifecycle(
+ makeParams({ session: 'token', state, syncVault, clearSession, setState }),
+ ),
+ { initialProps: { kind: 'checking-bw' } },
+ );
+
+ rerender({ kind: 'loading' });
+
+ await waitFor(() => {
+ expect(clearSession).toHaveBeenCalled();
+ expect(setState).toHaveBeenCalledWith(expect.objectContaining({ kind: 'needs-unlock' }));
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ style: 'failure', title: 'Failed to load vault' }),
+ );
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // logging-in state triggers handleLogin
+ // -------------------------------------------------------------------------
+ it('calls handleLogin when state transitions to logging-in', async () => {
+ const handleLogin = vi.fn<() => Promise>().mockResolvedValue(undefined);
+
+ const { rerender } = renderHook(
+ (state: UIState) => useVaultLifecycle(makeParams({ state, handleLogin })),
+ { initialProps: { kind: 'checking-bw' } },
+ );
+
+ rerender({ kind: 'logging-in' });
+
+ await waitFor(() => {
+ expect(handleLogin).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call handleLogin when state is not logging-in', async () => {
+ const handleLogin = vi.fn<() => Promise>().mockResolvedValue(undefined);
+
+ renderHook(() =>
+ useVaultLifecycle(makeParams({ state: { kind: 'needs-unlock' }, handleLogin })),
+ );
+
+ expect(handleLogin).not.toHaveBeenCalled();
+ });
+});
diff --git a/extensions/bitwarden/src/api-credential-store.ts b/extensions/bitwarden/src/api-credential-store.ts
new file mode 100644
index 00000000..fdb3b83d
--- /dev/null
+++ b/extensions/bitwarden/src/api-credential-store.ts
@@ -0,0 +1,79 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import Database from 'better-sqlite3';
+import { secretStore, secretLookup, secretClear } from './secret-store';
+import { safeJsonParse } from './json-utils';
+
+const exec = promisify(execFile);
+
+const ACCOUNT = 'api-creds';
+
+function getHome(): string {
+ const home = process.env.HOME;
+ if (!home) throw new Error('HOME environment variable is not set');
+ return home;
+}
+
+export async function storeApiCredentials(clientId: string, clientSecret: string): Promise {
+ await secretStore(
+ ACCOUNT,
+ JSON.stringify({ clientId, clientSecret }),
+ 'Vicinae Bitwarden API Key',
+ );
+}
+
+function parseJsonRecord(raw: string): { clientId: string; clientSecret: string } | null {
+ return safeJsonParse<{ clientId: string; clientSecret: string }>(raw, {
+ strings: ['clientId', 'clientSecret'],
+ });
+}
+
+export async function getApiCredentials(): Promise<{
+ clientId: string;
+ clientSecret: string;
+} | null> {
+ try {
+ const raw = await secretLookup(ACCOUNT);
+ if (!raw) return null;
+ return parseJsonRecord(raw);
+ } catch {
+ return null;
+ }
+}
+
+async function deleteApiCredentials(): Promise {
+ await secretClear(ACCOUNT);
+}
+
+export async function clearApiCredentialsFromDisk(): Promise {
+ try {
+ const settingsPath = join(getHome(), '.config', 'vicinae', 'settings.json');
+ const content = readFileSync(settingsPath, 'utf-8');
+ let updated = content.replace(
+ /"bitwardenApiClientId"\s*:\s*"[^"]*"/,
+ '"bitwardenApiClientId": ""',
+ );
+ updated = updated.replace(
+ /"bitwardenApiClientSecret"\s*:\s*"[^"]*"/,
+ '"bitwardenApiClientSecret": ""',
+ );
+ if (updated !== content) {
+ writeFileSync(settingsPath, updated, 'utf-8');
+ }
+ } catch (err) {
+ console.warn('Failed to clear API credentials from settings.json:', err);
+ }
+
+ try {
+ const dbPath = join(getHome(), '.local', 'share', 'vicinae', 'vicinae.db');
+ const db = new Database(dbPath);
+ db.prepare(
+ "DELETE FROM storage_data_item WHERE key IN ('bitwardenApiClientId', 'bitwardenApiClientSecret')",
+ ).run();
+ db.close();
+ } catch (err) {
+ console.warn('Failed to clear API credentials from vicinae.db:', err);
+ }
+}
diff --git a/extensions/bitwarden/src/bitwarden-types.ts b/extensions/bitwarden/src/bitwarden-types.ts
new file mode 100644
index 00000000..e3489afb
--- /dev/null
+++ b/extensions/bitwarden/src/bitwarden-types.ts
@@ -0,0 +1,103 @@
+/** Bitwarden item type enum (matching `bw` CLI output) */
+export const ItemType = {
+ Login: 1,
+ SecureNote: 2,
+ Card: 3,
+ Identity: 4,
+} as const;
+
+export type ItemTypeValue = (typeof ItemType)[keyof typeof ItemType];
+
+export interface BwItem {
+ id: string;
+ organizationId: string | null;
+ folderId: string | null;
+ type: ItemTypeValue;
+ name: string;
+ notes: string | null;
+ favorite: boolean;
+ login?: BwLogin;
+ secureNote?: BwSecureNote;
+ card?: BwCard;
+ identity?: BwIdentity;
+ fields?: BwField[];
+ attachments?: BwAttachment[];
+ revisionDate: string;
+ creationDate: string;
+ deletedDate: string | null;
+ collectionIds: string[] | null;
+}
+
+interface BwLogin {
+ username: string | null;
+ password: string | null;
+ totp: string | null;
+ uris?: { uri: string; match: number | null }[];
+ passwordRevisionDate?: string | null;
+}
+
+interface BwSecureNote {
+ type: number;
+}
+
+interface BwCard {
+ cardholderName: string | null;
+ brand: string | null;
+ number: string | null;
+ expMonth: string | null;
+ expYear: string | null;
+ code: string | null;
+}
+
+interface BwIdentity {
+ title: string | null;
+ firstName: string | null;
+ middleName: string | null;
+ lastName: string | null;
+ address1: string | null;
+ address2: string | null;
+ address3: string | null;
+ city: string | null;
+ state: string | null;
+ postalCode: string | null;
+ country: string | null;
+ company: string | null;
+ email: string | null;
+ phone: string | null;
+ ssn: string | null;
+ username: string | null;
+ passportNumber: string | null;
+ licenseNumber: string | null;
+}
+
+interface BwField {
+ name: string;
+ value: string;
+ type: number;
+ linkedId: number | null;
+}
+
+interface BwAttachment {
+ id: string;
+ fileName: string;
+ size: number;
+ sizeName: string;
+}
+
+export type { BwField };
+
+export interface BwFolder {
+ id: string;
+ name: string;
+}
+
+/** Error class for `bw` CLI failures */
+export class BwError extends Error {
+ constructor(
+ message: string,
+ public readonly code: string,
+ ) {
+ super(message);
+ this.name = 'BwError';
+ }
+}
diff --git a/extensions/bitwarden/src/bw-executor.ts b/extensions/bitwarden/src/bw-executor.ts
new file mode 100644
index 00000000..85c7c85f
--- /dev/null
+++ b/extensions/bitwarden/src/bw-executor.ts
@@ -0,0 +1,629 @@
+import { execFile, spawn } from 'node:child_process';
+import { promisify } from 'node:util';
+import { join } from 'node:path';
+import { BwError, BwFolder, BwItem, ItemTypeValue } from './bitwarden-types';
+import type { BwSend, CreateSendPayload } from './send-types';
+import { getDownloadDir, getPreferences } from './preferences';
+
+const exec = promisify(execFile);
+
+function execStdin(
+ bin: string,
+ args: string[],
+ stdin: string,
+ opts?: { env?: NodeJS.ProcessEnv; timeout?: number },
+): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(bin, args, {
+ env: opts?.env ?? process.env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ timeout: opts?.timeout,
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (d: Buffer) => {
+ stdout += d.toString();
+ });
+ child.stderr.on('data', (d: Buffer) => {
+ stderr += d.toString();
+ });
+ child.on('error', reject);
+ child.on('close', (code) => {
+ if (code === 0) resolve(stdout.trim());
+ else reject(new Error(stderr.trim() || `Process exited with code ${code}`));
+ });
+ child.stdin.write(stdin);
+ child.stdin.end();
+ });
+}
+
+/** Token that identifies an unlocked vault session */
+export type Session = string;
+
+interface BwStatus {
+ serverUrl: string | null;
+ lastSync: string | null;
+ userEmail: string;
+ userId: string;
+ status: 'unauthenticated' | 'locked' | 'unlocked';
+}
+
+function bwEnv(): NodeJS.ProcessEnv {
+ const env = { ...process.env };
+ try {
+ const prefs = getPreferences();
+ if (prefs.customCertPath) {
+ env.NODE_EXTRA_CA_CERTS = prefs.customCertPath;
+ }
+ } catch {
+ // Preferences not available
+ }
+ return env;
+}
+
+function parseJson(stdout: string): T {
+ try {
+ return JSON.parse(stdout) as T;
+ } catch {
+ throw new BwError('Failed to parse `bw` output as JSON', 'PARSE_ERROR');
+ }
+}
+
+function sessionEnv(session: Session): NodeJS.ProcessEnv {
+ return { ...bwEnv(), BW_SESSION: session };
+}
+
+function hasStderr(err: unknown): err is { stderr: unknown } {
+ return typeof err === 'object' && err !== null && 'stderr' in err;
+}
+
+async function execBw(
+ args: string[],
+ opts: { timeout?: number; maxBuffer?: number; env?: NodeJS.ProcessEnv },
+): Promise {
+ const { stdout } = await exec('bw', args, opts);
+ return stdout;
+}
+
+async function execBwJson(
+ args: string[],
+ opts: { timeout?: number; maxBuffer?: number; env?: NodeJS.ProcessEnv },
+): Promise {
+ const stdout = await execBw(args, opts);
+ return parseJson(stdout);
+}
+
+async function execBwTrim(
+ args: string[],
+ opts: { timeout?: number; env?: NodeJS.ProcessEnv },
+): Promise {
+ const stdout = await execBw(args, opts);
+ return stdout.trim();
+}
+
+export function getErrorMessage(err: unknown): string {
+ if (err instanceof Error) {
+ const stderrRaw = hasStderr(err) ? String(err.stderr ?? '').trim() : '';
+ const cleaned = stderrRaw
+ .split('\n')
+ .filter((line) => !line.includes('[DEP0') && !line.includes('DeprecationWarning'))
+ .join('\n')
+ .trim();
+ const raw = cleaned || err.message;
+ return friendlyMessage(raw);
+ }
+ return friendlyMessage(String(err));
+}
+
+function friendlyMessage(raw: string): string {
+ const lower = raw.toLowerCase();
+ if (lower.includes('incorrect client_secret') || lower.includes('incorrect clientid')) {
+ return 'Invalid API credentials — check your Client ID and Client Secret in extension preferences.';
+ }
+ if (lower.includes('invalid master password')) {
+ return 'Incorrect master password.';
+ }
+ if (lower.includes('not logged in')) {
+ return 'Not logged in.';
+ }
+ if (
+ lower.includes('econnrefused') ||
+ lower.includes('enotfound') ||
+ lower.includes('getaddrinfo') ||
+ lower.includes('econnreset')
+ ) {
+ return 'Cannot reach Bitwarden server — check your connection and server URL.';
+ }
+ if (lower.includes('two-factor') || lower.includes('two step')) {
+ return 'Two-step login is required but the CLI does not support it for API key logins.';
+ }
+ if (lower.includes('timed out') || lower.includes('etimedout')) {
+ return 'Request timed out — the Bitwarden server did not respond in time.';
+ }
+ if (lower.includes('rate limit') || lower.includes('429')) {
+ return 'Too many requests — wait a moment and try again.';
+ }
+ return raw;
+}
+
+function toBwError(err: unknown): BwError {
+ if (err instanceof BwError) return err;
+ return new BwError(getErrorMessage(err), 'CLI_ERROR');
+}
+
+/**
+ * Encode JSON payload and pipe it to a bw command that reads from stdin.
+ * Returns the trimmed stdout of the target command.
+ */
+async function encodeAndExec(
+ payload: unknown,
+ cmd: string,
+ args: string[],
+ session: Session,
+): Promise {
+ const json = JSON.stringify(payload);
+ const env = sessionEnv(session);
+ const encoded = await execStdin('bw', ['encode'], json, { env, timeout: 15000 });
+ return execStdin('bw', [cmd, ...args], encoded, { env, timeout: 15000 });
+}
+
+/**
+ * Check whether the `bw` binary is installed and on PATH.
+ */
+export async function checkInstalled(): Promise {
+ try {
+ await exec('bw', ['--version'], { timeout: 5000 });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Login to a Bitwarden Server using an API key.
+ * Must be called once before any Unlock.
+ * Sets the server URL for self-hosted instances before login.
+ */
+export async function login(params: {
+ clientId: string;
+ clientSecret: string;
+ serverUrl: string;
+}): Promise {
+ const env = {
+ ...bwEnv(),
+ BW_CLIENTID: params.clientId,
+ BW_CLIENTSECRET: params.clientSecret,
+ };
+
+ try {
+ await execBw(['config', 'server', params.serverUrl], {
+ timeout: 10000,
+ env,
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+
+ try {
+ await execBw(['login', '--apikey'], {
+ timeout: 30000,
+ env,
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Retrieve the current status of the `bw` CLI (unauthenticated / locked / unlocked).
+ */
+export async function status(): Promise {
+ try {
+ return await execBwJson(['status'], { timeout: 10000 });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Unlock the vault using the master password.
+ * Returns the Session token.
+ */
+export async function unlock(masterPassword: string): Promise {
+ try {
+ const { stdout } = await exec('bw', ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], {
+ timeout: 15000,
+ env: { ...bwEnv(), BW_PASSWORD: masterPassword },
+ });
+ return stdout.trim();
+ } catch (err) {
+ const bwErr = toBwError(err);
+ if (
+ bwErr.message.toLowerCase().includes('invalid') ||
+ bwErr.message.toLowerCase().includes('password')
+ ) {
+ throw new BwError('Invalid master password', 'INVALID_PASSWORD');
+ }
+ throw bwErr;
+ }
+}
+
+/**
+ * Pull the latest vault state from the Server.
+ * Requires a valid Session.
+ */
+export async function sync(session: Session): Promise {
+ try {
+ await execBw(['sync'], { timeout: 30000, env: sessionEnv(session) });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * List all Items in the vault.
+ * Requires a valid Session.
+ */
+export async function listItems(session: Session): Promise {
+ try {
+ return await execBwJson(['list', 'items'], {
+ timeout: 30000,
+ maxBuffer: 10 * 1024 * 1024,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * List all Folders in the vault.
+ * Requires a valid Session.
+ */
+export async function listFolders(session: Session): Promise {
+ try {
+ return await execBwJson(['list', 'folders'], {
+ timeout: 15000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Get a single Item by ID with full details (including password).
+ * Requires a valid Session.
+ */
+export async function getItem(id: string, session: Session): Promise {
+ try {
+ return await execBwJson(['get', 'item', id], {
+ timeout: 15000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Get a TOTP code for a Login item.
+ * Requires a valid Session.
+ */
+export async function getTotp(id: string, session: Session): Promise {
+ try {
+ return await execBwTrim(['get', 'totp', id], {
+ timeout: 10000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Create a new Item in the vault.
+ * Requires a valid Session. The `payload` is the full JSON object
+ * matching Bitwarden's internal item schema.
+ */
+export async function createItem(payload: CreateItemPayload, session: Session): Promise {
+ try {
+ const stdout = await encodeAndExec(payload, 'create', ['item'], session);
+ return parseJson(stdout);
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Edit an existing Item in the vault.
+ * Requires a valid Session. The `payload` is a partial item JSON
+ * with only the fields to update.
+ */
+export async function editItem(id: string, payload: object, session: Session): Promise {
+ try {
+ await encodeAndExec(payload, 'edit', ['item', id], session);
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Create a new Folder in the vault.
+ * Requires a valid Session. Returns the created folder with its id.
+ */
+export async function createFolder(name: string, session: Session): Promise {
+ try {
+ const stdout = await encodeAndExec({ name }, 'create', ['folder'], session);
+ return parseJson(stdout);
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Delete an Item from the vault by ID.
+ * Requires a valid Session.
+ */
+export async function deleteItem(id: string, session: Session): Promise {
+ try {
+ await execBw(['delete', 'item', id], {
+ timeout: 15000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Log out of Bitwarden, clearing the stored API key login state.
+ */
+export async function logout(): Promise {
+ try {
+ await exec('bw', ['logout'], { timeout: 10000 });
+ } catch (err) {
+ const message = getErrorMessage(err);
+ if (message.toLowerCase().includes('not logged in')) return;
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Lock the vault, invalidating the current Session.
+ */
+export async function lock(session: Session): Promise {
+ try {
+ await execBw(['lock'], { timeout: 10000, env: sessionEnv(session) });
+ } catch {
+ // Lock failures are non-fatal — the session is cleared client-side regardless
+ }
+}
+
+/**
+ * Generate a random password using the `bw generate` command.
+ * No session required — this is a local cryptographic operation.
+ */
+export async function generatePassword(options: {
+ length: number;
+ uppercase: boolean;
+ lowercase: boolean;
+ numbers: boolean;
+ symbols: boolean;
+}): Promise {
+ const flags: string[] = [];
+ if (options.uppercase) flags.push('-u');
+ if (options.lowercase) flags.push('-l');
+ if (options.numbers) flags.push('-n');
+ if (options.symbols) flags.push('-s');
+ flags.push('--length', String(options.length));
+
+ try {
+ return await execBwTrim(['generate', ...flags], {
+ timeout: 10000,
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Download an attached file from an Item to the Downloads folder.
+ * Returns the path to the downloaded file.
+ */
+export async function downloadAttachment(
+ attachmentId: string,
+ itemId: string,
+ fileName: string,
+ session: Session,
+): Promise {
+ let downloadDir: string;
+ try {
+ downloadDir = getDownloadDir(getPreferences());
+ } catch {
+ downloadDir = `${process.env.HOME ?? '/tmp'}/Downloads`;
+ }
+ const outPath = join(downloadDir, fileName);
+ try {
+ await execBw(['get', 'attachment', attachmentId, '--itemid', itemId, '--output', outPath], {
+ timeout: 30000,
+ env: sessionEnv(session),
+ });
+ return outPath;
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Attach a file from the local filesystem to an existing Item.
+ */
+export async function createAttachment(
+ itemId: string,
+ filePath: string,
+ session: Session,
+): Promise {
+ try {
+ await execBw(['create', 'attachment', '--itemid', itemId, '--file', filePath], {
+ timeout: 30000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * List all Sends.
+ * Requires a valid Session.
+ */
+export async function listSends(session: Session): Promise {
+ try {
+ return await execBwJson(['send', 'list'], {
+ timeout: 30000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Get a single Send by ID with full details.
+ * Requires a valid Session.
+ */
+export async function getSend(id: string, session: Session): Promise {
+ try {
+ return await execBwJson(['send', 'get', id], {
+ timeout: 15000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Create a new Send.
+ * Requires a valid Session. The payload is the full JSON object
+ * matching Bitwarden's internal send schema.
+ */
+export async function createSend(payload: CreateSendPayload, session: Session): Promise {
+ try {
+ const stdout = await encodeAndExec(payload, 'send', ['create'], session);
+ return parseJson(stdout);
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Edit an existing Send.
+ * Requires a valid Session.
+ */
+export async function editSend(id: string, payload: object, session: Session): Promise {
+ try {
+ await encodeAndExec(payload, 'send', ['edit', id], session);
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/**
+ * Delete a Send by ID.
+ * Requires a valid Session.
+ */
+export async function deleteSend(id: string, session: Session): Promise {
+ try {
+ await execBw(['send', 'delete', id], {
+ timeout: 15000,
+ env: sessionEnv(session),
+ });
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+interface ReceiveSendResult {
+ kind: 'text' | 'file';
+ text?: string;
+ path?: string;
+}
+
+/**
+ * Receive a Send by URL.
+ * No session required — `bw send receive` works without authentication.
+ * For file sends, provide `output` directory; the function returns the saved file path.
+ * For text sends, the function returns the decrypted text content.
+ */
+export async function receiveSend(
+ url: string,
+ password?: string,
+ output?: string,
+): Promise {
+ const args = ['send', 'receive', url];
+ const env: NodeJS.ProcessEnv = { ...bwEnv() };
+ if (password) {
+ args.push('--passwordenv', 'BW_SEND_PASSWORD');
+ env.BW_SEND_PASSWORD = password;
+ }
+ if (output) args.push('--output', output);
+ try {
+ const stdout = await execBw(args, { timeout: 30000, env });
+ const trimmed = stdout.trim();
+ if (output) return { kind: 'file', path: trimmed };
+ return { kind: 'text', text: trimmed };
+ } catch (err) {
+ throw toBwError(err);
+ }
+}
+
+/** Descriptor for an action the user can take on an Item */
+export interface ItemAction {
+ label: string;
+ value: string;
+ icon?: string;
+ /** When set, the action fetches the real value from the bw CLI instead of using `value`. */
+ fetchKind?: 'password' | 'totp' | 'cardNumber' | 'cardCode';
+}
+
+/** Payload passed to createItem() */
+export interface CreateItemPayload {
+ type: ItemTypeValue;
+ name: string;
+ notes: string | null;
+ folderId: string | null;
+ favorite: boolean;
+ login?: {
+ username: string | null;
+ password: string | null;
+ totp: string | null;
+ uris?: { uri: string; match: null }[];
+ };
+ card?: {
+ cardholderName: string | null;
+ brand: string | null;
+ number: string | null;
+ expMonth: string | null;
+ expYear: string | null;
+ code: string | null;
+ };
+ identity?: {
+ title: string | null;
+ firstName: string | null;
+ middleName: string | null;
+ lastName: string | null;
+ email: string | null;
+ phone: string | null;
+ address1: string | null;
+ address2: string | null;
+ city: string | null;
+ state: string | null;
+ postalCode: string | null;
+ country: string | null;
+ };
+ secureNote?: {
+ type: number;
+ };
+ fields?: { name: string; value: string; type: number }[];
+}
diff --git a/extensions/bitwarden/src/bw-not-installed.tsx b/extensions/bitwarden/src/bw-not-installed.tsx
new file mode 100644
index 00000000..9ee873ed
--- /dev/null
+++ b/extensions/bitwarden/src/bw-not-installed.tsx
@@ -0,0 +1,59 @@
+import { Action, ActionPanel, Detail } from '@vicinae/api';
+
+const BW_NOT_INSTALLED_MARKDOWN = `# Bitwarden CLI Not Found
+
+The \`bw\` binary is not installed or not on your \`PATH\`.
+
+## Install
+
+Download the Bitwarden CLI from [bitwarden.com/download](https://bitwarden.com/download/).
+Available as AppImage, Snap, or npm:
+
+\`\`\`
+npm install -g @bitwarden/cli
+\`\`\`
+
+After installing, restart Vicinae or reopen this command.`;
+
+export function BwNotInstalled() {
+ return (
+
+
+
+ }
+ />
+ );
+}
+
+const SECRET_TOOL_NOT_INSTALLED_MARKDOWN = `# libsecret-tools Not Found
+
+The \`secret-tool\` binary is not installed. It is required to store your vault session securely in the system keyring.
+
+## Install
+
+On Debian/Ubuntu:
+\`\`\`
+sudo apt install libsecret-tools
+\`\`\`
+
+On Fedora:
+\`\`\`
+sudo dnf install libsecret
+\`\`\`
+
+On Arch:
+\`\`\`
+sudo pacman -S libsecret
+\`\`\`
+
+After installing, reopen this command.`;
+
+export function SecretToolNotInstalled() {
+ return ;
+}
diff --git a/extensions/bitwarden/src/create-item.tsx b/extensions/bitwarden/src/create-item.tsx
new file mode 100644
index 00000000..9d0e7c64
--- /dev/null
+++ b/extensions/bitwarden/src/create-item.tsx
@@ -0,0 +1,326 @@
+import {
+ Action,
+ ActionPanel,
+ Clipboard,
+ Form,
+ Icon,
+ popToRoot,
+ showToast,
+ Toast,
+} from '@vicinae/api';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as bw from './bw-executor';
+import type { BwFolder } from './bitwarden-types';
+import { ItemType } from './bitwarden-types';
+import type { ItemTypeValue } from './bitwarden-types';
+import {
+ CARD_BRANDS,
+ digitsOnly,
+ readFormValues,
+ showFailureToast,
+ toCreatePayload,
+ uploadAttachments,
+} from './item-utils';
+import CustomFieldsSection from './custom-fields-section';
+import type { CustomField } from './custom-fields-section';
+import { useSession } from './use-session';
+import { getPasswordPrefs, getPreferences } from './preferences';
+import { renderFormGate, useGateEffects, castGateSetter } from './unlock-gate';
+import type { GateUIState } from './unlock-gate';
+
+type UIState = GateUIState | { kind: 'form' };
+
+const ITEM_TYPE_MAP: Record = {
+ Login: ItemType.Login,
+ Card: ItemType.Card,
+ Identity: ItemType.Identity,
+ 'Secure Note': ItemType.SecureNote,
+};
+
+const ITEM_TYPE_OPTIONS = Object.keys(ITEM_TYPE_MAP).map((label) => ({
+ value: label,
+ label,
+}));
+
+async function createFolderIfNeeded(
+ newFolderName: string | undefined,
+ session: bw.Session,
+): Promise {
+ const name = (newFolderName ?? '').trim();
+ if (!name) {
+ await showToast({ style: Toast.Style.Failure, title: 'Folder name is required' });
+ return null;
+ }
+ try {
+ const created = await bw.createFolder(name, session);
+ await showToast({ style: Toast.Style.Success, title: 'Folder created', message: name });
+ return created.id;
+ } catch (err) {
+ await showFailureToast(err, 'Failed to create folder');
+ return null;
+ }
+}
+
+export default function CreateItem() {
+ const { session, unlock, loginIfNeeded, loginError } = useSession();
+ const [state, setState] = useState({ kind: 'checking-bw' });
+ const [selectedType, setSelectedType] = useState('Login');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [folders, setFolders] = useState([]);
+ const [generatedPassword, setGeneratedPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [selectedFolder, setSelectedFolder] = useState('');
+ const [newFolderName, setNewFolderName] = useState('');
+ const [customFields, setCustomFields] = useState([]);
+ const [attachmentPaths, setAttachmentPaths] = useState([]);
+ const [expMonth, setExpMonth] = useState('');
+ const [expYear, setExpYear] = useState('');
+ const [cardCode, setCardCode] = useState('');
+ const fieldIdRef = useRef(0);
+
+ const { handleLogin, handleUnlock } = useGateEffects({
+ session,
+ state,
+ loginIfNeeded,
+ loginError,
+ unlock,
+ setState: castGateSetter(setState),
+ readyKind: 'form',
+ });
+
+ // Fetch folders — starts as soon as session is available, before form renders
+ useEffect(() => {
+ if (!session) return;
+ if (folders.length > 0) return;
+ void (async () => {
+ try {
+ setFolders(await bw.listFolders(session));
+ } catch {
+ // Folder list is optional — form still works without it
+ }
+ })();
+ }, [session, state.kind]);
+
+ const handleSubmit = useCallback(
+ async (values: Form.Values) => {
+ if (!session) return;
+
+ const itemValues = readFormValues(values);
+ const typeNum = ITEM_TYPE_MAP[selectedType] ?? ItemType.SecureNote;
+ let folderId = itemValues.folder || null;
+
+ if (folderId === '__new__') {
+ const createdFolderId = await createFolderIfNeeded(itemValues.newFolderName, session);
+ if (!createdFolderId) return;
+ folderId = createdFolderId;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const payload = toCreatePayload(
+ itemValues,
+ typeNum,
+ folderId === '' ? null : folderId,
+ customFields.length > 0
+ ? customFields.map((f) => ({ name: f.name, value: f.value, type: f.type }))
+ : undefined,
+ );
+ const created = await bw.createItem(payload, session);
+
+ await uploadAttachments(created.id, attachmentPaths, session);
+
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Item created',
+ message: itemValues.name,
+ });
+ await popToRoot();
+ } catch (err) {
+ await showFailureToast(err, 'Failed to create item');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [session, selectedType, customFields, attachmentPaths],
+ );
+
+ const gateRender = renderFormGate(state, handleUnlock, handleLogin);
+ if (gateRender) return gateRender;
+
+ return (
+
+ );
+}
diff --git a/extensions/bitwarden/src/create-send.tsx b/extensions/bitwarden/src/create-send.tsx
new file mode 100644
index 00000000..a6191383
--- /dev/null
+++ b/extensions/bitwarden/src/create-send.tsx
@@ -0,0 +1,146 @@
+// fallow-ignore-file unused-file
+import {
+ Action,
+ ActionPanel,
+ Clipboard,
+ Form,
+ Icon,
+ popToRoot,
+ showToast,
+ Toast,
+} from '@vicinae/api';
+import { useCallback, useState } from 'react';
+import * as bw from './bw-executor';
+import { SendType, type SendTypeValue } from './send-types';
+import { sendAccessUrl, toSendPayload, HOURS_OPTIONS } from './send-utils';
+import { digitsOnly, readFormValues, showFailureToast } from './item-utils';
+import { useSession } from './use-session';
+import { renderFormGate, useGateEffects, castGateSetter } from './unlock-gate';
+import type { GateUIState } from './unlock-gate';
+
+type UIState = GateUIState | { kind: 'form' };
+
+const SEND_TYPE_MAP: Record = {
+ Text: SendType.Text,
+ File: SendType.File,
+};
+
+const SEND_TYPE_OPTIONS = Object.keys(SEND_TYPE_MAP).map((label) => ({
+ value: label,
+ label,
+}));
+
+export default function CreateSend() {
+ const { session, unlock, loginIfNeeded, loginError } = useSession();
+ const [state, setState] = useState({ kind: 'checking-bw' });
+ const [selectedType, setSelectedType] = useState('Text');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [maxAccessCount, setMaxAccessCount] = useState('');
+
+ const { handleLogin, handleUnlock } = useGateEffects({
+ session,
+ state,
+ loginIfNeeded,
+ loginError,
+ unlock,
+ setState: castGateSetter(setState),
+ readyKind: 'form',
+ });
+
+ const handleSubmit = useCallback(
+ async (values: Form.Values) => {
+ if (!session) return;
+
+ const sendValues = readFormValues(values);
+ const typeNum = SEND_TYPE_MAP[selectedType] ?? SendType.Text;
+
+ setIsSubmitting(true);
+ try {
+ const payload = toSendPayload(sendValues, typeNum);
+ const created = await bw.createSend(payload, session);
+ const url = sendAccessUrl(created);
+ await Clipboard.copy(url);
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Send created',
+ message: 'Link copied to clipboard',
+ });
+ await popToRoot();
+ } catch (err) {
+ await showFailureToast(err, 'Failed to create send');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [session, selectedType],
+ );
+
+ const gateRender = renderFormGate(state, handleUnlock, handleLogin);
+ if (gateRender) return gateRender;
+
+ return (
+ setSelectedType(String(value ?? 'Text'))}
+ >
+ {SEND_TYPE_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
+
+
+ {selectedType === 'Text' && (
+ <>
+
+
+ >
+ )}
+
+ {selectedType === 'File' && }
+
+
+
+
+
+
+ {HOURS_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+ {HOURS_OPTIONS.map((opt) => (
+
+ ))}
+
+
+ setMaxAccessCount(digitsOnly(v))}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/extensions/bitwarden/src/custom-fields-section.tsx b/extensions/bitwarden/src/custom-fields-section.tsx
new file mode 100644
index 00000000..e456f457
--- /dev/null
+++ b/extensions/bitwarden/src/custom-fields-section.tsx
@@ -0,0 +1,111 @@
+import { Form } from '@vicinae/api';
+import { Fragment } from 'react';
+
+export interface CustomField {
+ id: number;
+ name: string;
+ value: string;
+ type: number; // 0=Text, 1=Hidden, 2=Boolean
+}
+
+const FIELD_TYPES = [
+ { title: 'Text', value: '0' },
+ { title: 'Hidden', value: '1' },
+ { title: 'Boolean', value: '2' },
+];
+
+interface CustomFieldsSectionProps {
+ customFields: CustomField[];
+ setCustomFields: React.Dispatch>;
+ notes?: string;
+}
+
+function updateField(
+ setCustomFields: React.Dispatch>,
+ fieldId: number,
+ patch: Partial>,
+) {
+ setCustomFields((prev) => prev.map((f) => (f.id === fieldId ? { ...f, ...patch } : f)));
+}
+
+function renderFieldValue(
+ field: CustomField,
+ setCustomFields: React.Dispatch>,
+) {
+ if (field.type === 1) {
+ return (
+ updateField(setCustomFields, field.id, { value: String(v ?? '') })}
+ />
+ );
+ }
+ if (field.type === 2) {
+ return (
+ updateField(setCustomFields, field.id, { value: String(!!v) })}
+ />
+ );
+ }
+ return (
+ updateField(setCustomFields, field.id, { value: String(v ?? '') })}
+ />
+ );
+}
+
+function normalizeBoolean(value: string): string {
+ return value === 'true' ? 'true' : 'false';
+}
+
+export default function CustomFieldsSection({
+ customFields,
+ setCustomFields,
+ notes,
+}: CustomFieldsSectionProps) {
+ return (
+ <>
+
+
+ {customFields.length > 0 && (
+ <>
+
+
+ >
+ )}
+ {customFields.map((field) => (
+
+ updateField(setCustomFields, field.id, { name: String(v ?? '') })}
+ />
+ {
+ const newType = Number(v ?? '0');
+ const value = newType === 2 ? normalizeBoolean(field.value) : field.value;
+ updateField(setCustomFields, field.id, { type: newType, value });
+ }}
+ >
+ {FIELD_TYPES.map((ft) => (
+
+ ))}
+
+ {renderFieldValue(field, setCustomFields)}
+
+ ))}
+ >
+ );
+}
diff --git a/extensions/bitwarden/src/edit-item.tsx b/extensions/bitwarden/src/edit-item.tsx
new file mode 100644
index 00000000..09fdab2f
--- /dev/null
+++ b/extensions/bitwarden/src/edit-item.tsx
@@ -0,0 +1,323 @@
+import {
+ Action,
+ ActionPanel,
+ Alert,
+ confirmAlert,
+ Form,
+ Icon,
+ popToRoot,
+ showToast,
+ Toast,
+} from '@vicinae/api';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import type { BwFolder, BwItem } from './bitwarden-types';
+import { ItemType } from './bitwarden-types';
+import type { ItemTypeValue } from './bitwarden-types';
+import {
+ CARD_BRANDS,
+ digitsOnly,
+ itemTypeLabel,
+ toCreatePayload,
+ uploadAttachments,
+} from './item-utils';
+import CustomFieldsSection, { type CustomField } from './custom-fields-section';
+
+interface EditItemProps {
+ item: BwItem;
+ session: string;
+ onSaved: () => void;
+}
+
+function renderLoginFields(login: NonNullable, showPassword: boolean) {
+ return (
+ <>
+
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
+
+function renderCardFields(
+ card: NonNullable,
+ expMonth: string,
+ expYear: string,
+ cardCode: string,
+ onExpMonthChange: (v: string) => void,
+ onExpYearChange: (v: string) => void,
+ onCardCodeChange: (v: string) => void,
+) {
+ return (
+ <>
+
+
+ {CARD_BRANDS.map((b) => (
+
+ ))}
+
+
+ onExpMonthChange(v)}
+ />
+ onExpYearChange(v)}
+ />
+ onCardCodeChange(v)}
+ />
+ >
+ );
+}
+
+function renderIdentityFields(identity: NonNullable) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default function EditItem({ item, session, onSaved }: EditItemProps) {
+ const [fullItem, setFullItem] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [folders, setFolders] = useState([]);
+ const [customFields, setCustomFields] = useState([]);
+ const [attachmentPaths, setAttachmentPaths] = useState([]);
+ const [showPassword, setShowPassword] = useState(false);
+ const [expMonth, setExpMonth] = useState('');
+ const [expYear, setExpYear] = useState('');
+ const [cardCode, setCardCode] = useState('');
+ const fieldIdRef = useRef(0);
+
+ useEffect(() => {
+ void (async () => {
+ let resolved: BwItem;
+ try {
+ resolved = await bw.getItem(item.id, session);
+ } catch {
+ resolved = item;
+ }
+
+ setFullItem(resolved);
+
+ if (resolved.card) {
+ setExpMonth(resolved.card.expMonth ?? '');
+ setExpYear(resolved.card.expYear ?? '');
+ setCardCode(resolved.card.code ?? '');
+ }
+
+ if (resolved.fields && resolved.fields.length > 0) {
+ setCustomFields(
+ resolved.fields.map((f, i) => ({
+ id: i,
+ name: f.name,
+ value: f.value,
+ type: f.type,
+ })),
+ );
+ fieldIdRef.current = resolved.fields.length;
+ }
+
+ setIsLoading(false);
+
+ try {
+ setFolders(await bw.listFolders(session));
+ } catch {
+ // Folder list is optional
+ }
+ })();
+ }, [item.id, session]);
+
+ const handleSubmit = useCallback(
+ async (values: Form.Values) => {
+ setIsSubmitting(true);
+ try {
+ const formValues: Record = {};
+ for (const [key, val] of Object.entries(values)) {
+ formValues[key] = String(val ?? '');
+ }
+
+ const fields =
+ customFields.length > 0
+ ? customFields.map((f) => ({ name: f.name, value: f.value, type: f.type }))
+ : undefined;
+
+ const payload = toCreatePayload(formValues, item.type, formValues.folder || null, fields);
+ await bw.editItem(item.id, payload, session);
+
+ await uploadAttachments(item.id, attachmentPaths, session);
+
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Item updated',
+ message: formValues.name,
+ });
+ onSaved();
+ await popToRoot();
+ } catch (err) {
+ await showFailureToast(err, 'Failed to update item');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [item.id, item.type, session, onSaved, customFields, attachmentPaths],
+ );
+
+ const handleDelete = useCallback(async () => {
+ const confirmed = await confirmAlert({
+ title: 'Delete Item',
+ message: `Are you sure you want to delete "${item.name}"?`,
+ primaryAction: {
+ title: 'Delete',
+ style: Alert.ActionStyle.Destructive,
+ },
+ });
+ if (!confirmed) return;
+ try {
+ await bw.deleteItem(item.id, session);
+ await showToast({ style: Toast.Style.Success, title: 'Item deleted', message: item.name });
+ onSaved();
+ await popToRoot();
+ } catch (err) {
+ await showFailureToast(err, 'Delete failed');
+ }
+ }, [item.id, item.name, session, onSaved]);
+
+ if (isLoading || !fullItem) {
+ return (
+
+
+ );
+ }
+
+ const typeLabel = itemTypeLabel(item);
+ const folderId = fullItem.folderId ?? '';
+
+ return (
+
+ );
+}
diff --git a/extensions/bitwarden/src/edit-send.tsx b/extensions/bitwarden/src/edit-send.tsx
new file mode 100644
index 00000000..9d5908af
--- /dev/null
+++ b/extensions/bitwarden/src/edit-send.tsx
@@ -0,0 +1,161 @@
+// fallow-ignore-file unused-file
+import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from '@vicinae/api';
+import { useCallback, useEffect, useState } from 'react';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import { SendType, type BwSend } from './send-types';
+import { readFormValues } from './item-utils';
+import {
+ deleteSendWithConfirm,
+ sendTypeLabel,
+ toSendPayload,
+ EDIT_HOURS_OPTIONS,
+} from './send-utils';
+
+interface EditSendProps {
+ send: BwSend;
+ session: string;
+ onSaved: () => void;
+}
+
+export default function EditSend({ send, session, onSaved }: EditSendProps) {
+ const [fullSend, setFullSend] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const type = send.type;
+
+ function formatDate(iso: string | null | undefined): string {
+ if (!iso) return 'Not set';
+ return new Date(iso).toLocaleString();
+ }
+
+ useEffect(() => {
+ void (async () => {
+ let resolved: BwSend;
+ try {
+ resolved = await bw.getSend(send.id, session);
+ } catch {
+ resolved = send;
+ }
+ setFullSend(resolved);
+ setIsLoading(false);
+ })();
+ }, [send.id, session]);
+
+ const handleSubmit = useCallback(
+ async (values: Form.Values) => {
+ setIsSubmitting(true);
+ try {
+ const sendValues = readFormValues(values);
+ const payload = toSendPayload(sendValues, type);
+ await bw.editSend(send.id, payload, session);
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Send updated',
+ message: sendValues.name,
+ });
+ onSaved();
+ await popToRoot();
+ } catch (err) {
+ await showFailureToast(err, 'Failed to update send');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [send.id, type, session, onSaved],
+ );
+
+ const handleDelete = useCallback(async () => {
+ await deleteSendWithConfirm(send, session, async () => {
+ onSaved();
+ await popToRoot();
+ });
+ }, [send.id, send.name, session, onSaved]);
+
+ if (isLoading || !fullSend) {
+ return (
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {type === SendType.Text && (
+ <>
+
+
+ >
+ )}
+
+ {type === SendType.File && (
+
+ )}
+
+
+
+
+
+
+ {EDIT_HOURS_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
+ {EDIT_HOURS_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/extensions/bitwarden/src/favicons.ts b/extensions/bitwarden/src/favicons.ts
new file mode 100644
index 00000000..313f7ac2
--- /dev/null
+++ b/extensions/bitwarden/src/favicons.ts
@@ -0,0 +1,276 @@
+import { LocalStorage, environment } from '@vicinae/api';
+import { createHash } from 'node:crypto';
+import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { PNG } from 'pngjs';
+
+const FAVICON_CACHE_KEY = 'vicinae-bitwarden-favicons';
+const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+export type FaviconMap = Record;
+
+interface CacheEntry {
+ dataUri: string;
+ timestamp: number;
+}
+
+let faviconCache: Record = {};
+
+function isCacheEntry(value: unknown): value is CacheEntry {
+ return typeof value === 'object' && value !== null && 'dataUri' in value && 'timestamp' in value;
+}
+
+function hydrateEntry(domain: string, value: unknown, result: FaviconMap): void {
+ if (isCacheEntry(value)) {
+ const entry = value;
+ // Legacy: entries stored as file paths need converting to data URIs
+ if (entry.dataUri.startsWith('/')) {
+ try {
+ entry.dataUri = fileToDataUri(entry.dataUri);
+ } catch {
+ entry.dataUri = '';
+ entry.timestamp = 0;
+ }
+ }
+ result[domain] = entry.dataUri;
+ if (!faviconCache[domain]) {
+ faviconCache[domain] = entry;
+ }
+ } else if (typeof value === 'string') {
+ // Old plain-string format — treat as stale so it gets replaced
+ result[domain] = value;
+ if (!faviconCache[domain]) {
+ faviconCache[domain] = { dataUri: value, timestamp: 0 };
+ }
+ }
+}
+
+export async function loadFaviconCache(): Promise {
+ try {
+ const raw = await LocalStorage.getItem(FAVICON_CACHE_KEY);
+ if (!raw) return {};
+ const parsed: Record = JSON.parse(raw);
+ const result: FaviconMap = {};
+ for (const [domain, value] of Object.entries(parsed)) {
+ hydrateEntry(domain, value, result);
+ }
+ return result;
+ } catch {
+ return {};
+ }
+}
+
+async function persistFaviconCache(): Promise {
+ const map: Record = {};
+ for (const [domain, entry] of Object.entries(faviconCache)) {
+ if (entry.dataUri) map[domain] = entry;
+ }
+ await LocalStorage.setItem(FAVICON_CACHE_KEY, JSON.stringify(map));
+}
+
+// Pre-warm the in-memory cache on module init
+void loadFaviconCache();
+
+export function extractHostname(uris?: { uri: string }[]): string | null {
+ if (!uris?.length) return null;
+ for (const u of uris) {
+ if (!u.uri) continue;
+ try {
+ const urlString = /^https?:\/\//.test(u.uri) ? u.uri : `https://${u.uri}`;
+ return new URL(urlString).hostname;
+ } catch {
+ continue;
+ }
+ }
+ return null;
+}
+
+function faviconDir(): string {
+ return join(environment.supportPath, 'favicons');
+}
+
+function ensureFaviconDir(): void {
+ const dir = faviconDir();
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+}
+
+// Google's globe placeholder — same 16x16 PNG regardless of sz param
+const GLOBE_MD5 = 'b8a0bf372c762e966cc99ede8682bc71';
+
+function isGlobeFavicon(buf: Buffer, status: number): boolean {
+ if (status === 404) return true;
+ if (buf.length < 24) return false;
+ if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) return false;
+ const width = buf.readUInt32BE(16);
+ if (width <= 16) return true;
+ const hash = createHash('md5').update(buf).digest('hex');
+ return hash === GLOBE_MD5;
+}
+
+// iOS-style corner radius ratio. Vicinae has no native cornerRadius prop, so
+// we mask the corners into the PNG bytes once at fetch (and on cold-disk hit
+// for icons cached before this change), then everything renders pre-rounded.
+const CORNER_RADIUS_RATIO = 0.22;
+
+function roundPngCorners(buf: Buffer): Buffer {
+ let png: PNG;
+ try {
+ png = PNG.sync.read(buf);
+ } catch {
+ return buf; // not decodable — hand back the original bytes
+ }
+ const { width, height, data } = png;
+ const radius = Math.max(1, Math.round(Math.min(width, height) * CORNER_RADIUS_RATIO));
+
+ const maskCorner = (
+ cx: number,
+ cy: number,
+ xRange: [number, number],
+ yRange: [number, number],
+ ) => {
+ for (let y = yRange[0]; y < yRange[1]; y++) {
+ for (let x = xRange[0]; x < xRange[1]; x++) {
+ const dx = x + 0.5 - cx;
+ const dy = y + 0.5 - cy;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= radius - 0.5) continue;
+ const idx = (y * width + x) * 4 + 3;
+ if (dist >= radius + 0.5) {
+ data[idx] = 0;
+ } else {
+ // 1px antialiased band
+ const factor = radius + 0.5 - dist;
+ data[idx] = Math.round(data[idx] * factor);
+ }
+ }
+ }
+ };
+ maskCorner(radius, radius, [0, radius], [0, radius]);
+ maskCorner(width - radius, radius, [width - radius, width], [0, radius]);
+ maskCorner(radius, height - radius, [0, radius], [height - radius, height]);
+ maskCorner(width - radius, height - radius, [width - radius, width], [height - radius, height]);
+ return PNG.sync.write(png);
+}
+
+function fileToDataUri(filePath: string): string {
+ const buf = readFileSync(filePath);
+ const rounded = roundPngCorners(buf);
+ return `data:image/png;base64,${rounded.toString('base64')}`;
+}
+
+function resolveDomain(domain: string, now: number, result: FaviconMap): boolean {
+ const entry = faviconCache[domain];
+ const filePath = join(faviconDir(), `${encodeURIComponent(domain)}.png`);
+
+ // In-memory cache hit
+ if (entry && entry.dataUri && now - entry.timestamp <= CACHE_TTL) {
+ if (existsSync(filePath)) {
+ result[domain] = entry.dataUri;
+ return true;
+ }
+ // File deleted since last cache — fall through to re-download
+ }
+
+ // Cold hit: file exists on disk from previous session
+ if (existsSync(filePath)) {
+ try {
+ const mtime = statSync(filePath).mtimeMs;
+ if (now - mtime <= CACHE_TTL) {
+ const dataUri = fileToDataUri(filePath);
+ result[domain] = dataUri;
+ faviconCache[domain] = { dataUri, timestamp: mtime };
+ return true;
+ }
+ } catch {
+ // stale or unreadable — re-download
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Fetch a fresh favicon from Google, write it to disk, and cache as a data URI.
+ * Returns a data URI on success, empty string on failure.
+ */
+async function fetchAndWrite(domain: string, filePath: string, now: number): Promise {
+ const url = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
+ try {
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
+ if (!res.ok) {
+ faviconCache[domain] = { dataUri: '', timestamp: now };
+ return '';
+ }
+ const buf = Buffer.from(await res.arrayBuffer());
+ if (isGlobeFavicon(buf, res.status)) {
+ faviconCache[domain] = { dataUri: '', timestamp: now };
+ return '';
+ }
+ const rounded = roundPngCorners(buf);
+ writeFileSync(filePath, rounded);
+ const dataUri = `data:image/png;base64,${rounded.toString('base64')}`;
+ faviconCache[domain] = { dataUri, timestamp: now };
+ return dataUri;
+ } catch {
+ faviconCache[domain] = { dataUri: '', timestamp: now };
+ return '';
+ }
+}
+
+// Cap concurrent network fetches: Google's favicon service times out under
+// burst load (we hit ~5% timeouts at ~50 parallel requests, more above that).
+const MAX_CONCURRENT_FETCHES = 8;
+
+export async function resolveFavicons(domains: string[]): Promise {
+ const now = Date.now();
+ ensureFaviconDir();
+ const result: FaviconMap = {};
+ const unique = [...new Set(domains)];
+
+ // First pass: resolve everything we can from cache/disk synchronously.
+ const toFetch: string[] = [];
+ for (const domain of unique) {
+ if (!resolveDomain(domain, now, result)) toFetch.push(domain);
+ }
+
+ // Second pass: fetch the rest with bounded concurrency.
+ let cursor = 0;
+ const workers = Array.from(
+ { length: Math.min(MAX_CONCURRENT_FETCHES, toFetch.length) },
+ async () => {
+ while (cursor < toFetch.length) {
+ const domain = toFetch[cursor++];
+ const filePath = join(faviconDir(), `${encodeURIComponent(domain)}.png`);
+ const dataUri = await fetchAndWrite(domain, filePath, now);
+ if (dataUri) result[domain] = dataUri;
+ }
+ },
+ );
+ await Promise.all(workers);
+
+ // Prune entries for domains no longer in the vault. Mirrors how
+ // saveCachedVault overwrites the items list — domains deleted from
+ // Bitwarden drop out of the favicon cache on next sync instead of
+ // accumulating forever.
+ if (unique.length > 0) {
+ const requested = new Set(unique);
+ for (const domain of Object.keys(faviconCache)) {
+ if (requested.has(domain)) continue;
+ delete faviconCache[domain];
+ try {
+ unlinkSync(join(faviconDir(), `${encodeURIComponent(domain)}.png`));
+ } catch {
+ // file already gone or never existed — nothing to do
+ }
+ }
+ }
+
+ await persistFaviconCache();
+ return result;
+}
+
+export function clearFaviconCache(): void {
+ faviconCache = {};
+}
diff --git a/extensions/bitwarden/src/generate-password.ts b/extensions/bitwarden/src/generate-password.ts
new file mode 100644
index 00000000..8a3dd723
--- /dev/null
+++ b/extensions/bitwarden/src/generate-password.ts
@@ -0,0 +1,21 @@
+// fallow-ignore-file unused-file
+import { Clipboard, showToast, Toast } from '@vicinae/api';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import { getPasswordPrefs, getPreferences } from './preferences';
+
+export default async function GeneratePassword() {
+ try {
+ const prefs = getPreferences();
+ const opts = getPasswordPrefs(prefs);
+ const pwd = await bw.generatePassword(opts);
+ await Clipboard.copy(pwd);
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Password generated',
+ message: 'Copied to clipboard',
+ });
+ } catch (err) {
+ await showFailureToast(err, 'Generation failed');
+ }
+}
diff --git a/extensions/bitwarden/src/item-detail-view.tsx b/extensions/bitwarden/src/item-detail-view.tsx
new file mode 100644
index 00000000..29e144e4
--- /dev/null
+++ b/extensions/bitwarden/src/item-detail-view.tsx
@@ -0,0 +1,453 @@
+import {
+ Action,
+ ActionPanel,
+ Clipboard,
+ Detail,
+ Icon,
+ showToast,
+ Toast,
+ useNavigation,
+} from '@vicinae/api';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { dirname } from 'node:path';
+import { useEffect, useState } from 'react';
+import * as bw from './bw-executor';
+import type { Session } from './bw-executor';
+import {
+ buildItemDetailMarkdown,
+ formatTotp,
+ itemActions as getItemActions,
+ itemTypeLabel,
+ actionIcon,
+} from './item-utils';
+import type { BwField, BwItem } from './bitwarden-types';
+import { ItemType } from './bitwarden-types';
+import EditItem from './edit-item';
+
+const exec = promisify(execFile);
+
+function resolveFetchValue(fetchKind: string, item: BwItem): string | undefined {
+ switch (fetchKind) {
+ case 'password':
+ return item.login?.password ?? undefined;
+ case 'cardNumber':
+ return item.card?.number ?? undefined;
+ case 'cardCode':
+ return item.card?.code ?? undefined;
+ default:
+ return undefined;
+ }
+}
+
+function renderItemActionElements(
+ actions: ReturnType,
+ onCopyTotp: (id: string) => void,
+ itemId: string,
+ session: Session | null,
+ showIcons?: boolean,
+) {
+ return actions.map((action) => {
+ if (action.fetchKind === 'totp' || action.label === 'Copy Verification Code') {
+ return (
+ onCopyTotp(itemId)}
+ />
+ );
+ }
+ if (action.label === 'Open URL') {
+ return (
+
+ );
+ }
+ if (action.fetchKind && session) {
+ const kind = action.fetchKind;
+ return (
+ {
+ try {
+ const fullItem = await bw.getItem(itemId, session);
+ const value = resolveFetchValue(kind, fullItem);
+ if (value) {
+ await Clipboard.copy(value);
+ await showToast({
+ style: Toast.Style.Success,
+ title: action.label,
+ });
+ }
+ } catch {
+ await showToast({
+ style: Toast.Style.Failure,
+ title: 'Failed to copy',
+ });
+ }
+ }}
+ />
+ );
+ }
+ return (
+
+ );
+ });
+}
+
+function fieldDisplayText(field: BwField, revealed: boolean): string {
+ if (field.type === 1) {
+ return revealed ? field.value : '••••••••';
+ }
+ if (field.type === 2) {
+ return field.value === 'true' ? 'Yes' : 'No';
+ }
+ return field.value;
+}
+
+function buildMetadata(
+ item: BwItem,
+ folderName: string | undefined,
+ showPassword: boolean,
+ revealedFields: Set,
+ totpCode?: string,
+ totpCountdown?: number,
+) {
+ return (
+
+
+ {folderName && }
+ {renderTypeMetadata(item, showPassword, totpCode, totpCountdown)}
+ {item.fields && item.fields.length > 0 && (
+ <>
+
+
+ {item.fields.map((field, i) => (
+
+ ))}
+ >
+ )}
+ {item.attachments && item.attachments.length > 0 && (
+ <>
+
+
+ {item.attachments.map((att) => (
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+function renderTypeMetadata(
+ item: BwItem,
+ showPassword: boolean,
+ totpCode?: string,
+ totpCountdown?: number,
+) {
+ switch (item.type) {
+ case ItemType.Login:
+ return item.login ? (
+
+ ) : null;
+ case ItemType.Card:
+ return item.card ? : null;
+ case ItemType.Identity:
+ return item.identity ? : null;
+ default:
+ return null;
+ }
+}
+
+function LoginMetadata({
+ login,
+ showPassword,
+ totpCode,
+ totpCountdown,
+}: {
+ login: NonNullable;
+ showPassword: boolean;
+ totpCode?: string;
+ totpCountdown: number;
+}) {
+ return (
+ <>
+
+ {login.username && }
+ {login.password && (
+
+ )}
+ {login.totp && (
+
+ )}
+ {login.uris && login.uris.length > 0 && (
+ u.uri).join(', ')} />
+ )}
+ >
+ );
+}
+
+function CardMetadata({ card }: { card: NonNullable }) {
+ return (
+ <>
+
+ {card.cardholderName && (
+
+ )}
+ {card.brand && }
+ {card.number && (
+
+ )}
+ {card.expMonth && card.expYear && (
+
+ )}
+ {card.code && }
+ >
+ );
+}
+
+function IdentityMetadata({ identity }: { identity: NonNullable }) {
+ return (
+ <>
+
+ {identity.title && }
+ {identity.firstName && }
+ {identity.lastName && }
+ {identity.email && }
+ {identity.phone && }
+ {(identity.address1 || identity.city) && (
+ <>
+
+ {identity.address1 && }
+ {identity.city && }
+ {identity.state && }
+ {identity.postalCode && (
+
+ )}
+ {identity.country && }
+ >
+ )}
+ >
+ );
+}
+
+export { renderItemActionElements };
+
+export default function ItemDetailView({
+ item,
+ session,
+ onCopyTotp,
+ folderName,
+}: {
+ item: BwItem;
+ session: Session | null;
+ onCopyTotp: (id: string) => Promise;
+ folderName?: string;
+}) {
+ const [fullItem, setFullItem] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [totpCode, setTotpCode] = useState();
+ const [totpCountdown, setTotpCountdown] = useState(30);
+ const [showPassword, setShowPassword] = useState(false);
+ const [revealedFields, setRevealedFields] = useState>(new Set());
+ const { pop, push } = useNavigation();
+
+ useEffect(() => {
+ if (!session) {
+ setIsLoading(false);
+ return;
+ }
+ void (async () => {
+ try {
+ const fetched = await bw.getItem(item.id, session);
+ setFullItem(fetched);
+ } catch {
+ setFullItem(item);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [item.id, session]);
+
+ useEffect(() => {
+ if (!session) return;
+ const resolved = fullItem ?? item;
+ if (resolved.type !== ItemType.Login || !resolved.login?.totp) return;
+
+ let active = true;
+ const fetch = async () => {
+ try {
+ const code = await bw.getTotp(item.id, session);
+ if (active) setTotpCode(code);
+ } catch {
+ if (active) setTotpCode(undefined);
+ }
+ };
+
+ fetch();
+ const interval = setInterval(fetch, 30_000);
+ return () => {
+ active = false;
+ clearInterval(interval);
+ };
+ }, [session, fullItem]);
+
+ useEffect(() => {
+ const resolved = fullItem ?? item;
+ if (resolved.type !== ItemType.Login || !resolved.login?.totp) return;
+
+ const tick = () => {
+ setTotpCountdown(30 - (Math.floor(Date.now() / 1000) % 30));
+ };
+ tick();
+ const interval = setInterval(tick, 1000);
+ return () => clearInterval(interval);
+ }, [session, fullItem]);
+
+ const resolved = fullItem ?? item;
+ const markdown = buildItemDetailMarkdown(resolved);
+ const actions = getItemActions(resolved);
+ const resolvedFolderName = folderName ?? resolved.folderId ?? undefined;
+ const metadata = buildMetadata(
+ resolved,
+ resolvedFolderName,
+ showPassword,
+ revealedFields,
+ totpCode,
+ totpCountdown,
+ );
+
+ return (
+
+
+
+ ) : (
+
+
+ {renderItemActionElements(actions.slice(0, 2), onCopyTotp, item.id, session, true)}
+ {resolved.type === ItemType.Login && resolved.login?.password && (
+ setShowPassword((prev) => !prev)}
+ />
+ )}
+ {resolved.fields?.map((field, i) => {
+ const elements: React.ReactNode[] = [];
+ if (field.type === 1) {
+ const revealed = revealedFields.has(i);
+ elements.push(
+ {
+ setRevealedFields((prev) => {
+ const next = new Set(prev);
+ if (revealed) next.delete(i);
+ else next.add(i);
+ return next;
+ });
+ }}
+ />,
+ );
+ }
+ elements.push(
+ ,
+ );
+ return elements;
+ })}
+ {renderItemActionElements(actions.slice(2), onCopyTotp, item.id, session, true)}
+ {session &&
+ resolved.attachments?.map((att) => (
+ {
+ try {
+ const path = await bw.downloadAttachment(
+ att.id,
+ resolved.id,
+ att.fileName,
+ session,
+ );
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Downloaded',
+ message: att.fileName,
+ });
+ exec('xdg-open', [dirname(path)]).catch(() => {});
+ } catch (err) {
+ await showToast({
+ style: Toast.Style.Failure,
+ title: 'Download failed',
+ message: bw.getErrorMessage(err),
+ });
+ }
+ }}
+ />
+ ))}
+ {session && (
+ {
+ push(
+ {
+ setFullItem(null);
+ setIsLoading(true);
+ }}
+ />,
+ );
+ }}
+ />
+ )}
+
+ )
+ }
+ />
+ );
+}
diff --git a/extensions/bitwarden/src/item-icons.ts b/extensions/bitwarden/src/item-icons.ts
new file mode 100644
index 00000000..c69c7f42
--- /dev/null
+++ b/extensions/bitwarden/src/item-icons.ts
@@ -0,0 +1,69 @@
+import type { Image } from '@vicinae/api';
+import type { BwItem, ItemTypeValue } from './bitwarden-types';
+import { ItemType } from './bitwarden-types';
+import { extractHostname } from './favicons';
+import type { FaviconMap } from './favicons';
+
+const SVG_PATHS: Partial> = {
+ [ItemType.Login]:
+ 'M7.5 5.5a3 3 0 1 1 3 3h-.75a.75.75 0 0 0-.53.22L7.44 10.5H6.25a.75.75 0 0 0-.75.75v1.136L4.43 13.5H2.5v-.593c0-.862.342-1.689.952-2.298L7.28 6.78a.75.75 0 0 0 .22-.53zm3-4.5A4.5 4.5 0 0 0 6 5.5v.439L2.39 9.55A4.75 4.75 0 0 0 1 12.906v1.343c0 .414.336.75.75.75h3a.75.75 0 0 0 .541-.23l1.5-1.563a.75.75 0 0 0 .209-.52V12h.75a.75.75 0 0 0 .53-.22L10.06 10h.44a4.5 4.5 0 1 0 0-9m.5 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2',
+ [ItemType.Card]:
+ 'M3.75 3.5c-.69 0-1.25.56-1.25 1.25V6h11V4.75c0-.69-.56-1.25-1.25-1.25zm9.75 4h-11v3.75c0 .69.56 1.25 1.25 1.25h8.5c.69 0 1.25-.56 1.25-1.25zM1 4.75A2.75 2.75 0 0 1 3.75 2h8.5A2.75 2.75 0 0 1 15 4.75v6.5A2.75 2.75 0 0 1 12.25 14h-8.5A2.75 2.75 0 0 1 1 11.25zm3 5A.75.75 0 0 1 4.75 9h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 4 9.75',
+ [ItemType.Identity]:
+ 'M8 2.5A1.75 1.75 0 1 0 8 6a1.75 1.75 0 0 0 0-3.5M4.75 4.25a3.25 3.25 0 1 1 6.5 0 3.25 3.25 0 0 1-6.5 0M8 10c-2.034 0-3.771.948-4.44 2.58-.087.213-.046.402.11.576.173.194.479.344.83.344h7c.351 0 .657-.15.83-.344.156-.174.197-.363.11-.576C11.772 10.948 10.034 10 8 10m-5.828 2.012C3.135 9.662 5.544 8.5 8 8.5s4.865 1.161 5.828 3.512c.332.81.109 1.598-.38 2.144-.473.528-1.193.844-1.947.844H4.499c-.754 0-1.474-.316-1.947-.844-.489-.546-.712-1.334-.38-2.144',
+ [ItemType.SecureNote]:
+ 'M4.75 2.5c-.69 0-1.25.56-1.25 1.25v8.5c0 .69.56 1.25 1.25 1.25h6.5c.69 0 1.25-.56 1.25-1.25V7H9.75A1.75 1.75 0 0 1 8 5.25V2.5zm4.75.81v1.94c0 .138.112.25.25.25h1.94zM2 3.75A2.75 2.75 0 0 1 4.75 1h3.836c.464 0 .909.184 1.237.513l3.664 3.664c.329.328.513.773.513 1.237v5.836A2.75 2.75 0 0 1 11.25 15h-6.5A2.75 2.75 0 0 1 2 12.25z',
+};
+
+const TYPE_COLORS: Partial> = {
+ [ItemType.Login]: { light: '#1F6FEB', dark: '#2F6FED' },
+ [ItemType.Card]: { light: '#3A9C61', dark: '#3A9C61' },
+ [ItemType.Identity]: { light: '#DA8A48', dark: '#F0883E' },
+ [ItemType.SecureNote]: { light: '#A48ED6', dark: '#BC8CFF' },
+};
+
+export function buildIcon(path: string, color: { light: string; dark: string }): Image.ImageLike {
+ const makeSvg = (bg: string) => {
+ const svg = ` `;
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
+ };
+ return { source: { light: makeSvg(color.light), dark: makeSvg(color.dark) } };
+}
+
+function buildPlaceholderIcon(type: ItemTypeValue): Image.ImageLike {
+ const path = SVG_PATHS[type];
+ const color = TYPE_COLORS[type];
+ if (!path || !color) return 'circle';
+ return buildIcon(path, color);
+}
+
+function isImageWithSource(
+ value: Image.ImageLike,
+): value is { source: { light: string; dark: string } } {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'source' in value &&
+ typeof value.source === 'object' &&
+ value.source !== null
+ );
+}
+
+export function itemIcon(item: BwItem, favicons?: FaviconMap): Image.ImageLike {
+ if (item.type !== ItemType.Login) return buildPlaceholderIcon(item.type);
+
+ const hostname = extractHostname(item.login?.uris);
+ if (!hostname) return buildPlaceholderIcon(item.type);
+
+ const cached = favicons?.[hostname];
+ if (cached === undefined || cached === '') return buildPlaceholderIcon(item.type);
+
+ const fallback = buildPlaceholderIcon(ItemType.Login);
+ if (isImageWithSource(fallback)) {
+ return {
+ source: cached,
+ fallback: fallback.source,
+ };
+ }
+ return { source: cached };
+}
diff --git a/extensions/bitwarden/src/item-utils.ts b/extensions/bitwarden/src/item-utils.ts
new file mode 100644
index 00000000..2680e94c
--- /dev/null
+++ b/extensions/bitwarden/src/item-utils.ts
@@ -0,0 +1,293 @@
+import { Form, Icon, showToast, Toast } from '@vicinae/api';
+import type { Image } from '@vicinae/api';
+import { BwItem, BwFolder, ItemType } from './bitwarden-types';
+import type { ItemTypeValue } from './bitwarden-types';
+import type { CreateItemPayload, ItemAction } from './bw-executor';
+import * as bw from './bw-executor';
+import { getErrorMessage } from './bw-executor';
+
+export async function showFailureToast(err: unknown, title: string): Promise {
+ const message = getErrorMessage(err);
+ await showToast({ style: Toast.Style.Failure, title, message });
+ return message;
+}
+
+export function formatTotp(code: string): string {
+ const mid = Math.floor(code.length / 2);
+ return `${code.slice(0, mid)} ${code.slice(mid)}`;
+}
+
+export const CARD_BRANDS = ['Visa', 'Mastercard', 'Amex', 'Discover', 'Other'];
+
+export function digitsOnly(value: string): string {
+ return value.replace(/\D/g, '');
+}
+
+export function readFormValues(values: Form.Values): Record {
+ const result: Record = {};
+ for (const [key, val] of Object.entries(values)) {
+ result[key] = String(val ?? '');
+ }
+ return result;
+}
+
+export { loadCachedVault, saveCachedVault, clearCachedVault } from './vault-cache';
+export { itemIcon } from './item-icons';
+
+/**
+ * Filter items by a case-insensitive substring match against the item name.
+ */
+export function filterItems(items: BwItem[], query: string): BwItem[] {
+ if (!query.trim()) return items;
+ const lower = query.toLowerCase();
+ return items.filter((item) => item.name.toLowerCase().includes(lower));
+}
+
+type GroupedItems = Map;
+
+/**
+ * Group items by folderId. Returns a Map where:
+ * - `null` key maps to unfiled items
+ * - Folder ID keys map to items in that folder
+ */
+export function groupByFolder(
+ items: BwItem[],
+ folders: { id: string; name: string }[],
+): GroupedItems {
+ const folderMap = new Map();
+ for (const f of folders) {
+ folderMap.set(f.id, f.name);
+ }
+
+ const grouped: GroupedItems = new Map();
+
+ for (const item of items) {
+ const key = item.folderId ?? null;
+ if (!grouped.has(key)) {
+ grouped.set(key, {
+ folderName: key ? (folderMap.get(key) ?? 'Unknown') : 'Unfiled',
+ items: [],
+ });
+ }
+ grouped.get(key)!.items.push(item);
+ }
+
+ return grouped;
+}
+
+/**
+ * Get the subtitle to display for an Item (contextual based on type).
+ */
+export function itemSubtitle(item: BwItem): string | undefined {
+ switch (item.type) {
+ case ItemType.Login:
+ return item.login?.username ?? undefined;
+ case ItemType.Card:
+ if (item.card?.cardholderName) return item.card.cardholderName;
+ if (item.card?.brand && item.card.number) {
+ return `${item.card.brand} *${item.card.number.slice(-4)}`;
+ }
+ return undefined;
+ case ItemType.Identity: {
+ const identity = item.identity;
+ if (!identity) return undefined;
+ const parts = [identity.firstName, identity.lastName].filter(Boolean);
+ return parts.length > 0 ? parts.join(' ') : undefined;
+ }
+ case ItemType.SecureNote:
+ return undefined;
+ default:
+ return undefined;
+ }
+}
+
+/**
+ * Get the type label to display for an Item.
+ */
+export function itemTypeLabel(item: BwItem): string {
+ switch (item.type) {
+ case ItemType.Login:
+ return 'Login';
+ case ItemType.Card:
+ return 'Card';
+ case ItemType.Identity:
+ return 'Identity';
+ case ItemType.SecureNote:
+ return 'Secure Note';
+ default:
+ return 'Unknown';
+ }
+}
+
+function getLoginActions(login: BwItem['login']): ItemAction[] {
+ const actions: ItemAction[] = [];
+ if (login?.password) {
+ actions.push({ label: 'Copy Password', value: login.password });
+ } else if (login && login.password !== null) {
+ actions.push({ label: 'Copy Password', value: '', fetchKind: 'password' });
+ }
+ if (login?.username) actions.push({ label: 'Copy Username', value: login.username });
+ if (login?.totp) {
+ actions.push({ label: 'Copy Verification Code', value: 'TOTP' });
+ } else if (login && login.totp !== null) {
+ actions.push({ label: 'Copy Verification Code', value: '', fetchKind: 'totp' });
+ }
+ if (login?.uris?.length) {
+ const primaryUri = login.uris[0]?.uri;
+ if (primaryUri) actions.push({ label: 'Open URL', value: primaryUri });
+ }
+ return actions;
+}
+
+function getCardActions(card: BwItem['card']): ItemAction[] {
+ const actions: ItemAction[] = [];
+ if (card?.number) {
+ actions.push({ label: 'Copy Card Number', value: card.number });
+ } else if (card && card.number !== null) {
+ actions.push({ label: 'Copy Card Number', value: '', fetchKind: 'cardNumber' });
+ }
+ if (card?.code) {
+ actions.push({ label: 'Copy Security Code', value: card.code });
+ } else if (card && card.code !== null) {
+ actions.push({ label: 'Copy Security Code', value: '', fetchKind: 'cardCode' });
+ }
+ return actions;
+}
+
+function getIdentityActions(identity: BwItem['identity']): ItemAction[] {
+ const actions: ItemAction[] = [];
+ if (identity?.firstName && identity?.lastName) {
+ actions.push({ label: 'Copy Name', value: `${identity.firstName} ${identity.lastName}` });
+ }
+ if (identity?.email) actions.push({ label: 'Copy Email', value: identity.email });
+ if (identity?.phone) actions.push({ label: 'Copy Phone', value: identity.phone });
+ return actions;
+}
+
+/**
+ * Get the list of actions for an Item based on its type.
+ */
+export function itemActions(item: BwItem): ItemAction[] {
+ switch (item.type) {
+ case ItemType.Login:
+ return getLoginActions(item.login);
+ case ItemType.Card:
+ return getCardActions(item.card);
+ case ItemType.Identity:
+ return getIdentityActions(item.identity);
+ default:
+ return [];
+ }
+}
+
+export function trimToNull(v: unknown): string | null {
+ return String(v ?? '').trim() || null;
+}
+
+function buildLoginFields(values: Record): CreateItemPayload['login'] {
+ return {
+ username: trimToNull(values.username),
+ password: trimToNull(values.password),
+ totp: trimToNull(values.totp),
+ uris: values.url?.trim() ? [{ uri: values.url.trim(), match: null }] : undefined,
+ };
+}
+
+function buildCardFields(values: Record): CreateItemPayload['card'] {
+ return {
+ cardholderName: trimToNull(values.cardholderName),
+ brand: trimToNull(values.brand),
+ number: trimToNull(values.number),
+ expMonth: trimToNull(values.expMonth),
+ expYear: trimToNull(values.expYear),
+ code: trimToNull(values.code),
+ };
+}
+
+function buildIdentityFields(values: Record): CreateItemPayload['identity'] {
+ return {
+ title: trimToNull(values.title),
+ firstName: trimToNull(values.firstName),
+ middleName: trimToNull(values.middleName),
+ lastName: trimToNull(values.lastName),
+ email: trimToNull(values.email),
+ phone: trimToNull(values.phone),
+ address1: trimToNull(values.address1),
+ address2: trimToNull(values.address2),
+ city: trimToNull(values.city),
+ state: trimToNull(values.state),
+ postalCode: trimToNull(values.postalCode),
+ country: trimToNull(values.country),
+ };
+}
+
+/**
+ * Serialize a form submission into the JSON structure `bw create item` expects.
+ */
+export function toCreatePayload(
+ formValues: Record,
+ type: ItemTypeValue,
+ folderId?: string | null,
+ fields?: { name: string; value: string; type: number }[],
+): CreateItemPayload {
+ const base: CreateItemPayload = {
+ type,
+ name: formValues.name ?? '',
+ notes: trimToNull(formValues.notes),
+ folderId: folderId ?? null,
+ favorite: false,
+ };
+
+ if (type === ItemType.Login) base.login = buildLoginFields(formValues);
+ if (type === ItemType.Card) base.card = buildCardFields(formValues);
+ if (type === ItemType.Identity) base.identity = buildIdentityFields(formValues);
+ if (type === ItemType.SecureNote) base.secureNote = { type: 0 };
+ if (fields && fields.length > 0) base.fields = fields;
+
+ return base;
+}
+
+/**
+ * Build a markdown detail string for an item.
+ */
+export function buildItemDetailMarkdown(item: BwItem): string {
+ if (item.notes) {
+ return `## Notes\n\n${item.notes}`;
+ }
+ return '';
+}
+
+export function actionIcon(action: { label: string }): Image.ImageLike | undefined {
+ switch (action.label) {
+ case 'Copy Password':
+ return Icon.Key;
+ case 'Copy Username':
+ return Icon.Person;
+ case 'Copy Card Number':
+ return Icon.CreditCard;
+ case 'Copy Security Code':
+ return Icon.Lock;
+ case 'Copy Name':
+ return Icon.Person;
+ case 'Copy Email':
+ return Icon.Envelope;
+ case 'Copy Phone':
+ return Icon.Phone;
+ default:
+ return undefined;
+ }
+}
+
+export async function uploadAttachments(
+ itemId: string,
+ filePaths: string[],
+ session: bw.Session,
+): Promise {
+ for (const filePath of filePaths) {
+ try {
+ await bw.createAttachment(itemId, filePath, session);
+ } catch (err) {
+ await showFailureToast(err, 'Failed to attach file');
+ }
+ }
+}
diff --git a/extensions/bitwarden/src/json-utils.ts b/extensions/bitwarden/src/json-utils.ts
new file mode 100644
index 00000000..f50c9f48
--- /dev/null
+++ b/extensions/bitwarden/src/json-utils.ts
@@ -0,0 +1,23 @@
+export function safeJsonParse(
+ raw: string,
+ requiredFields: {
+ strings?: (keyof T & string)[];
+ numbers?: (keyof T & string)[];
+ } = {},
+): T | null {
+ let obj: unknown;
+ try {
+ obj = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof obj !== 'object' || obj === null) return null;
+ const record = obj as Record;
+ for (const field of requiredFields.strings ?? []) {
+ if (typeof record[field] !== 'string') return null;
+ }
+ for (const field of requiredFields.numbers ?? []) {
+ if (typeof record[field] !== 'number') return null;
+ }
+ return record as unknown as T;
+}
diff --git a/extensions/bitwarden/src/logout.ts b/extensions/bitwarden/src/logout.ts
new file mode 100644
index 00000000..2d58c08e
--- /dev/null
+++ b/extensions/bitwarden/src/logout.ts
@@ -0,0 +1,21 @@
+import { showToast, Toast } from '@vicinae/api';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import { deleteSession } from './session-store';
+import { clearCachedSends, clearCachedVault } from './vault-cache';
+
+export default async function Logout() {
+ try {
+ await bw.logout();
+ await deleteSession();
+ await clearCachedVault();
+ await clearCachedSends();
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Logged out',
+ message: 'Your Bitwarden session has been cleared',
+ });
+ } catch (err) {
+ await showFailureToast(err, 'Logout failed');
+ }
+}
diff --git a/extensions/bitwarden/src/preferences.ts b/extensions/bitwarden/src/preferences.ts
new file mode 100644
index 00000000..6d135477
--- /dev/null
+++ b/extensions/bitwarden/src/preferences.ts
@@ -0,0 +1,62 @@
+import { getPreferenceValues } from '@vicinae/api';
+
+interface Preferences {
+ serverRegion: 'bitwarden.com' | 'bitwarden.eu' | 'self-hosted';
+ customServerUrl: string;
+ customCertPath: string;
+ bitwardenApiClientId: string;
+ bitwardenApiClientSecret: string;
+ autoLockTimeout: string;
+ downloadDir: string;
+ passwordLength: string;
+ passwordUppercase: boolean;
+ passwordLowercase: boolean;
+ passwordNumbers: boolean;
+ passwordSymbols: boolean;
+}
+
+interface PasswordPrefs {
+ length: number;
+ uppercase: boolean;
+ lowercase: boolean;
+ numbers: boolean;
+ symbols: boolean;
+}
+
+export function getPreferences(): Preferences {
+ return getPreferenceValues();
+}
+
+export function getAutoLockSeconds(prefs: Preferences): number {
+ return Math.max(0, Number(prefs.autoLockTimeout) || 0);
+}
+
+export function getServerUrl(prefs: Preferences): string {
+ if (prefs.serverRegion === 'self-hosted') {
+ const url = prefs.customServerUrl.trim();
+ if (!url) {
+ throw new Error(
+ 'Custom Server URL is required when using Self-hosted. Set it in extension preferences.',
+ );
+ }
+ return url.replace(/\/+$/, '');
+ }
+ return `https://${prefs.serverRegion}`;
+}
+
+export function getDownloadDir(prefs: Preferences): string {
+ const dir = (prefs.downloadDir ?? '').trim();
+ if (dir) return dir.replace(/\/+$/, '');
+ return `${process.env.HOME ?? '/tmp'}/Downloads`;
+}
+
+export function getPasswordPrefs(prefs: Preferences): PasswordPrefs {
+ const length = Math.max(5, Math.min(128, Number(prefs.passwordLength) || 20));
+ return {
+ length,
+ uppercase: prefs.passwordUppercase,
+ lowercase: prefs.passwordLowercase,
+ numbers: prefs.passwordNumbers,
+ symbols: prefs.passwordSymbols,
+ };
+}
diff --git a/extensions/bitwarden/src/receive-send.ts b/extensions/bitwarden/src/receive-send.ts
new file mode 100644
index 00000000..5c135102
--- /dev/null
+++ b/extensions/bitwarden/src/receive-send.ts
@@ -0,0 +1,95 @@
+// fallow-ignore-file unused-file
+import { Clipboard, closeMainWindow, showHUD, showToast, Toast } from '@vicinae/api';
+import * as bw from './bw-executor';
+import { getErrorMessage } from './bw-executor';
+import { getDownloadDir, getPreferences } from './preferences';
+
+async function handleReceiveError(err: unknown): Promise {
+ if (isPasswordError(err)) {
+ await showToast({
+ style: Toast.Style.Failure,
+ title: 'Send is password-protected',
+ message: 'Use the CLI with --passwordenv: bw send receive --passwordenv BW_PASSWORD',
+ });
+ return true;
+ }
+ if (isEmailVerificationError(err)) {
+ await showToast({
+ style: Toast.Style.Failure,
+ title: 'Email verification required',
+ message:
+ 'This Send requires email verification, which is a premium feature not supported here.',
+ });
+ return true;
+ }
+ return false;
+}
+
+function getDownloadDirectory(): string {
+ try {
+ return getDownloadDir(getPreferences());
+ } catch {
+ return `${process.env.HOME ?? '/tmp'}/Downloads`;
+ }
+}
+
+export default async function ReceiveSend() {
+ let url = '';
+ try {
+ url = (await Clipboard.readText()).trim();
+ } catch {
+ // clipboard read failed
+ }
+
+ if (!url) {
+ await showHUD('No Send URL in clipboard');
+ return;
+ }
+
+ try {
+ const result = await bw.receiveSend(url);
+
+ if (result.kind === 'text' && result.text) {
+ await Clipboard.copy(result.text);
+ const preview = result.text.length > 100 ? `${result.text.slice(0, 100)}…` : result.text;
+ await closeMainWindow();
+ await showHUD(`Send text copied: ${preview}`);
+ return;
+ }
+ } catch (textErr) {
+ if (await handleReceiveError(textErr)) return;
+ // Text receive failed, try file receive
+ }
+
+ try {
+ const downloadDir = getDownloadDirectory();
+ const result = await bw.receiveSend(url, undefined, downloadDir);
+
+ if (result.kind === 'file' && result.path) {
+ await closeMainWindow();
+ await showHUD(`File saved: ${result.path}`);
+ return;
+ }
+ } catch (fileErr) {
+ if (await handleReceiveError(fileErr)) return;
+ const message = getErrorMessage(fileErr);
+ await showToast({
+ style: Toast.Style.Failure,
+ title: 'Failed to receive send',
+ message,
+ });
+ }
+}
+
+function isPasswordError(err: unknown): boolean {
+ const message = getErrorMessage(err).toLowerCase();
+ return (
+ message.includes('password') &&
+ (message.includes('required') || message.includes('protected') || message.includes('incorrect'))
+ );
+}
+
+function isEmailVerificationError(err: unknown): boolean {
+ const message = getErrorMessage(err).toLowerCase();
+ return message.includes('email') && message.includes('verify');
+}
diff --git a/extensions/bitwarden/src/search-sends.tsx b/extensions/bitwarden/src/search-sends.tsx
new file mode 100644
index 00000000..2cb4227b
--- /dev/null
+++ b/extensions/bitwarden/src/search-sends.tsx
@@ -0,0 +1,277 @@
+// fallow-ignore-file unused-file
+import {
+ Action,
+ ActionPanel,
+ Clipboard,
+ Detail,
+ Icon,
+ List,
+ showToast,
+ Toast,
+ useNavigation,
+} from '@vicinae/api';
+import { useCallback, useEffect, useState } from 'react';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import type { BwSend } from './send-types';
+import type { SendAction } from './send-types';
+import {
+ buildDeletionCountdown,
+ buildExpirationCountdown,
+ deleteSendWithConfirm,
+ filterSends,
+ getSendActions,
+ sendAccessUrl,
+ sendActionIcon,
+ sendIcon,
+ sendSubtitle,
+ sendTypeLabel,
+} from './send-utils';
+import { useSession } from './use-session';
+import { loadCachedSends, saveCachedSends } from './vault-cache';
+import { castGateSetter, renderGate, useGateEffects } from './unlock-gate';
+import type { GateUIState } from './unlock-gate';
+import EditSend from './edit-send';
+
+function SendCopyActions({ actions }: { actions: SendAction[] }) {
+ return (
+ <>
+ {actions.map((action) => (
+ {
+ await Clipboard.copy(action.value);
+ await showToast({
+ style: Toast.Style.Success,
+ title: 'Copied',
+ message: action.label,
+ });
+ }}
+ />
+ ))}
+ >
+ );
+}
+
+type UIState = GateUIState | { kind: 'loading' } | { kind: 'list' };
+
+export default function SearchSends() {
+ const { session, unlock, loginIfNeeded, loginError } = useSession();
+ const [state, setState] = useState({ kind: 'checking-bw' });
+ const [sends, setSends] = useState([]);
+ const [searchText, setSearchText] = useState('');
+ const { push } = useNavigation();
+
+ const { handleLogin, handleUnlock } = useGateEffects({
+ session,
+ state,
+ loginIfNeeded,
+ loginError,
+ unlock,
+ setState: castGateSetter(setState),
+ readyKind: 'loading',
+ });
+
+ const loadSends = useCallback(async () => {
+ if (!session) return;
+ try {
+ await bw.sync(session);
+ const result = await bw.listSends(session);
+ setSends(result);
+ await saveCachedSends(result);
+ } catch (err) {
+ await showFailureToast(err, 'Failed to load sends');
+ }
+ }, [session]);
+
+ useEffect(() => {
+ if (state.kind !== 'loading') return;
+ void (async () => {
+ const cached = await loadCachedSends();
+ if (cached) setSends(cached);
+ await loadSends();
+ setState({ kind: 'list' });
+ })();
+ }, [state.kind]);
+
+ const handleSync = useCallback(async () => {
+ setState({ kind: 'loading' });
+ }, []);
+
+ const gateRender = renderGate(state, handleUnlock, handleLogin);
+ if (gateRender) return gateRender;
+
+ if (state.kind === 'checking-bw' || state.kind === 'logging-in' || state.kind === 'loading') {
+ return (
+
+
+
+ );
+ }
+
+ const filtered = filterSends(sends, searchText);
+
+ return (
+
+ {filtered.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+ filtered.map((send) => {
+ const daysLabel = buildDeletionCountdown(send);
+ const expirationLabel = buildExpirationCountdown(send);
+ const accessories: List.Item.Accessory[] = [{ text: sendTypeLabel(send) }];
+ if (daysLabel) accessories.push({ icon: Icon.Clock, text: daysLabel });
+ if (expirationLabel) accessories.push({ icon: Icon.Hourglass, text: expirationLabel });
+
+ return (
+
+ {renderSendActions(
+ send,
+ session,
+ push,
+ (id) => {
+ setSends((prev) => {
+ const next = prev.filter((s) => s.id !== id);
+ void saveCachedSends(next);
+ return next;
+ });
+ },
+ handleSync,
+ )}
+
+ }
+ />
+ );
+ })
+ )}
+
+ );
+}
+
+function renderSendActions(
+ send: BwSend,
+ session: bw.Session | null,
+ push: ReturnType['push'],
+ onRemoved: (id: string) => void,
+ onSync: () => Promise,
+) {
+ const actions = getSendActions(send);
+
+ return (
+ <>
+
+ {
+ push(
+ onRemoved(send.id)} />,
+ );
+ }}
+ />
+
+ >
+ );
+}
+
+function SendDetailView({
+ send,
+ session,
+ onDeleted,
+}: {
+ send: BwSend;
+ session: bw.Session | null;
+ onDeleted: () => void;
+}) {
+ const { pop, push } = useNavigation();
+ const url = sendAccessUrl(send);
+ const textContent = send.text?.text ?? '';
+ const notesSection = send.notes ? `## Notes\n${send.notes}` : '';
+ const separator = textContent && notesSection ? '\n---\n' : '';
+ const markdown = [textContent, separator, notesSection].filter(Boolean).join('');
+
+ const handleDelete = async () => {
+ await deleteSendWithConfirm(send, session, async () => {
+ onDeleted();
+ pop();
+ });
+ };
+
+ const sendActions = getSendActions(send);
+
+ return (
+
+
+ {send.file?.fileName && (
+
+ )}
+
+
+
+ {send.password ? : null}
+
+
+
+ }
+ actions={
+
+
+ {session && (
+ <>
+ {
+ push( pop()} />);
+ }}
+ />
+ void handleDelete()} />
+ >
+ )}
+
+ }
+ />
+ );
+}
diff --git a/extensions/bitwarden/src/search-totp.tsx b/extensions/bitwarden/src/search-totp.tsx
new file mode 100644
index 00000000..88877c9a
--- /dev/null
+++ b/extensions/bitwarden/src/search-totp.tsx
@@ -0,0 +1,120 @@
+// fallow-ignore-file unused-file
+import { Action, ActionPanel, Icon, List } from '@vicinae/api';
+import { useEffect, useState } from 'react';
+import * as bw from './bw-executor';
+import { formatTotp, itemIcon, itemSubtitle } from './item-utils';
+import { useVaultSearch } from './use-vault-search';
+import type { BwItem } from './bitwarden-types';
+import { ItemType } from './bitwarden-types';
+
+function totpItems(items: BwItem[]): BwItem[] {
+ return items.filter(
+ (item) =>
+ item.type === ItemType.Login && item.login?.totp !== null && item.login?.totp !== undefined,
+ );
+}
+
+export default function SearchTotp() {
+ const {
+ state,
+ session,
+ searchText,
+ setSearchText,
+ faviconMap,
+ handleSync,
+ handleCopyTotp,
+ gateRender,
+ isLoading,
+ sortedSections,
+ } = useVaultSearch(totpItems);
+
+ const [totpMap, setTotpMap] = useState>({});
+ const [countdown, setCountdown] = useState(30 - (Math.floor(Date.now() / 1000) % 30));
+
+ // TOTP countdown tick
+ useEffect(() => {
+ const tick = () => setCountdown(30 - (Math.floor(Date.now() / 1000) % 30));
+ tick();
+ const interval = setInterval(tick, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ // Fetch TOTP codes when session is available and vault is loaded
+ useEffect(() => {
+ if (!session) return;
+ if (state.kind !== 'vault') return;
+ const ids = totpItems(state.items).map((i) => i.id);
+
+ const fetchCodes = async () => {
+ const results = await Promise.allSettled(ids.map((id) => bw.getTotp(id, session)));
+ const map: Record = {};
+ results.forEach((r, i) => {
+ if (r.status === 'fulfilled') map[ids[i]] = r.value;
+ });
+ setTotpMap(map);
+ };
+
+ fetchCodes();
+ const interval = setInterval(fetchCodes, 30_000);
+ return () => clearInterval(interval);
+ }, [session, state.kind === 'vault' ? state.items.length : 0]);
+
+ if (gateRender) return gateRender;
+
+ function renderVaultContent() {
+ if (sortedSections.length === 0) {
+ return (
+
+ );
+ }
+
+ return sortedSections.map(([folderId, { folderName, items: sectionItems }]) => (
+
+ {sectionItems.map((item) => {
+ const code = totpMap[item.id];
+ return (
+
+ handleCopyTotp(item.id)}
+ />
+
+
+ }
+ />
+ );
+ })}
+
+ ));
+ }
+
+ return (
+
+ {state.kind === 'vault' ? renderVaultContent() : }
+
+ );
+}
diff --git a/extensions/bitwarden/src/search-vault.tsx b/extensions/bitwarden/src/search-vault.tsx
new file mode 100644
index 00000000..ec2f7390
--- /dev/null
+++ b/extensions/bitwarden/src/search-vault.tsx
@@ -0,0 +1,116 @@
+import { Action, ActionPanel, Icon, List, useNavigation } from '@vicinae/api';
+import * as bw from './bw-executor';
+import { itemActions as getItemActions, itemIcon, itemSubtitle, itemTypeLabel } from './item-utils';
+import { useVaultSearch } from './use-vault-search';
+import ItemDetailView, { renderItemActionElements } from './item-detail-view';
+import EditItem from './edit-item';
+import type { BwFolder, BwItem } from './bitwarden-types';
+
+export default function SearchVault() {
+ const {
+ state,
+ session,
+ searchText,
+ setSearchText,
+ faviconMap,
+ vaultFolders,
+ handleSync,
+ handleCopyTotp,
+ gateRender,
+ isLoading,
+ sortedSections,
+ } = useVaultSearch();
+
+ const { push } = useNavigation();
+
+ if (gateRender) return gateRender;
+
+ function renderVaultContent() {
+ if (sortedSections.length === 0) {
+ return (
+
+ );
+ }
+
+ return sortedSections.map(([folderId, { folderName, items: sectionItems }]) => (
+
+ {sectionItems.map((item) => (
+
+ {renderItemActions(item, session, handleCopyTotp, push, vaultFolders, handleSync)}
+
+
+ }
+ />
+ ))}
+
+ ));
+ }
+
+ return (
+
+ {state.kind === 'vault' ? renderVaultContent() : }
+
+ );
+}
+
+function renderItemActions(
+ item: BwItem,
+ session: bw.Session | null,
+ onCopyTotp: (id: string) => Promise,
+ push: ReturnType['push'],
+ folders: BwFolder[],
+ onSync: () => Promise,
+) {
+ const actions = getItemActions(item);
+ const folderName = item.folderId
+ ? (folders.find((f) => f.id === item.folderId)?.name ?? item.folderId)
+ : undefined;
+
+ return (
+ <>
+ {
+ push(
+ ,
+ );
+ }}
+ />
+ {renderItemActionElements(actions, onCopyTotp, item.id, session)}
+ {session && (
+ {
+ push( void onSync()} />);
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/extensions/bitwarden/src/secret-store.ts b/extensions/bitwarden/src/secret-store.ts
new file mode 100644
index 00000000..a04f2895
--- /dev/null
+++ b/extensions/bitwarden/src/secret-store.ts
@@ -0,0 +1,63 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { spawnWait } from './spawn-stdin';
+
+const exec = promisify(execFile);
+
+const SERVICE = 'vicinae-bitwarden';
+
+export async function secretLookup(account: string): Promise {
+ try {
+ const { stdout } = await exec(
+ 'secret-tool',
+ ['lookup', 'service', SERVICE, 'account', account],
+ { timeout: 5000 },
+ );
+ const raw = stdout.trim();
+ return raw || null;
+ } catch {
+ return null;
+ }
+}
+
+export async function secretStore(account: string, data: string, label: string): Promise {
+ await spawnWait(
+ 'secret-tool',
+ ['store', `--label=${label}`, 'service', SERVICE, 'account', account],
+ data,
+ );
+}
+
+export async function secretClear(account: string): Promise {
+ try {
+ await exec('secret-tool', ['clear', 'service', SERVICE, 'account', account], {
+ timeout: 5000,
+ });
+ } catch {
+ // Non-fatal
+ }
+}
+
+let installed: boolean | null = null;
+
+function isNodeError(err: unknown): err is { code: string } & Error {
+ return err instanceof Error && 'code' in err;
+}
+
+export async function checkSecretToolInstalled(): Promise {
+ if (installed !== null) return installed;
+ try {
+ await exec('secret-tool', ['lookup', 'service', SERVICE, 'account', 'session'], {
+ timeout: 5000,
+ });
+ installed = true;
+ return true;
+ } catch (err) {
+ if (isNodeError(err) && err.code === 'ENOENT') {
+ installed = false;
+ return false;
+ }
+ installed = true;
+ return true;
+ }
+}
diff --git a/extensions/bitwarden/src/send-types.ts b/extensions/bitwarden/src/send-types.ts
new file mode 100644
index 00000000..897b666d
--- /dev/null
+++ b/extensions/bitwarden/src/send-types.ts
@@ -0,0 +1,44 @@
+export const SendType = {
+ Text: 0,
+ File: 1,
+} as const;
+
+export type SendTypeValue = (typeof SendType)[keyof typeof SendType];
+
+export interface BwSend {
+ id: string;
+ accessId: string;
+ name: string;
+ notes: string | null;
+ type: SendTypeValue;
+ password: string | null;
+ text: { text: string; hidden: boolean } | null;
+ file: { id: string; fileName: string; size: number; sizeName: string } | null;
+ maxAccessCount: number | null;
+ accessCount: number;
+ deletionDate: string;
+ expirationDate: string | null;
+ creationDate: string;
+ revisionDate: string;
+ disabled: boolean;
+ hideEmail: boolean;
+}
+
+export interface CreateSendPayload {
+ name: string;
+ notes: string | null;
+ type: SendTypeValue;
+ text: { text: string; hidden: boolean } | null;
+ file: { fileName: string } | null;
+ password: string | null;
+ maxAccessCount: number | null;
+ deletionDate: string | null;
+ expirationDate: string | null;
+ disabled: boolean;
+ hideEmail: boolean;
+}
+
+export interface SendAction {
+ label: string;
+ value: string;
+}
diff --git a/extensions/bitwarden/src/send-utils.ts b/extensions/bitwarden/src/send-utils.ts
new file mode 100644
index 00000000..00403780
--- /dev/null
+++ b/extensions/bitwarden/src/send-utils.ts
@@ -0,0 +1,202 @@
+import { Alert, confirmAlert, Icon, showToast, Toast } from '@vicinae/api';
+import type { Image } from '@vicinae/api';
+import { SendType } from './send-types';
+import type { BwSend, CreateSendPayload, SendAction, SendTypeValue } from './send-types';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import { buildIcon } from './item-icons';
+import { getPreferences, getServerUrl } from './preferences';
+import { trimToNull } from './item-utils';
+
+export function filterSends(sends: BwSend[], query: string): BwSend[] {
+ if (!query.trim()) return sends;
+ const lower = query.toLowerCase();
+ return sends.filter((send) => send.name.toLowerCase().includes(lower));
+}
+
+export function sendTypeLabel(send: BwSend): string {
+ return send.type === SendType.File ? 'File' : 'Text';
+}
+
+export async function deleteSendWithConfirm(
+ send: { id: string; name: string },
+ session: bw.Session | null,
+ onSuccess: () => void,
+): Promise {
+ if (!session) return;
+ const confirmed = await confirmAlert({
+ title: 'Delete Send',
+ message: `Are you sure you want to delete "${send.name}"?`,
+ primaryAction: { title: 'Delete', style: Alert.ActionStyle.Destructive },
+ });
+ if (!confirmed) return;
+ try {
+ await bw.deleteSend(send.id, session);
+ await showToast({ style: Toast.Style.Success, title: 'Send deleted', message: send.name });
+ onSuccess();
+ } catch (err) {
+ await showFailureToast(err, 'Delete failed');
+ }
+}
+
+export function sendSubtitle(send: BwSend): string {
+ if (send.type === SendType.File && send.file?.fileName) {
+ return `File: ${send.file.fileName}`;
+ }
+ if (send.type === SendType.Text && send.text?.text && !send.text.hidden) {
+ const preview = send.text.text.slice(0, 50);
+ return send.text.text.length > 50 ? `${preview}…` : preview;
+ }
+ return sendTypeLabel(send);
+}
+
+export function getSendActions(send: BwSend): SendAction[] {
+ const actions: SendAction[] = [{ label: 'Copy Send Link', value: sendAccessUrl(send) }];
+ if (send.type === SendType.Text && send.text?.text) {
+ actions.push({ label: 'Copy Text', value: send.text.text });
+ }
+ return actions;
+}
+
+export function sendActionIcon(action: { label: string }): Image.ImageLike | undefined {
+ switch (action.label) {
+ case 'Copy Send Link':
+ return Icon.Link;
+ case 'Copy Text':
+ return Icon.CopyClipboard;
+ default:
+ return undefined;
+ }
+}
+
+export function sendAccessUrl(send: BwSend): string {
+ try {
+ const prefs = getPreferences();
+ const serverUrl = getServerUrl(prefs);
+ const base = serverUrl.replace(/\/+$/, '');
+ return `${base}/#/send/${send.accessId}`;
+ } catch {
+ return `https://vault.bitwarden.com/#/send/${send.accessId}`;
+ }
+}
+
+export function daysUntilDeletion(send: BwSend): number | null {
+ if (!send.deletionDate) return null;
+ const now = Date.now();
+ const deletion = new Date(send.deletionDate).getTime();
+ if (isNaN(deletion)) return null;
+ const diff = deletion - now;
+ return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
+}
+
+export function buildDeletionCountdown(send: BwSend): string {
+ const days = daysUntilDeletion(send);
+ if (days === null) return '';
+ if (days === 0) return 'Today';
+ return `${days}d`;
+}
+
+export function buildExpirationCountdown(send: BwSend): string {
+ if (!send.expirationDate) return '';
+ const now = Date.now();
+ const expiration = new Date(send.expirationDate).getTime();
+ if (isNaN(expiration)) return '';
+ const diff = expiration - now;
+ if (diff <= 0) return 'Expired';
+ const hours = Math.ceil(diff / (60 * 60 * 1000));
+ if (hours < 24) return `${hours}h`;
+ const days = Math.ceil(hours / 24);
+ return `${days}d`;
+}
+
+export const HOURS_OPTIONS = [
+ { value: '1', title: '1 hour' },
+ { value: '6', title: '6 hours' },
+ { value: '12', title: '12 hours' },
+ { value: '24', title: '1 day' },
+ { value: '48', title: '2 days' },
+ { value: '168', title: '7 days' },
+ { value: '336', title: '14 days' },
+ { value: '720', title: '30 days' },
+ { value: '0', title: 'Never' },
+];
+
+export const EDIT_HOURS_OPTIONS = [{ value: '-1', title: 'Keep existing' }, ...HOURS_OPTIONS];
+
+export function toSendPayload(
+ formValues: Record,
+ type: SendTypeValue,
+): CreateSendPayload {
+ const password = trimToNull(formValues.password);
+ const notes = trimToNull(formValues.notes);
+ let maxAccessCount: number | null = null;
+ if (formValues.maxAccessCount?.trim()) {
+ const raw = Number(formValues.maxAccessCount);
+ if (!isNaN(raw)) maxAccessCount = raw;
+ }
+
+ let deletionDate: string | null = null;
+ if (
+ formValues.deletionHours &&
+ formValues.deletionHours !== '0' &&
+ formValues.deletionHours !== '-1'
+ ) {
+ const hours = Number(formValues.deletionHours) || 0;
+ deletionDate = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
+ }
+
+ let expirationDate: string | null = null;
+ if (
+ formValues.expirationHours &&
+ formValues.expirationHours !== '0' &&
+ formValues.expirationHours !== '-1'
+ ) {
+ const hours = Number(formValues.expirationHours) || 0;
+ expirationDate = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
+ }
+
+ const text =
+ type === SendType.Text
+ ? {
+ text: formValues.textContent ?? '',
+ hidden: formValues.hideText === 'true',
+ }
+ : null;
+
+ const file =
+ type === SendType.File
+ ? {
+ fileName: formValues.fileName ?? '',
+ }
+ : null;
+
+ return {
+ name: formValues.name ?? '',
+ type,
+ notes,
+ disabled: formValues.disabled === 'true',
+ hideEmail: formValues.hideEmail === 'true',
+ password,
+ maxAccessCount,
+ deletionDate,
+ expirationDate,
+ text,
+ file,
+ };
+}
+
+const SEND_ICON_PATHS: Record = {
+ [SendType.Text]:
+ 'M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 12.25v-8.5A1.75 1.75 0 0 0 12.25 2zm.75 2.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1 0-1.5m0 3h5a.75.75 0 0 1 0 1.5h-5a.75.75 0 0 1 0-1.5',
+ [SendType.File]:
+ 'M5 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V5.414a1.5 1.5 0 0 0-.44-1.06L9.647 1.439A1.5 1.5 0 0 0 8.586 1zm.5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 0 1H6a.5.5 0 0 1-.5-.5M6 6h4a.5.5 0 0 1 0 1H6a.5.5 0 0 1 0-1m0 2h4a.5.5 0 0 1 0 1H6a.5.5 0 0 1 0-1m0 2h3a.5.5 0 0 1 0 1H6a.5.5 0 0 1 0-1',
+};
+
+const SEND_ICON_COLORS: Record = {
+ [SendType.Text]: { light: '#1F6FEB', dark: '#2F6FED' },
+ [SendType.File]: { light: '#3A9C61', dark: '#3A9C61' },
+};
+
+export function sendIcon(type: SendTypeValue): Image.ImageLike {
+ return buildIcon(SEND_ICON_PATHS[type], SEND_ICON_COLORS[type]);
+}
diff --git a/extensions/bitwarden/src/session-store.ts b/extensions/bitwarden/src/session-store.ts
new file mode 100644
index 00000000..6a4c2e62
--- /dev/null
+++ b/extensions/bitwarden/src/session-store.ts
@@ -0,0 +1,43 @@
+import { getAutoLockSeconds, getPreferences } from './preferences';
+import { secretStore, secretLookup, secretClear } from './secret-store';
+import { safeJsonParse } from './json-utils';
+
+export { checkSecretToolInstalled } from './secret-store';
+
+const ACCOUNT = 'session';
+
+interface SessionPayload {
+ token: string;
+ timestamp: number;
+}
+
+export async function getSession(): Promise {
+ try {
+ const raw = await secretLookup(ACCOUNT);
+ if (!raw) return null;
+
+ const parsed = safeJsonParse<{ token: string; timestamp: number }>(raw, {
+ strings: ['token'],
+ numbers: ['timestamp'],
+ });
+ if (!parsed) return null;
+
+ const timeout = getAutoLockSeconds(getPreferences());
+ if (timeout > 0 && Date.now() - parsed.timestamp > timeout * 1000) {
+ await deleteSession();
+ return null;
+ }
+ return parsed.token;
+ } catch {
+ return null;
+ }
+}
+
+export async function setSession(token: string): Promise {
+ const payload: SessionPayload = { token, timestamp: Date.now() };
+ await secretStore(ACCOUNT, JSON.stringify(payload), 'Vicinae Bitwarden');
+}
+
+export async function deleteSession(): Promise {
+ await secretClear(ACCOUNT);
+}
diff --git a/extensions/bitwarden/src/spawn-stdin.ts b/extensions/bitwarden/src/spawn-stdin.ts
new file mode 100644
index 00000000..53697c43
--- /dev/null
+++ b/extensions/bitwarden/src/spawn-stdin.ts
@@ -0,0 +1,30 @@
+import { spawn } from 'node:child_process';
+import type { SpawnOptions } from 'node:child_process';
+
+export function spawnWait(
+ bin: string,
+ args: string[],
+ data: string,
+ opts?: Omit,
+): Promise {
+ const proc = spawn(bin, args, {
+ ...opts,
+ stdio: ['pipe', 'ignore', 'ignore'],
+ });
+
+ return new Promise((resolve, reject) => {
+ proc.on('error', reject);
+ proc.on('close', (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`${bin} exited with code ${code}`));
+ });
+
+ if (!proc.stdin) {
+ reject(new Error('stdin is not available'));
+ return;
+ }
+ proc.stdin.on('error', reject);
+ proc.stdin.write(data);
+ proc.stdin.end();
+ });
+}
diff --git a/extensions/bitwarden/src/unlock-gate.tsx b/extensions/bitwarden/src/unlock-gate.tsx
new file mode 100644
index 00000000..3adfa14d
--- /dev/null
+++ b/extensions/bitwarden/src/unlock-gate.tsx
@@ -0,0 +1,257 @@
+import { Action, ActionPanel, Form, showToast, Toast } from '@vicinae/api';
+import { useCallback, useEffect } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+import { BwNotInstalled, SecretToolNotInstalled } from './bw-not-installed';
+import * as bw from './bw-executor';
+import { getErrorMessage } from './bw-executor';
+import { checkSecretToolInstalled } from './secret-store';
+
+export type GateUIState =
+ | { kind: 'checking-bw' }
+ | { kind: 'bw-not-installed' }
+ | { kind: 'secret-tool-not-installed' }
+ | { kind: 'logging-in' }
+ | { kind: 'login-failed'; error: string }
+ | { kind: 'needs-unlock'; error?: string }
+ | { kind: 'unlocking' };
+
+export async function checkBwGate(
+ session: string | null,
+): Promise<
+ | { kind: 'bw-not-installed' }
+ | { kind: 'secret-tool-not-installed' }
+ | { kind: 'logging-in' }
+ | { kind: 'needs-unlock' }
+ | { kind: 'ready' }
+> {
+ const [installed, stInstalled, statusResult] = await Promise.allSettled([
+ bw.checkInstalled(),
+ checkSecretToolInstalled(),
+ bw.status(),
+ ]);
+
+ if (installed.status === 'rejected' || !installed.value) {
+ return { kind: 'bw-not-installed' };
+ }
+
+ if (stInstalled.status === 'rejected' || !stInstalled.value) {
+ return { kind: 'secret-tool-not-installed' };
+ }
+
+ if (statusResult.status === 'fulfilled' && statusResult.value.status === 'unauthenticated') {
+ return { kind: 'logging-in' };
+ }
+
+ if (session) return { kind: 'ready' };
+ return { kind: 'needs-unlock' };
+}
+
+type GateSetState = (
+ next:
+ | { kind: 'unlocking' }
+ | { kind: 'needs-unlock'; error?: string }
+ | { kind: 'login-failed'; error: string },
+) => void;
+
+export function createUnlockCallbacks(
+ setState: GateSetState,
+ onUnlockReady: () => void,
+): Pick<
+ UnlockGateDeps,
+ 'onUnlockStart' | 'onUnlockReady' | 'onUnlockError' | 'onLoginReady' | 'onLoginError'
+> {
+ return {
+ onUnlockStart: () => setState({ kind: 'unlocking' }),
+ onUnlockReady,
+ onUnlockError: (error) => setState({ kind: 'needs-unlock', error }),
+ onLoginReady: () => setState({ kind: 'needs-unlock' }),
+ onLoginError: (error) => setState({ kind: 'login-failed', error }),
+ };
+}
+
+interface UnlockGateDeps {
+ loginIfNeeded: () => Promise;
+ loginError: string | null;
+ unlock: (password: string) => Promise;
+ onUnlockStart: () => void;
+ onUnlockReady: () => void;
+ onUnlockError: (error: string) => void;
+ onLoginReady: () => void;
+ onLoginError: (error: string) => void;
+}
+
+export function useUnlockGate(deps: UnlockGateDeps) {
+ const handleLogin = useCallback(async () => {
+ try {
+ await deps.loginIfNeeded();
+ deps.onLoginReady();
+ } catch {
+ const message = deps.loginError ?? 'Login failed — check preferences';
+ deps.onLoginError(message);
+ showToast({
+ style: Toast.Style.Failure,
+ title: 'Login failed',
+ message: deps.loginError ?? 'Check your API key in preferences',
+ });
+ }
+ }, [deps.loginIfNeeded, deps.loginError, deps.onLoginReady, deps.onLoginError]);
+
+ const handleUnlock = useCallback(
+ async (values: Form.Values) => {
+ deps.onUnlockStart();
+ try {
+ const password = String(values.password ?? '');
+ await deps.unlock(password);
+ deps.onUnlockReady();
+ } catch (err) {
+ const message = getErrorMessage(err);
+ deps.onUnlockError(message);
+ }
+ },
+ [deps.unlock, deps.onUnlockStart, deps.onUnlockReady, deps.onUnlockError],
+ );
+
+ return { handleLogin, handleUnlock };
+}
+
+interface GateState {
+ kind: string;
+ error?: string;
+}
+
+export function renderGate(
+ state: GateState,
+ handleUnlock: (values: Form.Values) => Promise,
+ handleLogin?: () => void,
+): React.ReactElement | null {
+ const gateError =
+ state.kind === 'needs-unlock' || state.kind === 'login-failed' ? state.error : undefined;
+ return renderUnlockGate(state.kind, gateError, handleUnlock, handleLogin);
+}
+
+/**
+ * Render the gate UI for a form-based command, or the loading placeholder
+ * while gate state is resolving. Returns null when the form itself should render.
+ */
+export function renderFormGate(
+ state: GateState,
+ handleUnlock: (values: Form.Values) => Promise,
+ handleLogin?: () => void,
+): React.ReactElement | null {
+ const gate = renderGate(state, handleUnlock, handleLogin);
+ if (gate) return gate;
+ if (state.kind === 'checking-bw' || state.kind === 'logging-in') {
+ return (
+
+
+ );
+ }
+ return null;
+}
+
+export function renderUnlockGate(
+ kind: string,
+ error: string | undefined,
+ onUnlock: (values: Form.Values) => Promise,
+ onRetryLogin?: () => void,
+) {
+ if (kind === 'bw-not-installed') return ;
+
+ if (kind === 'secret-tool-not-installed') return ;
+
+ if (kind === 'login-failed') {
+ return (
+
+
+ );
+ }
+
+ if (kind === 'needs-unlock' || kind === 'unlocking') {
+ return (
+
+
+ );
+ }
+
+ return null;
+}
+
+interface UseGateEffectsParams {
+ session: string | null;
+ state: { kind: string };
+ loginIfNeeded: () => Promise;
+ loginError: string | null;
+ unlock: (password: string) => Promise;
+ setState: (value: { kind: string; error?: string }) => void;
+ readyKind: string;
+}
+
+export function castGateSetter(
+ setState: Dispatch>,
+): (value: { kind: string; error?: string }) => void {
+ return (value) => setState(value as T);
+}
+
+export function useGateEffects(params: UseGateEffectsParams) {
+ const { session, state, loginIfNeeded, loginError, unlock, setState, readyKind } = params;
+
+ const { handleLogin, handleUnlock } = useUnlockGate({
+ loginIfNeeded,
+ loginError,
+ unlock,
+ ...createUnlockCallbacks(setState, () => setState({ kind: readyKind })),
+ });
+
+ useEffect(() => {
+ void (async () => {
+ const gate = await checkBwGate(session);
+ switch (gate.kind) {
+ case 'bw-not-installed':
+ case 'secret-tool-not-installed':
+ case 'logging-in':
+ case 'needs-unlock':
+ setState({ kind: gate.kind });
+ return;
+ case 'ready':
+ setState({ kind: readyKind });
+ return;
+ }
+ })();
+ }, []);
+
+ useEffect(() => {
+ if (!session) return;
+ if (state.kind !== 'needs-unlock') return;
+ setState({ kind: readyKind });
+ }, [session, state.kind]);
+
+ useEffect(() => {
+ if (state.kind !== 'logging-in') return;
+ void handleLogin();
+ }, [state.kind]);
+
+ return { handleLogin, handleUnlock };
+}
diff --git a/extensions/bitwarden/src/use-session.ts b/extensions/bitwarden/src/use-session.ts
new file mode 100644
index 00000000..ef021530
--- /dev/null
+++ b/extensions/bitwarden/src/use-session.ts
@@ -0,0 +1,120 @@
+import { useCallback, useEffect, useState } from 'react';
+import { getPreferences, getServerUrl } from './preferences';
+import * as bw from './bw-executor';
+import type { Session } from './bw-executor';
+import { getErrorMessage } from './bw-executor';
+import { deleteSession, getSession, setSession as storeSession } from './session-store';
+import {
+ getApiCredentials,
+ storeApiCredentials,
+ clearApiCredentialsFromDisk,
+} from './api-credential-store';
+import { clearCachedSends } from './vault-cache';
+
+interface SessionState {
+ session: Session | null;
+ unlock: (masterPassword: string) => Promise;
+ clearSession: () => Promise;
+ loginIfNeeded: () => Promise;
+ isLoggingIn: boolean;
+ loginError: string | null;
+}
+
+export function useSession(): SessionState {
+ const [session, setSession] = useState(null);
+ const [isLoggingIn, setIsLoggingIn] = useState(false);
+ const [loginError, setLoginError] = useState(null);
+
+ useEffect(() => {
+ void (async () => {
+ const cached = await getSession();
+ if (cached) {
+ setSession(cached);
+ }
+ })();
+ }, []);
+
+ const loginIfNeeded = useCallback(async () => {
+ setIsLoggingIn(true);
+ setLoginError(null);
+
+ try {
+ const prefs = getPreferences();
+ const serverUrl = getServerUrl(prefs);
+
+ const libsecretCreds = await getApiCredentials();
+
+ if (libsecretCreds) {
+ const prefClientId = prefs.bitwardenApiClientId;
+ const prefClientSecret = prefs.bitwardenApiClientSecret;
+ const isRotated =
+ prefClientId &&
+ prefClientSecret &&
+ (prefClientId !== libsecretCreds.clientId ||
+ prefClientSecret !== libsecretCreds.clientSecret);
+
+ if (isRotated) {
+ await bw.login({
+ clientId: prefClientId,
+ clientSecret: prefClientSecret,
+ serverUrl,
+ });
+ await storeApiCredentials(prefClientId, prefClientSecret);
+ } else {
+ await bw.login({
+ clientId: libsecretCreds.clientId,
+ clientSecret: libsecretCreds.clientSecret,
+ serverUrl,
+ });
+ }
+ } else {
+ const prefClientId = prefs.bitwardenApiClientId;
+ const prefClientSecret = prefs.bitwardenApiClientSecret;
+
+ if (!prefClientId || !prefClientSecret) {
+ throw new Error('No API credentials configured');
+ }
+
+ await bw.login({
+ clientId: prefClientId,
+ clientSecret: prefClientSecret,
+ serverUrl,
+ });
+
+ try {
+ await storeApiCredentials(prefClientId, prefClientSecret);
+ } catch {
+ // Migration failure is non-fatal — login already succeeded
+ }
+ }
+
+ void clearApiCredentialsFromDisk();
+ } catch (err) {
+ const message = getErrorMessage(err);
+ setLoginError(message);
+ throw err;
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, []);
+
+ const unlock = useCallback(async (masterPassword: string): Promise => {
+ const token = await bw.unlock(masterPassword);
+ await storeSession(token);
+ setSession(token);
+ return token;
+ }, []);
+
+ const clearSession = useCallback(async () => {
+ await deleteSession();
+ await clearCachedSends();
+ setSession(null);
+ if (session) {
+ void bw.lock(session).catch(() => {
+ // Non-fatal — session already cleared client-side
+ });
+ }
+ }, [session]);
+
+ return { session, unlock, clearSession, loginIfNeeded, isLoggingIn, loginError };
+}
diff --git a/extensions/bitwarden/src/use-vault-search.ts b/extensions/bitwarden/src/use-vault-search.ts
new file mode 100644
index 00000000..b208fb28
--- /dev/null
+++ b/extensions/bitwarden/src/use-vault-search.ts
@@ -0,0 +1,100 @@
+import { useState, useCallback, useMemo } from 'react';
+import { Clipboard, showToast, Toast } from '@vicinae/api';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import { filterItems, groupByFolder } from './item-utils';
+import { useSession } from './use-session';
+import { createUnlockCallbacks, renderGate, useUnlockGate } from './unlock-gate';
+import { useVaultSync } from './use-vault-sync';
+import { useVaultLifecycle, type UIState } from './vault-lifecycle';
+import type { BwFolder, BwItem } from './bitwarden-types';
+
+export function useVaultSearch(preFilter?: (items: BwItem[]) => BwItem[]) {
+ const { session, unlock, clearSession, loginIfNeeded, loginError } = useSession();
+ const [state, setState] = useState({ kind: 'checking-bw' });
+ const [searchText, setSearchText] = useState('');
+ const [faviconMap, setFaviconMap] = useState>({});
+
+ const setVault = useCallback((items: BwItem[], folders: BwFolder[]) => {
+ setState({ kind: 'vault', items, folders });
+ }, []);
+
+ const { handleLogin, handleUnlock } = useUnlockGate({
+ loginIfNeeded,
+ loginError,
+ unlock,
+ ...createUnlockCallbacks(setState, () => setState({ kind: 'loading' })),
+ });
+
+ const { syncVault, handleSync, isSyncing } = useVaultSync(session, setVault);
+
+ useVaultLifecycle({
+ session,
+ state,
+ setState,
+ setVault,
+ syncVault,
+ handleLogin,
+ clearSession,
+ setFaviconMap,
+ });
+
+ const vaultItems = state.kind === 'vault' ? state.items : [];
+ const vaultFolders = state.kind === 'vault' ? state.folders : [];
+
+ const handleCopyTotp = useCallback(
+ async (id: string) => {
+ if (!session) return;
+ try {
+ const totp = await bw.getTotp(id, session);
+ await Clipboard.copy(totp);
+ await showToast({ style: Toast.Style.Success, title: 'Copied TOTP' });
+ } catch (err) {
+ await showFailureToast(err, 'Failed to get TOTP');
+ }
+ },
+ [session],
+ );
+
+ const displayItems = useMemo(
+ () => (preFilter ? preFilter(vaultItems) : vaultItems),
+ [vaultItems, preFilter],
+ );
+
+ const filtered = useMemo(() => filterItems(displayItems, searchText), [displayItems, searchText]);
+ const grouped = useMemo(() => groupByFolder(filtered, vaultFolders), [filtered, vaultFolders]);
+
+ const gateRender = renderGate(state, handleUnlock, handleLogin);
+
+ const isLoading =
+ state.kind === 'checking-bw' ||
+ state.kind === 'logging-in' ||
+ state.kind === 'loading' ||
+ isSyncing;
+
+ const sortedSections = useMemo(
+ () => [...grouped.entries()].sort(([, a], [, b]) => a.folderName.localeCompare(b.folderName)),
+ [grouped],
+ );
+
+ return {
+ state,
+ session,
+ searchText,
+ setSearchText,
+ faviconMap,
+ setFaviconMap,
+ vaultItems,
+ vaultFolders,
+ handleUnlock,
+ handleLogin,
+ handleSync,
+ handleCopyTotp,
+ isSyncing,
+ gateRender,
+ isLoading,
+ sortedSections,
+ filtered,
+ grouped,
+ };
+}
diff --git a/extensions/bitwarden/src/use-vault-sync.ts b/extensions/bitwarden/src/use-vault-sync.ts
new file mode 100644
index 00000000..a87083bd
--- /dev/null
+++ b/extensions/bitwarden/src/use-vault-sync.ts
@@ -0,0 +1,38 @@
+import { useCallback, useState } from 'react';
+import { showToast, Toast } from '@vicinae/api';
+import * as bw from './bw-executor';
+import { showFailureToast } from './item-utils';
+import { saveCachedVault } from './vault-cache';
+import type { BwFolder, BwItem } from './bitwarden-types';
+
+export function useVaultSync(
+ session: string | null,
+ setVault: (items: BwItem[], folders: BwFolder[]) => void,
+) {
+ const [isSyncing, setIsSyncing] = useState(false);
+
+ const syncVault = useCallback(
+ async (token: string) => {
+ await bw.sync(token);
+ const [items, folders] = await Promise.all([bw.listItems(token), bw.listFolders(token)]);
+ await saveCachedVault(items, folders);
+ setVault(items, folders);
+ },
+ [setVault],
+ );
+
+ const handleSync = useCallback(async () => {
+ if (!session) return;
+ setIsSyncing(true);
+ try {
+ await syncVault(session);
+ await showToast({ style: Toast.Style.Success, title: 'Vault synced' });
+ } catch (err) {
+ await showFailureToast(err, 'Sync failed');
+ } finally {
+ setIsSyncing(false);
+ }
+ }, [session, syncVault]);
+
+ return { syncVault, handleSync, isSyncing };
+}
diff --git a/extensions/bitwarden/src/vault-cache.ts b/extensions/bitwarden/src/vault-cache.ts
new file mode 100644
index 00000000..adc7c57c
--- /dev/null
+++ b/extensions/bitwarden/src/vault-cache.ts
@@ -0,0 +1,177 @@
+import { LocalStorage } from '@vicinae/api';
+import { secretStore, secretLookup, secretClear } from './secret-store';
+import { BwItem, BwFolder } from './bitwarden-types';
+import type { BwSend } from './send-types';
+
+const CACHE_KEY = 'vicinae-bitwarden-cache';
+
+interface CachedVault {
+ items: BwItem[];
+ folders: BwFolder[];
+ timestamp: number;
+}
+
+const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
+
+function stripSensitiveFields(item: BwItem): BwItem {
+ const stripped: BwItem = {
+ id: item.id,
+ organizationId: null,
+ folderId: item.folderId,
+ type: item.type,
+ name: item.name,
+ notes: null,
+ favorite: item.favorite,
+ revisionDate: '',
+ creationDate: '',
+ deletedDate: null,
+ collectionIds: null,
+ };
+
+ if (item.login) {
+ stripped.login = {
+ username: item.login.username,
+ password: item.login.password ? '' : null,
+ totp: item.login.totp ? '' : null,
+ uris: item.login.uris,
+ passwordRevisionDate: null,
+ };
+ }
+
+ if (item.card) {
+ stripped.card = {
+ cardholderName: item.card.cardholderName,
+ brand: item.card.brand,
+ number: item.card.number ? '' : null,
+ expMonth: null,
+ expYear: null,
+ code: item.card.code ? '' : null,
+ };
+ }
+
+ if (item.identity) {
+ stripped.identity = {
+ title: null,
+ firstName: item.identity.firstName,
+ middleName: null,
+ lastName: item.identity.lastName,
+ address1: null,
+ address2: null,
+ address3: null,
+ city: null,
+ state: null,
+ postalCode: null,
+ country: null,
+ company: null,
+ email: null,
+ phone: null,
+ ssn: null,
+ username: null,
+ passportNumber: null,
+ licenseNumber: null,
+ };
+ }
+
+ if (item.secureNote) {
+ stripped.secureNote = { type: item.secureNote.type };
+ }
+
+ stripped.fields = [];
+ stripped.attachments = [];
+
+ return stripped;
+}
+
+export async function loadCachedVault(): Promise<{ items: BwItem[]; folders: BwFolder[] } | null> {
+ try {
+ const raw = await LocalStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const cached: CachedVault = JSON.parse(raw);
+ if (Date.now() - cached.timestamp > CACHE_TTL) return null;
+ return { items: cached.items, folders: cached.folders };
+ } catch {
+ return null;
+ }
+}
+
+export async function saveCachedVault(items: BwItem[], folders: BwFolder[]): Promise {
+ const cache: CachedVault = {
+ items: items.map(stripSensitiveFields),
+ folders,
+ timestamp: Date.now(),
+ };
+ await LocalStorage.setItem(CACHE_KEY, JSON.stringify(cache));
+}
+
+export async function clearCachedVault(): Promise {
+ await LocalStorage.removeItem(CACHE_KEY);
+}
+
+const SENDS_CACHE_KEY = 'vicinae-bitwarden-sends-cache';
+const SENDS_SECRET_KEY = 'sends-accessids';
+
+interface CachedSends {
+ sends: BwSend[];
+ timestamp: number;
+}
+
+const SENDS_CACHE_TTL = 24 * 60 * 60 * 1000;
+
+function stripAccessId(send: BwSend): BwSend {
+ return { ...send, accessId: '' };
+}
+
+function stripSensitiveSendFields(send: BwSend): BwSend {
+ return {
+ ...stripAccessId(send),
+ notes: null,
+ text: send.text ? { text: '', hidden: send.text.hidden } : null,
+ };
+}
+
+async function loadAccessIds(): Promise> {
+ try {
+ const raw = await secretLookup(SENDS_SECRET_KEY);
+ if (!raw) return {};
+ return JSON.parse(raw);
+ } catch {
+ return {};
+ }
+}
+
+async function saveAccessIds(map: Record): Promise {
+ await secretStore(SENDS_SECRET_KEY, JSON.stringify(map), 'Vicinae Bitwarden Sends');
+}
+
+export async function loadCachedSends(): Promise {
+ try {
+ const raw = await LocalStorage.getItem(SENDS_CACHE_KEY);
+ if (!raw) return null;
+ const cached: CachedSends = JSON.parse(raw);
+ if (Date.now() - cached.timestamp > SENDS_CACHE_TTL) return null;
+
+ const accessIds = await loadAccessIds();
+ return cached.sends.map((s) => ({ ...s, accessId: accessIds[s.id] ?? '' }));
+ } catch {
+ return null;
+ }
+}
+
+export async function saveCachedSends(sends: BwSend[]): Promise {
+ const cache: CachedSends = {
+ sends: sends.map(stripSensitiveSendFields),
+ timestamp: Date.now(),
+ };
+ await LocalStorage.setItem(SENDS_CACHE_KEY, JSON.stringify(cache));
+
+ const accessIds: Record = {};
+ for (const s of sends) {
+ accessIds[s.id] = s.accessId;
+ }
+ await saveAccessIds(accessIds);
+}
+
+export async function clearCachedSends(): Promise {
+ await LocalStorage.removeItem(SENDS_CACHE_KEY);
+ await secretClear(SENDS_SECRET_KEY);
+}
diff --git a/extensions/bitwarden/src/vault-lifecycle.ts b/extensions/bitwarden/src/vault-lifecycle.ts
new file mode 100644
index 00000000..32bfaebc
--- /dev/null
+++ b/extensions/bitwarden/src/vault-lifecycle.ts
@@ -0,0 +1,137 @@
+import { useEffect } from 'react';
+import { showToast, Toast } from '@vicinae/api';
+import { showFailureToast } from './item-utils';
+import { loadCachedVault } from './vault-cache';
+import { extractHostname, loadFaviconCache, resolveFavicons } from './favicons';
+import { checkBwGate } from './unlock-gate';
+import type { GateUIState } from './unlock-gate';
+import { ItemType } from './bitwarden-types';
+import type { BwFolder, BwItem } from './bitwarden-types';
+
+export type UIState =
+ | GateUIState
+ | { kind: 'loading' }
+ | { kind: 'vault'; items: BwItem[]; folders: BwFolder[] };
+
+interface VaultLifecycleParams {
+ session: string | null;
+ state: UIState;
+ setState: React.Dispatch>;
+ setVault: (items: BwItem[], folders: BwFolder[]) => void;
+ syncVault: (token: string) => Promise;
+ handleLogin: () => Promise;
+ clearSession: () => Promise;
+ setFaviconMap: React.Dispatch>>;
+}
+
+export function useVaultLifecycle(params: VaultLifecycleParams) {
+ const {
+ session,
+ state,
+ setState,
+ setVault,
+ syncVault,
+ handleLogin,
+ clearSession,
+ setFaviconMap,
+ } = params;
+
+ useEffect(() => {
+ void (async () => {
+ const map = await loadFaviconCache();
+ setFaviconMap(map);
+
+ const cached = await loadCachedVault();
+ if (cached) {
+ setVault(cached.items, cached.folders);
+ }
+
+ const gate = await checkBwGate(session);
+ switch (gate.kind) {
+ case 'bw-not-installed':
+ case 'secret-tool-not-installed':
+ case 'logging-in':
+ setState({ kind: gate.kind });
+ return;
+ case 'needs-unlock':
+ if (!cached) setState({ kind: 'needs-unlock' });
+ return;
+ case 'ready':
+ break;
+ }
+
+ try {
+ await syncVault(session!);
+ await showToast({ style: Toast.Style.Success, title: 'Vault synced' });
+ } catch {
+ if (!cached) {
+ await clearSession();
+ setState({ kind: 'needs-unlock', error: 'Session expired' });
+ }
+ }
+ })();
+ }, []);
+
+ useEffect(() => {
+ if (!session) return;
+ if (state.kind !== 'needs-unlock') return;
+ setState({ kind: 'loading' });
+ }, [session, state.kind]);
+
+ useEffect(() => {
+ if (!session) return;
+ if (state.kind !== 'vault') return;
+ void (async () => {
+ try {
+ await syncVault(session);
+ await showToast({ style: Toast.Style.Success, title: 'Vault synced' });
+ } catch {
+ // Cache already showing — silent fail
+ }
+ })();
+ }, [session]);
+
+ useEffect(() => {
+ if (state.kind !== 'vault') return;
+ const domains: string[] = [];
+ for (const item of state.items) {
+ if (item.type !== ItemType.Login) continue;
+ const hostname = extractHostname(item.login?.uris);
+ if (hostname) domains.push(hostname);
+ }
+ if (domains.length === 0) return;
+ let mounted = true;
+ void (async () => {
+ const map = await resolveFavicons(domains);
+ if (mounted) setFaviconMap(map);
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, [state]);
+
+ useEffect(() => {
+ if (!session) return;
+ if (state.kind !== 'loading') return;
+ void (async () => {
+ const cached = await loadCachedVault();
+ if (cached) {
+ setVault(cached.items, cached.folders);
+ }
+ try {
+ await syncVault(session);
+ } catch (err) {
+ if (!cached) {
+ const message = await showFailureToast(err, 'Failed to load vault');
+ await clearSession();
+ setState({ kind: 'needs-unlock', error: message });
+ }
+ }
+ })();
+ }, [session, state.kind]);
+
+ useEffect(() => {
+ if (state.kind !== 'logging-in') return;
+ void handleLogin();
+ }, [state.kind]);
+}
diff --git a/extensions/bitwarden/tsconfig.json b/extensions/bitwarden/tsconfig.json
new file mode 100644
index 00000000..e01d45b2
--- /dev/null
+++ b/extensions/bitwarden/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "lib": ["ES2022", "DOM"]
+ },
+ "include": ["src/**/*"]
+}