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 @@ +
+ Bitwarden for Vicinae Logo +

Bitwarden for Vicinae

+

+ + CI + + + version + + + fallow health + + + License: MIT + +

+
+ +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 ( +
+ + {selectedType === 'Login' && ( + <> + setShowPassword((prev) => !prev)} + /> + { + try { + const prefs = getPreferences(); + const opts = getPasswordPrefs(prefs); + const pwd = await bw.generatePassword(opts); + setGeneratedPassword(pwd); + await Clipboard.copy(pwd); + showToast({ + style: Toast.Style.Success, + title: 'Password generated', + message: 'Copied to clipboard', + }); + } catch (err) { + await showFailureToast(err, 'Generation failed'); + } + }} + /> + + )} + + setCustomFields((prev) => [ + ...prev, + { id: fieldIdRef.current++, name: '', value: '', type: 0 }, + ]) + } + /> + + } + > + setSelectedType(String(value ?? 'Login'))} + > + {ITEM_TYPE_OPTIONS.map((opt) => ( + + ))} + + + {folders.length > 0 && ( + setSelectedFolder(String(value ?? ''))} + > + {folders.map((f) => ( + + ))} + + + )} + + {selectedFolder === '__new__' && ( + setNewFolderName(String(value ?? ''))} + /> + )} + + + + + + {selectedType === 'Login' && ( + <> + + {showPassword ? ( + setGeneratedPassword(String(value ?? ''))} + /> + ) : ( + setGeneratedPassword(String(value ?? ''))} + /> + )} + + + + )} + + {selectedType === 'Card' && ( + <> + + + {CARD_BRANDS.map((b) => ( + + ))} + + + setExpMonth(digitsOnly(v))} + /> + setExpYear(digitsOnly(v))} + /> + setCardCode(digitsOnly(v))} + /> + + )} + + {selectedType === 'Identity' && ( + <> + + + + + + + + + + + + + + + )} + + {selectedType === 'Secure Note' && ( + + )} + + + + + + + + setAttachmentPaths(paths)} + /> + + ); +} 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 ( +
+ + {item.type === ItemType.Login && fullItem.login?.password && ( + setShowPassword((prev) => !prev)} + /> + )} + + setCustomFields((prev) => [ + ...prev, + { id: fieldIdRef.current++, name: '', value: '', type: 0 }, + ]) + } + /> + + + } + > + + + {folders.length > 0 && ( + + {folders.map((f) => ( + + ))} + + )} + + + + + + {item.type === ItemType.Login && + fullItem.login && + renderLoginFields(fullItem.login, showPassword)} + + {item.type === ItemType.Card && + fullItem.card && + renderCardFields( + fullItem.card, + expMonth, + expYear, + cardCode, + (v) => setExpMonth(digitsOnly(v)), + (v) => setExpYear(digitsOnly(v)), + (v) => setCardCode(digitsOnly(v)), + )} + + {item.type === ItemType.Identity && + fullItem.identity && + renderIdentityFields(fullItem.identity)} + + {item.type === ItemType.SecureNote && ( + + )} + + + + + + + + setAttachmentPaths(paths)} + /> + + ); +} 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 ( +
+ {onRetryLogin && } + + } + > + + + ); + } + + 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/**/*"] +}