diff --git a/.gitignore b/.gitignore index f86f9fa..5e92e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,11 @@ coverage.xml # Other .hypothesis totp_export.json.enc + +# src + +src/seedpass.egg-info/PKG-INFO +src/seedpass.egg-info/SOURCES.txt +src/seedpass.egg-info/dependency_links.txt +src/seedpass.egg-info/entry_points.txt +src/seedpass.egg-info/top_level.txt \ No newline at end of file diff --git a/README.md b/README.md index 92cb53c..f503b9d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ +````markdown # SeedPass ![SeedPass Logo](https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/refs/heads/main/logo/png/SeedPass-Logo-03.png) -**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data. +**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data. [Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp) @@ -10,7 +11,7 @@ **⚠️ Disclaimer** -This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk. +This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk. --- ### Supported OS @@ -18,7 +19,6 @@ This software was not developed by an experienced security expert and should be ✔ Windows 10/11 • macOS 12+ • Any modern Linux SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required. - ## Table of Contents - [Features](#features) @@ -42,7 +42,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords. - **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. - **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. -- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. +- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field stores the UNIX timestamp of the latest delta event. - **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds. @@ -52,18 +52,29 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps. - **Display TOTP Codes:** Show all active 2FA codes with a countdown timer. - **Optional External Backup Location:** Configure a second directory where backups are automatically copied. -- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security. +- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security. +- **Quick Unlock:** Optionally skip the password prompt after verifying once. - **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay. - **Tagging Support:** Organize entries with optional tags and find them quickly via search. +- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API. +- **Parent Seed Backup:** Securely save an encrypted copy of the master seed. +- **Manual Vault Locking:** Instantly clear keys from memory when needed. +- **Vault Statistics:** View counts for entries and other profile metrics. +- **Change Master Password:** Rotate your encryption password at any time. +- **Checksum Verification Utilities:** Verify or regenerate the script checksum. +- **Relay Management:** List, add, remove or reset configured Nostr relays. +- **Offline Mode:** Disable all Nostr communication for local-only operation. + +A small on-screen notification area now shows queued messages for 10 seconds +before fading. ## Prerequisites -- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools. -*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload. +- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools. + *Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload. ## Installation - ### Quick Installer Use the automated installer to download SeedPass and its dependencies in one step. @@ -81,77 +92,66 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/ ```powershell Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) ``` -Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly. -The Windows installer will attempt to install Git automatically if it is not already available. It also tries to -install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` -isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from -. When Python 3.13 or newer is detected without the Microsoft C++ build tools, -the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. +Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly. +The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from . When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. **Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. -*Install the beta branch:* +### Uninstall + +Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: + +**Linux and macOS:** +```bash +bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.sh)" +``` +If you see a warning that an old executable couldn't be removed, delete the file manually. + +**Windows (PowerShell):** ```powershell -Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -Branch beta +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.ps1'); & ([scriptblock]::create($scriptContent)) ``` ### Manual Setup + Follow these steps to set up SeedPass on your local machine. -### 1. Clone the Repository +1. **Clone the Repository** -First, clone the SeedPass repository from GitHub: + ```bash + git clone https://github.com/PR0M3TH3AN/SeedPass.git + cd SeedPass + ``` -```bash -git clone https://github.com/PR0M3TH3AN/SeedPass.git -``` +2. **Create a Virtual Environment** -Navigate to the project directory: + ```bash + python3 -m venv venv + ``` -```bash -cd SeedPass -``` +3. **Activate the Virtual Environment** -### 2. Create a Virtual Environment + - **Linux/macOS:** + ```bash + source venv/bin/activate + ``` + - **Windows:** + ```bash + venv\Scripts\activate + ``` -It's recommended to use a virtual environment to manage your project's dependencies. Create a virtual environment named `venv`: +4. **Install Dependencies** -```bash -python3 -m venv venv -``` - -### 3. Activate the Virtual Environment - -Activate the virtual environment using the appropriate command for your operating system. - -- **On Linux and macOS:** - - ```bash - source venv/bin/activate - ``` - -- **On Windows:** - - ```bash - venv\Scripts\activate - ``` - -Once activated, your terminal prompt should be prefixed with `(venv)` indicating that the virtual environment is active. - -### 4. Install Dependencies - -Install the required Python packages and build dependencies using `pip`. -When upgrading pip, use `python -m pip` inside the virtual environment so that pip can update itself cleanly: - -```bash -python -m pip install --upgrade pip -python -m pip install -r src/requirements.txt -``` + ```bash + python -m pip install --upgrade pip + python -m pip install -r src/requirements.txt + python -m pip install -e . + ``` +// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta +After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on Windows to confirm the command resolves to your virtual environment's `seedpass` executable. #### Linux Clipboard Support -On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. -SeedPass will attempt to install **xclip** automatically if neither tool is -available. If the automatic installation fails, you can install it manually: +On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. SeedPass will attempt to install **xclip** automatically if neither tool is available. If the automatic installation fails, you can install it manually: ```bash sudo apt-get install xclip @@ -159,12 +159,18 @@ sudo apt-get install xclip ## Quick Start -After installing dependencies and activating your virtual environment, launch -SeedPass and create a backup: +After installing dependencies and activating your virtual environment, install the package in editable mode so the `seedpass` command is available: ```bash -# Start the application -python src/main.py +python -m pip install -e . +``` + + +You can then launch SeedPass and create a backup: + +```bash +# Start the application (interactive TUI) +seedpass # Export your index seedpass export --file "~/seedpass_backup.json" @@ -188,8 +194,7 @@ seedpass list --filter totp # on an external drive. ``` -For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). -Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md). +For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md). ### Vault JSON Layout @@ -209,28 +214,64 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers } ``` +> **Note** +> +> Opening a vault created by older versions automatically converts the legacy +> `seedpass_passwords_db.json.enc` (Fernet) to AES-GCM as +> `seedpass_entries_db.json.enc`. The original file is kept with a `.fernet` +> extension. +> The same migration occurs for a legacy `parent_seed.enc` encrypted with +> Fernet: it is transparently decrypted, re-encrypted with AES-GCM and the old +> file saved as `parent_seed.enc.fernet`. ## Usage -After successfully installing the dependencies, you can run SeedPass using the following command: +After successfully installing the dependencies, install the package with: + +```bash +python -m pip install -e . +``` + +Once installed, launch the interactive TUI with: + +```bash +seedpass +``` + +You can also run directly from the repository with: ```bash python src/main.py ``` -You can also use the new Typer-based CLI: +You can explore other CLI commands using: + ```bash seedpass --help ``` + +If this command displays `usage: main.py` instead of the Typer help output, an old `seedpass` executable is still on your `PATH`. Remove it with `pip uninstall seedpass` or delete the stale launcher and rerun: + +```bash +python -m pip install -e . +``` +// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta +You can confirm which executable will run with: + +```bash +which seedpass # or 'where seedpass' on Windows +``` + For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md). ### Running the Application 1. **Start the Application:** - ```bash - python src/main.py - ``` + ```bash + seedpass + ``` + *(or `python src/main.py` when running directly from the repository)* 2. **Follow the Prompts:** @@ -240,18 +281,18 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th Example menu: - ```bash - Select an option: - 1. Add Entry - 2. Retrieve Entry - 3. Search Entries - 4. List Entries - 5. Modify an Existing Entry - 6. 2FA Codes - 7. Settings + ```bash + Select an option: + 1. Add Entry + 2. Retrieve Entry + 3. Search Entries + 4. List Entries + 5. Modify an Existing Entry + 6. 2FA Codes + 7. Settings - Enter your choice (1-7) or press Enter to exit: - ``` + Enter your choice (1-7) or press Enter to exit: + ``` When choosing **Add Entry**, you can now select from: @@ -297,45 +338,40 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also - **SSH Key** – deterministically derive an Ed25519 key pair for servers or git hosting platforms. - **Seed Phrase** – store only the BIP-85 index and word count. The mnemonic is regenerated on demand. - **PGP Key** – derive an OpenPGP key pair from your master seed. -- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. - When you retrieve one of these entries, SeedPass can display QR codes for the - keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan - it, while the `nsec` QR is shown only after a security warning. +- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning. - **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data. - **Managed Account** – derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows ` > Managed Account > `. Press Enter on the main menu to return to the parent profile. -The table below summarizes the extra fields stored for each entry type. Every -entry includes a `label`, while only password entries track a `url`. - -| Entry Type | Extra Fields | -|---------------|---------------------------------------------------------------------------------------------------------------------------------------| -| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` | -| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` | -| SSH Key | `index`, `archived`, optional `notes`, optional `tags` | -| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` | -| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` | -| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` | -| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` | -| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` | +The table below summarizes the extra fields stored for each entry type. Every entry includes a `label`, while only password entries track a `url`. +| Entry Type | Extra Fields | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` | +| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` | +| SSH Key | `index`, `archived`, optional `notes`, optional `tags` | +| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` | +| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` | +| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` | +| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` | +| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` | ### Managing Multiple Seeds SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords. - **Add a New Seed Profile:** - - From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile". - - Choose to enter an existing seed or generate a new one. - - If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.** + 1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile". + 2. Choose to enter an existing seed or generate a new one. + 3. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.** - **Switch Between Seed Profiles:** - - From the **Profiles** menu, select "Switch Seed Profile". - - You'll see a list of available seed profiles. - - Enter the number corresponding to the seed profile you wish to switch to. - - Enter the master password associated with that seed profile. + 1. From the **Profiles** menu, select "Switch Seed Profile". + 2. You'll see a list of available seed profiles. + 3. Enter the number corresponding to the seed profile you wish to switch to. + 4. Enter the master password associated with that seed profile. -- **List All Seed Profiles:** - - In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles. +- **List All Seed Profiles:** + In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles. **Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords. @@ -364,29 +400,38 @@ You can manage your relays and sync with Nostr from the **Settings** menu: Back in the Settings menu you can: -* Select `3` to change your master password. -* Choose `4` to verify the script checksum. -* Select `5` to generate a new script checksum. -* Choose `6` to back up the parent seed. -* Select `7` to export the database to an encrypted file. -* Choose `8` to import a database from a backup file. -* Select `9` to export all 2FA codes. -* Choose `10` to set an additional backup location. A backup is created - immediately after the directory is configured. -* Select `11` to change the inactivity timeout. -* Choose `12` to lock the vault and require re-entry of your password. -* Select `13` to view seed profile stats. The summary lists counts for - passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows - whether both the encrypted database and the script itself pass checksum - validation. -* Choose `14` to toggle Secret Mode and set the clipboard clear delay. -* Select `15` to return to the main menu. +- Select `3` to change your master password. +- Choose `4` to verify the script checksum. +- Select `5` to generate a new script checksum. +- Choose `6` to back up the parent seed. +- Select `7` to export the database to an encrypted file. +- Choose `8` to import a database from a backup file. +- Select `9` to export all 2FA codes. +- Choose `10` to set an additional backup location. A backup is created immediately after the directory is configured. +- Select `11` to set the PBKDF2 iteration count used for encryption. +- Choose `12` to change the inactivity timeout. +- Select `13` to lock the vault and require re-entry of your password. +- Select `14` to view seed profile stats. The summary lists counts for passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows whether both the encrypted database and the script itself pass checksum validation. +- Choose `15` to toggle Secret Mode and set the clipboard clear delay. +- Select `16` to toggle Offline Mode and disable Nostr synchronization. +- Choose `17` to toggle Quick Unlock for skipping the password prompt after the first unlock. +Press **Enter** at any time to return to the main menu. +You can adjust these settings directly from the command line: + +```bash +seedpass config set kdf_iterations 200000 +seedpass config set backup_interval 3600 +seedpass config set quick_unlock true +seedpass config set nostr_max_retries 2 +seedpass config set nostr_retry_delay 1 +``` + +The default configuration uses **50,000** PBKDF2 iterations. Increase this value for stronger password hashing or lower it for faster startup (not recommended). Offline Mode skips all Nostr communication, keeping your data local until you re-enable syncing. Quick Unlock stores a hashed copy of your password in the encrypted config so that after the initial unlock, subsequent operations won't prompt for the password until you exit the program. Avoid enabling Quick Unlock on shared machines. ## Running Tests SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test: - ```bash pip install -r src/requirements.txt pytest -vv @@ -394,10 +439,7 @@ pytest -vv ### Exploring Nostr Index Size Limits -`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. -Each chunk is limited to 50 KB, so the test gradually grows the vault to observe -when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment -variable to control the delay between publishes when experimenting with large vaults. +`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. Each chunk is limited to 50 KB, so the test gradually grows the vault to observe when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment variable to control the delay between publishes when experimenting with large vaults. ```bash pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000 @@ -411,23 +453,24 @@ Use the helper script below to populate a profile with sample entries for testin python scripts/generate_test_profile.py --profile demo_profile --count 100 ``` -The script now determines the fingerprint from the generated seed and stores the -vault under `~/.seedpass/`. It also prints the fingerprint after -creation and publishes the encrypted index to Nostr. Use that same seed phrase -to load SeedPass. The app checks Nostr on startup and pulls any newer snapshot -so your vault stays in sync across machines. +The script determines the fingerprint from the generated seed and stores the +vault under `~/.seedpass/tests/`. SeedPass only looks for profiles +in `~/.seedpass/`, so move or copy the fingerprint directory out of the `tests` +subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with +the main application. The fingerprint is printed after creation and the +encrypted index is published to Nostr. Use that same seed phrase to load +SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your +vault stays in sync across machines. ### Automatically Updating the Script Checksum -SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. -To keep this value in sync with the source code, install the pre‑push git hook: +SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. To keep this value in sync with the source code, install the pre-push git hook: ```bash pre-commit install -t pre-push ``` -After running this command, every `git push` will execute `scripts/update_checksum.py`, -updating the checksum file automatically. +After running this command, every `git push` will execute `scripts/update_checksum.py`, updating the checksum file automatically. If the checksum file is missing, generate it manually: @@ -455,35 +498,32 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment. - **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt. - **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications. -- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information. +- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information. - **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password. - **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt. +- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Adjust this with `seedpass config set kdf_iterations`. +- **KDF Iteration Caution:** Lowering `kdf_iterations` makes password cracking easier, while a high `backup_interval` leaves fewer recent backups. +- **Offline Mode:** When enabled, SeedPass skips all Nostr operations so your vault stays local until syncing is turned back on. +- **Quick Unlock:** Stores a hashed copy of your password in the encrypted config so you only need to enter it once per session. Avoid this on shared computers. ## Contributing Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps: 1. **Fork the Repository:** Click the "Fork" button on the top right of the repository page. - -2. **Create a Branch:** Create a new branch for your feature or bugfix. - +1. **Create a Branch:** Create a new branch for your feature or bugfix. ```bash git checkout -b feature/YourFeatureName ``` - -3. **Commit Your Changes:** Make your changes and commit them with clear messages. - +1. **Commit Your Changes:** Make your changes and commit them with clear messages. ```bash git commit -m "Add feature X" ``` - -4. **Push to GitHub:** Push your changes to your forked repository. - +1. **Push to GitHub:** Push your changes to your forked repository. ```bash git push origin feature/YourFeatureName ``` - -5. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes. +1. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes. ## License @@ -496,5 +536,4 @@ For any questions, suggestions, or support, please open an issue on the [GitHub --- *Stay secure and keep your passwords safe with SeedPass!* - ---- +```` diff --git a/docs/.gitattributes b/docs/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/docs/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/docs/.github/workflows/ci.yml b/docs/.github/workflows/ci.yml new file mode 100644 index 0000000..dc733e1 --- /dev/null +++ b/docs/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - run: npm install + - run: npm test diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..e87ee29 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +_site/ +node_modules/ diff --git a/docs/README.md b/docs/README.md index bece44b..2ade75d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,25 +1,55 @@ -# SeedPass Documentation +# Archivox -This directory contains supplementary guides for using SeedPass. +Archivox is a lightweight static site generator aimed at producing documentation sites similar to "Read the Docs". Write your content in Markdown, run the generator, and deploy the static files anywhere. -## Quick Example: Get a TOTP Code +[![Build Status](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml/badge.svg)](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml) -Run `seedpass entry get ` to retrieve a time-based one-time password (TOTP). -The `` can be a label, title, or index. A progress bar shows the remaining -seconds in the current period. +## Features +- Markdown based pages with automatic navigation +- Responsive layout with sidebar and search powered by Lunr.js +- Simple configuration through `config.yaml` +- Extensible via plugins and custom templates + +## Getting Started +Install the dependencies and start the development server: ```bash -$ seedpass entry get "email" -[##########----------] 15s -Code: 123456 +npm install +npm run dev ``` -To show all stored TOTP codes with their countdown timers, run: +The site will be available at `http://localhost:8080`. Edit files inside the `content/` directory to update pages. + +To create a new project from the starter template you can run: ```bash -$ seedpass entry totp-codes +npx create-archivox my-docs --install ``` -## CLI and API Reference +## Building +When you are ready to publish your documentation run: -See [advanced_cli.md](advanced_cli.md) for a list of command examples. Detailed information about the REST API is available in [api_reference.md](api_reference.md). When starting the API, set `SEEDPASS_CORS_ORIGINS` if you need to allow requests from specific web origins. +```bash +npm run build +``` + +The generated site is placed in the `_site/` folder. + +## Customization +- **`config.yaml`** – change the site title, theme options and other settings. +- **`plugins/`** – add JavaScript files exporting hook functions such as `onPageRendered` to extend the build process. +- **`templates/`** – modify or replace the Nunjucks templates for full control over the HTML. + +## Hosting +Upload the contents of `_site/` to any static host. For Netlify you can use the provided `netlify.toml`: + +```toml +[build] + command = "npm run build" + publish = "_site" +``` + +## Documentation +See the files under the `docs/` directory for a full guide to Archivox including an integration tutorial for existing projects. + +Archivox is released under the MIT License. diff --git a/docs/__tests__/buildNav.test.js b/docs/__tests__/buildNav.test.js new file mode 100644 index 0000000..26a5768 --- /dev/null +++ b/docs/__tests__/buildNav.test.js @@ -0,0 +1,34 @@ +const { buildNav } = require('../src/generator'); + +test('generates navigation tree', () => { + const pages = [ + { file: 'guide/install.md', data: { title: 'Install', order: 1 } }, + { file: 'guide/usage.md', data: { title: 'Usage', order: 2 } }, + { file: 'guide/nested/info.md', data: { title: 'Info', order: 1 } } + ]; + const tree = buildNav(pages); + const guide = tree.find(n => n.name === 'guide'); + expect(guide).toBeDefined(); + expect(guide.children.length).toBe(3); + const install = guide.children.find(c => c.name === 'install.md'); + expect(install.path).toBe('/guide/install.html'); +}); + +test('adds display names and section flags', () => { + const pages = [ + { file: '02-api.md', data: { title: 'API', order: 2 } }, + { file: '01-guide/index.md', data: { title: 'Guide', order: 1 } }, + { file: '01-guide/setup.md', data: { title: 'Setup', order: 2 } }, + { file: 'index.md', data: { title: 'Home', order: 10 } } + ]; + const nav = buildNav(pages); + expect(nav[0].name).toBe('index.md'); + const guide = nav.find(n => n.name === '01-guide'); + expect(guide.displayName).toBe('Guide'); + expect(guide.isSection).toBe(true); + const api = nav.find(n => n.name === '02-api.md'); + expect(api.displayName).toBe('API'); + // alphabetical within same order + expect(nav[1].name).toBe('01-guide'); + expect(nav[2].name).toBe('02-api.md'); +}); diff --git a/docs/__tests__/loadConfig.test.js b/docs/__tests__/loadConfig.test.js new file mode 100644 index 0000000..d96b23f --- /dev/null +++ b/docs/__tests__/loadConfig.test.js @@ -0,0 +1,13 @@ +const fs = require('fs'); +const path = require('path'); +const loadConfig = require('../src/config/loadConfig'); + +test('loads configuration and merges defaults', () => { + const dir = fs.mkdtempSync(path.join(__dirname, 'cfg-')); + const file = path.join(dir, 'config.yaml'); + fs.writeFileSync(file, 'site:\n title: Test Site\n'); + const cfg = loadConfig(file); + expect(cfg.site.title).toBe('Test Site'); + expect(cfg.navigation.search).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/docs/__tests__/pluginHooks.test.js b/docs/__tests__/pluginHooks.test.js new file mode 100644 index 0000000..dd0dfb5 --- /dev/null +++ b/docs/__tests__/pluginHooks.test.js @@ -0,0 +1,23 @@ +const fs = require('fs'); +const path = require('path'); +const loadPlugins = require('../src/config/loadPlugins'); + +test('plugin hook modifies data', async () => { + const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'plugins-')); + const pluginFile = path.join(dir, 'test.plugin.js'); + fs.writeFileSync( + pluginFile, + "module.exports = { onParseMarkdown: ({ content }) => ({ content: content + '!!' }) };\n" + ); + + const plugins = loadPlugins({ pluginsDir: dir, plugins: ['test.plugin'] }); + let data = { content: 'hello' }; + for (const plugin of plugins) { + if (typeof plugin.onParseMarkdown === 'function') { + const res = await plugin.onParseMarkdown(data); + if (res !== undefined) data = res; + } + } + expect(data.content).toBe('hello!!'); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/docs/__tests__/renderMarkdown.test.js b/docs/__tests__/renderMarkdown.test.js new file mode 100644 index 0000000..58c4c8f --- /dev/null +++ b/docs/__tests__/renderMarkdown.test.js @@ -0,0 +1,77 @@ +jest.mock('@11ty/eleventy', () => { + const fs = require('fs'); + const path = require('path'); + return class Eleventy { + constructor(input, output) { + this.input = input; + this.output = output; + } + setConfig() {} + async write() { + const walk = d => { + const entries = fs.readdirSync(d, { withFileTypes: true }); + let files = []; + for (const e of entries) { + const p = path.join(d, e.name); + if (e.isDirectory()) files = files.concat(walk(p)); + else if (p.endsWith('.md')) files.push(p); + } + return files; + }; + for (const file of walk(this.input)) { + const rel = path.relative(this.input, file).replace(/\.md$/, '.html'); + const dest = path.join(this.output, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, '
'); + } + } + }; +}); + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { generate } = require('../src/generator'); + +function getPaths(tree) { + const paths = []; + for (const node of tree) { + if (node.path) paths.push(node.path); + if (node.children) paths.push(...getPaths(node.children)); + } + return paths; +} + +test('markdown files render with layout and appear in nav/search', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-test-')); + const contentDir = path.join(tmp, 'content'); + const outputDir = path.join(tmp, '_site'); + fs.mkdirSync(path.join(contentDir, 'guide'), { recursive: true }); + fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\nWelcome'); + fs.writeFileSync(path.join(contentDir, 'guide', 'install.md'), '# Install\nSteps'); + const configPath = path.join(tmp, 'config.yaml'); + fs.writeFileSync(configPath, 'site:\n title: Test\n'); + + await generate({ contentDir, outputDir, configPath }); + + const indexHtml = fs.readFileSync(path.join(outputDir, 'index.html'), 'utf8'); + const installHtml = fs.readFileSync(path.join(outputDir, 'guide', 'install.html'), 'utf8'); + expect(indexHtml).toContain(' d.id); + expect(docs).toContain('index.html'); + expect(docs).toContain('guide/install.html'); + const installDoc = search.docs.find(d => d.id === 'guide/install.html'); + expect(installDoc.body).toContain('Steps'); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/docs/__tests__/responsive.test.js b/docs/__tests__/responsive.test.js new file mode 100644 index 0000000..44b2440 --- /dev/null +++ b/docs/__tests__/responsive.test.js @@ -0,0 +1,128 @@ +jest.mock('@11ty/eleventy', () => { + const fs = require('fs'); + const path = require('path'); + return class Eleventy { + constructor(input, output) { + this.input = input; + this.output = output; + } + setConfig() {} + async write() { + const walk = d => { + const entries = fs.readdirSync(d, { withFileTypes: true }); + let files = []; + for (const e of entries) { + const p = path.join(d, e.name); + if (e.isDirectory()) files = files.concat(walk(p)); + else if (p.endsWith('.md')) files.push(p); + } + return files; + }; + for (const file of walk(this.input)) { + const rel = path.relative(this.input, file).replace(/\.md$/, '.html'); + const dest = path.join(this.output, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync( + dest, + `
` + ); + } + } + }; +}); + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const os = require('os'); +const puppeteer = require('puppeteer'); +const { generate } = require('../src/generator'); + +jest.setTimeout(30000); + +let server; +let browser; +let port; +let tmp; + +beforeAll(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-responsive-')); + const contentDir = path.join(tmp, 'content'); + const outputDir = path.join(tmp, '_site'); + fs.mkdirSync(contentDir, { recursive: true }); + fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\n'); + await generate({ contentDir, outputDir }); + fs.cpSync(path.join(__dirname, '../assets'), path.join(outputDir, 'assets'), { recursive: true }); + + server = http.createServer((req, res) => { + let filePath = path.join(outputDir, req.url === '/' ? 'index.html' : req.url); + if (req.url.startsWith('/assets')) { + filePath = path.join(outputDir, req.url); + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).slice(1); + const type = { html: 'text/html', js: 'text/javascript', css: 'text/css' }[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': type }); + res.end(data); + }); + }); + await new Promise(resolve => { + server.listen(0, () => { + port = server.address().port; + resolve(); + }); + }); + + browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); +}); + +afterAll(async () => { + if (browser) await browser.close(); + if (server) server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('sidebar opens on small screens', async () => { + const page = await browser.newPage(); + await page.setViewport({ width: 500, height: 800 }); + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector('#sidebar-toggle'); + await page.click('#sidebar-toggle'); + await new Promise(r => setTimeout(r, 300)); + const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open')); + const sidebarLeft = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).left); + expect(bodyClass).toBe(true); + expect(sidebarLeft).toBe('0px'); +}); + +test('clicking outside closes sidebar on small screens', async () => { + const page = await browser.newPage(); + await page.setViewport({ width: 500, height: 800 }); + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector('#sidebar-toggle'); + await page.click('#sidebar-toggle'); + await new Promise(r => setTimeout(r, 300)); + await page.click('main'); + await new Promise(r => setTimeout(r, 300)); + const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open')); + expect(bodyClass).toBe(false); +}); + +test('sidebar toggles on large screens', async () => { + const page = await browser.newPage(); + await page.setViewport({ width: 1024, height: 800 }); + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector('#sidebar-toggle'); + await new Promise(r => setTimeout(r, 300)); + let sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width); + expect(sidebarWidth).toBe('240px'); + await page.click('#sidebar-toggle'); + await new Promise(r => setTimeout(r, 300)); + sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width); + expect(sidebarWidth).toBe('0px'); +}); diff --git a/docs/assets/lunr.js b/docs/assets/lunr.js new file mode 100644 index 0000000..6aa370f --- /dev/null +++ b/docs/assets/lunr.js @@ -0,0 +1,3475 @@ +/** + * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.3.9 + * Copyright (C) 2020 Oliver Nightingale + * @license MIT + */ + +;(function(){ + +/** + * A convenience function for configuring and constructing + * a new lunr Index. + * + * A lunr.Builder instance is created and the pipeline setup + * with a trimmer, stop word filter and stemmer. + * + * This builder object is yielded to the configuration function + * that is passed as a parameter, allowing the list of fields + * and other builder parameters to be customised. + * + * All documents _must_ be added within the passed config function. + * + * @example + * var idx = lunr(function () { + * this.field('title') + * this.field('body') + * this.ref('id') + * + * documents.forEach(function (doc) { + * this.add(doc) + * }, this) + * }) + * + * @see {@link lunr.Builder} + * @see {@link lunr.Pipeline} + * @see {@link lunr.trimmer} + * @see {@link lunr.stopWordFilter} + * @see {@link lunr.stemmer} + * @namespace {function} lunr + */ +var lunr = function (config) { + var builder = new lunr.Builder + + builder.pipeline.add( + lunr.trimmer, + lunr.stopWordFilter, + lunr.stemmer + ) + + builder.searchPipeline.add( + lunr.stemmer + ) + + config.call(builder, builder) + return builder.build() +} + +lunr.version = "2.3.9" +/*! + * lunr.utils + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * A namespace containing utils for the rest of the lunr library + * @namespace lunr.utils + */ +lunr.utils = {} + +/** + * Print a warning message to the console. + * + * @param {String} message The message to be printed. + * @memberOf lunr.utils + * @function + */ +lunr.utils.warn = (function (global) { + /* eslint-disable no-console */ + return function (message) { + if (global.console && console.warn) { + console.warn(message) + } + } + /* eslint-enable no-console */ +})(this) + +/** + * Convert an object to a string. + * + * In the case of `null` and `undefined` the function returns + * the empty string, in all other cases the result of calling + * `toString` on the passed object is returned. + * + * @param {Any} obj The object to convert to a string. + * @return {String} string representation of the passed object. + * @memberOf lunr.utils + */ +lunr.utils.asString = function (obj) { + if (obj === void 0 || obj === null) { + return "" + } else { + return obj.toString() + } +} + +/** + * Clones an object. + * + * Will create a copy of an existing object such that any mutations + * on the copy cannot affect the original. + * + * Only shallow objects are supported, passing a nested object to this + * function will cause a TypeError. + * + * Objects with primitives, and arrays of primitives are supported. + * + * @param {Object} obj The object to clone. + * @return {Object} a clone of the passed object. + * @throws {TypeError} when a nested object is passed. + * @memberOf Utils + */ +lunr.utils.clone = function (obj) { + if (obj === null || obj === undefined) { + return obj + } + + var clone = Object.create(null), + keys = Object.keys(obj) + + for (var i = 0; i < keys.length; i++) { + var key = keys[i], + val = obj[key] + + if (Array.isArray(val)) { + clone[key] = val.slice() + continue + } + + if (typeof val === 'string' || + typeof val === 'number' || + typeof val === 'boolean') { + clone[key] = val + continue + } + + throw new TypeError("clone is not deep and does not support nested objects") + } + + return clone +} +lunr.FieldRef = function (docRef, fieldName, stringValue) { + this.docRef = docRef + this.fieldName = fieldName + this._stringValue = stringValue +} + +lunr.FieldRef.joiner = "/" + +lunr.FieldRef.fromString = function (s) { + var n = s.indexOf(lunr.FieldRef.joiner) + + if (n === -1) { + throw "malformed field ref string" + } + + var fieldRef = s.slice(0, n), + docRef = s.slice(n + 1) + + return new lunr.FieldRef (docRef, fieldRef, s) +} + +lunr.FieldRef.prototype.toString = function () { + if (this._stringValue == undefined) { + this._stringValue = this.fieldName + lunr.FieldRef.joiner + this.docRef + } + + return this._stringValue +} +/*! + * lunr.Set + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * A lunr set. + * + * @constructor + */ +lunr.Set = function (elements) { + this.elements = Object.create(null) + + if (elements) { + this.length = elements.length + + for (var i = 0; i < this.length; i++) { + this.elements[elements[i]] = true + } + } else { + this.length = 0 + } +} + +/** + * A complete set that contains all elements. + * + * @static + * @readonly + * @type {lunr.Set} + */ +lunr.Set.complete = { + intersect: function (other) { + return other + }, + + union: function () { + return this + }, + + contains: function () { + return true + } +} + +/** + * An empty set that contains no elements. + * + * @static + * @readonly + * @type {lunr.Set} + */ +lunr.Set.empty = { + intersect: function () { + return this + }, + + union: function (other) { + return other + }, + + contains: function () { + return false + } +} + +/** + * Returns true if this set contains the specified object. + * + * @param {object} object - Object whose presence in this set is to be tested. + * @returns {boolean} - True if this set contains the specified object. + */ +lunr.Set.prototype.contains = function (object) { + return !!this.elements[object] +} + +/** + * Returns a new set containing only the elements that are present in both + * this set and the specified set. + * + * @param {lunr.Set} other - set to intersect with this set. + * @returns {lunr.Set} a new set that is the intersection of this and the specified set. + */ + +lunr.Set.prototype.intersect = function (other) { + var a, b, elements, intersection = [] + + if (other === lunr.Set.complete) { + return this + } + + if (other === lunr.Set.empty) { + return other + } + + if (this.length < other.length) { + a = this + b = other + } else { + a = other + b = this + } + + elements = Object.keys(a.elements) + + for (var i = 0; i < elements.length; i++) { + var element = elements[i] + if (element in b.elements) { + intersection.push(element) + } + } + + return new lunr.Set (intersection) +} + +/** + * Returns a new set combining the elements of this and the specified set. + * + * @param {lunr.Set} other - set to union with this set. + * @return {lunr.Set} a new set that is the union of this and the specified set. + */ + +lunr.Set.prototype.union = function (other) { + if (other === lunr.Set.complete) { + return lunr.Set.complete + } + + if (other === lunr.Set.empty) { + return this + } + + return new lunr.Set(Object.keys(this.elements).concat(Object.keys(other.elements))) +} +/** + * A function to calculate the inverse document frequency for + * a posting. This is shared between the builder and the index + * + * @private + * @param {object} posting - The posting for a given term + * @param {number} documentCount - The total number of documents. + */ +lunr.idf = function (posting, documentCount) { + var documentsWithTerm = 0 + + for (var fieldName in posting) { + if (fieldName == '_index') continue // Ignore the term index, its not a field + documentsWithTerm += Object.keys(posting[fieldName]).length + } + + var x = (documentCount - documentsWithTerm + 0.5) / (documentsWithTerm + 0.5) + + return Math.log(1 + Math.abs(x)) +} + +/** + * A token wraps a string representation of a token + * as it is passed through the text processing pipeline. + * + * @constructor + * @param {string} [str=''] - The string token being wrapped. + * @param {object} [metadata={}] - Metadata associated with this token. + */ +lunr.Token = function (str, metadata) { + this.str = str || "" + this.metadata = metadata || {} +} + +/** + * Returns the token string that is being wrapped by this object. + * + * @returns {string} + */ +lunr.Token.prototype.toString = function () { + return this.str +} + +/** + * A token update function is used when updating or optionally + * when cloning a token. + * + * @callback lunr.Token~updateFunction + * @param {string} str - The string representation of the token. + * @param {Object} metadata - All metadata associated with this token. + */ + +/** + * Applies the given function to the wrapped string token. + * + * @example + * token.update(function (str, metadata) { + * return str.toUpperCase() + * }) + * + * @param {lunr.Token~updateFunction} fn - A function to apply to the token string. + * @returns {lunr.Token} + */ +lunr.Token.prototype.update = function (fn) { + this.str = fn(this.str, this.metadata) + return this +} + +/** + * Creates a clone of this token. Optionally a function can be + * applied to the cloned token. + * + * @param {lunr.Token~updateFunction} [fn] - An optional function to apply to the cloned token. + * @returns {lunr.Token} + */ +lunr.Token.prototype.clone = function (fn) { + fn = fn || function (s) { return s } + return new lunr.Token (fn(this.str, this.metadata), this.metadata) +} +/*! + * lunr.tokenizer + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * A function for splitting a string into tokens ready to be inserted into + * the search index. Uses `lunr.tokenizer.separator` to split strings, change + * the value of this property to change how strings are split into tokens. + * + * This tokenizer will convert its parameter to a string by calling `toString` and + * then will split this string on the character in `lunr.tokenizer.separator`. + * Arrays will have their elements converted to strings and wrapped in a lunr.Token. + * + * Optional metadata can be passed to the tokenizer, this metadata will be cloned and + * added as metadata to every token that is created from the object to be tokenized. + * + * @static + * @param {?(string|object|object[])} obj - The object to convert into tokens + * @param {?object} metadata - Optional metadata to associate with every token + * @returns {lunr.Token[]} + * @see {@link lunr.Pipeline} + */ +lunr.tokenizer = function (obj, metadata) { + if (obj == null || obj == undefined) { + return [] + } + + if (Array.isArray(obj)) { + return obj.map(function (t) { + return new lunr.Token( + lunr.utils.asString(t).toLowerCase(), + lunr.utils.clone(metadata) + ) + }) + } + + var str = obj.toString().toLowerCase(), + len = str.length, + tokens = [] + + for (var sliceEnd = 0, sliceStart = 0; sliceEnd <= len; sliceEnd++) { + var char = str.charAt(sliceEnd), + sliceLength = sliceEnd - sliceStart + + if ((char.match(lunr.tokenizer.separator) || sliceEnd == len)) { + + if (sliceLength > 0) { + var tokenMetadata = lunr.utils.clone(metadata) || {} + tokenMetadata["position"] = [sliceStart, sliceLength] + tokenMetadata["index"] = tokens.length + + tokens.push( + new lunr.Token ( + str.slice(sliceStart, sliceEnd), + tokenMetadata + ) + ) + } + + sliceStart = sliceEnd + 1 + } + + } + + return tokens +} + +/** + * The separator used to split a string into tokens. Override this property to change the behaviour of + * `lunr.tokenizer` behaviour when tokenizing strings. By default this splits on whitespace and hyphens. + * + * @static + * @see lunr.tokenizer + */ +lunr.tokenizer.separator = /[\s\-]+/ +/*! + * lunr.Pipeline + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * lunr.Pipelines maintain an ordered list of functions to be applied to all + * tokens in documents entering the search index and queries being ran against + * the index. + * + * An instance of lunr.Index created with the lunr shortcut will contain a + * pipeline with a stop word filter and an English language stemmer. Extra + * functions can be added before or after either of these functions or these + * default functions can be removed. + * + * When run the pipeline will call each function in turn, passing a token, the + * index of that token in the original list of all tokens and finally a list of + * all the original tokens. + * + * The output of functions in the pipeline will be passed to the next function + * in the pipeline. To exclude a token from entering the index the function + * should return undefined, the rest of the pipeline will not be called with + * this token. + * + * For serialisation of pipelines to work, all functions used in an instance of + * a pipeline should be registered with lunr.Pipeline. Registered functions can + * then be loaded. If trying to load a serialised pipeline that uses functions + * that are not registered an error will be thrown. + * + * If not planning on serialising the pipeline then registering pipeline functions + * is not necessary. + * + * @constructor + */ +lunr.Pipeline = function () { + this._stack = [] +} + +lunr.Pipeline.registeredFunctions = Object.create(null) + +/** + * A pipeline function maps lunr.Token to lunr.Token. A lunr.Token contains the token + * string as well as all known metadata. A pipeline function can mutate the token string + * or mutate (or add) metadata for a given token. + * + * A pipeline function can indicate that the passed token should be discarded by returning + * null, undefined or an empty string. This token will not be passed to any downstream pipeline + * functions and will not be added to the index. + * + * Multiple tokens can be returned by returning an array of tokens. Each token will be passed + * to any downstream pipeline functions and all will returned tokens will be added to the index. + * + * Any number of pipeline functions may be chained together using a lunr.Pipeline. + * + * @interface lunr.PipelineFunction + * @param {lunr.Token} token - A token from the document being processed. + * @param {number} i - The index of this token in the complete list of tokens for this document/field. + * @param {lunr.Token[]} tokens - All tokens for this document/field. + * @returns {(?lunr.Token|lunr.Token[])} + */ + +/** + * Register a function with the pipeline. + * + * Functions that are used in the pipeline should be registered if the pipeline + * needs to be serialised, or a serialised pipeline needs to be loaded. + * + * Registering a function does not add it to a pipeline, functions must still be + * added to instances of the pipeline for them to be used when running a pipeline. + * + * @param {lunr.PipelineFunction} fn - The function to check for. + * @param {String} label - The label to register this function with + */ +lunr.Pipeline.registerFunction = function (fn, label) { + if (label in this.registeredFunctions) { + lunr.utils.warn('Overwriting existing registered function: ' + label) + } + + fn.label = label + lunr.Pipeline.registeredFunctions[fn.label] = fn +} + +/** + * Warns if the function is not registered as a Pipeline function. + * + * @param {lunr.PipelineFunction} fn - The function to check for. + * @private + */ +lunr.Pipeline.warnIfFunctionNotRegistered = function (fn) { + var isRegistered = fn.label && (fn.label in this.registeredFunctions) + + if (!isRegistered) { + lunr.utils.warn('Function is not registered with pipeline. This may cause problems when serialising the index.\n', fn) + } +} + +/** + * Loads a previously serialised pipeline. + * + * All functions to be loaded must already be registered with lunr.Pipeline. + * If any function from the serialised data has not been registered then an + * error will be thrown. + * + * @param {Object} serialised - The serialised pipeline to load. + * @returns {lunr.Pipeline} + */ +lunr.Pipeline.load = function (serialised) { + var pipeline = new lunr.Pipeline + + serialised.forEach(function (fnName) { + var fn = lunr.Pipeline.registeredFunctions[fnName] + + if (fn) { + pipeline.add(fn) + } else { + throw new Error('Cannot load unregistered function: ' + fnName) + } + }) + + return pipeline +} + +/** + * Adds new functions to the end of the pipeline. + * + * Logs a warning if the function has not been registered. + * + * @param {lunr.PipelineFunction[]} functions - Any number of functions to add to the pipeline. + */ +lunr.Pipeline.prototype.add = function () { + var fns = Array.prototype.slice.call(arguments) + + fns.forEach(function (fn) { + lunr.Pipeline.warnIfFunctionNotRegistered(fn) + this._stack.push(fn) + }, this) +} + +/** + * Adds a single function after a function that already exists in the + * pipeline. + * + * Logs a warning if the function has not been registered. + * + * @param {lunr.PipelineFunction} existingFn - A function that already exists in the pipeline. + * @param {lunr.PipelineFunction} newFn - The new function to add to the pipeline. + */ +lunr.Pipeline.prototype.after = function (existingFn, newFn) { + lunr.Pipeline.warnIfFunctionNotRegistered(newFn) + + var pos = this._stack.indexOf(existingFn) + if (pos == -1) { + throw new Error('Cannot find existingFn') + } + + pos = pos + 1 + this._stack.splice(pos, 0, newFn) +} + +/** + * Adds a single function before a function that already exists in the + * pipeline. + * + * Logs a warning if the function has not been registered. + * + * @param {lunr.PipelineFunction} existingFn - A function that already exists in the pipeline. + * @param {lunr.PipelineFunction} newFn - The new function to add to the pipeline. + */ +lunr.Pipeline.prototype.before = function (existingFn, newFn) { + lunr.Pipeline.warnIfFunctionNotRegistered(newFn) + + var pos = this._stack.indexOf(existingFn) + if (pos == -1) { + throw new Error('Cannot find existingFn') + } + + this._stack.splice(pos, 0, newFn) +} + +/** + * Removes a function from the pipeline. + * + * @param {lunr.PipelineFunction} fn The function to remove from the pipeline. + */ +lunr.Pipeline.prototype.remove = function (fn) { + var pos = this._stack.indexOf(fn) + if (pos == -1) { + return + } + + this._stack.splice(pos, 1) +} + +/** + * Runs the current list of functions that make up the pipeline against the + * passed tokens. + * + * @param {Array} tokens The tokens to run through the pipeline. + * @returns {Array} + */ +lunr.Pipeline.prototype.run = function (tokens) { + var stackLength = this._stack.length + + for (var i = 0; i < stackLength; i++) { + var fn = this._stack[i] + var memo = [] + + for (var j = 0; j < tokens.length; j++) { + var result = fn(tokens[j], j, tokens) + + if (result === null || result === void 0 || result === '') continue + + if (Array.isArray(result)) { + for (var k = 0; k < result.length; k++) { + memo.push(result[k]) + } + } else { + memo.push(result) + } + } + + tokens = memo + } + + return tokens +} + +/** + * Convenience method for passing a string through a pipeline and getting + * strings out. This method takes care of wrapping the passed string in a + * token and mapping the resulting tokens back to strings. + * + * @param {string} str - The string to pass through the pipeline. + * @param {?object} metadata - Optional metadata to associate with the token + * passed to the pipeline. + * @returns {string[]} + */ +lunr.Pipeline.prototype.runString = function (str, metadata) { + var token = new lunr.Token (str, metadata) + + return this.run([token]).map(function (t) { + return t.toString() + }) +} + +/** + * Resets the pipeline by removing any existing processors. + * + */ +lunr.Pipeline.prototype.reset = function () { + this._stack = [] +} + +/** + * Returns a representation of the pipeline ready for serialisation. + * + * Logs a warning if the function has not been registered. + * + * @returns {Array} + */ +lunr.Pipeline.prototype.toJSON = function () { + return this._stack.map(function (fn) { + lunr.Pipeline.warnIfFunctionNotRegistered(fn) + + return fn.label + }) +} +/*! + * lunr.Vector + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * A vector is used to construct the vector space of documents and queries. These + * vectors support operations to determine the similarity between two documents or + * a document and a query. + * + * Normally no parameters are required for initializing a vector, but in the case of + * loading a previously dumped vector the raw elements can be provided to the constructor. + * + * For performance reasons vectors are implemented with a flat array, where an elements + * index is immediately followed by its value. E.g. [index, value, index, value]. This + * allows the underlying array to be as sparse as possible and still offer decent + * performance when being used for vector calculations. + * + * @constructor + * @param {Number[]} [elements] - The flat list of element index and element value pairs. + */ +lunr.Vector = function (elements) { + this._magnitude = 0 + this.elements = elements || [] +} + + +/** + * Calculates the position within the vector to insert a given index. + * + * This is used internally by insert and upsert. If there are duplicate indexes then + * the position is returned as if the value for that index were to be updated, but it + * is the callers responsibility to check whether there is a duplicate at that index + * + * @param {Number} insertIdx - The index at which the element should be inserted. + * @returns {Number} + */ +lunr.Vector.prototype.positionForIndex = function (index) { + // For an empty vector the tuple can be inserted at the beginning + if (this.elements.length == 0) { + return 0 + } + + var start = 0, + end = this.elements.length / 2, + sliceLength = end - start, + pivotPoint = Math.floor(sliceLength / 2), + pivotIndex = this.elements[pivotPoint * 2] + + while (sliceLength > 1) { + if (pivotIndex < index) { + start = pivotPoint + } + + if (pivotIndex > index) { + end = pivotPoint + } + + if (pivotIndex == index) { + break + } + + sliceLength = end - start + pivotPoint = start + Math.floor(sliceLength / 2) + pivotIndex = this.elements[pivotPoint * 2] + } + + if (pivotIndex == index) { + return pivotPoint * 2 + } + + if (pivotIndex > index) { + return pivotPoint * 2 + } + + if (pivotIndex < index) { + return (pivotPoint + 1) * 2 + } +} + +/** + * Inserts an element at an index within the vector. + * + * Does not allow duplicates, will throw an error if there is already an entry + * for this index. + * + * @param {Number} insertIdx - The index at which the element should be inserted. + * @param {Number} val - The value to be inserted into the vector. + */ +lunr.Vector.prototype.insert = function (insertIdx, val) { + this.upsert(insertIdx, val, function () { + throw "duplicate index" + }) +} + +/** + * Inserts or updates an existing index within the vector. + * + * @param {Number} insertIdx - The index at which the element should be inserted. + * @param {Number} val - The value to be inserted into the vector. + * @param {function} fn - A function that is called for updates, the existing value and the + * requested value are passed as arguments + */ +lunr.Vector.prototype.upsert = function (insertIdx, val, fn) { + this._magnitude = 0 + var position = this.positionForIndex(insertIdx) + + if (this.elements[position] == insertIdx) { + this.elements[position + 1] = fn(this.elements[position + 1], val) + } else { + this.elements.splice(position, 0, insertIdx, val) + } +} + +/** + * Calculates the magnitude of this vector. + * + * @returns {Number} + */ +lunr.Vector.prototype.magnitude = function () { + if (this._magnitude) return this._magnitude + + var sumOfSquares = 0, + elementsLength = this.elements.length + + for (var i = 1; i < elementsLength; i += 2) { + var val = this.elements[i] + sumOfSquares += val * val + } + + return this._magnitude = Math.sqrt(sumOfSquares) +} + +/** + * Calculates the dot product of this vector and another vector. + * + * @param {lunr.Vector} otherVector - The vector to compute the dot product with. + * @returns {Number} + */ +lunr.Vector.prototype.dot = function (otherVector) { + var dotProduct = 0, + a = this.elements, b = otherVector.elements, + aLen = a.length, bLen = b.length, + aVal = 0, bVal = 0, + i = 0, j = 0 + + while (i < aLen && j < bLen) { + aVal = a[i], bVal = b[j] + if (aVal < bVal) { + i += 2 + } else if (aVal > bVal) { + j += 2 + } else if (aVal == bVal) { + dotProduct += a[i + 1] * b[j + 1] + i += 2 + j += 2 + } + } + + return dotProduct +} + +/** + * Calculates the similarity between this vector and another vector. + * + * @param {lunr.Vector} otherVector - The other vector to calculate the + * similarity with. + * @returns {Number} + */ +lunr.Vector.prototype.similarity = function (otherVector) { + return this.dot(otherVector) / this.magnitude() || 0 +} + +/** + * Converts the vector to an array of the elements within the vector. + * + * @returns {Number[]} + */ +lunr.Vector.prototype.toArray = function () { + var output = new Array (this.elements.length / 2) + + for (var i = 1, j = 0; i < this.elements.length; i += 2, j++) { + output[j] = this.elements[i] + } + + return output +} + +/** + * A JSON serializable representation of the vector. + * + * @returns {Number[]} + */ +lunr.Vector.prototype.toJSON = function () { + return this.elements +} +/* eslint-disable */ +/*! + * lunr.stemmer + * Copyright (C) 2020 Oliver Nightingale + * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt + */ + +/** + * lunr.stemmer is an english language stemmer, this is a JavaScript + * implementation of the PorterStemmer taken from http://tartarus.org/~martin + * + * @static + * @implements {lunr.PipelineFunction} + * @param {lunr.Token} token - The string to stem + * @returns {lunr.Token} + * @see {@link lunr.Pipeline} + * @function + */ +lunr.stemmer = (function(){ + var step2list = { + "ational" : "ate", + "tional" : "tion", + "enci" : "ence", + "anci" : "ance", + "izer" : "ize", + "bli" : "ble", + "alli" : "al", + "entli" : "ent", + "eli" : "e", + "ousli" : "ous", + "ization" : "ize", + "ation" : "ate", + "ator" : "ate", + "alism" : "al", + "iveness" : "ive", + "fulness" : "ful", + "ousness" : "ous", + "aliti" : "al", + "iviti" : "ive", + "biliti" : "ble", + "logi" : "log" + }, + + step3list = { + "icate" : "ic", + "ative" : "", + "alize" : "al", + "iciti" : "ic", + "ical" : "ic", + "ful" : "", + "ness" : "" + }, + + c = "[^aeiou]", // consonant + v = "[aeiouy]", // vowel + C = c + "[^aeiouy]*", // consonant sequence + V = v + "[aeiou]*", // vowel sequence + + mgr0 = "^(" + C + ")?" + V + C, // [C]VC... is m>0 + meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$", // [C]VC[V] is m=1 + mgr1 = "^(" + C + ")?" + V + C + V + C, // [C]VCVC... is m>1 + s_v = "^(" + C + ")?" + v; // vowel in stem + + var re_mgr0 = new RegExp(mgr0); + var re_mgr1 = new RegExp(mgr1); + var re_meq1 = new RegExp(meq1); + var re_s_v = new RegExp(s_v); + + var re_1a = /^(.+?)(ss|i)es$/; + var re2_1a = /^(.+?)([^s])s$/; + var re_1b = /^(.+?)eed$/; + var re2_1b = /^(.+?)(ed|ing)$/; + var re_1b_2 = /.$/; + var re2_1b_2 = /(at|bl|iz)$/; + var re3_1b_2 = new RegExp("([^aeiouylsz])\\1$"); + var re4_1b_2 = new RegExp("^" + C + v + "[^aeiouwxy]$"); + + var re_1c = /^(.+?[^aeiou])y$/; + var re_2 = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; + + var re_3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; + + var re_4 = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; + var re2_4 = /^(.+?)(s|t)(ion)$/; + + var re_5 = /^(.+?)e$/; + var re_5_1 = /ll$/; + var re3_5 = new RegExp("^" + C + v + "[^aeiouwxy]$"); + + var porterStemmer = function porterStemmer(w) { + var stem, + suffix, + firstch, + re, + re2, + re3, + re4; + + if (w.length < 3) { return w; } + + firstch = w.substr(0,1); + if (firstch == "y") { + w = firstch.toUpperCase() + w.substr(1); + } + + // Step 1a + re = re_1a + re2 = re2_1a; + + if (re.test(w)) { w = w.replace(re,"$1$2"); } + else if (re2.test(w)) { w = w.replace(re2,"$1$2"); } + + // Step 1b + re = re_1b; + re2 = re2_1b; + if (re.test(w)) { + var fp = re.exec(w); + re = re_mgr0; + if (re.test(fp[1])) { + re = re_1b_2; + w = w.replace(re,""); + } + } else if (re2.test(w)) { + var fp = re2.exec(w); + stem = fp[1]; + re2 = re_s_v; + if (re2.test(stem)) { + w = stem; + re2 = re2_1b_2; + re3 = re3_1b_2; + re4 = re4_1b_2; + if (re2.test(w)) { w = w + "e"; } + else if (re3.test(w)) { re = re_1b_2; w = w.replace(re,""); } + else if (re4.test(w)) { w = w + "e"; } + } + } + + // Step 1c - replace suffix y or Y by i if preceded by a non-vowel which is not the first letter of the word (so cry -> cri, by -> by, say -> say) + re = re_1c; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + w = stem + "i"; + } + + // Step 2 + re = re_2; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + suffix = fp[2]; + re = re_mgr0; + if (re.test(stem)) { + w = stem + step2list[suffix]; + } + } + + // Step 3 + re = re_3; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + suffix = fp[2]; + re = re_mgr0; + if (re.test(stem)) { + w = stem + step3list[suffix]; + } + } + + // Step 4 + re = re_4; + re2 = re2_4; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = re_mgr1; + if (re.test(stem)) { + w = stem; + } + } else if (re2.test(w)) { + var fp = re2.exec(w); + stem = fp[1] + fp[2]; + re2 = re_mgr1; + if (re2.test(stem)) { + w = stem; + } + } + + // Step 5 + re = re_5; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = re_mgr1; + re2 = re_meq1; + re3 = re3_5; + if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) { + w = stem; + } + } + + re = re_5_1; + re2 = re_mgr1; + if (re.test(w) && re2.test(w)) { + re = re_1b_2; + w = w.replace(re,""); + } + + // and turn initial Y back to y + + if (firstch == "y") { + w = firstch.toLowerCase() + w.substr(1); + } + + return w; + }; + + return function (token) { + return token.update(porterStemmer); + } +})(); + +lunr.Pipeline.registerFunction(lunr.stemmer, 'stemmer') +/*! + * lunr.stopWordFilter + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * lunr.generateStopWordFilter builds a stopWordFilter function from the provided + * list of stop words. + * + * The built in lunr.stopWordFilter is built using this generator and can be used + * to generate custom stopWordFilters for applications or non English languages. + * + * @function + * @param {Array} token The token to pass through the filter + * @returns {lunr.PipelineFunction} + * @see lunr.Pipeline + * @see lunr.stopWordFilter + */ +lunr.generateStopWordFilter = function (stopWords) { + var words = stopWords.reduce(function (memo, stopWord) { + memo[stopWord] = stopWord + return memo + }, {}) + + return function (token) { + if (token && words[token.toString()] !== token.toString()) return token + } +} + +/** + * lunr.stopWordFilter is an English language stop word list filter, any words + * contained in the list will not be passed through the filter. + * + * This is intended to be used in the Pipeline. If the token does not pass the + * filter then undefined will be returned. + * + * @function + * @implements {lunr.PipelineFunction} + * @params {lunr.Token} token - A token to check for being a stop word. + * @returns {lunr.Token} + * @see {@link lunr.Pipeline} + */ +lunr.stopWordFilter = lunr.generateStopWordFilter([ + 'a', + 'able', + 'about', + 'across', + 'after', + 'all', + 'almost', + 'also', + 'am', + 'among', + 'an', + 'and', + 'any', + 'are', + 'as', + 'at', + 'be', + 'because', + 'been', + 'but', + 'by', + 'can', + 'cannot', + 'could', + 'dear', + 'did', + 'do', + 'does', + 'either', + 'else', + 'ever', + 'every', + 'for', + 'from', + 'get', + 'got', + 'had', + 'has', + 'have', + 'he', + 'her', + 'hers', + 'him', + 'his', + 'how', + 'however', + 'i', + 'if', + 'in', + 'into', + 'is', + 'it', + 'its', + 'just', + 'least', + 'let', + 'like', + 'likely', + 'may', + 'me', + 'might', + 'most', + 'must', + 'my', + 'neither', + 'no', + 'nor', + 'not', + 'of', + 'off', + 'often', + 'on', + 'only', + 'or', + 'other', + 'our', + 'own', + 'rather', + 'said', + 'say', + 'says', + 'she', + 'should', + 'since', + 'so', + 'some', + 'than', + 'that', + 'the', + 'their', + 'them', + 'then', + 'there', + 'these', + 'they', + 'this', + 'tis', + 'to', + 'too', + 'twas', + 'us', + 'wants', + 'was', + 'we', + 'were', + 'what', + 'when', + 'where', + 'which', + 'while', + 'who', + 'whom', + 'why', + 'will', + 'with', + 'would', + 'yet', + 'you', + 'your' +]) + +lunr.Pipeline.registerFunction(lunr.stopWordFilter, 'stopWordFilter') +/*! + * lunr.trimmer + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * lunr.trimmer is a pipeline function for trimming non word + * characters from the beginning and end of tokens before they + * enter the index. + * + * This implementation may not work correctly for non latin + * characters and should either be removed or adapted for use + * with languages with non-latin characters. + * + * @static + * @implements {lunr.PipelineFunction} + * @param {lunr.Token} token The token to pass through the filter + * @returns {lunr.Token} + * @see lunr.Pipeline + */ +lunr.trimmer = function (token) { + return token.update(function (s) { + return s.replace(/^\W+/, '').replace(/\W+$/, '') + }) +} + +lunr.Pipeline.registerFunction(lunr.trimmer, 'trimmer') +/*! + * lunr.TokenSet + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * A token set is used to store the unique list of all tokens + * within an index. Token sets are also used to represent an + * incoming query to the index, this query token set and index + * token set are then intersected to find which tokens to look + * up in the inverted index. + * + * A token set can hold multiple tokens, as in the case of the + * index token set, or it can hold a single token as in the + * case of a simple query token set. + * + * Additionally token sets are used to perform wildcard matching. + * Leading, contained and trailing wildcards are supported, and + * from this edit distance matching can also be provided. + * + * Token sets are implemented as a minimal finite state automata, + * where both common prefixes and suffixes are shared between tokens. + * This helps to reduce the space used for storing the token set. + * + * @constructor + */ +lunr.TokenSet = function () { + this.final = false + this.edges = {} + this.id = lunr.TokenSet._nextId + lunr.TokenSet._nextId += 1 +} + +/** + * Keeps track of the next, auto increment, identifier to assign + * to a new tokenSet. + * + * TokenSets require a unique identifier to be correctly minimised. + * + * @private + */ +lunr.TokenSet._nextId = 1 + +/** + * Creates a TokenSet instance from the given sorted array of words. + * + * @param {String[]} arr - A sorted array of strings to create the set from. + * @returns {lunr.TokenSet} + * @throws Will throw an error if the input array is not sorted. + */ +lunr.TokenSet.fromArray = function (arr) { + var builder = new lunr.TokenSet.Builder + + for (var i = 0, len = arr.length; i < len; i++) { + builder.insert(arr[i]) + } + + builder.finish() + return builder.root +} + +/** + * Creates a token set from a query clause. + * + * @private + * @param {Object} clause - A single clause from lunr.Query. + * @param {string} clause.term - The query clause term. + * @param {number} [clause.editDistance] - The optional edit distance for the term. + * @returns {lunr.TokenSet} + */ +lunr.TokenSet.fromClause = function (clause) { + if ('editDistance' in clause) { + return lunr.TokenSet.fromFuzzyString(clause.term, clause.editDistance) + } else { + return lunr.TokenSet.fromString(clause.term) + } +} + +/** + * Creates a token set representing a single string with a specified + * edit distance. + * + * Insertions, deletions, substitutions and transpositions are each + * treated as an edit distance of 1. + * + * Increasing the allowed edit distance will have a dramatic impact + * on the performance of both creating and intersecting these TokenSets. + * It is advised to keep the edit distance less than 3. + * + * @param {string} str - The string to create the token set from. + * @param {number} editDistance - The allowed edit distance to match. + * @returns {lunr.Vector} + */ +lunr.TokenSet.fromFuzzyString = function (str, editDistance) { + var root = new lunr.TokenSet + + var stack = [{ + node: root, + editsRemaining: editDistance, + str: str + }] + + while (stack.length) { + var frame = stack.pop() + + // no edit + if (frame.str.length > 0) { + var char = frame.str.charAt(0), + noEditNode + + if (char in frame.node.edges) { + noEditNode = frame.node.edges[char] + } else { + noEditNode = new lunr.TokenSet + frame.node.edges[char] = noEditNode + } + + if (frame.str.length == 1) { + noEditNode.final = true + } + + stack.push({ + node: noEditNode, + editsRemaining: frame.editsRemaining, + str: frame.str.slice(1) + }) + } + + if (frame.editsRemaining == 0) { + continue + } + + // insertion + if ("*" in frame.node.edges) { + var insertionNode = frame.node.edges["*"] + } else { + var insertionNode = new lunr.TokenSet + frame.node.edges["*"] = insertionNode + } + + if (frame.str.length == 0) { + insertionNode.final = true + } + + stack.push({ + node: insertionNode, + editsRemaining: frame.editsRemaining - 1, + str: frame.str + }) + + // deletion + // can only do a deletion if we have enough edits remaining + // and if there are characters left to delete in the string + if (frame.str.length > 1) { + stack.push({ + node: frame.node, + editsRemaining: frame.editsRemaining - 1, + str: frame.str.slice(1) + }) + } + + // deletion + // just removing the last character from the str + if (frame.str.length == 1) { + frame.node.final = true + } + + // substitution + // can only do a substitution if we have enough edits remaining + // and if there are characters left to substitute + if (frame.str.length >= 1) { + if ("*" in frame.node.edges) { + var substitutionNode = frame.node.edges["*"] + } else { + var substitutionNode = new lunr.TokenSet + frame.node.edges["*"] = substitutionNode + } + + if (frame.str.length == 1) { + substitutionNode.final = true + } + + stack.push({ + node: substitutionNode, + editsRemaining: frame.editsRemaining - 1, + str: frame.str.slice(1) + }) + } + + // transposition + // can only do a transposition if there are edits remaining + // and there are enough characters to transpose + if (frame.str.length > 1) { + var charA = frame.str.charAt(0), + charB = frame.str.charAt(1), + transposeNode + + if (charB in frame.node.edges) { + transposeNode = frame.node.edges[charB] + } else { + transposeNode = new lunr.TokenSet + frame.node.edges[charB] = transposeNode + } + + if (frame.str.length == 1) { + transposeNode.final = true + } + + stack.push({ + node: transposeNode, + editsRemaining: frame.editsRemaining - 1, + str: charA + frame.str.slice(2) + }) + } + } + + return root +} + +/** + * Creates a TokenSet from a string. + * + * The string may contain one or more wildcard characters (*) + * that will allow wildcard matching when intersecting with + * another TokenSet. + * + * @param {string} str - The string to create a TokenSet from. + * @returns {lunr.TokenSet} + */ +lunr.TokenSet.fromString = function (str) { + var node = new lunr.TokenSet, + root = node + + /* + * Iterates through all characters within the passed string + * appending a node for each character. + * + * When a wildcard character is found then a self + * referencing edge is introduced to continually match + * any number of any characters. + */ + for (var i = 0, len = str.length; i < len; i++) { + var char = str[i], + final = (i == len - 1) + + if (char == "*") { + node.edges[char] = node + node.final = final + + } else { + var next = new lunr.TokenSet + next.final = final + + node.edges[char] = next + node = next + } + } + + return root +} + +/** + * Converts this TokenSet into an array of strings + * contained within the TokenSet. + * + * This is not intended to be used on a TokenSet that + * contains wildcards, in these cases the results are + * undefined and are likely to cause an infinite loop. + * + * @returns {string[]} + */ +lunr.TokenSet.prototype.toArray = function () { + var words = [] + + var stack = [{ + prefix: "", + node: this + }] + + while (stack.length) { + var frame = stack.pop(), + edges = Object.keys(frame.node.edges), + len = edges.length + + if (frame.node.final) { + /* In Safari, at this point the prefix is sometimes corrupted, see: + * https://github.com/olivernn/lunr.js/issues/279 Calling any + * String.prototype method forces Safari to "cast" this string to what + * it's supposed to be, fixing the bug. */ + frame.prefix.charAt(0) + words.push(frame.prefix) + } + + for (var i = 0; i < len; i++) { + var edge = edges[i] + + stack.push({ + prefix: frame.prefix.concat(edge), + node: frame.node.edges[edge] + }) + } + } + + return words +} + +/** + * Generates a string representation of a TokenSet. + * + * This is intended to allow TokenSets to be used as keys + * in objects, largely to aid the construction and minimisation + * of a TokenSet. As such it is not designed to be a human + * friendly representation of the TokenSet. + * + * @returns {string} + */ +lunr.TokenSet.prototype.toString = function () { + // NOTE: Using Object.keys here as this.edges is very likely + // to enter 'hash-mode' with many keys being added + // + // avoiding a for-in loop here as it leads to the function + // being de-optimised (at least in V8). From some simple + // benchmarks the performance is comparable, but allowing + // V8 to optimize may mean easy performance wins in the future. + + if (this._str) { + return this._str + } + + var str = this.final ? '1' : '0', + labels = Object.keys(this.edges).sort(), + len = labels.length + + for (var i = 0; i < len; i++) { + var label = labels[i], + node = this.edges[label] + + str = str + label + node.id + } + + return str +} + +/** + * Returns a new TokenSet that is the intersection of + * this TokenSet and the passed TokenSet. + * + * This intersection will take into account any wildcards + * contained within the TokenSet. + * + * @param {lunr.TokenSet} b - An other TokenSet to intersect with. + * @returns {lunr.TokenSet} + */ +lunr.TokenSet.prototype.intersect = function (b) { + var output = new lunr.TokenSet, + frame = undefined + + var stack = [{ + qNode: b, + output: output, + node: this + }] + + while (stack.length) { + frame = stack.pop() + + // NOTE: As with the #toString method, we are using + // Object.keys and a for loop instead of a for-in loop + // as both of these objects enter 'hash' mode, causing + // the function to be de-optimised in V8 + var qEdges = Object.keys(frame.qNode.edges), + qLen = qEdges.length, + nEdges = Object.keys(frame.node.edges), + nLen = nEdges.length + + for (var q = 0; q < qLen; q++) { + var qEdge = qEdges[q] + + for (var n = 0; n < nLen; n++) { + var nEdge = nEdges[n] + + if (nEdge == qEdge || qEdge == '*') { + var node = frame.node.edges[nEdge], + qNode = frame.qNode.edges[qEdge], + final = node.final && qNode.final, + next = undefined + + if (nEdge in frame.output.edges) { + // an edge already exists for this character + // no need to create a new node, just set the finality + // bit unless this node is already final + next = frame.output.edges[nEdge] + next.final = next.final || final + + } else { + // no edge exists yet, must create one + // set the finality bit and insert it + // into the output + next = new lunr.TokenSet + next.final = final + frame.output.edges[nEdge] = next + } + + stack.push({ + qNode: qNode, + output: next, + node: node + }) + } + } + } + } + + return output +} +lunr.TokenSet.Builder = function () { + this.previousWord = "" + this.root = new lunr.TokenSet + this.uncheckedNodes = [] + this.minimizedNodes = {} +} + +lunr.TokenSet.Builder.prototype.insert = function (word) { + var node, + commonPrefix = 0 + + if (word < this.previousWord) { + throw new Error ("Out of order word insertion") + } + + for (var i = 0; i < word.length && i < this.previousWord.length; i++) { + if (word[i] != this.previousWord[i]) break + commonPrefix++ + } + + this.minimize(commonPrefix) + + if (this.uncheckedNodes.length == 0) { + node = this.root + } else { + node = this.uncheckedNodes[this.uncheckedNodes.length - 1].child + } + + for (var i = commonPrefix; i < word.length; i++) { + var nextNode = new lunr.TokenSet, + char = word[i] + + node.edges[char] = nextNode + + this.uncheckedNodes.push({ + parent: node, + char: char, + child: nextNode + }) + + node = nextNode + } + + node.final = true + this.previousWord = word +} + +lunr.TokenSet.Builder.prototype.finish = function () { + this.minimize(0) +} + +lunr.TokenSet.Builder.prototype.minimize = function (downTo) { + for (var i = this.uncheckedNodes.length - 1; i >= downTo; i--) { + var node = this.uncheckedNodes[i], + childKey = node.child.toString() + + if (childKey in this.minimizedNodes) { + node.parent.edges[node.char] = this.minimizedNodes[childKey] + } else { + // Cache the key for this node since + // we know it can't change anymore + node.child._str = childKey + + this.minimizedNodes[childKey] = node.child + } + + this.uncheckedNodes.pop() + } +} +/*! + * lunr.Index + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * An index contains the built index of all documents and provides a query interface + * to the index. + * + * Usually instances of lunr.Index will not be created using this constructor, instead + * lunr.Builder should be used to construct new indexes, or lunr.Index.load should be + * used to load previously built and serialized indexes. + * + * @constructor + * @param {Object} attrs - The attributes of the built search index. + * @param {Object} attrs.invertedIndex - An index of term/field to document reference. + * @param {Object} attrs.fieldVectors - Field vectors + * @param {lunr.TokenSet} attrs.tokenSet - An set of all corpus tokens. + * @param {string[]} attrs.fields - The names of indexed document fields. + * @param {lunr.Pipeline} attrs.pipeline - The pipeline to use for search terms. + */ +lunr.Index = function (attrs) { + this.invertedIndex = attrs.invertedIndex + this.fieldVectors = attrs.fieldVectors + this.tokenSet = attrs.tokenSet + this.fields = attrs.fields + this.pipeline = attrs.pipeline +} + +/** + * A result contains details of a document matching a search query. + * @typedef {Object} lunr.Index~Result + * @property {string} ref - The reference of the document this result represents. + * @property {number} score - A number between 0 and 1 representing how similar this document is to the query. + * @property {lunr.MatchData} matchData - Contains metadata about this match including which term(s) caused the match. + */ + +/** + * Although lunr provides the ability to create queries using lunr.Query, it also provides a simple + * query language which itself is parsed into an instance of lunr.Query. + * + * For programmatically building queries it is advised to directly use lunr.Query, the query language + * is best used for human entered text rather than program generated text. + * + * At its simplest queries can just be a single term, e.g. `hello`, multiple terms are also supported + * and will be combined with OR, e.g `hello world` will match documents that contain either 'hello' + * or 'world', though those that contain both will rank higher in the results. + * + * Wildcards can be included in terms to match one or more unspecified characters, these wildcards can + * be inserted anywhere within the term, and more than one wildcard can exist in a single term. Adding + * wildcards will increase the number of documents that will be found but can also have a negative + * impact on query performance, especially with wildcards at the beginning of a term. + * + * Terms can be restricted to specific fields, e.g. `title:hello`, only documents with the term + * hello in the title field will match this query. Using a field not present in the index will lead + * to an error being thrown. + * + * Modifiers can also be added to terms, lunr supports edit distance and boost modifiers on terms. A term + * boost will make documents matching that term score higher, e.g. `foo^5`. Edit distance is also supported + * to provide fuzzy matching, e.g. 'hello~2' will match documents with hello with an edit distance of 2. + * Avoid large values for edit distance to improve query performance. + * + * Each term also supports a presence modifier. By default a term's presence in document is optional, however + * this can be changed to either required or prohibited. For a term's presence to be required in a document the + * term should be prefixed with a '+', e.g. `+foo bar` is a search for documents that must contain 'foo' and + * optionally contain 'bar'. Conversely a leading '-' sets the terms presence to prohibited, i.e. it must not + * appear in a document, e.g. `-foo bar` is a search for documents that do not contain 'foo' but may contain 'bar'. + * + * To escape special characters the backslash character '\' can be used, this allows searches to include + * characters that would normally be considered modifiers, e.g. `foo\~2` will search for a term "foo~2" instead + * of attempting to apply a boost of 2 to the search term "foo". + * + * @typedef {string} lunr.Index~QueryString + * @example Simple single term query + * hello + * @example Multiple term query + * hello world + * @example term scoped to a field + * title:hello + * @example term with a boost of 10 + * hello^10 + * @example term with an edit distance of 2 + * hello~2 + * @example terms with presence modifiers + * -foo +bar baz + */ + +/** + * Performs a search against the index using lunr query syntax. + * + * Results will be returned sorted by their score, the most relevant results + * will be returned first. For details on how the score is calculated, please see + * the {@link https://lunrjs.com/guides/searching.html#scoring|guide}. + * + * For more programmatic querying use lunr.Index#query. + * + * @param {lunr.Index~QueryString} queryString - A string containing a lunr query. + * @throws {lunr.QueryParseError} If the passed query string cannot be parsed. + * @returns {lunr.Index~Result[]} + */ +lunr.Index.prototype.search = function (queryString) { + return this.query(function (query) { + var parser = new lunr.QueryParser(queryString, query) + parser.parse() + }) +} + +/** + * A query builder callback provides a query object to be used to express + * the query to perform on the index. + * + * @callback lunr.Index~queryBuilder + * @param {lunr.Query} query - The query object to build up. + * @this lunr.Query + */ + +/** + * Performs a query against the index using the yielded lunr.Query object. + * + * If performing programmatic queries against the index, this method is preferred + * over lunr.Index#search so as to avoid the additional query parsing overhead. + * + * A query object is yielded to the supplied function which should be used to + * express the query to be run against the index. + * + * Note that although this function takes a callback parameter it is _not_ an + * asynchronous operation, the callback is just yielded a query object to be + * customized. + * + * @param {lunr.Index~queryBuilder} fn - A function that is used to build the query. + * @returns {lunr.Index~Result[]} + */ +lunr.Index.prototype.query = function (fn) { + // for each query clause + // * process terms + // * expand terms from token set + // * find matching documents and metadata + // * get document vectors + // * score documents + + var query = new lunr.Query(this.fields), + matchingFields = Object.create(null), + queryVectors = Object.create(null), + termFieldCache = Object.create(null), + requiredMatches = Object.create(null), + prohibitedMatches = Object.create(null) + + /* + * To support field level boosts a query vector is created per + * field. An empty vector is eagerly created to support negated + * queries. + */ + for (var i = 0; i < this.fields.length; i++) { + queryVectors[this.fields[i]] = new lunr.Vector + } + + fn.call(query, query) + + for (var i = 0; i < query.clauses.length; i++) { + /* + * Unless the pipeline has been disabled for this term, which is + * the case for terms with wildcards, we need to pass the clause + * term through the search pipeline. A pipeline returns an array + * of processed terms. Pipeline functions may expand the passed + * term, which means we may end up performing multiple index lookups + * for a single query term. + */ + var clause = query.clauses[i], + terms = null, + clauseMatches = lunr.Set.empty + + if (clause.usePipeline) { + terms = this.pipeline.runString(clause.term, { + fields: clause.fields + }) + } else { + terms = [clause.term] + } + + for (var m = 0; m < terms.length; m++) { + var term = terms[m] + + /* + * Each term returned from the pipeline needs to use the same query + * clause object, e.g. the same boost and or edit distance. The + * simplest way to do this is to re-use the clause object but mutate + * its term property. + */ + clause.term = term + + /* + * From the term in the clause we create a token set which will then + * be used to intersect the indexes token set to get a list of terms + * to lookup in the inverted index + */ + var termTokenSet = lunr.TokenSet.fromClause(clause), + expandedTerms = this.tokenSet.intersect(termTokenSet).toArray() + + /* + * If a term marked as required does not exist in the tokenSet it is + * impossible for the search to return any matches. We set all the field + * scoped required matches set to empty and stop examining any further + * clauses. + */ + if (expandedTerms.length === 0 && clause.presence === lunr.Query.presence.REQUIRED) { + for (var k = 0; k < clause.fields.length; k++) { + var field = clause.fields[k] + requiredMatches[field] = lunr.Set.empty + } + + break + } + + for (var j = 0; j < expandedTerms.length; j++) { + /* + * For each term get the posting and termIndex, this is required for + * building the query vector. + */ + var expandedTerm = expandedTerms[j], + posting = this.invertedIndex[expandedTerm], + termIndex = posting._index + + for (var k = 0; k < clause.fields.length; k++) { + /* + * For each field that this query term is scoped by (by default + * all fields are in scope) we need to get all the document refs + * that have this term in that field. + * + * The posting is the entry in the invertedIndex for the matching + * term from above. + */ + var field = clause.fields[k], + fieldPosting = posting[field], + matchingDocumentRefs = Object.keys(fieldPosting), + termField = expandedTerm + "/" + field, + matchingDocumentsSet = new lunr.Set(matchingDocumentRefs) + + /* + * if the presence of this term is required ensure that the matching + * documents are added to the set of required matches for this clause. + * + */ + if (clause.presence == lunr.Query.presence.REQUIRED) { + clauseMatches = clauseMatches.union(matchingDocumentsSet) + + if (requiredMatches[field] === undefined) { + requiredMatches[field] = lunr.Set.complete + } + } + + /* + * if the presence of this term is prohibited ensure that the matching + * documents are added to the set of prohibited matches for this field, + * creating that set if it does not yet exist. + */ + if (clause.presence == lunr.Query.presence.PROHIBITED) { + if (prohibitedMatches[field] === undefined) { + prohibitedMatches[field] = lunr.Set.empty + } + + prohibitedMatches[field] = prohibitedMatches[field].union(matchingDocumentsSet) + + /* + * Prohibited matches should not be part of the query vector used for + * similarity scoring and no metadata should be extracted so we continue + * to the next field + */ + continue + } + + /* + * The query field vector is populated using the termIndex found for + * the term and a unit value with the appropriate boost applied. + * Using upsert because there could already be an entry in the vector + * for the term we are working with. In that case we just add the scores + * together. + */ + queryVectors[field].upsert(termIndex, clause.boost, function (a, b) { return a + b }) + + /** + * If we've already seen this term, field combo then we've already collected + * the matching documents and metadata, no need to go through all that again + */ + if (termFieldCache[termField]) { + continue + } + + for (var l = 0; l < matchingDocumentRefs.length; l++) { + /* + * All metadata for this term/field/document triple + * are then extracted and collected into an instance + * of lunr.MatchData ready to be returned in the query + * results + */ + var matchingDocumentRef = matchingDocumentRefs[l], + matchingFieldRef = new lunr.FieldRef (matchingDocumentRef, field), + metadata = fieldPosting[matchingDocumentRef], + fieldMatch + + if ((fieldMatch = matchingFields[matchingFieldRef]) === undefined) { + matchingFields[matchingFieldRef] = new lunr.MatchData (expandedTerm, field, metadata) + } else { + fieldMatch.add(expandedTerm, field, metadata) + } + + } + + termFieldCache[termField] = true + } + } + } + + /** + * If the presence was required we need to update the requiredMatches field sets. + * We do this after all fields for the term have collected their matches because + * the clause terms presence is required in _any_ of the fields not _all_ of the + * fields. + */ + if (clause.presence === lunr.Query.presence.REQUIRED) { + for (var k = 0; k < clause.fields.length; k++) { + var field = clause.fields[k] + requiredMatches[field] = requiredMatches[field].intersect(clauseMatches) + } + } + } + + /** + * Need to combine the field scoped required and prohibited + * matching documents into a global set of required and prohibited + * matches + */ + var allRequiredMatches = lunr.Set.complete, + allProhibitedMatches = lunr.Set.empty + + for (var i = 0; i < this.fields.length; i++) { + var field = this.fields[i] + + if (requiredMatches[field]) { + allRequiredMatches = allRequiredMatches.intersect(requiredMatches[field]) + } + + if (prohibitedMatches[field]) { + allProhibitedMatches = allProhibitedMatches.union(prohibitedMatches[field]) + } + } + + var matchingFieldRefs = Object.keys(matchingFields), + results = [], + matches = Object.create(null) + + /* + * If the query is negated (contains only prohibited terms) + * we need to get _all_ fieldRefs currently existing in the + * index. This is only done when we know that the query is + * entirely prohibited terms to avoid any cost of getting all + * fieldRefs unnecessarily. + * + * Additionally, blank MatchData must be created to correctly + * populate the results. + */ + if (query.isNegated()) { + matchingFieldRefs = Object.keys(this.fieldVectors) + + for (var i = 0; i < matchingFieldRefs.length; i++) { + var matchingFieldRef = matchingFieldRefs[i] + var fieldRef = lunr.FieldRef.fromString(matchingFieldRef) + matchingFields[matchingFieldRef] = new lunr.MatchData + } + } + + for (var i = 0; i < matchingFieldRefs.length; i++) { + /* + * Currently we have document fields that match the query, but we + * need to return documents. The matchData and scores are combined + * from multiple fields belonging to the same document. + * + * Scores are calculated by field, using the query vectors created + * above, and combined into a final document score using addition. + */ + var fieldRef = lunr.FieldRef.fromString(matchingFieldRefs[i]), + docRef = fieldRef.docRef + + if (!allRequiredMatches.contains(docRef)) { + continue + } + + if (allProhibitedMatches.contains(docRef)) { + continue + } + + var fieldVector = this.fieldVectors[fieldRef], + score = queryVectors[fieldRef.fieldName].similarity(fieldVector), + docMatch + + if ((docMatch = matches[docRef]) !== undefined) { + docMatch.score += score + docMatch.matchData.combine(matchingFields[fieldRef]) + } else { + var match = { + ref: docRef, + score: score, + matchData: matchingFields[fieldRef] + } + matches[docRef] = match + results.push(match) + } + } + + /* + * Sort the results objects by score, highest first. + */ + return results.sort(function (a, b) { + return b.score - a.score + }) +} + +/** + * Prepares the index for JSON serialization. + * + * The schema for this JSON blob will be described in a + * separate JSON schema file. + * + * @returns {Object} + */ +lunr.Index.prototype.toJSON = function () { + var invertedIndex = Object.keys(this.invertedIndex) + .sort() + .map(function (term) { + return [term, this.invertedIndex[term]] + }, this) + + var fieldVectors = Object.keys(this.fieldVectors) + .map(function (ref) { + return [ref, this.fieldVectors[ref].toJSON()] + }, this) + + return { + version: lunr.version, + fields: this.fields, + fieldVectors: fieldVectors, + invertedIndex: invertedIndex, + pipeline: this.pipeline.toJSON() + } +} + +/** + * Loads a previously serialized lunr.Index + * + * @param {Object} serializedIndex - A previously serialized lunr.Index + * @returns {lunr.Index} + */ +lunr.Index.load = function (serializedIndex) { + var attrs = {}, + fieldVectors = {}, + serializedVectors = serializedIndex.fieldVectors, + invertedIndex = Object.create(null), + serializedInvertedIndex = serializedIndex.invertedIndex, + tokenSetBuilder = new lunr.TokenSet.Builder, + pipeline = lunr.Pipeline.load(serializedIndex.pipeline) + + if (serializedIndex.version != lunr.version) { + lunr.utils.warn("Version mismatch when loading serialised index. Current version of lunr '" + lunr.version + "' does not match serialized index '" + serializedIndex.version + "'") + } + + for (var i = 0; i < serializedVectors.length; i++) { + var tuple = serializedVectors[i], + ref = tuple[0], + elements = tuple[1] + + fieldVectors[ref] = new lunr.Vector(elements) + } + + for (var i = 0; i < serializedInvertedIndex.length; i++) { + var tuple = serializedInvertedIndex[i], + term = tuple[0], + posting = tuple[1] + + tokenSetBuilder.insert(term) + invertedIndex[term] = posting + } + + tokenSetBuilder.finish() + + attrs.fields = serializedIndex.fields + + attrs.fieldVectors = fieldVectors + attrs.invertedIndex = invertedIndex + attrs.tokenSet = tokenSetBuilder.root + attrs.pipeline = pipeline + + return new lunr.Index(attrs) +} +/*! + * lunr.Builder + * Copyright (C) 2020 Oliver Nightingale + */ + +/** + * lunr.Builder performs indexing on a set of documents and + * returns instances of lunr.Index ready for querying. + * + * All configuration of the index is done via the builder, the + * fields to index, the document reference, the text processing + * pipeline and document scoring parameters are all set on the + * builder before indexing. + * + * @constructor + * @property {string} _ref - Internal reference to the document reference field. + * @property {string[]} _fields - Internal reference to the document fields to index. + * @property {object} invertedIndex - The inverted index maps terms to document fields. + * @property {object} documentTermFrequencies - Keeps track of document term frequencies. + * @property {object} documentLengths - Keeps track of the length of documents added to the index. + * @property {lunr.tokenizer} tokenizer - Function for splitting strings into tokens for indexing. + * @property {lunr.Pipeline} pipeline - The pipeline performs text processing on tokens before indexing. + * @property {lunr.Pipeline} searchPipeline - A pipeline for processing search terms before querying the index. + * @property {number} documentCount - Keeps track of the total number of documents indexed. + * @property {number} _b - A parameter to control field length normalization, setting this to 0 disabled normalization, 1 fully normalizes field lengths, the default value is 0.75. + * @property {number} _k1 - A parameter to control how quickly an increase in term frequency results in term frequency saturation, the default value is 1.2. + * @property {number} termIndex - A counter incremented for each unique term, used to identify a terms position in the vector space. + * @property {array} metadataWhitelist - A list of metadata keys that have been whitelisted for entry in the index. + */ +lunr.Builder = function () { + this._ref = "id" + this._fields = Object.create(null) + this._documents = Object.create(null) + this.invertedIndex = Object.create(null) + this.fieldTermFrequencies = {} + this.fieldLengths = {} + this.tokenizer = lunr.tokenizer + this.pipeline = new lunr.Pipeline + this.searchPipeline = new lunr.Pipeline + this.documentCount = 0 + this._b = 0.75 + this._k1 = 1.2 + this.termIndex = 0 + this.metadataWhitelist = [] +} + +/** + * Sets the document field used as the document reference. Every document must have this field. + * The type of this field in the document should be a string, if it is not a string it will be + * coerced into a string by calling toString. + * + * The default ref is 'id'. + * + * The ref should _not_ be changed during indexing, it should be set before any documents are + * added to the index. Changing it during indexing can lead to inconsistent results. + * + * @param {string} ref - The name of the reference field in the document. + */ +lunr.Builder.prototype.ref = function (ref) { + this._ref = ref +} + +/** + * A function that is used to extract a field from a document. + * + * Lunr expects a field to be at the top level of a document, if however the field + * is deeply nested within a document an extractor function can be used to extract + * the right field for indexing. + * + * @callback fieldExtractor + * @param {object} doc - The document being added to the index. + * @returns {?(string|object|object[])} obj - The object that will be indexed for this field. + * @example Extracting a nested field + * function (doc) { return doc.nested.field } + */ + +/** + * Adds a field to the list of document fields that will be indexed. Every document being + * indexed should have this field. Null values for this field in indexed documents will + * not cause errors but will limit the chance of that document being retrieved by searches. + * + * All fields should be added before adding documents to the index. Adding fields after + * a document has been indexed will have no effect on already indexed documents. + * + * Fields can be boosted at build time. This allows terms within that field to have more + * importance when ranking search results. Use a field boost to specify that matches within + * one field are more important than other fields. + * + * @param {string} fieldName - The name of a field to index in all documents. + * @param {object} attributes - Optional attributes associated with this field. + * @param {number} [attributes.boost=1] - Boost applied to all terms within this field. + * @param {fieldExtractor} [attributes.extractor] - Function to extract a field from a document. + * @throws {RangeError} fieldName cannot contain unsupported characters '/' + */ +lunr.Builder.prototype.field = function (fieldName, attributes) { + if (/\//.test(fieldName)) { + throw new RangeError ("Field '" + fieldName + "' contains illegal character '/'") + } + + this._fields[fieldName] = attributes || {} +} + +/** + * A parameter to tune the amount of field length normalisation that is applied when + * calculating relevance scores. A value of 0 will completely disable any normalisation + * and a value of 1 will fully normalise field lengths. The default is 0.75. Values of b + * will be clamped to the range 0 - 1. + * + * @param {number} number - The value to set for this tuning parameter. + */ +lunr.Builder.prototype.b = function (number) { + if (number < 0) { + this._b = 0 + } else if (number > 1) { + this._b = 1 + } else { + this._b = number + } +} + +/** + * A parameter that controls the speed at which a rise in term frequency results in term + * frequency saturation. The default value is 1.2. Setting this to a higher value will give + * slower saturation levels, a lower value will result in quicker saturation. + * + * @param {number} number - The value to set for this tuning parameter. + */ +lunr.Builder.prototype.k1 = function (number) { + this._k1 = number +} + +/** + * Adds a document to the index. + * + * Before adding fields to the index the index should have been fully setup, with the document + * ref and all fields to index already having been specified. + * + * The document must have a field name as specified by the ref (by default this is 'id') and + * it should have all fields defined for indexing, though null or undefined values will not + * cause errors. + * + * Entire documents can be boosted at build time. Applying a boost to a document indicates that + * this document should rank higher in search results than other documents. + * + * @param {object} doc - The document to add to the index. + * @param {object} attributes - Optional attributes associated with this document. + * @param {number} [attributes.boost=1] - Boost applied to all terms within this document. + */ +lunr.Builder.prototype.add = function (doc, attributes) { + var docRef = doc[this._ref], + fields = Object.keys(this._fields) + + this._documents[docRef] = attributes || {} + this.documentCount += 1 + + for (var i = 0; i < fields.length; i++) { + var fieldName = fields[i], + extractor = this._fields[fieldName].extractor, + field = extractor ? extractor(doc) : doc[fieldName], + tokens = this.tokenizer(field, { + fields: [fieldName] + }), + terms = this.pipeline.run(tokens), + fieldRef = new lunr.FieldRef (docRef, fieldName), + fieldTerms = Object.create(null) + + this.fieldTermFrequencies[fieldRef] = fieldTerms + this.fieldLengths[fieldRef] = 0 + + // store the length of this field for this document + this.fieldLengths[fieldRef] += terms.length + + // calculate term frequencies for this field + for (var j = 0; j < terms.length; j++) { + var term = terms[j] + + if (fieldTerms[term] == undefined) { + fieldTerms[term] = 0 + } + + fieldTerms[term] += 1 + + // add to inverted index + // create an initial posting if one doesn't exist + if (this.invertedIndex[term] == undefined) { + var posting = Object.create(null) + posting["_index"] = this.termIndex + this.termIndex += 1 + + for (var k = 0; k < fields.length; k++) { + posting[fields[k]] = Object.create(null) + } + + this.invertedIndex[term] = posting + } + + // add an entry for this term/fieldName/docRef to the invertedIndex + if (this.invertedIndex[term][fieldName][docRef] == undefined) { + this.invertedIndex[term][fieldName][docRef] = Object.create(null) + } + + // store all whitelisted metadata about this token in the + // inverted index + for (var l = 0; l < this.metadataWhitelist.length; l++) { + var metadataKey = this.metadataWhitelist[l], + metadata = term.metadata[metadataKey] + + if (this.invertedIndex[term][fieldName][docRef][metadataKey] == undefined) { + this.invertedIndex[term][fieldName][docRef][metadataKey] = [] + } + + this.invertedIndex[term][fieldName][docRef][metadataKey].push(metadata) + } + } + + } +} + +/** + * Calculates the average document length for this index + * + * @private + */ +lunr.Builder.prototype.calculateAverageFieldLengths = function () { + + var fieldRefs = Object.keys(this.fieldLengths), + numberOfFields = fieldRefs.length, + accumulator = {}, + documentsWithField = {} + + for (var i = 0; i < numberOfFields; i++) { + var fieldRef = lunr.FieldRef.fromString(fieldRefs[i]), + field = fieldRef.fieldName + + documentsWithField[field] || (documentsWithField[field] = 0) + documentsWithField[field] += 1 + + accumulator[field] || (accumulator[field] = 0) + accumulator[field] += this.fieldLengths[fieldRef] + } + + var fields = Object.keys(this._fields) + + for (var i = 0; i < fields.length; i++) { + var fieldName = fields[i] + accumulator[fieldName] = accumulator[fieldName] / documentsWithField[fieldName] + } + + this.averageFieldLength = accumulator +} + +/** + * Builds a vector space model of every document using lunr.Vector + * + * @private + */ +lunr.Builder.prototype.createFieldVectors = function () { + var fieldVectors = {}, + fieldRefs = Object.keys(this.fieldTermFrequencies), + fieldRefsLength = fieldRefs.length, + termIdfCache = Object.create(null) + + for (var i = 0; i < fieldRefsLength; i++) { + var fieldRef = lunr.FieldRef.fromString(fieldRefs[i]), + fieldName = fieldRef.fieldName, + fieldLength = this.fieldLengths[fieldRef], + fieldVector = new lunr.Vector, + termFrequencies = this.fieldTermFrequencies[fieldRef], + terms = Object.keys(termFrequencies), + termsLength = terms.length + + + var fieldBoost = this._fields[fieldName].boost || 1, + docBoost = this._documents[fieldRef.docRef].boost || 1 + + for (var j = 0; j < termsLength; j++) { + var term = terms[j], + tf = termFrequencies[term], + termIndex = this.invertedIndex[term]._index, + idf, score, scoreWithPrecision + + if (termIdfCache[term] === undefined) { + idf = lunr.idf(this.invertedIndex[term], this.documentCount) + termIdfCache[term] = idf + } else { + idf = termIdfCache[term] + } + + score = idf * ((this._k1 + 1) * tf) / (this._k1 * (1 - this._b + this._b * (fieldLength / this.averageFieldLength[fieldName])) + tf) + score *= fieldBoost + score *= docBoost + scoreWithPrecision = Math.round(score * 1000) / 1000 + // Converts 1.23456789 to 1.234. + // Reducing the precision so that the vectors take up less + // space when serialised. Doing it now so that they behave + // the same before and after serialisation. Also, this is + // the fastest approach to reducing a number's precision in + // JavaScript. + + fieldVector.insert(termIndex, scoreWithPrecision) + } + + fieldVectors[fieldRef] = fieldVector + } + + this.fieldVectors = fieldVectors +} + +/** + * Creates a token set of all tokens in the index using lunr.TokenSet + * + * @private + */ +lunr.Builder.prototype.createTokenSet = function () { + this.tokenSet = lunr.TokenSet.fromArray( + Object.keys(this.invertedIndex).sort() + ) +} + +/** + * Builds the index, creating an instance of lunr.Index. + * + * This completes the indexing process and should only be called + * once all documents have been added to the index. + * + * @returns {lunr.Index} + */ +lunr.Builder.prototype.build = function () { + this.calculateAverageFieldLengths() + this.createFieldVectors() + this.createTokenSet() + + return new lunr.Index({ + invertedIndex: this.invertedIndex, + fieldVectors: this.fieldVectors, + tokenSet: this.tokenSet, + fields: Object.keys(this._fields), + pipeline: this.searchPipeline + }) +} + +/** + * Applies a plugin to the index builder. + * + * A plugin is a function that is called with the index builder as its context. + * Plugins can be used to customise or extend the behaviour of the index + * in some way. A plugin is just a function, that encapsulated the custom + * behaviour that should be applied when building the index. + * + * The plugin function will be called with the index builder as its argument, additional + * arguments can also be passed when calling use. The function will be called + * with the index builder as its context. + * + * @param {Function} plugin The plugin to apply. + */ +lunr.Builder.prototype.use = function (fn) { + var args = Array.prototype.slice.call(arguments, 1) + args.unshift(this) + fn.apply(this, args) +} +/** + * Contains and collects metadata about a matching document. + * A single instance of lunr.MatchData is returned as part of every + * lunr.Index~Result. + * + * @constructor + * @param {string} term - The term this match data is associated with + * @param {string} field - The field in which the term was found + * @param {object} metadata - The metadata recorded about this term in this field + * @property {object} metadata - A cloned collection of metadata associated with this document. + * @see {@link lunr.Index~Result} + */ +lunr.MatchData = function (term, field, metadata) { + var clonedMetadata = Object.create(null), + metadataKeys = Object.keys(metadata || {}) + + // Cloning the metadata to prevent the original + // being mutated during match data combination. + // Metadata is kept in an array within the inverted + // index so cloning the data can be done with + // Array#slice + for (var i = 0; i < metadataKeys.length; i++) { + var key = metadataKeys[i] + clonedMetadata[key] = metadata[key].slice() + } + + this.metadata = Object.create(null) + + if (term !== undefined) { + this.metadata[term] = Object.create(null) + this.metadata[term][field] = clonedMetadata + } +} + +/** + * An instance of lunr.MatchData will be created for every term that matches a + * document. However only one instance is required in a lunr.Index~Result. This + * method combines metadata from another instance of lunr.MatchData with this + * objects metadata. + * + * @param {lunr.MatchData} otherMatchData - Another instance of match data to merge with this one. + * @see {@link lunr.Index~Result} + */ +lunr.MatchData.prototype.combine = function (otherMatchData) { + var terms = Object.keys(otherMatchData.metadata) + + for (var i = 0; i < terms.length; i++) { + var term = terms[i], + fields = Object.keys(otherMatchData.metadata[term]) + + if (this.metadata[term] == undefined) { + this.metadata[term] = Object.create(null) + } + + for (var j = 0; j < fields.length; j++) { + var field = fields[j], + keys = Object.keys(otherMatchData.metadata[term][field]) + + if (this.metadata[term][field] == undefined) { + this.metadata[term][field] = Object.create(null) + } + + for (var k = 0; k < keys.length; k++) { + var key = keys[k] + + if (this.metadata[term][field][key] == undefined) { + this.metadata[term][field][key] = otherMatchData.metadata[term][field][key] + } else { + this.metadata[term][field][key] = this.metadata[term][field][key].concat(otherMatchData.metadata[term][field][key]) + } + + } + } + } +} + +/** + * Add metadata for a term/field pair to this instance of match data. + * + * @param {string} term - The term this match data is associated with + * @param {string} field - The field in which the term was found + * @param {object} metadata - The metadata recorded about this term in this field + */ +lunr.MatchData.prototype.add = function (term, field, metadata) { + if (!(term in this.metadata)) { + this.metadata[term] = Object.create(null) + this.metadata[term][field] = metadata + return + } + + if (!(field in this.metadata[term])) { + this.metadata[term][field] = metadata + return + } + + var metadataKeys = Object.keys(metadata) + + for (var i = 0; i < metadataKeys.length; i++) { + var key = metadataKeys[i] + + if (key in this.metadata[term][field]) { + this.metadata[term][field][key] = this.metadata[term][field][key].concat(metadata[key]) + } else { + this.metadata[term][field][key] = metadata[key] + } + } +} +/** + * A lunr.Query provides a programmatic way of defining queries to be performed + * against a {@link lunr.Index}. + * + * Prefer constructing a lunr.Query using the {@link lunr.Index#query} method + * so the query object is pre-initialized with the right index fields. + * + * @constructor + * @property {lunr.Query~Clause[]} clauses - An array of query clauses. + * @property {string[]} allFields - An array of all available fields in a lunr.Index. + */ +lunr.Query = function (allFields) { + this.clauses = [] + this.allFields = allFields +} + +/** + * Constants for indicating what kind of automatic wildcard insertion will be used when constructing a query clause. + * + * This allows wildcards to be added to the beginning and end of a term without having to manually do any string + * concatenation. + * + * The wildcard constants can be bitwise combined to select both leading and trailing wildcards. + * + * @constant + * @default + * @property {number} wildcard.NONE - The term will have no wildcards inserted, this is the default behaviour + * @property {number} wildcard.LEADING - Prepend the term with a wildcard, unless a leading wildcard already exists + * @property {number} wildcard.TRAILING - Append a wildcard to the term, unless a trailing wildcard already exists + * @see lunr.Query~Clause + * @see lunr.Query#clause + * @see lunr.Query#term + * @example query term with trailing wildcard + * query.term('foo', { wildcard: lunr.Query.wildcard.TRAILING }) + * @example query term with leading and trailing wildcard + * query.term('foo', { + * wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING + * }) + */ + +lunr.Query.wildcard = new String ("*") +lunr.Query.wildcard.NONE = 0 +lunr.Query.wildcard.LEADING = 1 +lunr.Query.wildcard.TRAILING = 2 + +/** + * Constants for indicating what kind of presence a term must have in matching documents. + * + * @constant + * @enum {number} + * @see lunr.Query~Clause + * @see lunr.Query#clause + * @see lunr.Query#term + * @example query term with required presence + * query.term('foo', { presence: lunr.Query.presence.REQUIRED }) + */ +lunr.Query.presence = { + /** + * Term's presence in a document is optional, this is the default value. + */ + OPTIONAL: 1, + + /** + * Term's presence in a document is required, documents that do not contain + * this term will not be returned. + */ + REQUIRED: 2, + + /** + * Term's presence in a document is prohibited, documents that do contain + * this term will not be returned. + */ + PROHIBITED: 3 +} + +/** + * A single clause in a {@link lunr.Query} contains a term and details on how to + * match that term against a {@link lunr.Index}. + * + * @typedef {Object} lunr.Query~Clause + * @property {string[]} fields - The fields in an index this clause should be matched against. + * @property {number} [boost=1] - Any boost that should be applied when matching this clause. + * @property {number} [editDistance] - Whether the term should have fuzzy matching applied, and how fuzzy the match should be. + * @property {boolean} [usePipeline] - Whether the term should be passed through the search pipeline. + * @property {number} [wildcard=lunr.Query.wildcard.NONE] - Whether the term should have wildcards appended or prepended. + * @property {number} [presence=lunr.Query.presence.OPTIONAL] - The terms presence in any matching documents. + */ + +/** + * Adds a {@link lunr.Query~Clause} to this query. + * + * Unless the clause contains the fields to be matched all fields will be matched. In addition + * a default boost of 1 is applied to the clause. + * + * @param {lunr.Query~Clause} clause - The clause to add to this query. + * @see lunr.Query~Clause + * @returns {lunr.Query} + */ +lunr.Query.prototype.clause = function (clause) { + if (!('fields' in clause)) { + clause.fields = this.allFields + } + + if (!('boost' in clause)) { + clause.boost = 1 + } + + if (!('usePipeline' in clause)) { + clause.usePipeline = true + } + + if (!('wildcard' in clause)) { + clause.wildcard = lunr.Query.wildcard.NONE + } + + if ((clause.wildcard & lunr.Query.wildcard.LEADING) && (clause.term.charAt(0) != lunr.Query.wildcard)) { + clause.term = "*" + clause.term + } + + if ((clause.wildcard & lunr.Query.wildcard.TRAILING) && (clause.term.slice(-1) != lunr.Query.wildcard)) { + clause.term = "" + clause.term + "*" + } + + if (!('presence' in clause)) { + clause.presence = lunr.Query.presence.OPTIONAL + } + + this.clauses.push(clause) + + return this +} + +/** + * A negated query is one in which every clause has a presence of + * prohibited. These queries require some special processing to return + * the expected results. + * + * @returns boolean + */ +lunr.Query.prototype.isNegated = function () { + for (var i = 0; i < this.clauses.length; i++) { + if (this.clauses[i].presence != lunr.Query.presence.PROHIBITED) { + return false + } + } + + return true +} + +/** + * Adds a term to the current query, under the covers this will create a {@link lunr.Query~Clause} + * to the list of clauses that make up this query. + * + * The term is used as is, i.e. no tokenization will be performed by this method. Instead conversion + * to a token or token-like string should be done before calling this method. + * + * The term will be converted to a string by calling `toString`. Multiple terms can be passed as an + * array, each term in the array will share the same options. + * + * @param {object|object[]} term - The term(s) to add to the query. + * @param {object} [options] - Any additional properties to add to the query clause. + * @returns {lunr.Query} + * @see lunr.Query#clause + * @see lunr.Query~Clause + * @example adding a single term to a query + * query.term("foo") + * @example adding a single term to a query and specifying search fields, term boost and automatic trailing wildcard + * query.term("foo", { + * fields: ["title"], + * boost: 10, + * wildcard: lunr.Query.wildcard.TRAILING + * }) + * @example using lunr.tokenizer to convert a string to tokens before using them as terms + * query.term(lunr.tokenizer("foo bar")) + */ +lunr.Query.prototype.term = function (term, options) { + if (Array.isArray(term)) { + term.forEach(function (t) { this.term(t, lunr.utils.clone(options)) }, this) + return this + } + + var clause = options || {} + clause.term = term.toString() + + this.clause(clause) + + return this +} +lunr.QueryParseError = function (message, start, end) { + this.name = "QueryParseError" + this.message = message + this.start = start + this.end = end +} + +lunr.QueryParseError.prototype = new Error +lunr.QueryLexer = function (str) { + this.lexemes = [] + this.str = str + this.length = str.length + this.pos = 0 + this.start = 0 + this.escapeCharPositions = [] +} + +lunr.QueryLexer.prototype.run = function () { + var state = lunr.QueryLexer.lexText + + while (state) { + state = state(this) + } +} + +lunr.QueryLexer.prototype.sliceString = function () { + var subSlices = [], + sliceStart = this.start, + sliceEnd = this.pos + + for (var i = 0; i < this.escapeCharPositions.length; i++) { + sliceEnd = this.escapeCharPositions[i] + subSlices.push(this.str.slice(sliceStart, sliceEnd)) + sliceStart = sliceEnd + 1 + } + + subSlices.push(this.str.slice(sliceStart, this.pos)) + this.escapeCharPositions.length = 0 + + return subSlices.join('') +} + +lunr.QueryLexer.prototype.emit = function (type) { + this.lexemes.push({ + type: type, + str: this.sliceString(), + start: this.start, + end: this.pos + }) + + this.start = this.pos +} + +lunr.QueryLexer.prototype.escapeCharacter = function () { + this.escapeCharPositions.push(this.pos - 1) + this.pos += 1 +} + +lunr.QueryLexer.prototype.next = function () { + if (this.pos >= this.length) { + return lunr.QueryLexer.EOS + } + + var char = this.str.charAt(this.pos) + this.pos += 1 + return char +} + +lunr.QueryLexer.prototype.width = function () { + return this.pos - this.start +} + +lunr.QueryLexer.prototype.ignore = function () { + if (this.start == this.pos) { + this.pos += 1 + } + + this.start = this.pos +} + +lunr.QueryLexer.prototype.backup = function () { + this.pos -= 1 +} + +lunr.QueryLexer.prototype.acceptDigitRun = function () { + var char, charCode + + do { + char = this.next() + charCode = char.charCodeAt(0) + } while (charCode > 47 && charCode < 58) + + if (char != lunr.QueryLexer.EOS) { + this.backup() + } +} + +lunr.QueryLexer.prototype.more = function () { + return this.pos < this.length +} + +lunr.QueryLexer.EOS = 'EOS' +lunr.QueryLexer.FIELD = 'FIELD' +lunr.QueryLexer.TERM = 'TERM' +lunr.QueryLexer.EDIT_DISTANCE = 'EDIT_DISTANCE' +lunr.QueryLexer.BOOST = 'BOOST' +lunr.QueryLexer.PRESENCE = 'PRESENCE' + +lunr.QueryLexer.lexField = function (lexer) { + lexer.backup() + lexer.emit(lunr.QueryLexer.FIELD) + lexer.ignore() + return lunr.QueryLexer.lexText +} + +lunr.QueryLexer.lexTerm = function (lexer) { + if (lexer.width() > 1) { + lexer.backup() + lexer.emit(lunr.QueryLexer.TERM) + } + + lexer.ignore() + + if (lexer.more()) { + return lunr.QueryLexer.lexText + } +} + +lunr.QueryLexer.lexEditDistance = function (lexer) { + lexer.ignore() + lexer.acceptDigitRun() + lexer.emit(lunr.QueryLexer.EDIT_DISTANCE) + return lunr.QueryLexer.lexText +} + +lunr.QueryLexer.lexBoost = function (lexer) { + lexer.ignore() + lexer.acceptDigitRun() + lexer.emit(lunr.QueryLexer.BOOST) + return lunr.QueryLexer.lexText +} + +lunr.QueryLexer.lexEOS = function (lexer) { + if (lexer.width() > 0) { + lexer.emit(lunr.QueryLexer.TERM) + } +} + +// This matches the separator used when tokenising fields +// within a document. These should match otherwise it is +// not possible to search for some tokens within a document. +// +// It is possible for the user to change the separator on the +// tokenizer so it _might_ clash with any other of the special +// characters already used within the search string, e.g. :. +// +// This means that it is possible to change the separator in +// such a way that makes some words unsearchable using a search +// string. +lunr.QueryLexer.termSeparator = lunr.tokenizer.separator + +lunr.QueryLexer.lexText = function (lexer) { + while (true) { + var char = lexer.next() + + if (char == lunr.QueryLexer.EOS) { + return lunr.QueryLexer.lexEOS + } + + // Escape character is '\' + if (char.charCodeAt(0) == 92) { + lexer.escapeCharacter() + continue + } + + if (char == ":") { + return lunr.QueryLexer.lexField + } + + if (char == "~") { + lexer.backup() + if (lexer.width() > 0) { + lexer.emit(lunr.QueryLexer.TERM) + } + return lunr.QueryLexer.lexEditDistance + } + + if (char == "^") { + lexer.backup() + if (lexer.width() > 0) { + lexer.emit(lunr.QueryLexer.TERM) + } + return lunr.QueryLexer.lexBoost + } + + // "+" indicates term presence is required + // checking for length to ensure that only + // leading "+" are considered + if (char == "+" && lexer.width() === 1) { + lexer.emit(lunr.QueryLexer.PRESENCE) + return lunr.QueryLexer.lexText + } + + // "-" indicates term presence is prohibited + // checking for length to ensure that only + // leading "-" are considered + if (char == "-" && lexer.width() === 1) { + lexer.emit(lunr.QueryLexer.PRESENCE) + return lunr.QueryLexer.lexText + } + + if (char.match(lunr.QueryLexer.termSeparator)) { + return lunr.QueryLexer.lexTerm + } + } +} + +lunr.QueryParser = function (str, query) { + this.lexer = new lunr.QueryLexer (str) + this.query = query + this.currentClause = {} + this.lexemeIdx = 0 +} + +lunr.QueryParser.prototype.parse = function () { + this.lexer.run() + this.lexemes = this.lexer.lexemes + + var state = lunr.QueryParser.parseClause + + while (state) { + state = state(this) + } + + return this.query +} + +lunr.QueryParser.prototype.peekLexeme = function () { + return this.lexemes[this.lexemeIdx] +} + +lunr.QueryParser.prototype.consumeLexeme = function () { + var lexeme = this.peekLexeme() + this.lexemeIdx += 1 + return lexeme +} + +lunr.QueryParser.prototype.nextClause = function () { + var completedClause = this.currentClause + this.query.clause(completedClause) + this.currentClause = {} +} + +lunr.QueryParser.parseClause = function (parser) { + var lexeme = parser.peekLexeme() + + if (lexeme == undefined) { + return + } + + switch (lexeme.type) { + case lunr.QueryLexer.PRESENCE: + return lunr.QueryParser.parsePresence + case lunr.QueryLexer.FIELD: + return lunr.QueryParser.parseField + case lunr.QueryLexer.TERM: + return lunr.QueryParser.parseTerm + default: + var errorMessage = "expected either a field or a term, found " + lexeme.type + + if (lexeme.str.length >= 1) { + errorMessage += " with value '" + lexeme.str + "'" + } + + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } +} + +lunr.QueryParser.parsePresence = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + switch (lexeme.str) { + case "-": + parser.currentClause.presence = lunr.Query.presence.PROHIBITED + break + case "+": + parser.currentClause.presence = lunr.Query.presence.REQUIRED + break + default: + var errorMessage = "unrecognised presence operator'" + lexeme.str + "'" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + var errorMessage = "expecting term or field, found nothing" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.FIELD: + return lunr.QueryParser.parseField + case lunr.QueryLexer.TERM: + return lunr.QueryParser.parseTerm + default: + var errorMessage = "expecting term or field, found '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseField = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + if (parser.query.allFields.indexOf(lexeme.str) == -1) { + var possibleFields = parser.query.allFields.map(function (f) { return "'" + f + "'" }).join(', '), + errorMessage = "unrecognised field '" + lexeme.str + "', possible fields: " + possibleFields + + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + parser.currentClause.fields = [lexeme.str] + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + var errorMessage = "expecting term, found nothing" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + return lunr.QueryParser.parseTerm + default: + var errorMessage = "expecting term, found '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseTerm = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + parser.currentClause.term = lexeme.str.toLowerCase() + + if (lexeme.str.indexOf("*") != -1) { + parser.currentClause.usePipeline = false + } + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + parser.nextClause() + return + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + parser.nextClause() + return lunr.QueryParser.parseTerm + case lunr.QueryLexer.FIELD: + parser.nextClause() + return lunr.QueryParser.parseField + case lunr.QueryLexer.EDIT_DISTANCE: + return lunr.QueryParser.parseEditDistance + case lunr.QueryLexer.BOOST: + return lunr.QueryParser.parseBoost + case lunr.QueryLexer.PRESENCE: + parser.nextClause() + return lunr.QueryParser.parsePresence + default: + var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseEditDistance = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + var editDistance = parseInt(lexeme.str, 10) + + if (isNaN(editDistance)) { + var errorMessage = "edit distance must be numeric" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + parser.currentClause.editDistance = editDistance + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + parser.nextClause() + return + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + parser.nextClause() + return lunr.QueryParser.parseTerm + case lunr.QueryLexer.FIELD: + parser.nextClause() + return lunr.QueryParser.parseField + case lunr.QueryLexer.EDIT_DISTANCE: + return lunr.QueryParser.parseEditDistance + case lunr.QueryLexer.BOOST: + return lunr.QueryParser.parseBoost + case lunr.QueryLexer.PRESENCE: + parser.nextClause() + return lunr.QueryParser.parsePresence + default: + var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseBoost = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + var boost = parseInt(lexeme.str, 10) + + if (isNaN(boost)) { + var errorMessage = "boost must be numeric" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + parser.currentClause.boost = boost + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + parser.nextClause() + return + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + parser.nextClause() + return lunr.QueryParser.parseTerm + case lunr.QueryLexer.FIELD: + parser.nextClause() + return lunr.QueryParser.parseField + case lunr.QueryLexer.EDIT_DISTANCE: + return lunr.QueryParser.parseEditDistance + case lunr.QueryLexer.BOOST: + return lunr.QueryParser.parseBoost + case lunr.QueryLexer.PRESENCE: + parser.nextClause() + return lunr.QueryParser.parsePresence + default: + var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + + /** + * export the module via AMD, CommonJS or as a browser global + * Export code from https://github.com/umdjs/umd/blob/master/returnExports.js + */ + ;(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(factory) + } else if (typeof exports === 'object') { + /** + * Node. Does not work with strict CommonJS, but + * only CommonJS-like enviroments that support module.exports, + * like Node. + */ + module.exports = factory() + } else { + // Browser globals (root is window) + root.lunr = factory() + } + }(this, function () { + /** + * Just return a value to define the module export. + * This example returns an object, but the module + * can return a function as the exported value. + */ + return lunr + })) +})(); diff --git a/docs/assets/theme.css b/docs/assets/theme.css new file mode 100644 index 0000000..347f19f --- /dev/null +++ b/docs/assets/theme.css @@ -0,0 +1,160 @@ +:root { + --bg-color: #ffffff; + --text-color: #333333; + --sidebar-bg: #f3f3f3; + --sidebar-width: 240px; +} +[data-theme="dark"] { + --bg-color: #222222; + --text-color: #eeeeee; + --sidebar-bg: #333333; +} +body { + margin: 0; + background: var(--bg-color); + color: var(--text-color); + font-family: Arial, sans-serif; + display: flex; + flex-direction: column; + min-height: 100vh; +} +.header { + display: flex; + align-items: center; + padding: 0.5rem 1rem; + background: var(--sidebar-bg); + position: sticky; + top: 0; + z-index: 1100; +} +.search-input { + margin-left: auto; + padding: 0.25rem; +} +.search-results { + display: none; + position: absolute; + right: 1rem; + top: 100%; + background: var(--bg-color); + border: 1px solid #ccc; + width: 250px; + max-height: 200px; + overflow-y: auto; + z-index: 100; +} +.search-results a { + display: block; + padding: 0.25rem; + color: var(--text-color); + text-decoration: none; +} +.search-results a:hover { + background: var(--sidebar-bg); +} +.search-results .no-results { + padding: 0.25rem; +} +.logo { text-decoration: none; color: var(--text-color); font-weight: bold; } +.sidebar-toggle, +.theme-toggle { background: none; border: none; font-size: 1.2rem; margin-right: 1rem; cursor: pointer; } +.container { display: flex; flex: 1; } +.sidebar { + width: var(--sidebar-width); + background: var(--sidebar-bg); + padding: 1rem; + box-sizing: border-box; +} +.sidebar ul { list-style: none; padding: 0; margin: 0; } +.sidebar li { margin: 0.25rem 0; } +.sidebar a { text-decoration: none; color: var(--text-color); display: block; padding: 0.25rem 0; } +.sidebar nav { font-size: 0.9rem; } +.nav-link:hover { text-decoration: underline; } +.nav-link.active { font-weight: bold; } +.nav-section summary { + list-style: none; + cursor: pointer; + position: relative; + display: flex; + align-items: center; +} +.nav-section summary::-webkit-details-marker { display: none; } +.nav-section summary::before { + content: '▸'; + display: inline-block; + margin-right: 0.25rem; + transition: transform 0.2s ease; +} +.nav-section[open] > summary::before { transform: rotate(90deg); } +.nav-level { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; } +.sidebar ul ul { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; } +main { + flex: 1; + padding: 2rem; +} +.breadcrumbs a { color: var(--text-color); text-decoration: none; } +.footer { + text-align: center; + padding: 1rem; + background: var(--sidebar-bg); + position: relative; +} +.footer-links { + margin-bottom: 0.5rem; +} +.footer-links a { + margin: 0 0.5rem; + text-decoration: none; + color: var(--text-color); +} +.footer-permanent-links { + position: absolute; + right: 0.5rem; + bottom: 0.25rem; + font-size: 0.8rem; + opacity: 0.7; +} +.footer-permanent-links a { + margin-left: 0.5rem; + text-decoration: none; + color: var(--text-color); +} + +.sidebar-overlay { + display: none; +} +@media (max-width: 768px) { + body.sidebar-open .sidebar-overlay { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 999; + } +} +@media (max-width: 768px) { + .sidebar { + position: fixed; + left: -100%; + top: 0; + height: 100%; + overflow-y: auto; + transition: none; + z-index: 1000; + } + body.sidebar-open .sidebar { left: 0; } +} + +@media (min-width: 769px) { + .sidebar { + transition: width 0.2s ease; + } + body:not(.sidebar-open) .sidebar { + width: 0; + padding: 0; + overflow: hidden; + } +} diff --git a/docs/assets/theme.js b/docs/assets/theme.js new file mode 100644 index 0000000..518779e --- /dev/null +++ b/docs/assets/theme.js @@ -0,0 +1,107 @@ +document.addEventListener('DOMContentLoaded', () => { + const sidebarToggle = document.getElementById('sidebar-toggle'); + const themeToggle = document.getElementById('theme-toggle'); + const searchInput = document.getElementById('search-input'); + const searchResults = document.getElementById('search-results'); + const sidebar = document.getElementById('sidebar'); + const sidebarOverlay = document.getElementById('sidebar-overlay'); + const root = document.documentElement; + + function setTheme(theme) { + root.dataset.theme = theme; + localStorage.setItem('theme', theme); + } + const stored = localStorage.getItem('theme'); + if (stored) setTheme(stored); + + if (window.innerWidth > 768) { + document.body.classList.add('sidebar-open'); + } + + sidebarToggle?.addEventListener('click', () => { + document.body.classList.toggle('sidebar-open'); + }); + + sidebarOverlay?.addEventListener('click', () => { + document.body.classList.remove('sidebar-open'); + }); + + themeToggle?.addEventListener('click', () => { + const next = root.dataset.theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + }); + + // search + let lunrIndex; + let docs = []; + async function loadIndex() { + if (lunrIndex) return; + try { + const res = await fetch('/search-index.json'); + const data = await res.json(); + lunrIndex = lunr.Index.load(data.index); + docs = data.docs; + } catch (e) { + console.error('Search index failed to load', e); + } + } + + function highlight(text, q) { + const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + ')', 'gi'); + return text.replace(re, '$1'); + } + + searchInput?.addEventListener('input', async e => { + const q = e.target.value.trim(); + await loadIndex(); + if (!lunrIndex || !q) { + searchResults.style.display = 'none'; + searchResults.innerHTML = ''; + return; + } + const matches = lunrIndex.search(q); + searchResults.innerHTML = ''; + if (!matches.length) { + searchResults.innerHTML = '
No matches found
'; + searchResults.style.display = 'block'; + return; + } + matches.forEach(m => { + const doc = docs.find(d => d.id === m.ref); + if (!doc) return; + const a = document.createElement('a'); + a.href = doc.url; + const snippet = doc.body ? doc.body.slice(0, 160) + (doc.body.length > 160 ? '...' : '') : ''; + a.innerHTML = '' + highlight(doc.title, q) + '
' + highlight(snippet, q) + ''; + searchResults.appendChild(a); + }); + searchResults.style.display = 'block'; + }); + + document.addEventListener('click', e => { + if (!searchResults.contains(e.target) && e.target !== searchInput) { + searchResults.style.display = 'none'; + } + if ( + window.innerWidth <= 768 && + document.body.classList.contains('sidebar-open') && + sidebar && + !sidebar.contains(e.target) && + e.target !== sidebarToggle + ) { + document.body.classList.remove('sidebar-open'); + } + }); + + // breadcrumbs + const bc = document.getElementById('breadcrumbs'); + if (bc) { + const parts = location.pathname.split('/').filter(Boolean); + let path = ''; + bc.innerHTML = 'Home'; + parts.forEach((p) => { + path += '/' + p; + bc.innerHTML += ' / ' + p.replace(/-/g, ' ') + ''; + }); + } +}); diff --git a/docs/bin/create-archivox.js b/docs/bin/create-archivox.js new file mode 100755 index 0000000..66c7481 --- /dev/null +++ b/docs/bin/create-archivox.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +function copyDir(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function main() { + const args = process.argv.slice(2); + const install = args.includes('--install'); + const targetArg = args.find(a => !a.startsWith('-')) || '.'; + const targetDir = path.resolve(process.cwd(), targetArg); + + const templateDir = path.join(__dirname, '..', 'starter'); + copyDir(templateDir, targetDir); + + const pkgPath = path.join(targetDir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const version = require('../package.json').version; + if (pkg.dependencies && pkg.dependencies.archivox) + pkg.dependencies.archivox = `^${version}`; + pkg.name = path.basename(targetDir); + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); + } + + if (install) { + execSync('npm install', { cwd: targetDir, stdio: 'inherit' }); + } + + console.log(`Archivox starter created at ${targetDir}`); +} + +main(); diff --git a/docs/build-docs.js b/docs/build-docs.js new file mode 100755 index 0000000..c41a2bb --- /dev/null +++ b/docs/build-docs.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node +const path = require('path'); +const { generate } = require('./src/generator'); + +(async () => { + try { + const contentDir = path.join(__dirname, 'docs', 'content'); + const configPath = path.join(__dirname, 'docs', 'config.yaml'); + const outputDir = path.join(__dirname, '_site'); + await generate({ contentDir, outputDir, configPath }); + } catch (err) { + console.error(err); + process.exit(1); + } +})(); diff --git a/docs/docs/config.yaml b/docs/docs/config.yaml new file mode 100644 index 0000000..952a12a --- /dev/null +++ b/docs/docs/config.yaml @@ -0,0 +1,12 @@ +site: + title: "SeedPass Docs" + description: "One seed to rule them all." + +navigation: + search: true + +footer: + links: + - text: "SeedPass" + url: "https://seedpass.me/" + diff --git a/docs/advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md similarity index 86% rename from docs/advanced_cli.md rename to docs/docs/content/01-getting-started/01-advanced_cli.md index 0148aa5..7fa3768 100644 --- a/docs/advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -70,10 +70,11 @@ Manage the entire vault for a profile. | Action | Command | Examples | | :--- | :--- | :--- | | Export the vault | `vault export` | `seedpass vault export --file backup.json` | -| Import a vault | `vault import` | `seedpass vault import --file backup.json` | +| Import a vault | `vault import` | `seedpass vault import --file backup.json` *(also syncs with Nostr)* | | Change the master password | `vault change-password` | `seedpass vault change-password` | | Lock the vault | `vault lock` | `seedpass vault lock` | | Show profile statistics | `vault stats` | `seedpass vault stats` | +| Reveal or back up the parent seed | `vault reveal-parent-seed` | `seedpass vault reveal-parent-seed --file backup.enc` | ### Nostr Commands @@ -90,8 +91,9 @@ Manage profile‑specific settings. | Action | Command | Examples | | :--- | :--- | :--- | -| Get a setting value | `config get` | `seedpass config get inactivity_timeout` | -| Set a setting value | `config set` | `seedpass config set inactivity_timeout 300` | +| Get a setting value | `config get` | `seedpass config get kdf_iterations` | +| Set a setting value | `config set` | `seedpass config set backup_interval 3600` | +| Toggle offline mode | `config toggle-offline` | `seedpass config toggle-offline` | ### Fingerprint Commands @@ -157,10 +159,11 @@ Code: 123456 ### `vault` Commands - **`seedpass vault export`** – Export the entire vault to an encrypted JSON file. -- **`seedpass vault import`** – Import a vault from an encrypted JSON file. +- **`seedpass vault import`** – Import a vault from an encrypted JSON file and automatically sync via Nostr. - **`seedpass vault change-password`** – Change the master password used for encryption. - **`seedpass vault lock`** – Clear sensitive data from memory and require reauthentication. - **`seedpass vault stats`** – Display statistics about the active seed profile. +- **`seedpass vault reveal-parent-seed`** – Print the parent seed or write an encrypted backup with `--file`. ### `nostr` Commands @@ -169,9 +172,10 @@ Code: 123456 ### `config` Commands -- **`seedpass config get `** – Retrieve a configuration value such as `inactivity_timeout`, `secret_mode`, or `auto_sync`. -- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set inactivity_timeout 300`. +- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`. +- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings. - **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay. +- **`seedpass config toggle-offline`** – Enable or disable offline mode to skip Nostr operations. ### `fingerprint` Commands @@ -206,5 +210,6 @@ Shut down the server with `seedpass api stop`. - Use the `--help` flag for details on any command. - Set a strong master password and regularly export encrypted backups. -- Adjust configuration values like `inactivity_timeout` or `secret_mode` through the `config` commands. +- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands. +- Customize password complexity with `config set min_uppercase 3`, `config set min_digits 4`, and similar commands. - `entry get` is script‑friendly and can be piped into other commands. diff --git a/docs/api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md similarity index 56% rename from docs/api_reference.md rename to docs/docs/content/01-getting-started/02-api_reference.md index ec6619d..5c23bf6 100644 --- a/docs/api_reference.md +++ b/docs/docs/content/01-getting-started/02-api_reference.md @@ -31,13 +31,20 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `GET /api/v1/totp/export` – Export all TOTP entries as JSON. - `GET /api/v1/totp` – Return current TOTP codes and remaining time. - `GET /api/v1/stats` – Return statistics about the active seed profile. +- `GET /api/v1/notifications` – Retrieve and clear queued notifications. Messages appear in the persistent notification box but remain queued until fetched. - `GET /api/v1/parent-seed` – Reveal the parent seed or save it with `?file=`. - `GET /api/v1/nostr/pubkey` – Fetch the Nostr public key for the active seed. - `POST /api/v1/checksum/verify` – Verify the checksum of the running script. - `POST /api/v1/checksum/update` – Update the stored script checksum. - `POST /api/v1/change-password` – Change the master password for the active profile. - `POST /api/v1/vault/import` – Import a vault backup from a file or path. +- `POST /api/v1/vault/export` – Export the vault and download the encrypted file. +- `POST /api/v1/vault/backup-parent-seed` – Save an encrypted backup of the parent seed. - `POST /api/v1/vault/lock` – Lock the vault and clear sensitive data from memory. +- `GET /api/v1/relays` – List configured Nostr relays. +- `POST /api/v1/relays` – Add a relay URL. +- `DELETE /api/v1/relays/{idx}` – Remove the relay at the given index (1‑based). +- `POST /api/v1/relays/reset` – Reset the relay list to defaults. - `POST /api/v1/shutdown` – Stop the server gracefully. **Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment. @@ -96,6 +103,22 @@ curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \ -d '{"value": 300}' ``` +To raise the PBKDF2 work factor or change how often backups are written: + +```bash +curl -X PUT http://127.0.0.1:8000/api/v1/config/kdf_iterations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"value": 200000}' + +curl -X PUT http://127.0.0.1:8000/api/v1/config/backup_interval \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"value": 3600}' +``` + +Using fewer iterations or a long interval reduces security, so adjust these values carefully. + ### Toggling Secret Mode Send both `enabled` and `delay` values to `/api/v1/secret-mode`: @@ -115,7 +138,119 @@ Change the active seed profile via `POST /api/v1/fingerprint/select`: curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"fingerprint": "abc123"}' + -d '{"fingerprint": "abc123"}' +``` + +### Exporting the Vault + +Download an encrypted vault backup via `POST /api/v1/vault/export`: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/vault/export \ + -H "Authorization: Bearer " \ + -o backup.json +``` + +### Importing a Vault + +Restore a backup with `POST /api/v1/vault/import`. Use `-F` to upload a file: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/vault/import \ + -H "Authorization: Bearer " \ + -F file=@backup.json +``` + +### Locking the Vault + +Clear sensitive data from memory using `/api/v1/vault/lock`: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/vault/lock \ + -H "Authorization: Bearer " +``` + +### Backing Up the Parent Seed + +Trigger an encrypted seed backup with `/api/v1/vault/backup-parent-seed`: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/vault/backup-parent-seed \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"path": "seed_backup.enc"}' +``` + +### Retrieving Vault Statistics + +Get profile stats such as entry counts with `GET /api/v1/stats`: + +```bash +curl -H "Authorization: Bearer " \ + http://127.0.0.1:8000/api/v1/stats +``` + +### Checking Notifications + +Get queued messages with `GET /api/v1/notifications`: + +```bash +curl -H "Authorization: Bearer " \ + http://127.0.0.1:8000/api/v1/notifications +``` + +The TUI displays these alerts in a persistent notification box for 10 seconds, +but the endpoint returns all queued messages even if they have already +disappeared from the screen. + +### Changing the Master Password + +Update the vault password via `POST /api/v1/change-password`: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/change-password \ + -H "Authorization: Bearer " +``` + +### Verifying the Script Checksum + +Check that the running script matches the stored checksum: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/checksum/verify \ + -H "Authorization: Bearer " +``` + +### Updating the Script Checksum + +Regenerate the stored checksum using `/api/v1/checksum/update`: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/checksum/update \ + -H "Authorization: Bearer " +``` + +### Managing Relays + +List, add, or remove Nostr relays: + +```bash +# list +curl -H "Authorization: Bearer " http://127.0.0.1:8000/api/v1/relays + +# add +curl -X POST http://127.0.0.1:8000/api/v1/relays \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"url": "wss://relay.example.com"}' + +# remove first relay +curl -X DELETE http://127.0.0.1:8000/api/v1/relays/1 \ + -H "Authorization: Bearer " + +# reset to defaults +curl -X POST http://127.0.0.1:8000/api/v1/relays/reset \ + -H "Authorization: Bearer " ``` ### Enabling CORS diff --git a/docs/json_entries.md b/docs/docs/content/01-getting-started/03-json_entries.md similarity index 100% rename from docs/json_entries.md rename to docs/docs/content/01-getting-started/03-json_entries.md diff --git a/docs/docs/content/01-getting-started/04-migrations.md b/docs/docs/content/01-getting-started/04-migrations.md new file mode 100644 index 0000000..f6a1ce0 --- /dev/null +++ b/docs/docs/content/01-getting-started/04-migrations.md @@ -0,0 +1,47 @@ +# Index Migrations + +SeedPass stores its password index in an encrypted JSON file. Each index contains +a `schema_version` field so the application knows how to upgrade older files. + +## How migrations work + +When the vault loads the index, `Vault.load_index()` checks the version and +applies migrations defined in `password_manager/migrations.py`. The +`apply_migrations()` function iterates through registered migrations until the +file reaches `LATEST_VERSION`. + +If an old file lacks `schema_version`, it is treated as version 0 and upgraded +to the latest format. Attempting to load an index from a future version will +raise an error. + +## Upgrading an index + +1. The JSON is decrypted and parsed. +2. `apply_migrations()` applies any necessary steps, such as injecting the + `schema_version` field on first upgrade. +3. After migration, the updated index is saved back to disk. + +This process happens automatically; users only need to open their vault to +upgrade older indices. + +### Legacy Fernet migration + +Older versions stored the vault index in a file named +`seedpass_passwords_db.json.enc` encrypted with Fernet. When opening such a +vault, SeedPass now automatically decrypts the legacy file, re‑encrypts it using +AES‑GCM, and saves it under the new name `seedpass_entries_db.json.enc`. +The original Fernet file is preserved as +`seedpass_entries_db.json.enc.fernet` and the legacy checksum file, if present, +is renamed to `seedpass_entries_db_checksum.txt.fernet`. + +No additional command is required – simply open your existing vault and the +conversion happens transparently. + +### Parent seed backup migration + +If your vault contains a `parent_seed.enc` file that was encrypted with Fernet, +SeedPass performs a similar upgrade. Upon loading the vault, the application +decrypts the old file, re‑encrypts it with AES‑GCM, and writes the result back to +`parent_seed.enc`. The legacy Fernet file is preserved as +`parent_seed.enc.fernet` so you can revert if needed. No manual steps are +required – simply unlock your vault and the conversion runs automatically. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md new file mode 100644 index 0000000..09b38fd --- /dev/null +++ b/docs/docs/content/index.md @@ -0,0 +1,546 @@ +# SeedPass + +**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data. + +[Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp) + +--- + +**⚠️ Disclaimer** + +This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk. + +--- +### Supported OS + +✔ Windows 10/11 • macOS 12+ • Any modern Linux +SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required. + + +## Table of Contents + +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [1. Clone the Repository](#1-clone-the-repository) + - [2. Create a Virtual Environment](#2-create-a-virtual-environment) + - [3. Activate the Virtual Environment](#3-activate-the-virtual-environment) + - [4. Install Dependencies](#4-install-dependencies) +- [Usage](#usage) + - [Running the Application](#running-the-application) + - [Managing Multiple Seeds](#managing-multiple-seeds) + - [Additional Entry Types](#additional-entry-types) +- [Security Considerations](#security-considerations) +- [Contributing](#contributing) +- [License](#license) +- [Contact](#contact) + +## Features + +- **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords. +- **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. +- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. +- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field stores the UNIX timestamp of the latest delta event. +- **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering. +- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. +- **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds. +- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings. +- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar. +- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs. +- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps. +- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer. +- **Optional External Backup Location:** Configure a second directory where backups are automatically copied. +- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security. +- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected. +- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay. +- **Tagging Support:** Organize entries with optional tags and find them quickly via search. +- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API. +- **Parent Seed Backup:** Securely save an encrypted copy of the master seed. +- **Manual Vault Locking:** Instantly clear keys from memory when needed. +- **Vault Statistics:** View counts for entries and other profile metrics. +- **Change Master Password:** Rotate your encryption password at any time. +- **Checksum Verification Utilities:** Verify or regenerate the script checksum. +- **Relay Management:** List, add, remove or reset configured Nostr relays. +- **Offline Mode:** Disable network sync to work entirely locally. + +## Prerequisites + +- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools. +*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload. + +## Installation + + +### Quick Installer + +Use the automated installer to download SeedPass and its dependencies in one step. + +**Linux and macOS:** +```bash +bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" +``` +*Install the beta branch:* +```bash +bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta +``` + +**Windows (PowerShell):** +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) +``` +Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly. +The Windows installer will attempt to install Git automatically if it is not already available. It also tries to +install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` +isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from +. When Python 3.13 or newer is detected without the Microsoft C++ build tools, +the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. + +**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. +### Uninstall + +Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: + +**Linux and macOS:** +```bash +bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.sh)" +``` +If the script warns that it couldn't remove an executable, delete that file manually. + +**Windows (PowerShell):** +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.ps1'); & ([scriptblock]::create($scriptContent)) +``` + +*Install the beta branch:* +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -Branch beta +``` + +### Manual Setup +Follow these steps to set up SeedPass on your local machine. + +### 1. Clone the Repository + +First, clone the SeedPass repository from GitHub: + +```bash +git clone https://github.com/PR0M3TH3AN/SeedPass.git +``` + +Navigate to the project directory: + +```bash +cd SeedPass +``` + +### 2. Create a Virtual Environment + +It's recommended to use a virtual environment to manage your project's dependencies. Create a virtual environment named `venv`: + +```bash +python3 -m venv venv +``` + +### 3. Activate the Virtual Environment + +Activate the virtual environment using the appropriate command for your operating system. + +- **On Linux and macOS:** + + ```bash + source venv/bin/activate + ``` + +- **On Windows:** + + ```bash + venv\Scripts\activate + ``` + +Once activated, your terminal prompt should be prefixed with `(venv)` indicating that the virtual environment is active. + +### 4. Install Dependencies + +Install the required Python packages and build dependencies using `pip`. +When upgrading pip, use `python -m pip` inside the virtual environment so that pip can update itself cleanly: + +```bash +python -m pip install --upgrade pip +python -m pip install -r src/requirements.txt +python -m pip install -e . +``` + +#### Linux Clipboard Support + +On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. +SeedPass will attempt to install **xclip** automatically if neither tool is +available. If the automatic installation fails, you can install it manually: + +```bash +sudo apt-get install xclip +``` + +## Quick Start + +After installing dependencies, activate your virtual environment and install +the package so the `seedpass` command is available, then launch SeedPass and +create a backup: + +```bash +# Start the application +seedpass + +# Export your index +seedpass export --file "~/seedpass_backup.json" + +# Later you can restore it +seedpass import --file "~/seedpass_backup.json" +# Import also performs a Nostr sync to pull any changes + +# Quickly find or retrieve entries +seedpass search "github" +seedpass search --tags "work,personal" +seedpass get "github" +# Retrieve a TOTP entry +seedpass entry get "email" +# The code is printed and copied to your clipboard + +# Sort or filter the list view +seedpass list --sort label +seedpass list --filter totp + +# Use the **Settings** menu to configure an extra backup directory +# on an external drive. +``` + +For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). +Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md). + +### Vault JSON Layout + +The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers. + +```json +{ + "schema_version": 2, + "entries": { + "0": { + "label": "example.com", + "length": 8, + "type": "password", + "notes": "" + } + } +} +``` + + +## Usage + +After successfully installing the dependencies, launch the interactive TUI with: + +```bash +seedpass +``` + +You can also run directly from the repository using: + +```bash +python src/main.py +``` + +You can explore other CLI commands using: +```bash +seedpass --help +``` +For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md). + +### Running the Application + +1. **Start the Application:** + + ```bash + seedpass + ``` + *(or `python src/main.py` if running directly from the repository)* + +2. **Follow the Prompts:** + + - **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one. + - **Enter Your Password:** This password is crucial as it is used to encrypt and decrypt your parent seed and seed index data. + - **Select an Option:** Navigate through the menu by entering the number corresponding to your desired action. + + Example menu: + + ```bash + Select an option: + 1. Add Entry + 2. Retrieve Entry + 3. Search Entries + 4. List Entries + 5. Modify an Existing Entry + 6. 2FA Codes + 7. Settings + + Enter your choice (1-7) or press Enter to exit: + ``` + +When choosing **Add Entry**, you can now select from: + +- **Password** +- **2FA (TOTP)** +- **SSH Key** +- **Seed Phrase** +- **Nostr Key Pair** +- **PGP Key** +- **Key/Value** +- **Managed Account** + +### Adding a 2FA Entry + +1. From the main menu choose **Add Entry** and select **2FA (TOTP)**. +2. Pick **Make 2FA** to derive a new secret from your seed or **Import 2FA** to paste an existing `otpauth://` URI or secret. +3. Provide a label for the account (for example, `GitHub`). +4. SeedPass automatically chooses the next available derivation index when deriving. +5. Optionally specify the TOTP period and digit count. +6. SeedPass displays the URI and secret, along with a QR code you can scan to import it into your authenticator app. + +### Modifying a 2FA Entry + +1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit. +2. SeedPass will show the current label, period, digit count, and archived status. +3. Enter new values or press **Enter** to keep the existing settings. +4. When retrieving a 2FA entry you can press **E** to edit the label, period or digit count, or **A** to archive/unarchive it. +5. The updated entry is saved back to your encrypted vault. +6. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu. +7. When editing an archived entry you'll be prompted to restore it after saving your changes. + +### Using Secret Mode + +When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose. + +1. From the main menu open **Settings** and select **Toggle Secret Mode**. +2. Choose how many seconds to keep passwords on the clipboard. +3. Retrieve an entry and SeedPass will confirm the password was copied. + +### Additional Entry Types + +SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for: +- **SSH Key** – deterministically derive an Ed25519 key pair for servers or git hosting platforms. +- **Seed Phrase** – store only the BIP-85 index and word count. The mnemonic is regenerated on demand. +- **PGP Key** – derive an OpenPGP key pair from your master seed. +- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. + When you retrieve one of these entries, SeedPass can display QR codes for the + keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan + it, while the `nsec` QR is shown only after a security warning. +- **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data. +- **Managed Account** – derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows ` > Managed Account > `. Press Enter on the main menu to return to the parent profile. + +The table below summarizes the extra fields stored for each entry type. Every +entry includes a `label`, while only password entries track a `url`. + +| Entry Type | Extra Fields | +|---------------|---------------------------------------------------------------------------------------------------------------------------------------| +| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` | +| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` | +| SSH Key | `index`, `archived`, optional `notes`, optional `tags` | +| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` | +| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` | +| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` | +| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` | +| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` | + + +### Managing Multiple Seeds + +SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords. + +- **Add a New Seed Profile:** + - From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile". + - Choose to enter an existing seed or generate a new one. + - If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.** + +- **Switch Between Seed Profiles:** + - From the **Profiles** menu, select "Switch Seed Profile". + - You'll see a list of available seed profiles. + - Enter the number corresponding to the seed profile you wish to switch to. + - Enter the master password associated with that seed profile. + +- **List All Seed Profiles:** + - In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles. + +**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords. + +### Configuration File and Settings + +SeedPass keeps per-profile settings in an encrypted file named `seedpass_config.json.enc` inside each profile directory under `~/.seedpass/`. This file stores your chosen Nostr relays and the optional settings PIN. New profiles start with the following default relays: + +``` +wss://relay.snort.social +wss://nostr.oxtr.dev +wss://relay.primal.net +``` + +You can manage your relays and sync with Nostr from the **Settings** menu: + +1. From the main menu choose `6` (**Settings**). +2. Select `2` (**Nostr**) to open the Nostr submenu. +3. Choose `1` to back up your encrypted index to Nostr. +4. Select `2` to restore the index from Nostr. +5. Choose `3` to view your current relays. +6. Select `4` to add a new relay URL. +7. Choose `5` to remove a relay by number. +8. Select `6` to reset to the default relay list. +9. Choose `7` to display your Nostr public key. +10. Select `8` to return to the Settings menu. + +Back in the Settings menu you can: + +* Select `3` to change your master password. +* Choose `4` to verify the script checksum. +* Select `5` to generate a new script checksum. +* Choose `6` to back up the parent seed. +* Select `7` to export the database to an encrypted file. +* Choose `8` to import a database from a backup file. This also performs a Nostr sync automatically. +* Select `9` to export all 2FA codes. +* Choose `10` to set an additional backup location. A backup is created + immediately after the directory is configured. +* Select `11` to change the inactivity timeout. +* Choose `12` to lock the vault and require re-entry of your password. +* Select `13` to view seed profile stats. The summary lists counts for + passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows + whether both the encrypted database and the script itself pass checksum + validation. +* Choose `14` to toggle Secret Mode and set the clipboard clear delay. +* Select `15` to toggle Offline Mode and work locally without contacting Nostr. +* Choose `16` to toggle Quick Unlock so subsequent actions skip the password prompt. Startup delay is unchanged. +* Select `17` to return to the main menu. + +## Running Tests + +SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test: + + +```bash +pip install -r src/requirements.txt +pytest -vv +``` + +`test_fuzz_key_derivation.py` uses Hypothesis to generate random passwords, +seeds and configuration data. It performs round-trip encryption tests with the +`EncryptionManager` to catch edge cases automatically. These fuzz tests run in +CI alongside the rest of the suite. + +### Exploring Nostr Index Size Limits + +`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. +Each chunk is limited to 50 KB, so the test gradually grows the vault to observe +when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment +variable to control the delay between publishes when experimenting with large vaults. + +```bash +pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000 +``` + +### Generating a Test Profile + +Use the helper script below to populate a profile with sample entries for testing: + +```bash +python scripts/generate_test_profile.py --profile demo_profile --count 100 +``` + +The script determines the fingerprint from the generated seed and stores the +vault under `~/.seedpass/tests/`. SeedPass only discovers profiles +inside `~/.seedpass/`, so copy the fingerprint directory out of the `tests` +subfolder (or adjust `APP_DIR` in `constants.py`) if you want to use the +generated seed with the main application. The fingerprint is printed after +creation and the encrypted index is published to Nostr. Use that same seed +phrase to load SeedPass. The app checks Nostr on startup and pulls any newer +snapshot so your vault stays in sync across machines. Synchronization also runs +in the background after unlocking or when switching profiles. + +### Automatically Updating the Script Checksum + +SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. +To keep this value in sync with the source code, install the pre‑push git hook: + +```bash +pre-commit install -t pre-push +``` + +After running this command, every `git push` will execute `scripts/update_checksum.py`, +updating the checksum file automatically. + +If the checksum file is missing, generate it manually: + +```bash +python scripts/update_checksum.py +``` + +To run mutation tests locally, generate coverage data first and then execute `mutmut`: + +```bash +pytest --cov=src src/tests +python -m mutmut run --paths-to-mutate src --tests-dir src/tests --runner "python -m pytest -q" --use-coverage --no-progress +python -m mutmut results +``` + +Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead. + +## Security Considerations + +**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password. + +- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss. +- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays. +- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique. +- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment. +- **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt. +- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications. +- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information. +- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password. +- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt. +- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Use `seedpass config set kdf_iterations` to change this. +- **Offline Mode:** Disable Nostr sync to keep all operations local until you re-enable networking. + - **Quick Unlock:** Store a hashed copy of your password so future actions skip the prompt. Startup delay no longer changes. Use with caution on shared systems. + +## Contributing + +Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps: + +1. **Fork the Repository:** Click the "Fork" button on the top right of the repository page. + +2. **Create a Branch:** Create a new branch for your feature or bugfix. + + ```bash + git checkout -b feature/YourFeatureName + ``` + +3. **Commit Your Changes:** Make your changes and commit them with clear messages. + + ```bash + git commit -m "Add feature X" + ``` + +4. **Push to GitHub:** Push your changes to your forked repository. + + ```bash + git push origin feature/YourFeatureName + ``` + +5. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes. + +## License + +This project is licensed under the [MIT License](LICENSE). See the [LICENSE](LICENSE) file for details. + +## Contact + +For any questions, suggestions, or support, please open an issue on the [GitHub repository](https://github.com/PR0M3TH3AN/SeedPass/issues) or contact the maintainer directly on [Nostr](https://primal.net/p/npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx). + +--- + +*Stay secure and keep your passwords safe with SeedPass!* + +--- diff --git a/docs/docs/package.json b/docs/docs/package.json new file mode 100644 index 0000000..aef6364 --- /dev/null +++ b/docs/docs/package.json @@ -0,0 +1,11 @@ +{ + "name": "docs", + "private": true, + "scripts": { + "dev": "eleventy --serve", + "build": "node node_modules/archivox/src/generator/index.js" + }, + "dependencies": { + "archivox": "^1.0.0" + } +} \ No newline at end of file diff --git a/docs/migrations.md b/docs/migrations.md deleted file mode 100644 index 2c1cf46..0000000 --- a/docs/migrations.md +++ /dev/null @@ -1,25 +0,0 @@ -# Index Migrations - -SeedPass stores its password index in an encrypted JSON file. Each index contains -a `schema_version` field so the application knows how to upgrade older files. - -## How migrations work - -When the vault loads the index, `Vault.load_index()` checks the version and -applies migrations defined in `password_manager/migrations.py`. The -`apply_migrations()` function iterates through registered migrations until the -file reaches `LATEST_VERSION`. - -If an old file lacks `schema_version`, it is treated as version 0 and upgraded -to the latest format. Attempting to load an index from a future version will -raise an error. - -## Upgrading an index - -1. The JSON is decrypted and parsed. -2. `apply_migrations()` applies any necessary steps, such as injecting the - `schema_version` field on first upgrade. -3. After migration, the updated index is saved back to disk. - -This process happens automatically; users only need to open their vault to -upgrade older indices. diff --git a/docs/netlify.toml b/docs/netlify.toml new file mode 100644 index 0000000..8f544bb --- /dev/null +++ b/docs/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "node build-docs.js" + publish = "_site" diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..2c4bca6 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,6357 @@ +{ + "name": "archivox", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "archivox", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@11ty/eleventy": "^2.0.1", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", + "lunr": "^2.3.9", + "marked": "^11.1.1" + }, + "bin": { + "create-archivox": "bin/create-archivox.js" + }, + "devDependencies": { + "jest": "^29.6.1", + "puppeteer": "^24.12.1" + } + }, + "node_modules/@11ty/dependency-tree": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-2.0.1.tgz", + "integrity": "sha512-5R+DsT9LJ9tXiSQ4y+KLFppCkQyXhzAm1AIuBWE/sbU0hSXY5pkhoqQYEcPJQFg/nglL+wD55iv2j+7O96UAvg==", + "license": "MIT" + }, + "node_modules/@11ty/eleventy": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-2.0.1.tgz", + "integrity": "sha512-t8XVUbCJByhVEa1RzO0zS2QzbL3wPY8ot1yUw9noqiSHxJWUwv6jiwm1/MZDPTYtkZH2ZHvdQIRQ5/SjG9XmLw==", + "license": "MIT", + "dependencies": { + "@11ty/dependency-tree": "^2.0.1", + "@11ty/eleventy-dev-server": "^1.0.4", + "@11ty/eleventy-utils": "^1.0.1", + "@11ty/lodash-custom": "^4.17.21", + "@iarna/toml": "^2.2.5", + "@sindresorhus/slugify": "^1.1.2", + "bcp-47-normalize": "^1.1.1", + "chokidar": "^3.5.3", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "dependency-graph": "^0.11.0", + "ejs": "^3.1.9", + "fast-glob": "^3.2.12", + "graceful-fs": "^4.2.11", + "gray-matter": "^4.0.3", + "hamljs": "^0.6.2", + "handlebars": "^4.7.7", + "is-glob": "^4.0.3", + "iso-639-1": "^2.1.15", + "kleur": "^4.1.5", + "liquidjs": "^10.7.0", + "luxon": "^3.3.0", + "markdown-it": "^13.0.1", + "micromatch": "^4.0.5", + "minimist": "^1.2.8", + "moo": "^0.5.2", + "multimatch": "^5.0.0", + "mustache": "^4.2.0", + "normalize-path": "^3.0.0", + "nunjucks": "^3.2.3", + "path-to-regexp": "^6.2.1", + "please-upgrade-node": "^3.2.0", + "posthtml": "^0.16.6", + "posthtml-urls": "^1.0.0", + "pug": "^3.0.2", + "recursive-copy": "^2.0.14", + "semver": "^7.3.8", + "slugify": "^1.6.6" + }, + "bin": { + "eleventy": "cmd.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, + "node_modules/@11ty/eleventy-dev-server": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-dev-server/-/eleventy-dev-server-1.0.4.tgz", + "integrity": "sha512-qVBmV2G1KF/0o5B/3fITlrrDHy4bONUI2YuN3/WJ3BNw4NU1d/we8XhKrlgq13nNvHoBx5czYp3LZt8qRG53Fg==", + "license": "MIT", + "dependencies": { + "@11ty/eleventy-utils": "^1.0.1", + "chokidar": "^3.5.3", + "debug": "^4.3.4", + "dev-ip": "^1.0.1", + "finalhandler": "^1.2.0", + "mime": "^3.0.0", + "minimist": "^1.2.8", + "morphdom": "^2.7.0", + "please-upgrade-node": "^3.2.0", + "ssri": "^8.0.1", + "ws": "^8.13.0" + }, + "bin": { + "eleventy-dev-server": "cmd.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, + "node_modules/@11ty/eleventy-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-utils/-/eleventy-utils-1.0.3.tgz", + "integrity": "sha512-nULO91om7vQw4Y/UBjM8i7nJ1xl+/nyK4rImZ41lFxiY2d+XUz7ChAj1CDYFjrLZeu0utAYJTZ45LlcHTkUG4g==", + "license": "MIT", + "dependencies": { + "normalize-path": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, + "node_modules/@11ty/lodash-custom": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@11ty/lodash-custom/-/lodash-custom-4.17.21.tgz", + "integrity": "sha512-Mqt6im1xpb1Ykn3nbcCovWXK3ggywRJa+IXIdoz4wIIK+cvozADH63lexcuPpGS/gJ6/m2JxyyXDyupkMr5DHw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", + "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.2", + "tar-fs": "^3.0.8", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/slugify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-1.1.2.tgz", + "integrity": "sha512-V9nR/W0Xd9TSGXpZ4iFUcFGhuOJtZX82Fzxj1YISlbSgKvIiNa7eLEZrT0vAraPOt++KHauIVNYgGRgjc13dXA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/transliterate": "^0.1.1", + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-0.1.2.tgz", + "integrity": "sha512-5/kmIOY9FF32nicXH+5yLNTX4NJ4atl7jRgqAJuIn/iyDFXBktOKDxCvyGE/EzmF4ngSUvjXxQUQlQiZ5lfw+w==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0", + "lodash.deburr": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", + "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/any-promise": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-0.1.0.tgz", + "integrity": "sha512-lqzY9o+BbeGHRCOyxQkt/Tgvz0IZhTmQiA+LxQW8wSNpcTbj8K+0cZiSEvbpNZZP9/11Gy7dnLO3GNWUXO4d1g==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-differ": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", + "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "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/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-jest/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "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/bare-events": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", + "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", + "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcp-47": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-1.0.8.tgz", + "integrity": "sha512-Y9y1QNBBtYtv7hcmoX0tR+tUNSFZGZ6OL6vKPObq8BbOhkCoyayF6ogfLTgAli/KuAEbsYHYUNq2AQuY6IuLag==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-1.0.3.tgz", + "integrity": "sha512-LggQ4YTdjWQSKELZF5JwchnBa1u0pIQSZf5lSdOHEdbVP55h0qICA/FUp3+W99q0xqxYa1ZQizTUH87gecII5w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-normalize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bcp-47-normalize/-/bcp-47-normalize-1.1.1.tgz", + "integrity": "sha512-jWZ1Jdu3cs0EZdfCkS0UE9Gg01PtxnChjEBySeB+Zo6nkqtFfnvtoQQgP1qU1Oo4qgJgxhTI6Sf9y/pZIhPs0A==", + "license": "MIT", + "dependencies": { + "bcp-47": "^1.0.0", + "bcp-47-match": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", + "license": "MIT", + "dependencies": { + "is-regex": "^1.0.3" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chromium-bidi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-5.1.0.tgz", + "integrity": "sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "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/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "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/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dev-ip": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dev-ip/-/dev-ip-1.0.1.tgz", + "integrity": "sha512-LmVkry/oDShEgSZPNgqCIp2/TlqtExeGmymru3uCELnfyjY11IzpAproLYs+1X88fXO6DBoYP3ul2Xo2yz2j6A==", + "bin": { + "dev-ip": "lib/dev-ip.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1464554", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz", + "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "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/electron-to-chromium": { + "version": "1.5.181", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.181.tgz", + "integrity": "sha512-+ISMj8OIQ+0qEeDj14Rt8WwcTOiqHyAB+5bnK1K7xNNLjBJ4hRCQfUkw8RWtcLbfBzDwc15ZnKH0c7SNOfwiyA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "license": "MIT", + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "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/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/hamljs": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/hamljs/-/hamljs-0.6.2.tgz", + "integrity": "sha512-/chXRp4WpL47I+HX1vCCdSbEXAljEG2FBMmgO7Am0bYsqgnEjreeWzUdX1onXqwZtcfgxbCg5WtEYYvuZ5muBg==" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "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/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", + "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.2", + "domutils": "^2.8.0", + "entities": "^3.0.1" + } + }, + "node_modules/http-equiv-refresh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/http-equiv-refresh/-/http-equiv-refresh-1.0.0.tgz", + "integrity": "sha512-TScO04soylRN9i/QdOdgZyhydXg9z6XdaGzEyOgDKycePeDeTT4KvigjBcI+tgfTlieLWauGORMq5F1eIDa+1w==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-json": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz", + "integrity": "sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==", + "license": "ISC" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iso-639-1": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-2.1.15.tgz", + "integrity": "sha512-7c7mBznZu2ktfvyT582E2msM+Udc1EjOyhVRE/0ZsjD9LBtWSm23h3PtiRh2a35XoUsTQQjJXaJzuLjXsOdFDg==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", + "license": "MIT", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/junk": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/junk/-/junk-1.0.3.tgz", + "integrity": "sha512-3KF80UaaSSxo8jVnRYtMKNGFOoVPBdkkVPsw+Ad0y4oxKXPduS6G6iHkrf69yJVff/VAaYXkV42rtZ7daJxU3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/liquidjs": { + "version": "10.21.1", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.21.1.tgz", + "integrity": "sha512-NZXmCwv3RG5nire3fmIn9HsOyJX3vo+ptp0yaXUHAMzSNBhx74Hm+dAGJvscUA6lNqbLuYfXgNavRQ9UbUJhQQ==", + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, + "node_modules/list-to-array": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/list-to-array/-/list-to-array-1.1.0.tgz", + "integrity": "sha512-+dAZZ2mM+/m+vY9ezfoueVvrgnHIGi5FvgSymbIgJOFwiznWyA59mav95L+Mc6xPtL3s9gm5eNTlNtxJLbNM1g==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.deburr": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz", + "integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/markdown-it": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz", + "integrity": "sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~3.0.1", + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/marked": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz", + "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/maximatch": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", + "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", + "license": "MIT", + "dependencies": { + "array-differ": "^1.0.0", + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "minimatch": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/maximatch/node_modules/array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/maximatch/node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/maximatch/node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "license": "BSD-3-Clause" + }, + "node_modules/morphdom": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.5.tgz", + "integrity": "sha512-z6bfWFMra7kBqDjQGHud1LSXtq5JJC060viEkQFMBX6baIecpkNr2Ywrn2OQfWP3rXiNFQRPoFjD8/TvJcWcDg==", + "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/multimatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", + "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "license": "MIT", + "dependencies": { + "semver-compare": "^1.0.0" + } + }, + "node_modules/posthtml": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.6.tgz", + "integrity": "sha512-JcEmHlyLK/o0uGAlj65vgg+7LIms0xKXe60lcDOTU7oVX/3LuEuLwrQpW3VJ7de5TaFKiW4kWkaIpJL42FEgxQ==", + "license": "MIT", + "dependencies": { + "posthtml-parser": "^0.11.0", + "posthtml-render": "^3.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/posthtml-parser": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz", + "integrity": "sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==", + "license": "MIT", + "dependencies": { + "htmlparser2": "^7.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/posthtml-render": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz", + "integrity": "sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==", + "license": "MIT", + "dependencies": { + "is-json": "^2.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/posthtml-urls": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/posthtml-urls/-/posthtml-urls-1.0.0.tgz", + "integrity": "sha512-CMJ0L009sGQVUuYM/g6WJdscsq6ooAwhUuF6CDlYPMLxKp2rmCYVebEU+wZGxnQstGJhZPMvXsRhtqekILd5/w==", + "license": "MIT", + "dependencies": { + "http-equiv-refresh": "^1.0.0", + "list-to-array": "^1.1.0", + "parse-srcset": "^1.0.2", + "promise-each": "^2.2.0" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/promise-each": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/promise-each/-/promise-each-2.2.0.tgz", + "integrity": "sha512-67roqt1k3QDA41DZ8xi0V+rF3GoaMiX7QilbXu0vXimut+9RcKBNZ/t60xCRgcsihmNUsEjh48xLfNqOrKblUg==", + "license": "MIT", + "dependencies": { + "any-promise": "^0.1.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "license": "MIT" + }, + "node_modules/pug": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", + "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", + "license": "MIT", + "dependencies": { + "pug-code-gen": "^3.0.3", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "node_modules/pug-code-gen": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.3.tgz", + "integrity": "sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==", + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", + "license": "MIT" + }, + "node_modules/pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "license": "MIT", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", + "license": "MIT" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "24.12.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.12.1.tgz", + "integrity": "sha512-+vvwl+Xo4z5uXLLHG+XW8uXnUXQ62oY6KU6bEFZJvHWLutbmv5dw9A/jcMQ0fqpQdLydHmK0Uy7/9Ilj8ufwSQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.5", + "chromium-bidi": "5.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1464554", + "puppeteer-core": "24.12.1", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.12.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.12.1.tgz", + "integrity": "sha512-8odp6d3ERKBa3BAVaYWXn95UxQv3sxvP1reD+xZamaX6ed8nCykhwlOiHSaHR9t/MtmIB+rJmNencI6Zy4Gxvg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.5", + "chromium-bidi": "5.1.0", + "debug": "^4.4.1", + "devtools-protocol": "0.0.1464554", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recursive-copy": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/recursive-copy/-/recursive-copy-2.0.14.tgz", + "integrity": "sha512-K8WNY8f8naTpfbA+RaXmkaQuD1IeW9EgNEfyGxSqqTQukpVtoOKros9jUqbpEsSw59YOmpd8nCBgtqJZy5nvog==", + "license": "ISC", + "dependencies": { + "errno": "^0.1.2", + "graceful-fs": "^4.1.4", + "junk": "^1.0.1", + "maximatch": "^0.1.0", + "mkdirp": "^0.5.1", + "pify": "^2.3.0", + "promise": "^7.0.1", + "rimraf": "^2.7.1", + "slash": "^1.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz", + "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar-fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "license": "MIT" + }, + "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" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "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==", + "dev": true, + "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/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..d43bd3e --- /dev/null +++ b/docs/package.json @@ -0,0 +1,25 @@ +{ + "name": "archivox", + "version": "1.0.0", + "description": "Archivox static site generator", + "scripts": { + "dev": "eleventy --serve", + "build": "node src/generator/index.js", + "test": "jest" + }, + "dependencies": { + "@11ty/eleventy": "^2.0.1", + "gray-matter": "^4.0.3", + "marked": "^11.1.1", + "lunr": "^2.3.9", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "jest": "^29.6.1", + "puppeteer": "^24.12.1" + }, + "license": "MIT", + "bin": { + "create-archivox": "./bin/create-archivox.js" + } +} diff --git a/docs/plugins/analytics.js b/docs/plugins/analytics.js new file mode 100644 index 0000000..cfba19b --- /dev/null +++ b/docs/plugins/analytics.js @@ -0,0 +1,7 @@ +module.exports = { + onPageRendered: async ({ html, file }) => { + // Example: inject analytics script into each page + const snippet = '\n'; + return { html: html.replace('', `${snippet}`) }; + } +}; diff --git a/docs/src/config/loadConfig.js b/docs/src/config/loadConfig.js new file mode 100644 index 0000000..4cba23a --- /dev/null +++ b/docs/src/config/loadConfig.js @@ -0,0 +1,70 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + if ( + source[key] && + typeof source[key] === 'object' && + !Array.isArray(source[key]) + ) { + target[key] = deepMerge(target[key] || {}, source[key]); + } else if (source[key] !== undefined) { + target[key] = source[key]; + } + } + return target; +} + +function loadConfig(configPath = path.join(process.cwd(), 'config.yaml')) { + let raw = {}; + if (fs.existsSync(configPath)) { + try { + raw = yaml.load(fs.readFileSync(configPath, 'utf8')) || {}; + } catch (e) { + console.error(`Failed to parse ${configPath}: ${e.message}`); + process.exit(1); + } + } + + const defaults = { + site: { + title: 'Archivox', + description: '', + logo: '', + favicon: '' + }, + navigation: { + search: true + }, + footer: {}, + theme: { + name: 'minimal', + darkMode: false + }, + features: {}, + pluginsDir: 'plugins', + plugins: [] + }; + + const config = deepMerge(defaults, raw); + + const errors = []; + if ( + !config.site || + typeof config.site.title !== 'string' || + !config.site.title.trim() + ) { + errors.push('site.title is required in config.yaml'); + } + + if (errors.length) { + errors.forEach(err => console.error(`Config error: ${err}`)); + process.exit(1); + } + + return config; +} + +module.exports = loadConfig; diff --git a/docs/src/config/loadPlugins.js b/docs/src/config/loadPlugins.js new file mode 100644 index 0000000..57d6b3d --- /dev/null +++ b/docs/src/config/loadPlugins.js @@ -0,0 +1,24 @@ +const path = require('path'); +const fs = require('fs'); + +function loadPlugins(config) { + const dir = path.resolve(process.cwd(), config.pluginsDir || 'plugins'); + const names = Array.isArray(config.plugins) ? config.plugins : []; + const plugins = []; + for (const name of names) { + const file = path.join(dir, name.endsWith('.js') ? name : `${name}.js`); + if (fs.existsSync(file)) { + try { + const mod = require(file); + plugins.push(mod); + } catch (e) { + console.error(`Failed to load plugin ${name}:`, e); + } + } else { + console.warn(`Plugin not found: ${file}`); + } + } + return plugins; +} + +module.exports = loadPlugins; diff --git a/docs/src/generator/index.js b/docs/src/generator/index.js new file mode 100644 index 0000000..8fbd4da --- /dev/null +++ b/docs/src/generator/index.js @@ -0,0 +1,235 @@ +// Generator entry point for Archivox +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); +const lunr = require('lunr'); +const marked = require('marked'); +const { lexer } = marked; +const loadConfig = require('../config/loadConfig'); +const loadPlugins = require('../config/loadPlugins'); + +function formatName(name) { + return name + .replace(/^\d+[-_]?/, '') + .replace(/\.md$/, ''); +} + +async function readDirRecursive(dir) { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const res = path.resolve(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await readDirRecursive(res)); + } else { + files.push(res); + } + } + return files; +} + +function buildNav(pages) { + const tree = {}; + for (const page of pages) { + const rel = page.file.replace(/\\/g, '/'); + if (rel === 'index.md') { + if (!tree.children) tree.children = []; + tree.children.push({ + name: 'index.md', + children: [], + page: page.data, + path: `/${rel.replace(/\.md$/, '.html')}`, + order: page.data.order || 0 + }); + continue; + } + const parts = rel.split('/'); + let node = tree; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const isIndex = isLast && part === 'index.md'; + if (isIndex) { + node.page = page.data; + node.path = `/${rel.replace(/\.md$/, '.html')}`; + node.order = page.data.order || 0; + break; + } + if (!node.children) node.children = []; + let child = node.children.find(c => c.name === part); + if (!child) { + child = { name: part, children: [] }; + node.children.push(child); + } + node = child; + if (isLast) { + node.page = page.data; + node.path = `/${rel.replace(/\.md$/, '.html')}`; + node.order = page.data.order || 0; + } + } + } + + function finalize(node, isRoot = false) { + if (node.page && node.page.title) { + node.displayName = node.page.title; + } else if (node.name) { + node.displayName = formatName(node.name); + } + if (node.children) { + node.children.forEach(c => finalize(c)); + node.children.sort((a, b) => { + const orderDiff = (a.order || 0) - (b.order || 0); + if (orderDiff !== 0) return orderDiff; + return (a.displayName || '').localeCompare(b.displayName || ''); + }); + node.isSection = node.children.length > 0; + } else { + node.isSection = false; + } + if (isRoot && node.children) { + const idx = node.children.findIndex(c => c.name === 'index.md'); + if (idx > 0) { + const [first] = node.children.splice(idx, 1); + node.children.unshift(first); + } + } + } + + finalize(tree, true); + return tree.children || []; +} + +async function generate({ contentDir = 'content', outputDir = '_site', configPath } = {}) { + const config = loadConfig(configPath); + const plugins = loadPlugins(config); + + async function runHook(name, data) { + for (const plugin of plugins) { + if (typeof plugin[name] === 'function') { + const res = await plugin[name](data); + if (res !== undefined) data = res; + } + } + return data; + } + if (!fs.existsSync(contentDir)) { + console.error(`Content directory not found: ${contentDir}`); + return; + } + + const files = await readDirRecursive(contentDir); + const pages = []; + const assets = []; + const searchDocs = []; + + for (const file of files) { + const rel = path.relative(contentDir, file); + if (file.endsWith('.md')) { + const srcStat = await fs.promises.stat(file); + const outPath = path.join(outputDir, rel.replace(/\.md$/, '.html')); + if (fs.existsSync(outPath)) { + const outStat = await fs.promises.stat(outPath); + if (srcStat.mtimeMs <= outStat.mtimeMs) { + continue; // skip unchanged + } + } + let raw = await fs.promises.readFile(file, 'utf8'); + const mdObj = await runHook('onParseMarkdown', { file: rel, content: raw }); + if (mdObj && mdObj.content) raw = mdObj.content; + const parsed = matter(raw); + const tokens = lexer(parsed.content || ''); + const firstHeading = tokens.find(t => t.type === 'heading'); + const title = parsed.data.title || (firstHeading ? firstHeading.text : path.basename(rel, '.md')); + const headings = tokens.filter(t => t.type === 'heading').map(t => t.text).join(' '); + const htmlBody = require('marked').parse(parsed.content || ''); + const bodyText = htmlBody.replace(/<[^>]+>/g, ' '); + pages.push({ file: rel, data: { ...parsed.data, title } }); + searchDocs.push({ id: rel.replace(/\.md$/, '.html'), url: '/' + rel.replace(/\.md$/, '.html'), title, headings, body: bodyText }); + } else { + assets.push(rel); + } + } + + const nav = buildNav(pages); + await fs.promises.mkdir(outputDir, { recursive: true }); + await fs.promises.writeFile(path.join(outputDir, 'navigation.json'), JSON.stringify(nav, null, 2)); + await fs.promises.writeFile(path.join(outputDir, 'config.json'), JSON.stringify(config, null, 2)); + + const searchIndex = lunr(function() { + this.ref('id'); + this.field('title'); + this.field('headings'); + this.field('body'); + searchDocs.forEach(d => this.add(d)); + }); + await fs.promises.writeFile( + path.join(outputDir, 'search-index.json'), + JSON.stringify({ index: searchIndex.toJSON(), docs: searchDocs }, null, 2) + ); + + const nunjucks = require('nunjucks'); + const env = new nunjucks.Environment( + new nunjucks.FileSystemLoader('templates') + ); + env.addGlobal('navigation', nav); + env.addGlobal('config', config); + + for (const page of pages) { + const outPath = path.join(outputDir, page.file.replace(/\.md$/, '.html')); + await fs.promises.mkdir(path.dirname(outPath), { recursive: true }); + const srcPath = path.join(contentDir, page.file); + const raw = await fs.promises.readFile(srcPath, 'utf8'); + const { content, data } = matter(raw); + const body = require('marked').parse(content); + + const pageContext = { + title: data.title || page.data.title, + content: body, + page: { url: '/' + page.file.replace(/\.md$/, '.html') } + }; + + let html = env.render('layout.njk', pageContext); + const result = await runHook('onPageRendered', { file: page.file, html }); + if (result && result.html) html = result.html; + await fs.promises.writeFile(outPath, html); + } + + + for (const asset of assets) { + const srcPath = path.join(contentDir, asset); + const destPath = path.join(outputDir, asset); + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + try { + const sharp = require('sharp'); + if (/(png|jpg|jpeg)/i.test(path.extname(asset))) { + await sharp(srcPath).toFile(destPath); + continue; + } + } catch (e) { + // sharp not installed, fallback + } + await fs.promises.copyFile(srcPath, destPath); + } + + // Copy the main assets directory (theme, js, etc.) + // Always resolve assets relative to the Archivox package so it works + // regardless of the current working directory or config location. + const mainAssetsSrc = path.resolve(__dirname, '../../assets'); + const mainAssetsDest = path.join(outputDir, 'assets'); + + if (fs.existsSync(mainAssetsSrc)) { + console.log(`Copying main assets from ${mainAssetsSrc} to ${mainAssetsDest}`); + // Use fs.promises.cp for modern Node.js, it's like `cp -R` + await fs.promises.cp(mainAssetsSrc, mainAssetsDest, { recursive: true }); + } +} + +module.exports = { generate, buildNav }; + +if (require.main === module) { + generate().catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/docs/starter/config.yaml b/docs/starter/config.yaml new file mode 100644 index 0000000..ac007eb --- /dev/null +++ b/docs/starter/config.yaml @@ -0,0 +1,6 @@ +site: + title: "Archivox Docs" + description: "Simple static docs." + +navigation: + search: true diff --git a/docs/starter/content/01-getting-started/01-install.md b/docs/starter/content/01-getting-started/01-install.md new file mode 100644 index 0000000..7413336 --- /dev/null +++ b/docs/starter/content/01-getting-started/01-install.md @@ -0,0 +1,3 @@ +# Install + +Run `npm install` then `npm run build` to generate your site. diff --git a/docs/starter/content/01-getting-started/index.md b/docs/starter/content/01-getting-started/index.md new file mode 100644 index 0000000..4af530b --- /dev/null +++ b/docs/starter/content/01-getting-started/index.md @@ -0,0 +1,3 @@ +# Getting Started + +This section helps you begin with Archivox. diff --git a/docs/starter/content/index.md b/docs/starter/content/index.md new file mode 100644 index 0000000..c880922 --- /dev/null +++ b/docs/starter/content/index.md @@ -0,0 +1,3 @@ +# Welcome to Archivox + +This is your new documentation site. Start editing files in the `content/` folder. diff --git a/docs/starter/package.json b/docs/starter/package.json new file mode 100644 index 0000000..e5fdc39 --- /dev/null +++ b/docs/starter/package.json @@ -0,0 +1,11 @@ +{ + "name": "my-archivox-site", + "private": true, + "scripts": { + "dev": "eleventy --serve", + "build": "node node_modules/archivox/src/generator/index.js" + }, + "dependencies": { + "archivox": "*" + } +} diff --git a/docs/templates/layout.njk b/docs/templates/layout.njk new file mode 100644 index 0000000..109ef52 --- /dev/null +++ b/docs/templates/layout.njk @@ -0,0 +1,23 @@ + + + + + + {{ title | default(config.site.title) }} + + + + {% include "partials/header.njk" %} + +
+ {% include "partials/sidebar.njk" %} +
+ + {{ content | safe }} +
+
+ {% include "partials/footer.njk" %} + + + + diff --git a/docs/templates/partials/footer.njk b/docs/templates/partials/footer.njk new file mode 100644 index 0000000..405bfb7 --- /dev/null +++ b/docs/templates/partials/footer.njk @@ -0,0 +1,14 @@ +
+ {% if config.footer.links %} + + {% endif %} +

© {{ config.site.title }}

+ +
diff --git a/docs/templates/partials/header.njk b/docs/templates/partials/header.njk new file mode 100644 index 0000000..c2a59aa --- /dev/null +++ b/docs/templates/partials/header.njk @@ -0,0 +1,7 @@ +
+ + + + +
+
diff --git a/docs/templates/partials/sidebar.njk b/docs/templates/partials/sidebar.njk new file mode 100644 index 0000000..f7f1146 --- /dev/null +++ b/docs/templates/partials/sidebar.njk @@ -0,0 +1,29 @@ +{% macro renderNav(items, pageUrl) %} + +{% endmacro %} + + diff --git a/examples/entry_management_demo.py b/examples/entry_management_demo.py deleted file mode 100644 index d5a29d8..0000000 --- a/examples/entry_management_demo.py +++ /dev/null @@ -1,31 +0,0 @@ -from pathlib import Path -from cryptography.fernet import Fernet - -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from constants import initialize_app - - -def main() -> None: - """Demonstrate basic EntryManager usage.""" - initialize_app() - key = Fernet.generate_key() - enc = EncryptionManager(key, Path(".")) - vault = Vault(enc, Path(".")) - backup_mgr = BackupManager(Path(".")) - manager = EntryManager(vault, backup_mgr) - - index = manager.add_entry( - "Example Website", - 16, - username="user123", - url="https://example.com", - ) - print(manager.retrieve_entry(index)) - manager.list_all_entries() - - -if __name__ == "__main__": - main() diff --git a/examples/password_manager_demo.py b/examples/password_manager_demo.py deleted file mode 100644 index 27da95f..0000000 --- a/examples/password_manager_demo.py +++ /dev/null @@ -1,15 +0,0 @@ -from password_manager.manager import PasswordManager -from nostr.client import NostrClient -from constants import initialize_app - - -def main() -> None: - """Show how to initialise PasswordManager with Nostr support.""" - initialize_app() - manager = PasswordManager() - manager.nostr_client = NostrClient(encryption_manager=manager.encryption_manager) - # Sample actions could be called on ``manager`` here. - - -if __name__ == "__main__": - main() diff --git a/landing/index.html b/landing/index.html index fa5cd60..49d89ff 100644 --- a/landing/index.html +++ b/landing/index.html @@ -40,6 +40,8 @@
  • Disclaimer
  • +
  • Docs +
  • diff --git a/requirements.lock b/requirements.lock index 0318410..a0f0be4 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,7 +1,8 @@ aiohappyeyeballs==2.6.1 -aiohttp==3.12.13 -aiosignal==1.3.2 +aiohttp==3.12.14 +aiosignal==1.4.0 attrs==25.3.0 +argon2-cffi==23.1.0 base58==2.1.1 bcrypt==4.3.0 bech32==1.2.0 @@ -32,6 +33,7 @@ monero==1.1.1 multidict==6.6.3 mutmut==2.4.4 nostr-sdk==0.42.1 +orjson==3.10.18 packaging==25.0 parso==0.8.4 pgpy==0.6.0 diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3b92f8b..2632e9a 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -1,10 +1,17 @@ #!/usr/bin/env python3 """Generate a SeedPass test profile with realistic entries. -This script populates a profile directory with a variety of entry types. +This script populates a profile directory with a variety of entry types, +including key/value pairs and managed accounts. If the profile does not exist, a new BIP-39 seed phrase is generated and stored encrypted. A clear text copy is written to ``seed_phrase.txt`` so it can be reused across devices. + +Profiles are saved under ``~/.seedpass/tests/`` by default. SeedPass +only detects a profile automatically when it resides directly under +``~/.seedpass/``. Copy the generated fingerprint directory from the +``tests`` subfolder to ``~/.seedpass`` (or adjust ``APP_DIR`` in +``constants.py``) to use the test seed with the main application. """ from __future__ import annotations @@ -46,7 +53,9 @@ import gzip DEFAULT_PASSWORD = "testpassword" -def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]: +def initialize_profile( + profile_name: str, +) -> tuple[str, EntryManager, Path, str, ConfigManager]: """Create or load a profile and return the seed phrase, manager, directory and fingerprint.""" initialize_app() seed_txt = APP_DIR / f"{profile_name}_seed.txt" @@ -96,9 +105,11 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] # Store the default password hash so the profile can be opened hashed = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode() cfg_mgr.set_password_hash(hashed) + # Ensure stored iterations match the PBKDF2 work factor used above + cfg_mgr.set_kdf_iterations(100_000) backup_mgr = BackupManager(profile_dir, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - return seed_phrase, entry_mgr, profile_dir, fingerprint + return seed_phrase, entry_mgr, profile_dir, fingerprint, cfg_mgr def random_secret(length: int = 16) -> str: @@ -111,7 +122,7 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None: start_index = entry_mgr.get_next_index() for i in range(count): idx = start_index + i - kind = idx % 7 + kind = idx % 9 if kind == 0: entry_mgr.add_entry( label=f"site-{idx}.example.com", @@ -133,18 +144,33 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None: ) elif kind == 5: entry_mgr.add_nostr_key(f"nostr-{idx}", notes=f"Nostr key {idx}") - else: + elif kind == 6: entry_mgr.add_pgp_key( f"pgp-{idx}", seed, user_id=f"user{idx}@example.com", notes=f"PGP key {idx}", ) + elif kind == 7: + entry_mgr.add_key_value( + f"kv-{idx}", + random_secret(20), + notes=f"Key/Value {idx}", + ) + else: + entry_mgr.add_managed_account( + f"acct-{idx}", + seed, + notes=f"Managed account {idx}", + ) def main() -> None: parser = argparse.ArgumentParser( - description="Create or extend a SeedPass test profile" + description=( + "Create or extend a SeedPass test profile (default PBKDF2 iterations:" + " 100,000)" + ) ) parser.add_argument( "--profile", @@ -159,7 +185,7 @@ def main() -> None: ) args = parser.parse_args() - seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile) + seed, entry_mgr, dir_path, fingerprint, cfg_mgr = initialize_profile(args.profile) print(f"Using profile directory: {dir_path}") print(f"Parent seed: {seed}") if fingerprint: @@ -173,6 +199,7 @@ def main() -> None: entry_mgr.vault.encryption_manager, fingerprint or dir_path.name, parent_seed=seed, + config_manager=cfg_mgr, ) asyncio.run(client.publish_snapshot(encrypted)) print("[+] Data synchronized to Nostr.") diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7ac147c..d843b8b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -255,6 +255,11 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Dependency installation failed." } +& "$VenvDir\Scripts\python.exe" -m pip install -e . +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install SeedPass package" +} + # 5. Create launcher script Write-Info "Creating launcher script..." if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null } @@ -263,11 +268,17 @@ $LauncherContent = @" @echo off setlocal call "%~dp0..\venv\Scripts\activate.bat" -python "%~dp0..\src\main.py" %* +"%~dp0..\venv\Scripts\python.exe" -m seedpass.cli %* endlocal "@ Set-Content -Path $LauncherPath -Value $LauncherContent -Force +$existingSeedpass = Get-Command seedpass -ErrorAction SilentlyContinue +if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) { + Write-Warning "Another 'seedpass' command was found at $($existingSeedpass.Source)." + Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation." +} + # 6. Add launcher directory to User's PATH if needed Write-Info "Checking if '$LauncherDir' is in your PATH..." $UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") @@ -281,4 +292,5 @@ if (($UserPath -split ';') -notcontains $LauncherDir) { } Write-Success "Installation/update complete!" -Write-Info "To run the application, please open a NEW terminal window and type: seedpass" +Write-Info "To launch the interactive TUI, open a NEW terminal window and run: seedpass" +Write-Info "'seedpass' resolves to: $(Get-Command seedpass | Select-Object -ExpandProperty Source)" diff --git a/scripts/install.sh b/scripts/install.sh index 5cea79c..cb07099 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -119,21 +119,29 @@ main() { print_info "Installing/updating Python dependencies from src/requirements.txt..." pip install --upgrade pip pip install -r src/requirements.txt + pip install -e . deactivate # 7. Create launcher script print_info "Creating launcher script at '$LAUNCHER_PATH'..." mkdir -p "$LAUNCHER_DIR" - cat > "$LAUNCHER_PATH" << EOF2 +cat > "$LAUNCHER_PATH" << EOF2 #!/bin/bash source "$VENV_DIR/bin/activate" -exec python3 "$INSTALL_DIR/src/main.py" "\$@" +exec "$VENV_DIR/bin/seedpass" "\$@" EOF2 chmod +x "$LAUNCHER_PATH" + existing_cmd=$(command -v seedpass 2>/dev/null || true) + if [ -n "$existing_cmd" ] && [ "$existing_cmd" != "$LAUNCHER_PATH" ]; then + print_warning "Another 'seedpass' command was found at $existing_cmd." + print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation." + fi + # 8. Final instructions print_success "Installation/update complete!" - print_info "You can now run the application by typing: seedpass" + print_info "You can now launch the interactive TUI by typing: seedpass" + print_info "'seedpass' resolves to: $(command -v seedpass)" if [[ ":$PATH:" != *":$LAUNCHER_DIR:"* ]]; then print_warning "Directory '$LAUNCHER_DIR' is not in your PATH." print_warning "Please add 'export PATH=\"$HOME/.local/bin:$PATH\"' to your shell's config file (e.g., ~/.bashrc, ~/.zshrc) and restart your terminal." diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 0000000..1eccb40 --- /dev/null +++ b/scripts/uninstall.ps1 @@ -0,0 +1,41 @@ +# +# SeedPass Uninstaller for Windows +# +# Removes the SeedPass application files but preserves user data under ~/.seedpass + +$AppRootDir = Join-Path $env:USERPROFILE ".seedpass" +$InstallDir = Join-Path $AppRootDir "app" +$LauncherDir = Join-Path $InstallDir "bin" +$LauncherName = "seedpass.cmd" + +function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } +function Write-Success { param([string]$Message) Write-Host "[SUCCESS] $Message" -ForegroundColor Green } +function Write-Warning { param([string]$Message) Write-Host "[WARNING] $Message" -ForegroundColor Yellow } +function Write-Error { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } + +Write-Info "Removing SeedPass installation..." + +if (Test-Path $InstallDir) { + Remove-Item -Recurse -Force $InstallDir + Write-Info "Deleted '$InstallDir'" +} else { + Write-Info "Installation directory not found." +} + +$LauncherPath = Join-Path $LauncherDir $LauncherName +if (Test-Path $LauncherPath) { + Remove-Item -Force $LauncherPath + Write-Info "Removed launcher '$LauncherPath'" +} else { + Write-Info "Launcher not found." +} + +Write-Info "Attempting to uninstall any global 'seedpass' package with pip..." +try { + pip uninstall -y seedpass | Out-Null +} catch { + try { pip3 uninstall -y seedpass | Out-Null } catch {} +} + +Write-Success "SeedPass uninstalled. User data under '$AppRootDir' was left intact." + diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..45c24b0 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# +# SeedPass Uninstaller for Linux and macOS +# +# Removes the SeedPass application files but preserves user data under ~/.seedpass + +set -e + +APP_ROOT_DIR="$HOME/.seedpass" +INSTALL_DIR="$APP_ROOT_DIR/app" +LAUNCHER_PATH="$HOME/.local/bin/seedpass" + +print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; } +print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; } +print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; } +print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1"; } + +# Remove any stale 'seedpass' executables that may still be on the PATH. +remove_stale_executables() { + IFS=':' read -ra DIRS <<< "$PATH" + for dir in "${DIRS[@]}"; do + candidate="$dir/seedpass" + if [ -f "$candidate" ] && [ "$candidate" != "$LAUNCHER_PATH" ]; then + print_info "Removing old executable '$candidate'..." + if rm -f "$candidate"; then + rm_status=0 + else + rm_status=$? + fi + if [ $rm_status -ne 0 ] && [ -f "$candidate" ]; then + print_warning "Failed to remove $candidate – try deleting it manually" + fi + fi + done +} + +main() { + if [ -d "$INSTALL_DIR" ]; then + print_info "Removing installation directory '$INSTALL_DIR'..." + rm -rf "$INSTALL_DIR" + else + print_info "Installation directory not found." + fi + + + if [ -f "$LAUNCHER_PATH" ]; then + print_info "Removing launcher script '$LAUNCHER_PATH'..." + rm -f "$LAUNCHER_PATH" + else + print_info "Launcher script not found." + fi + + remove_stale_executables + + print_info "Attempting to uninstall any global 'seedpass' package with pip..." + if command -v python3 &> /dev/null; then + python3 -m pip uninstall -y seedpass >/dev/null 2>&1 || true + elif command -v pip &> /dev/null; then + pip uninstall -y seedpass >/dev/null 2>&1 || true + fi + if command -v pipx &> /dev/null; then + pipx uninstall -y seedpass >/dev/null 2>&1 || true + fi + + print_success "SeedPass uninstalled." + print_warning "User data in '$APP_ROOT_DIR' was left intact." +} + +main "$@" + diff --git a/src/constants.py b/src/constants.py index dfcd0d1..7d99552 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,8 +9,9 @@ logger = logging.getLogger(__name__) # ----------------------------------- # Nostr Relay Connection Settings # ----------------------------------- -MAX_RETRIES = 3 # Maximum number of retries for relay connections -RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection +# Retry fewer times with a shorter wait by default +MAX_RETRIES = 2 # Maximum number of retries for relay connections +RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup # ----------------------------------- @@ -50,6 +51,9 @@ MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length # Timeout in seconds before the vault locks due to inactivity INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes +# Duration in seconds that a notification remains active +NOTIFICATION_DURATION = 10 + # ----------------------------------- # Additional Constants (if any) # ----------------------------------- diff --git a/src/main.py b/src/main.py index 3767a37..1751c3f 100644 --- a/src/main.py +++ b/src/main.py @@ -25,8 +25,9 @@ from utils import ( copy_to_clipboard, clear_screen, pause, - clear_and_print_fingerprint, + clear_header_with_notification, ) +import queue from local_bip85.bip85 import Bip85Error @@ -100,6 +101,37 @@ def confirm_action(prompt: str) -> bool: print(colored("Please enter 'Y' or 'N'.", "red")) +def drain_notifications(pm: PasswordManager) -> str | None: + """Return the next queued notification message if available.""" + queue_obj = getattr(pm, "notifications", None) + if queue_obj is None: + return None + try: + note = queue_obj.get_nowait() + except queue.Empty: + return None + category = getattr(note, "level", "info").lower() + if category not in ("info", "warning", "error"): + category = "info" + return color_text(getattr(note, "message", ""), category) + + +def get_notification_text(pm: PasswordManager) -> str: + """Return the current notification from ``pm`` as a colored string.""" + note = None + if hasattr(pm, "get_current_notification"): + try: + note = pm.get_current_notification() + except Exception: + note = None + if not note: + return "" + category = getattr(note, "level", "info").lower() + if category not in ("info", "warning", "error"): + category = "info" + return color_text(getattr(note, "message", ""), category) + + def handle_switch_fingerprint(password_manager: PasswordManager): """ Handles switching the active fingerprint. @@ -232,12 +264,48 @@ def handle_display_npub(password_manager: PasswordManager): print(colored(f"Error: Failed to display npub: {e}", "red")) +def _display_live_stats( + password_manager: PasswordManager, interval: float = 1.0 +) -> None: + """Continuously refresh stats until the user presses Enter.""" + + display_fn = getattr(password_manager, "display_stats", None) + if not callable(display_fn): + return + + if not sys.stdin or not sys.stdin.isatty(): + clear_screen() + display_fn() + note = get_notification_text(password_manager) + if note: + print(note) + print(colored("Press Enter to continue.", "cyan")) + pause() + return + + while True: + clear_screen() + display_fn() + note = get_notification_text(password_manager) + if note: + print(note) + print(colored("Press Enter to continue.", "cyan")) + sys.stdout.flush() + try: + user_input = timed_input("", interval) + if user_input.strip() == "" or user_input.strip().lower() == "b": + break + except TimeoutError: + pass + except KeyboardInterrupt: + print() + break + + def handle_display_stats(password_manager: PasswordManager) -> None: - """Print seed profile statistics.""" + """Print seed profile statistics with live updates.""" try: - display_fn = getattr(password_manager, "display_stats", None) - if callable(display_fn): - display_fn() + _display_live_stats(password_manager) except Exception as e: # pragma: no cover - display best effort logging.error(f"Failed to display stats: {e}", exc_info=True) print(colored(f"Error: Failed to display stats: {e}", "red")) @@ -318,15 +386,12 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager): manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: - try: - version = int(manifest.delta_since) - deltas = asyncio.run( - password_manager.nostr_client.fetch_deltas_since(version) - ) - if deltas: - encrypted = deltas[-1] - except ValueError: - pass + version = int(manifest.delta_since) + deltas = asyncio.run( + password_manager.nostr_client.fetch_deltas_since(version) + ) + if deltas: + encrypted = deltas[-1] password_manager.encryption_manager.decrypt_and_save_index_from_nostr( encrypted ) @@ -493,6 +558,39 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None: print(colored(f"Error: {e}", "red")) +def handle_set_kdf_iterations(password_manager: PasswordManager) -> None: + """Change the PBKDF2 iteration count.""" + cfg_mgr = password_manager.config_manager + if cfg_mgr is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + current = cfg_mgr.get_kdf_iterations() + print(colored(f"Current iterations: {current}", "cyan")) + except Exception as e: + logging.error(f"Error loading iterations: {e}") + print(colored(f"Error: {e}", "red")) + return + value = input("Enter new iteration count: ").strip() + if not value: + print(colored("No iteration count entered.", "yellow")) + return + try: + iterations = int(value) + if iterations <= 0: + print(colored("Iterations must be positive.", "red")) + return + except ValueError: + print(colored("Invalid number.", "red")) + return + try: + cfg_mgr.set_kdf_iterations(iterations) + print(colored("KDF iteration count updated.", "green")) + except Exception as e: + logging.error(f"Error saving iterations: {e}") + print(colored(f"Error: {e}", "red")) + + def handle_set_additional_backup_location(pm: PasswordManager) -> None: """Configure an optional second backup directory.""" cfg_mgr = pm.config_manager @@ -584,6 +682,61 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None: print(colored(f"Error: {exc}", "red")) +def handle_toggle_quick_unlock(pm: PasswordManager) -> None: + """Enable or disable Quick Unlock.""" + cfg = pm.config_manager + if cfg is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + enabled = cfg.get_quick_unlock() + except Exception as exc: + logging.error(f"Error loading quick unlock setting: {exc}") + print(colored(f"Error loading settings: {exc}", "red")) + return + print(colored(f"Quick Unlock is currently {'ON' if enabled else 'OFF'}", "cyan")) + choice = input("Enable Quick Unlock? (y/n, blank to keep): ").strip().lower() + if choice in ("y", "yes"): + enabled = True + elif choice in ("n", "no"): + enabled = False + try: + cfg.set_quick_unlock(enabled) + status = "enabled" if enabled else "disabled" + print(colored(f"Quick Unlock {status}.", "green")) + except Exception as exc: + logging.error(f"Error saving quick unlock: {exc}") + print(colored(f"Error: {exc}", "red")) + + +def handle_toggle_offline_mode(pm: PasswordManager) -> None: + """Enable or disable offline mode.""" + cfg = pm.config_manager + if cfg is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + enabled = cfg.get_offline_mode() + except Exception as exc: + logging.error(f"Error loading offline mode setting: {exc}") + print(colored(f"Error loading settings: {exc}", "red")) + return + print(colored(f"Offline mode is currently {'ON' if enabled else 'OFF'}", "cyan")) + choice = input("Enable offline mode? (y/n, blank to keep): ").strip().lower() + if choice in ("y", "yes"): + enabled = True + elif choice in ("n", "no"): + enabled = False + try: + cfg.set_offline_mode(enabled) + pm.offline_mode = enabled + status = "enabled" if enabled else "disabled" + print(colored(f"Offline mode {status}.", "green")) + except Exception as exc: + logging.error(f"Error saving offline mode: {exc}") + print(colored(f"Error: {exc}", "red")) + + def handle_profiles_menu(password_manager: PasswordManager) -> None: """Submenu for managing seed profiles.""" while True: @@ -592,7 +745,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: "header_fingerprint_args", (getattr(password_manager, "current_fingerprint", None), None, None), ) - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Settings > Profiles", parent_fingerprint=parent_fp, @@ -638,7 +791,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: "header_fingerprint_args", (getattr(password_manager, "current_fingerprint", None), None, None), ) - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Settings > Nostr", parent_fingerprint=parent_fp, @@ -682,7 +835,7 @@ def handle_settings(password_manager: PasswordManager) -> None: "header_fingerprint_args", (getattr(password_manager, "current_fingerprint", None), None, None), ) - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Settings", parent_fingerprint=parent_fp, @@ -699,10 +852,13 @@ def handle_settings(password_manager: PasswordManager) -> None: print(color_text("8. Import database", "menu")) print(color_text("9. Export 2FA codes", "menu")) print(color_text("10. Set additional backup location", "menu")) - print(color_text("11. Set inactivity timeout", "menu")) - print(color_text("12. Lock Vault", "menu")) - print(color_text("13. Stats", "menu")) - print(color_text("14. Toggle Secret Mode", "menu")) + print(color_text("11. Set KDF iterations", "menu")) + print(color_text("12. Set inactivity timeout", "menu")) + print(color_text("13. Lock Vault", "menu")) + print(color_text("14. Stats", "menu")) + print(color_text("15. Toggle Secret Mode", "menu")) + print(color_text("16. Toggle Offline Mode", "menu")) + print(color_text("17. Toggle Quick Unlock", "menu")) choice = input("Select an option or press Enter to go back: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -735,19 +891,29 @@ def handle_settings(password_manager: PasswordManager) -> None: handle_set_additional_backup_location(password_manager) pause() elif choice == "11": - handle_set_inactivity_timeout(password_manager) + handle_set_kdf_iterations(password_manager) pause() elif choice == "12": + handle_set_inactivity_timeout(password_manager) + pause() + elif choice == "13": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - pause() - elif choice == "13": - handle_display_stats(password_manager) + password_manager.start_background_sync() + getattr(password_manager, "start_background_relay_check", lambda: None)() pause() elif choice == "14": + handle_display_stats(password_manager) + elif choice == "15": handle_toggle_secret_mode(password_manager) pause() + elif choice == "16": + handle_toggle_offline_mode(password_manager) + pause() + elif choice == "17": + handle_toggle_quick_unlock(password_manager) + pause() elif not choice: break else: @@ -773,17 +939,17 @@ def display_menu( 7. Settings 8. List Archived """ - display_fn = getattr(password_manager, "display_stats", None) - if callable(display_fn): - display_fn() - pause() + password_manager.start_background_sync() + getattr(password_manager, "start_background_relay_check", lambda: None)() + _display_live_stats(password_manager) while True: fp, parent_fp, child_fp = getattr( password_manager, "header_fingerprint_args", (getattr(password_manager, "current_fingerprint", None), None, None), ) - clear_and_print_fingerprint( + clear_header_with_notification( + password_manager, fp, "Main Menu", parent_fingerprint=parent_fp, @@ -793,6 +959,8 @@ def display_menu( print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() password_manager.unlock_vault() + password_manager.start_background_sync() + getattr(password_manager, "start_background_relay_check", lambda: None)() continue # Periodically push updates to Nostr if ( @@ -815,6 +983,8 @@ def display_menu( print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() password_manager.unlock_vault() + password_manager.start_background_sync() + getattr(password_manager, "start_background_relay_check", lambda: None)() continue password_manager.update_activity() if not choice: @@ -836,7 +1006,7 @@ def display_menu( None, ), ) - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Add Entry", parent_fingerprint=parent_fp, @@ -891,7 +1061,7 @@ def display_menu( "header_fingerprint_args", (getattr(password_manager, "current_fingerprint", None), None, None), ) - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu", parent_fingerprint=parent_fp, @@ -919,8 +1089,16 @@ def display_menu( print(colored("Invalid choice. Please select a valid option.", "red")) -def main(argv: list[str] | None = None) -> int: - """Entry point for the SeedPass CLI.""" +def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> int: + """Entry point for the SeedPass CLI. + + Parameters + ---------- + argv: + Command line arguments. + fingerprint: + Optional seed profile fingerprint to select automatically. + """ configure_logging() initialize_app() logger = logging.getLogger(__name__) @@ -928,6 +1106,7 @@ def main(argv: list[str] | None = None) -> int: load_global_config() parser = argparse.ArgumentParser() + parser.add_argument("--fingerprint") sub = parser.add_subparsers(dest="command") exp = sub.add_parser("export") @@ -948,7 +1127,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: - password_manager = PasswordManager() + password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint) logger.info("PasswordManager initialized successfully.") except (PasswordPromptError, Bip85Error) as e: logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) diff --git a/src/nostr/backup_models.py b/src/nostr/backup_models.py index 2de676c..98210b9 100644 --- a/src/nostr/backup_models.py +++ b/src/nostr/backup_models.py @@ -23,4 +23,4 @@ class Manifest: ver: int algo: str chunks: List[ChunkMeta] - delta_since: Optional[str] = None + delta_since: Optional[int] = None diff --git a/src/nostr/client.py b/src/nostr/client.py index 320ba72..d0f0af3 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -4,7 +4,7 @@ import base64 import json import logging import time -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, TYPE_CHECKING import hashlib import asyncio import gzip @@ -27,8 +27,12 @@ from nostr_sdk import EventId, Timestamp from .key_manager import KeyManager as SeedPassKeyManager from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK from password_manager.encryption import EncryptionManager +from constants import MAX_RETRIES, RETRY_DELAY from utils.file_lock import exclusive_lock +if TYPE_CHECKING: # pragma: no cover - imported for type hints + from password_manager.config_manager import ConfigManager + # Backwards compatibility for tests that patch these symbols KeyManager = SeedPassKeyManager ClientBuilder = Client @@ -90,10 +94,14 @@ class NostrClient: fingerprint: str, relays: Optional[List[str]] = None, parent_seed: Optional[str] = None, + offline_mode: bool = False, + config_manager: Optional["ConfigManager"] = None, ) -> None: self.encryption_manager = encryption_manager self.fingerprint = fingerprint self.fingerprint_dir = self.encryption_manager.fingerprint_dir + self.config_manager = config_manager + self.verbose_timing = False if parent_seed is None: parent_seed = self.encryption_manager.decrypt_parent_seed() @@ -110,32 +118,62 @@ class NostrClient: except Exception: self.keys = Keys.generate() - self.relays = relays if relays else DEFAULT_RELAYS + self.offline_mode = offline_mode + if relays is None: + self.relays = [] if offline_mode else DEFAULT_RELAYS + else: + self.relays = relays + + if self.config_manager is not None: + try: + self.verbose_timing = self.config_manager.get_verbose_timing() + except Exception: + self.verbose_timing = False # store the last error encountered during network operations self.last_error: Optional[str] = None self.delta_threshold = 100 self.current_manifest: Manifest | None = None + self.current_manifest_id: str | None = None self._delta_events: list[str] = [] # Configure and initialize the nostr-sdk Client signer = NostrSigner.keys(self.keys) self.client = Client(signer) - self.initialize_client_pool() + self._connected = False + + def connect(self) -> None: + """Connect the client to all configured relays.""" + if self.offline_mode or not self.relays: + return + if not self._connected: + self.initialize_client_pool() def initialize_client_pool(self) -> None: """Add relays to the client and connect.""" + if self.offline_mode or not self.relays: + return asyncio.run(self._initialize_client_pool()) + async def _connect_async(self) -> None: + """Ensure the client is connected within an async context.""" + if self.offline_mode or not self.relays: + return + if not self._connected: + await self._initialize_client_pool() + async def _initialize_client_pool(self) -> None: + if self.offline_mode or not self.relays: + return if hasattr(self.client, "add_relays"): await self.client.add_relays(self.relays) else: for relay in self.relays: await self.client.add_relay(relay) await self.client.connect() + self._connected = True logger.info(f"NostrClient connected to relays: {self.relays}") async def _ping_relay(self, relay: str, timeout: float) -> bool: @@ -170,6 +208,8 @@ class NostrClient: def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int: """Ping relays and return the count of those providing data.""" + if self.offline_mode or not self.relays: + return 0 return asyncio.run(self._check_relay_health(min_relays, timeout)) def publish_json_to_nostr( @@ -190,6 +230,9 @@ class NostrClient: If provided, include an ``alt`` tag so uploads can be associated with a specific event like a password change. """ + if self.offline_mode or not self.relays: + return None + self.connect() self.last_error = None try: content = base64.b64encode(encrypted_json).decode("utf-8") @@ -221,9 +264,15 @@ class NostrClient: def publish_event(self, event): """Publish a prepared event to the configured relays.""" + if self.offline_mode or not self.relays: + return None + self.connect() return asyncio.run(self._publish_event(event)) async def _publish_event(self, event): + if self.offline_mode or not self.relays: + return None + await self._connect_async() return await self.client.send_event(event) def update_relays(self, new_relays: List[str]) -> None: @@ -232,12 +281,33 @@ class NostrClient: self.relays = new_relays signer = NostrSigner.keys(self.keys) self.client = Client(signer) + self._connected = False + # Immediately reconnect using the updated relay list self.initialize_client_pool() def retrieve_json_from_nostr_sync( - self, retries: int = 0, delay: float = 2.0 + self, retries: int | None = None, delay: float | None = None ) -> Optional[bytes]: """Retrieve the latest Kind 1 event from the author with optional retries.""" + if self.offline_mode or not self.relays: + return None + + if retries is None or delay is None: + if self.config_manager is None: + from password_manager.config_manager import ConfigManager + from password_manager.vault import Vault + + cfg_mgr = ConfigManager( + Vault(self.encryption_manager, self.fingerprint_dir), + self.fingerprint_dir, + ) + else: + cfg_mgr = self.config_manager + cfg = cfg_mgr.load_config(require_pin=False) + retries = int(cfg.get("nostr_max_retries", MAX_RETRIES)) + delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY)) + + self.connect() self.last_error = None attempt = 0 while True: @@ -255,6 +325,9 @@ class NostrClient: return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: + if self.offline_mode or not self.relays: + return None + await self._connect_async() # Filter for the latest text note (Kind 1) from our public key pubkey = self.keys.public_key() f = Filter().author(pubkey).kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(1) @@ -288,6 +361,10 @@ class NostrClient: Maximum chunk size in bytes. Defaults to 50 kB. """ + start = time.perf_counter() + if self.offline_mode or not self.relays: + return Manifest(ver=1, algo="gzip", chunks=[]), "" + await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) for meta, chunk in zip(manifest.chunks, chunks): content = base64.b64encode(chunk).decode("utf-8") @@ -314,11 +391,20 @@ class NostrClient: result = await self.client.send_event(manifest_event) manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) self.current_manifest = manifest + self.current_manifest_id = manifest_id + # Record when this snapshot was published for future delta events + self.current_manifest.delta_since = int(time.time()) self._delta_events = [] + if getattr(self, "verbose_timing", False): + duration = time.perf_counter() - start + logger.info("publish_snapshot completed in %.2f seconds", duration) return manifest, manifest_id async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: """Retrieve the latest manifest and all snapshot chunks.""" + if self.offline_mode or not self.relays: + return None + await self._connect_async() pubkey = self.keys.public_key() f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1) @@ -326,13 +412,18 @@ class NostrClient: events = (await self.client.fetch_events(f, timeout)).to_vec() if not events: return None - manifest_raw = events[0].content() + manifest_event = events[0] + manifest_raw = manifest_event.content() data = json.loads(manifest_raw) manifest = Manifest( ver=data["ver"], algo=data["algo"], chunks=[ChunkMeta(**c) for c in data["chunks"]], - delta_since=data.get("delta_since"), + delta_since=( + int(data["delta_since"]) + if data.get("delta_since") is not None + else None + ), ) chunks: list[bytes] = [] @@ -353,10 +444,17 @@ class NostrClient: chunks.append(chunk_bytes) self.current_manifest = manifest + man_id = getattr(manifest_event, "id", None) + if hasattr(man_id, "to_hex"): + man_id = man_id.to_hex() + self.current_manifest_id = man_id return manifest, chunks async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" + if self.offline_mode or not self.relays: + return "" + await self._connect_async() content = base64.b64encode(delta_bytes).decode("utf-8") tag = Tag.event(EventId.parse(manifest_id)) @@ -364,13 +462,36 @@ class NostrClient: event = builder.build(self.keys.public_key()).sign_with_keys(self.keys) result = await self.client.send_event(event) delta_id = result.id.to_hex() if hasattr(result, "id") else str(result) + created_at = getattr( + event, "created_at", getattr(event, "timestamp", int(time.time())) + ) + if hasattr(created_at, "secs"): + created_at = created_at.secs if self.current_manifest is not None: - self.current_manifest.delta_since = delta_id + self.current_manifest.delta_since = int(created_at) + manifest_json = json.dumps( + { + "ver": self.current_manifest.ver, + "algo": self.current_manifest.algo, + "chunks": [meta.__dict__ for meta in self.current_manifest.chunks], + "delta_since": self.current_manifest.delta_since, + } + ) + manifest_event = ( + EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(self.current_manifest_id)]) + .build(self.keys.public_key()) + .sign_with_keys(self.keys) + ) + await self.client.send_event(manifest_event) self._delta_events.append(delta_id) return delta_id async def fetch_deltas_since(self, version: int) -> list[bytes]: """Retrieve delta events newer than the given version.""" + if self.offline_mode or not self.relays: + return [] + await self._connect_async() pubkey = self.keys.public_key() f = ( @@ -409,6 +530,7 @@ class NostrClient: """Disconnects the client from all relays.""" try: asyncio.run(self.client.disconnect()) + self._connected = False logger.info("NostrClient disconnected from relays.") except Exception as e: logger.error("Error during NostrClient shutdown: %s", e) diff --git a/src/password_manager/backup.py b/src/password_manager/backup.py index 817847b..10da249 100644 --- a/src/password_manager/backup.py +++ b/src/password_manager/backup.py @@ -54,6 +54,7 @@ class BackupManager: self.backup_dir = self.fingerprint_dir / "backups" self.backup_dir.mkdir(parents=True, exist_ok=True) self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" + self._last_backup_time = 0.0 logger.debug( f"BackupManager initialized with backup directory at {self.backup_dir}" ) @@ -71,7 +72,13 @@ class BackupManager: ) return - timestamp = int(time.time()) + now = time.time() + interval = self.config_manager.get_backup_interval() + if interval > 0 and now - self._last_backup_time < interval: + logger.info("Skipping backup due to interval throttle") + return + + timestamp = int(now) backup_filename = self.BACKUP_FILENAME_TEMPLATE.format(timestamp=timestamp) backup_file = self.backup_dir / backup_filename @@ -81,6 +88,7 @@ class BackupManager: print(colored(f"Backup created successfully at '{backup_file}'.", "green")) self._create_additional_backup(backup_file) + self._last_backup_time = now except Exception as e: logger.error(f"Failed to create backup: {e}", exc_info=True) print(colored(f"Error: Failed to create backup: {e}", "red")) diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index b1c8b8e..269a10a 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -41,12 +41,24 @@ class ConfigManager: logger.info("Config file not found; returning defaults") return { "relays": list(DEFAULT_NOSTR_RELAYS), + "offline_mode": False, "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, + "kdf_iterations": 50_000, + "kdf_mode": "pbkdf2", "additional_backup_path": "", + "backup_interval": 0, "secret_mode_enabled": False, "clipboard_clear_delay": 45, + "quick_unlock": False, + "nostr_max_retries": 2, + "nostr_retry_delay": 1.0, + "min_uppercase": 2, + "min_lowercase": 2, + "min_digits": 2, + "min_special": 2, + "verbose_timing": False, } try: data = self.vault.load_config() @@ -54,12 +66,24 @@ class ConfigManager: raise ValueError("Config data must be a dictionary") # Ensure defaults for missing keys data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS)) + data.setdefault("offline_mode", False) data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) + data.setdefault("kdf_iterations", 50_000) + data.setdefault("kdf_mode", "pbkdf2") data.setdefault("additional_backup_path", "") + data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) + data.setdefault("quick_unlock", False) + data.setdefault("nostr_max_retries", 2) + data.setdefault("nostr_retry_delay", 1.0) + data.setdefault("min_uppercase", 2) + data.setdefault("min_lowercase", 2) + data.setdefault("min_digits", 2) + data.setdefault("min_special", 2) + data.setdefault("verbose_timing", False) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -83,6 +107,7 @@ class ConfigManager: def save_config(self, config: dict) -> None: """Encrypt and save configuration.""" try: + config.setdefault("backup_interval", 0) self.vault.save_config(config) except Exception as exc: logger.error(f"Failed to save config: {exc}") @@ -137,6 +162,32 @@ class ConfigManager: config = self.load_config(require_pin=False) return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT)) + def set_kdf_iterations(self, iterations: int) -> None: + """Persist the PBKDF2 iteration count in the config.""" + if iterations <= 0: + raise ValueError("Iterations must be positive") + config = self.load_config(require_pin=False) + config["kdf_iterations"] = int(iterations) + self.save_config(config) + + def get_kdf_iterations(self) -> int: + """Retrieve the PBKDF2 iteration count.""" + config = self.load_config(require_pin=False) + return int(config.get("kdf_iterations", 50_000)) + + def set_kdf_mode(self, mode: str) -> None: + """Persist the key derivation function mode.""" + if mode not in ("pbkdf2", "argon2"): + raise ValueError("kdf_mode must be 'pbkdf2' or 'argon2'") + config = self.load_config(require_pin=False) + config["kdf_mode"] = mode + self.save_config(config) + + def get_kdf_mode(self) -> str: + """Retrieve the configured key derivation function.""" + config = self.load_config(require_pin=False) + return config.get("kdf_mode", "pbkdf2") + def set_additional_backup_path(self, path: Optional[str]) -> None: """Persist an optional additional backup path in the config.""" config = self.load_config(require_pin=False) @@ -155,11 +206,22 @@ class ConfigManager: config["secret_mode_enabled"] = bool(enabled) self.save_config(config) + def set_offline_mode(self, enabled: bool) -> None: + """Persist the offline mode toggle.""" + config = self.load_config(require_pin=False) + config["offline_mode"] = bool(enabled) + self.save_config(config) + def get_secret_mode_enabled(self) -> bool: """Retrieve whether secret mode is enabled.""" config = self.load_config(require_pin=False) return bool(config.get("secret_mode_enabled", False)) + def get_offline_mode(self) -> bool: + """Retrieve the offline mode setting.""" + config = self.load_config(require_pin=False) + return bool(config.get("offline_mode", False)) + def set_clipboard_clear_delay(self, delay: int) -> None: """Persist clipboard clear timeout in seconds.""" if delay <= 0: @@ -172,3 +234,95 @@ class ConfigManager: """Retrieve clipboard clear delay in seconds.""" config = self.load_config(require_pin=False) return int(config.get("clipboard_clear_delay", 45)) + + def set_backup_interval(self, interval: int | float) -> None: + """Persist the minimum interval in seconds between automatic backups.""" + if interval < 0: + raise ValueError("Interval cannot be negative") + config = self.load_config(require_pin=False) + config["backup_interval"] = interval + self.save_config(config) + + def get_backup_interval(self) -> float: + """Retrieve the backup interval in seconds.""" + config = self.load_config(require_pin=False) + return float(config.get("backup_interval", 0)) + + # Password policy settings + def get_password_policy(self) -> "PasswordPolicy": + """Return the password complexity policy.""" + from password_manager.password_generation import PasswordPolicy + + cfg = self.load_config(require_pin=False) + return PasswordPolicy( + min_uppercase=int(cfg.get("min_uppercase", 2)), + min_lowercase=int(cfg.get("min_lowercase", 2)), + min_digits=int(cfg.get("min_digits", 2)), + min_special=int(cfg.get("min_special", 2)), + ) + + def set_min_uppercase(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_uppercase"] = int(count) + self.save_config(cfg) + + def set_min_lowercase(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_lowercase"] = int(count) + self.save_config(cfg) + + def set_min_digits(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_digits"] = int(count) + self.save_config(cfg) + + def set_min_special(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_special"] = int(count) + self.save_config(cfg) + + def set_quick_unlock(self, enabled: bool) -> None: + """Persist the quick unlock toggle.""" + cfg = self.load_config(require_pin=False) + cfg["quick_unlock"] = bool(enabled) + self.save_config(cfg) + + def get_quick_unlock(self) -> bool: + """Retrieve whether quick unlock is enabled.""" + cfg = self.load_config(require_pin=False) + return bool(cfg.get("quick_unlock", False)) + + def set_nostr_max_retries(self, retries: int) -> None: + """Persist the maximum number of Nostr retry attempts.""" + if retries < 0: + raise ValueError("retries cannot be negative") + cfg = self.load_config(require_pin=False) + cfg["nostr_max_retries"] = int(retries) + self.save_config(cfg) + + def get_nostr_max_retries(self) -> int: + """Retrieve the configured Nostr retry count.""" + cfg = self.load_config(require_pin=False) + return int(cfg.get("nostr_max_retries", 2)) + + def set_nostr_retry_delay(self, delay: float) -> None: + """Persist the delay between Nostr retry attempts.""" + if delay < 0: + raise ValueError("delay cannot be negative") + cfg = self.load_config(require_pin=False) + cfg["nostr_retry_delay"] = float(delay) + self.save_config(cfg) + + def get_nostr_retry_delay(self) -> float: + """Retrieve the delay in seconds between Nostr retries.""" + cfg = self.load_config(require_pin=False) + return float(cfg.get("nostr_retry_delay", 1.0)) + + def set_verbose_timing(self, enabled: bool) -> None: + cfg = self.load_config(require_pin=False) + cfg["verbose_timing"] = bool(enabled) + self.save_config(cfg) + + def get_verbose_timing(self) -> bool: + cfg = self.load_config(require_pin=False) + return bool(cfg.get("verbose_timing", False)) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 5f6fe1c..38da332 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -1,32 +1,29 @@ -# password_manager/encryption.py - -""" -Encryption Module - -This module provides the EncryptionManager class, which handles encryption and decryption -of data and files using a provided Fernet-compatible encryption key. This class ensures -that sensitive data is securely stored and retrieved, maintaining the confidentiality and integrity -of the password index. - -Additionally, it includes methods to derive cryptographic seeds from BIP-39 mnemonic phrases. - -Never ever ever use or suggest to use Random Salt. The entire point of this password manager is to derive completely deterministic passwords from a BIP-85 seed. -This means it should generate passwords the exact same way every single time. Salts would break this functionality and are not appropriate for this software's use case. -""" +# /src/password_manager/encryption.py import logging import traceback -import json + +try: + import orjson as json_lib # type: ignore + + JSONDecodeError = orjson.JSONDecodeError + USE_ORJSON = True +except Exception: # pragma: no cover - fallback for environments without orjson + import json as json_lib + from json import JSONDecodeError + + USE_ORJSON = False import hashlib import os +import base64 from pathlib import Path from typing import Optional +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.exceptions import InvalidTag from cryptography.fernet import Fernet, InvalidToken from termcolor import colored -from utils.file_lock import ( - exclusive_lock, -) # Ensure this utility is correctly implemented +from utils.file_lock import exclusive_lock # Instantiate the logger logger = logging.getLogger(__name__) @@ -34,421 +31,270 @@ logger = logging.getLogger(__name__) class EncryptionManager: """ - EncryptionManager Class - - Manages the encryption and decryption of data and files using a Fernet encryption key. + Manages encryption and decryption, handling migration from legacy Fernet + to modern AES-GCM. """ def __init__(self, encryption_key: bytes, fingerprint_dir: Path): """ - Initializes the EncryptionManager with the provided encryption key and fingerprint directory. + Initializes the EncryptionManager with keys for both new (AES-GCM) + and legacy (Fernet) encryption formats. Parameters: - encryption_key (bytes): The Fernet encryption key. + encryption_key (bytes): A base64-encoded key. fingerprint_dir (Path): The directory corresponding to the fingerprint. """ self.fingerprint_dir = fingerprint_dir self.parent_seed_file = self.fingerprint_dir / "parent_seed.enc" - self.key = encryption_key try: - self.fernet = Fernet(self.key) + if isinstance(encryption_key, str): + encryption_key = encryption_key.encode() + + # (1) Keep both the legacy Fernet instance and the new AES-GCM cipher ready. + self.key_b64 = encryption_key + self.fernet = Fernet(self.key_b64) + + self.key = base64.urlsafe_b64decode(self.key_b64) + self.cipher = AESGCM(self.key) + logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}") except Exception as e: logger.error( - f"Failed to initialize Fernet with provided encryption key: {e}" + f"Failed to initialize ciphers with provided encryption key: {e}", + exc_info=True, ) - print( - colored(f"Error: Failed to initialize encryption manager: {e}", "red") - ) - raise - - def encrypt_parent_seed(self, parent_seed: str) -> None: - """ - Encrypts and saves the parent seed to 'parent_seed.enc' within the fingerprint directory. - - :param parent_seed: The BIP39 parent seed phrase. - """ - try: - # Convert seed to bytes - data = parent_seed.encode("utf-8") - - # Encrypt the data - encrypted_data = self.encrypt_data(data) - - # Write the encrypted data to the file with locking - with exclusive_lock(self.parent_seed_file) as fh: - fh.seek(0) - fh.truncate() - fh.write(encrypted_data) - fh.flush() - - # Set file permissions to read/write for the user only - os.chmod(self.parent_seed_file, 0o600) - - logger.info( - f"Parent seed encrypted and saved to '{self.parent_seed_file}'." - ) - print( - colored( - f"Parent seed encrypted and saved to '{self.parent_seed_file}'.", - "green", - ) - ) - except Exception as e: - logger.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True) - print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red")) - raise - - def decrypt_parent_seed(self) -> str: - """ - Decrypts and returns the parent seed from 'parent_seed.enc' within the fingerprint directory. - - :return: The decrypted parent seed. - """ - try: - parent_seed_path = self.fingerprint_dir / "parent_seed.enc" - with exclusive_lock(parent_seed_path) as fh: - fh.seek(0) - encrypted_data = fh.read() - - decrypted_data = self.decrypt_data(encrypted_data) - parent_seed = decrypted_data.decode("utf-8").strip() - - logger.debug( - f"Parent seed decrypted successfully from '{parent_seed_path}'." - ) - return parent_seed - except InvalidToken: - logger.error( - "Invalid encryption key or corrupted data while decrypting parent seed." - ) - raise - except Exception as e: - logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True) - print(colored(f"Error: Failed to decrypt parent seed: {e}", "red")) raise def encrypt_data(self, data: bytes) -> bytes: """ - Encrypts the given data using Fernet. - - :param data: Data to encrypt. - :return: Encrypted data. + (2) Encrypts data using the NEW AES-GCM format, prepending a version + header and the nonce. All new data will be in this format. """ try: - encrypted_data = self.fernet.encrypt(data) - logger.debug("Data encrypted successfully.") - return encrypted_data + nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM + ciphertext = self.cipher.encrypt(nonce, data, None) + return b"V2:" + nonce + ciphertext except Exception as e: logger.error(f"Failed to encrypt data: {e}", exc_info=True) - print(colored(f"Error: Failed to encrypt data: {e}", "red")) raise def decrypt_data(self, encrypted_data: bytes) -> bytes: """ - Decrypts the provided encrypted data using the derived key. - - :param encrypted_data: The encrypted data to decrypt. - :return: The decrypted data as bytes. + (3) The core migration logic. Tries the new format first, then falls back + to the old one. This is the ONLY place decryption logic should live. """ - try: - decrypted_data = self.fernet.decrypt(encrypted_data) - logger.debug("Data decrypted successfully.") - return decrypted_data - except InvalidToken: - logger.error( - "Invalid encryption key or corrupted data while decrypting data." - ) - raise - except Exception as e: - logger.error(f"Failed to decrypt data: {e}", exc_info=True) - print(colored(f"Error: Failed to decrypt data: {e}", "red")) - raise + # Try the new V2 format first + if encrypted_data.startswith(b"V2:"): + try: + nonce = encrypted_data[3:15] + ciphertext = encrypted_data[15:] + if len(ciphertext) < 16: + logger.error("AES-GCM payload too short") + raise InvalidToken("AES-GCM payload too short") + return self.cipher.decrypt(nonce, ciphertext, None) + except InvalidTag as e: + logger.error("AES-GCM decryption failed: Invalid authentication tag.") + try: + result = self.fernet.decrypt(encrypted_data[3:]) + logger.warning( + "Legacy-format file had incorrect 'V2:' header; decrypted with Fernet" + ) + return result + except InvalidToken: + raise InvalidToken("AES-GCM decryption failed.") from e + + # If it's not V2, it must be the legacy Fernet format + else: + logger.warning("Data is in legacy Fernet format. Attempting migration.") + try: + return self.fernet.decrypt(encrypted_data) + except InvalidToken as e: + logger.error( + "Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect." + ) + raise InvalidToken( + "Could not decrypt data with any available method." + ) from e + + # --- All functions below this point now use the smart `decrypt_data` method --- + + def encrypt_parent_seed(self, parent_seed: str) -> None: + """Encrypts and saves the parent seed to 'parent_seed.enc'.""" + data = parent_seed.encode("utf-8") + encrypted_data = self.encrypt_data(data) # This now creates V2 format + with exclusive_lock(self.parent_seed_file) as fh: + fh.seek(0) + fh.truncate() + fh.write(encrypted_data) + os.chmod(self.parent_seed_file, 0o600) + logger.info(f"Parent seed encrypted and saved to '{self.parent_seed_file}'.") + + def decrypt_parent_seed(self) -> str: + """Decrypts and returns the parent seed, handling migration.""" + with exclusive_lock(self.parent_seed_file) as fh: + fh.seek(0) + encrypted_data = fh.read() + + is_legacy = not encrypted_data.startswith(b"V2:") + decrypted_data = self.decrypt_data(encrypted_data) + + if is_legacy: + logger.info("Parent seed was in legacy format. Re-encrypting to V2 format.") + self.encrypt_parent_seed(decrypted_data.decode("utf-8").strip()) + + return decrypted_data.decode("utf-8").strip() def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None: - """ - Encrypts data and saves it to a specified relative path within the fingerprint directory. - - :param data: Data to encrypt. - :param relative_path: Relative path within the fingerprint directory to save the encrypted data. - """ - try: - # Define the full path - file_path = self.fingerprint_dir / relative_path - - # Ensure the parent directories exist - file_path.parent.mkdir(parents=True, exist_ok=True) - - # Encrypt the data - encrypted_data = self.encrypt_data(data) - - # Write the encrypted data to the file with locking - with exclusive_lock(file_path) as fh: - fh.seek(0) - fh.truncate() - fh.write(encrypted_data) - fh.flush() - - # Set file permissions to read/write for the user only - os.chmod(file_path, 0o600) - - logger.info(f"Data encrypted and saved to '{file_path}'.") - print(colored(f"Data encrypted and saved to '{file_path}'.", "green")) - except Exception as e: - logger.error( - f"Failed to encrypt and save data to '{relative_path}': {e}", - exc_info=True, - ) - print( - colored( - f"Error: Failed to encrypt and save data to '{relative_path}': {e}", - "red", - ) - ) - raise + file_path = self.fingerprint_dir / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + encrypted_data = self.encrypt_data(data) + with exclusive_lock(file_path) as fh: + fh.seek(0) + fh.truncate() + fh.write(encrypted_data) + fh.flush() + os.fsync(fh.fileno()) + os.chmod(file_path, 0o600) def decrypt_file(self, relative_path: Path) -> bytes: - """ - Decrypts data from a specified relative path within the fingerprint directory. - - :param relative_path: Relative path within the fingerprint directory to decrypt the data from. - :return: Decrypted data as bytes. - """ - try: - # Define the full path - file_path = self.fingerprint_dir / relative_path - - # Read the encrypted data with locking - with exclusive_lock(file_path) as fh: - fh.seek(0) - encrypted_data = fh.read() - - # Decrypt the data - decrypted_data = self.decrypt_data(encrypted_data) - logger.debug(f"Data decrypted successfully from '{file_path}'.") - return decrypted_data - except InvalidToken: - logger.error( - "Invalid encryption key or corrupted data while decrypting file." - ) - raise - except Exception as e: - logger.error( - f"Failed to decrypt data from '{relative_path}': {e}", exc_info=True - ) - print( - colored( - f"Error: Failed to decrypt data from '{relative_path}': {e}", "red" - ) - ) - raise + file_path = self.fingerprint_dir / relative_path + with exclusive_lock(file_path) as fh: + fh.seek(0) + encrypted_data = fh.read() + return self.decrypt_data(encrypted_data) def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None: - """ - Encrypts and saves the provided JSON data to the specified relative path within the fingerprint directory. - - :param data: The JSON data to save. - :param relative_path: The relative path within the fingerprint directory where data will be saved. - Defaults to 'seedpass_entries_db.json.enc'. - """ if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") - try: - json_data = json.dumps(data, indent=4).encode("utf-8") - self.encrypt_and_save_file(json_data, relative_path) - logger.debug(f"JSON data encrypted and saved to '{relative_path}'.") - print( - colored(f"JSON data encrypted and saved to '{relative_path}'.", "green") - ) - except Exception as e: - logger.error( - f"Failed to save JSON data to '{relative_path}': {e}", exc_info=True - ) - print( - colored( - f"Error: Failed to save JSON data to '{relative_path}': {e}", "red" - ) - ) - raise + if USE_ORJSON: + json_data = json_lib.dumps(data) + else: + json_data = json_lib.dumps(data, separators=(",", ":")).encode("utf-8") + self.encrypt_and_save_file(json_data, relative_path) + logger.debug(f"JSON data encrypted and saved to '{relative_path}'.") def load_json_data(self, relative_path: Optional[Path] = None) -> dict: """ - Decrypts and loads JSON data from the specified relative path within the fingerprint directory. - - :param relative_path: The relative path within the fingerprint directory from which data will be loaded. - Defaults to 'seedpass_entries_db.json.enc'. - :return: The decrypted JSON data as a dictionary. + Loads and decrypts JSON data, automatically migrating and re-saving + if it's in the legacy format. """ if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") file_path = self.fingerprint_dir / relative_path - if not file_path.exists(): - logger.info( - f"Index file '{file_path}' does not exist. Initializing empty data." + return {"entries": {}} + + with exclusive_lock(file_path) as fh: + fh.seek(0) + encrypted_data = fh.read() + + is_legacy = not encrypted_data.startswith(b"V2:") + + try: + decrypted_data = self.decrypt_data(encrypted_data) + if USE_ORJSON: + data = json_lib.loads(decrypted_data) + else: + data = json_lib.loads(decrypted_data.decode("utf-8")) + + # If it was a legacy file, re-save it in the new format now + if is_legacy: + logger.info(f"Migrating and re-saving legacy vault file: {file_path}") + self.save_json_data(data, relative_path) + self.update_checksum(relative_path) + + return data + except (InvalidToken, InvalidTag, JSONDecodeError) as e: + logger.error( + f"FATAL: Could not decrypt or parse data from {file_path}: {e}", + exc_info=True, + ) + raise + + def get_encrypted_index(self) -> Optional[bytes]: + relative_path = Path("seedpass_entries_db.json.enc") + file_path = self.fingerprint_dir / relative_path + if not file_path.exists(): + return None + with exclusive_lock(file_path) as fh: + fh.seek(0) + return fh.read() + + def decrypt_and_save_index_from_nostr( + self, encrypted_data: bytes, relative_path: Optional[Path] = None + ) -> None: + """Decrypts data from Nostr and saves it, automatically using the new format.""" + if relative_path is None: + relative_path = Path("seedpass_entries_db.json.enc") + try: + decrypted_data = self.decrypt_data( + encrypted_data + ) # This now handles both formats + if USE_ORJSON: + data = json_lib.loads(decrypted_data) + else: + data = json_lib.loads(decrypted_data.decode("utf-8")) + self.save_json_data(data, relative_path) # This always saves in V2 format + self.update_checksum(relative_path) + logger.info("Index file from Nostr was processed and saved successfully.") + print(colored("Index file updated from Nostr successfully.", "green")) + except Exception as e: + logger.error( + f"Failed to decrypt and save data from Nostr: {e}", + exc_info=True, ) print( colored( - f"Info: Index file '{file_path}' not found. Initializing new password database.", - "yellow", + f"Error: Failed to decrypt and save data from Nostr: {e}", + "red", ) ) - return {"entries": {}} - - try: - decrypted_data = self.decrypt_file(relative_path) - json_content = decrypted_data.decode("utf-8").strip() - data = json.loads(json_content) - logger.debug(f"JSON data loaded and decrypted from '{file_path}': {data}") - return data - except json.JSONDecodeError as e: - logger.error( - f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True - ) - raise - except InvalidToken: - logger.error( - "Invalid encryption key or corrupted data while decrypting JSON data." - ) - raise - except Exception as e: - logger.error( - f"Failed to load JSON data from '{file_path}': {e}", exc_info=True - ) raise def update_checksum(self, relative_path: Optional[Path] = None) -> None: - """ - Updates the checksum file for the specified file within the fingerprint directory. - - :param relative_path: The relative path within the fingerprint directory for which the checksum will be updated. - Defaults to 'seedpass_entries_db.json.enc'. - """ + """Updates the checksum file for the specified file.""" if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") - try: - file_path = self.fingerprint_dir / relative_path - logger.debug("Calculating checksum of the encrypted file bytes.") + file_path = self.fingerprint_dir / relative_path + if not file_path.exists(): + return + + try: with exclusive_lock(file_path) as fh: fh.seek(0) encrypted_bytes = fh.read() - checksum = hashlib.sha256(encrypted_bytes).hexdigest() - logger.debug(f"New checksum: {checksum}") - checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt" - - # Write the checksum to the file with locking with exclusive_lock(checksum_file) as fh: fh.seek(0) fh.truncate() fh.write(checksum.encode("utf-8")) fh.flush() - - # Set file permissions to read/write for the user only + os.fsync(fh.fileno()) os.chmod(checksum_file, 0o600) - - logger.debug( - f"Checksum for '{file_path}' updated and written to '{checksum_file}'." - ) - print(colored(f"Checksum for '{file_path}' updated.", "green")) except Exception as e: logger.error( - f"Failed to update checksum for '{relative_path}': {e}", exc_info=True - ) - print( - colored( - f"Error: Failed to update checksum for '{relative_path}': {e}", - "red", - ) - ) - raise - - def get_encrypted_index(self) -> Optional[bytes]: - """ - Retrieves the encrypted password index file content. - - :return: Encrypted data as bytes or None if the index file does not exist. - """ - try: - relative_path = Path("seedpass_entries_db.json.enc") - if not (self.fingerprint_dir / relative_path).exists(): - # Missing index is normal on first run - logger.info( - f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'." - ) - return None - - file_path = self.fingerprint_dir / relative_path - with exclusive_lock(file_path) as fh: - fh.seek(0) - encrypted_data = fh.read() - - logger.debug(f"Encrypted index data read from '{relative_path}'.") - return encrypted_data - except Exception as e: - logger.error( - f"Failed to read encrypted index file '{relative_path}': {e}", + f"Failed to update checksum for '{relative_path}': {e}", exc_info=True, ) - print( - colored( - f"Error: Failed to read encrypted index file '{relative_path}': {e}", - "red", - ) - ) - return None - - def decrypt_and_save_index_from_nostr( - self, encrypted_data: bytes, relative_path: Optional[Path] = None - ) -> None: - """ - Decrypts the encrypted data retrieved from Nostr and updates the local index file. - - :param encrypted_data: The encrypted data retrieved from Nostr. - :param relative_path: The relative path within the fingerprint directory to update. - Defaults to 'seedpass_entries_db.json.enc'. - """ - if relative_path is None: - relative_path = Path("seedpass_entries_db.json.enc") - try: - decrypted_data = self.decrypt_data(encrypted_data) - data = json.loads(decrypted_data.decode("utf-8")) - self.save_json_data(data, relative_path) - self.update_checksum(relative_path) - logger.info("Index file updated from Nostr successfully.") - print(colored("Index file updated from Nostr successfully.", "green")) - except Exception as e: - logger.error( - f"Failed to decrypt and save data from Nostr: {e}", exc_info=True - ) - print( - colored( - f"Error: Failed to decrypt and save data from Nostr: {e}", "red" - ) - ) - # Re-raise the exception to inform the calling function of the failure raise + # ... validate_seed and derive_seed_from_mnemonic can remain the same ... def validate_seed(self, seed_phrase: str) -> bool: - """ - Validates the seed phrase format using BIP-39 standards. - - :param seed_phrase: The BIP39 seed phrase to validate. - :return: True if valid, False otherwise. - """ try: words = seed_phrase.split() if len(words) != 12: logger.error("Seed phrase does not contain exactly 12 words.") print( - colored("Error: Seed phrase must contain exactly 12 words.", "red") + colored( + "Error: Seed phrase must contain exactly 12 words.", + "red", + ) ) return False - # Additional validation can be added here (e.g., word list checks) logger.debug("Seed phrase validated successfully.") return True except Exception as e: @@ -457,13 +303,6 @@ class EncryptionManager: return False def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes: - """ - Derives a cryptographic seed from a BIP39 mnemonic (seed phrase). - - :param mnemonic: The BIP39 mnemonic phrase. - :param passphrase: An optional passphrase for additional security. - :return: The derived seed as bytes. - """ try: if not isinstance(mnemonic, str): if isinstance(mnemonic, list): diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index b8c9bcb..0aaed6a 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -15,7 +15,14 @@ completely deterministic passwords from a BIP-85 seed, ensuring that passwords a the same way every time. Salts would break this functionality and are not suitable for this software. """ -import json +try: + import orjson as json_lib # type: ignore + + USE_ORJSON = True +except Exception: # pragma: no cover - fallback when orjson is missing + import json as json_lib + + USE_ORJSON = False import logging import hashlib import sys @@ -28,6 +35,7 @@ from password_manager.migrations import LATEST_VERSION from password_manager.entry_types import EntryType from password_manager.totp import TotpManager from utils.fingerprint import generate_fingerprint +from utils.checksum import canonical_json_dumps from password_manager.vault import Vault from password_manager.backup import BackupManager @@ -53,9 +61,18 @@ class EntryManager: self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" self.checksum_file = self.fingerprint_dir / "seedpass_entries_db_checksum.txt" + self._index_cache: dict | None = None + logger.debug(f"EntryManager initialized with index file at {self.index_file}") - def _load_index(self) -> Dict[str, Any]: + def clear_cache(self) -> None: + """Clear the cached index data.""" + self._index_cache = None + + def _load_index(self, force_reload: bool = False) -> Dict[str, Any]: + if not force_reload and self._index_cache is not None: + return self._index_cache + if self.index_file.exists(): try: data = self.vault.load_index() @@ -81,6 +98,7 @@ class EntryManager: entry.pop("words", None) entry.setdefault("tags", []) logger.debug("Index loaded successfully.") + self._index_cache = data return data except Exception as e: logger.error(f"Failed to load index: {e}") @@ -89,11 +107,14 @@ class EntryManager: logger.info( f"Index file '{self.index_file}' not found. Initializing new entries database." ) - return {"schema_version": LATEST_VERSION, "entries": {}} + data = {"schema_version": LATEST_VERSION, "entries": {}} + self._index_cache = data + return data def _save_index(self, data: Dict[str, Any]) -> None: try: self.vault.save_index(data) + self._index_cache = data logger.debug("Index saved successfully.") except Exception as e: logger.error(f"Failed to save index: {e}") @@ -106,7 +127,7 @@ class EntryManager: :return: The next index number as an integer. """ try: - data = self.vault.load_index() + data = self._load_index() if "entries" in data and isinstance(data["entries"], dict): indices = [int(idx) for idx in data["entries"].keys()] next_index = max(indices) + 1 if indices else 0 @@ -143,7 +164,7 @@ class EntryManager: """ try: index = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { @@ -177,7 +198,7 @@ class EntryManager: def get_next_totp_index(self) -> int: """Return the next available derivation index for TOTP secrets.""" - data = self.vault.load_index() + data = self._load_index() entries = data.get("entries", {}) indices = [ int(v.get("index", 0)) @@ -204,7 +225,7 @@ class EntryManager: ) -> str: """Add a new TOTP entry and return the provisioning URI.""" entry_id = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) if secret is None: @@ -266,7 +287,7 @@ class EntryManager: if index is None: index = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.SSH.value, @@ -312,7 +333,7 @@ class EntryManager: if index is None: index = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.PGP.value, @@ -364,7 +385,7 @@ class EntryManager: if index is None: index = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.NOSTR.value, @@ -394,7 +415,7 @@ class EntryManager: index = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.KEY_VALUE.value, @@ -452,7 +473,7 @@ class EntryManager: if index is None: index = self.get_next_index() - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.SEED.value, @@ -524,7 +545,7 @@ class EntryManager: account_dir = self.fingerprint_dir / "accounts" / fingerprint account_dir.mkdir(parents=True, exist_ok=True) - data = self.vault.load_index() + data = self._load_index() data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.MANAGED_ACCOUNT.value, @@ -599,7 +620,7 @@ class EntryManager: def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]: """Return all TOTP secrets and metadata for external use.""" - data = self.vault.load_index() + data = self._load_index() entries = data.get("entries", {}) exported: list[dict[str, Any]] = [] for entry in entries.values(): @@ -649,7 +670,7 @@ class EntryManager: :return: A dictionary containing the entry details or None if not found. """ try: - data = self.vault.load_index() + data = self._load_index() entry = data.get("entries", {}).get(str(index)) if entry: @@ -706,7 +727,7 @@ class EntryManager: :param value: (Optional) New value for key/value entries. """ try: - data = self.vault.load_index() + data = self._load_index() entry = data.get("entries", {}).get(str(index)) if not entry: @@ -723,6 +744,93 @@ class EntryManager: entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + provided_fields = { + "username": username, + "url": url, + "archived": archived, + "notes": notes, + "label": label, + "period": period, + "digits": digits, + "value": value, + "custom_fields": custom_fields, + "tags": tags, + } + + allowed = { + EntryType.PASSWORD.value: { + "username", + "url", + "label", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.TOTP.value: { + "label", + "period", + "digits", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.KEY_VALUE.value: { + "label", + "value", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.MANAGED_ACCOUNT.value: { + "label", + "value", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.SSH.value: { + "label", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.PGP.value: { + "label", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.NOSTR.value: { + "label", + "archived", + "notes", + "custom_fields", + "tags", + }, + EntryType.SEED.value: { + "label", + "archived", + "notes", + "custom_fields", + "tags", + }, + } + + allowed_fields = allowed.get(entry_type, set()) + invalid = { + k for k, v in provided_fields.items() if v is not None + } - allowed_fields + if invalid: + raise ValueError( + f"Entry type '{entry_type}' does not support fields: {', '.join(sorted(invalid))}" + ) + if entry_type == EntryType.TOTP.value: if label is not None: entry["label"] = label @@ -796,6 +904,7 @@ class EntryManager: print( colored(f"Error: Failed to modify entry at index {index}: {e}", "red") ) + raise def archive_entry(self, index: int) -> None: """Mark the specified entry as archived.""" @@ -818,7 +927,7 @@ class EntryManager: ``True``. """ try: - data = self.vault.load_index() + data = self._load_index() entries_data = data.get("entries", {}) if not entries_data: @@ -929,7 +1038,7 @@ class EntryManager: self, query: str ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: """Return entries matching the query across common fields.""" - data = self.vault.load_index() + data = self._load_index() entries_data = data.get("entries", {}) if not entries_data: @@ -1018,11 +1127,11 @@ class EntryManager: :param index: The index number of the entry to delete. """ try: - data = self.vault.load_index() + data = self._load_index() if "entries" in data and str(index) in data["entries"]: del data["entries"][str(index)] logger.debug(f"Deleted entry at index {index}.") - self.vault.save_index(data) + self._save_index(data) self.update_checksum() self.backup_manager.create_backup() logger.info(f"Entry at index {index} deleted successfully.") @@ -1053,9 +1162,9 @@ class EntryManager: Updates the checksum file for the password database to ensure data integrity. """ try: - data = self.vault.load_index() - json_content = json.dumps(data, indent=4) - checksum = hashlib.sha256(json_content.encode("utf-8")).hexdigest() + data = self._load_index() + canonical = canonical_json_dumps(data) + checksum = hashlib.sha256(canonical.encode("utf-8")).hexdigest() # The checksum file path already includes the fingerprint directory checksum_path = self.checksum_file @@ -1099,6 +1208,7 @@ class EntryManager: ) ) + self.clear_cache() self.update_checksum() except Exception as e: @@ -1152,7 +1262,7 @@ class EntryManager: ) -> list[tuple[int, str, str]]: """Return a list of entry index, type, and display labels.""" try: - data = self.vault.load_index() + data = self._load_index() entries_data = data.get("entries", {}) summaries: list[tuple[int, str, str]] = [] diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 56a151a..874c598 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -19,6 +19,9 @@ from typing import Optional import shutil import time import builtins +import threading +import queue +from dataclasses import dataclass from termcolor import colored from utils.color_scheme import color_text from utils.input_utils import timed_input @@ -34,6 +37,7 @@ from password_manager.entry_types import EntryType from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, + derive_key_from_password_argon2, derive_index_key, EncryptionMode, ) @@ -55,11 +59,12 @@ from utils.clipboard import copy_to_clipboard from utils.terminal_utils import ( clear_screen, pause, - clear_and_print_fingerprint, clear_and_print_profile_chain, + clear_header_with_notification, ) from utils.fingerprint import generate_fingerprint from constants import MIN_HEALTHY_RELAYS +from password_manager.migrations import LATEST_VERSION from constants import ( APP_DIR, @@ -70,6 +75,7 @@ from constants import ( DEFAULT_PASSWORD_LENGTH, INACTIVITY_TIMEOUT, DEFAULT_SEED_BACKUP_FILENAME, + NOTIFICATION_DURATION, initialize_app, ) @@ -93,6 +99,14 @@ from password_manager.config_manager import ConfigManager logger = logging.getLogger(__name__) +@dataclass +class Notification: + """Simple message container for UI notifications.""" + + message: str + level: str = "INFO" + + class PasswordManager: """ PasswordManager Class @@ -102,8 +116,14 @@ class PasswordManager: verification, ensuring the integrity and confidentiality of the stored password database. """ - def __init__(self) -> None: - """Initialize the PasswordManager.""" + def __init__(self, fingerprint: Optional[str] = None) -> None: + """Initialize the PasswordManager. + + Parameters + ---------- + fingerprint: + Optional seed profile fingerprint to select without prompting. + """ initialize_app() self.ensure_script_checksum() self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY @@ -117,6 +137,9 @@ class PasswordManager: self.bip85: Optional[BIP85] = None self.nostr_client: Optional[NostrClient] = None self.config_manager: Optional[ConfigManager] = None + self.notifications: queue.Queue[Notification] = queue.Queue() + self._current_notification: Optional[Notification] = None + self._notification_expiry: float = 0.0 # Track changes to trigger periodic Nostr sync self.is_dirty: bool = False @@ -126,16 +149,24 @@ class PasswordManager: self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.secret_mode_enabled: bool = False self.clipboard_clear_delay: int = 45 + self.offline_mode: bool = False self.profile_stack: list[tuple[str, Path, str]] = [] + self.last_unlock_duration: float | None = None + self.verbose_timing: bool = False # Initialize the fingerprint manager first self.initialize_fingerprint_manager() - # Ensure a parent seed is set up before accessing the fingerprint directory - self.setup_parent_seed() - - # Set the current fingerprint directory - self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir() + if fingerprint: + # Load the specified profile without prompting + self.select_fingerprint(fingerprint) + else: + # Ensure a parent seed is set up before accessing the fingerprint directory + self.setup_parent_seed() + # Set the current fingerprint directory after selection + self.fingerprint_dir = ( + self.fingerprint_manager.get_current_fingerprint_dir() + ) def ensure_script_checksum(self) -> None: """Initialize or verify the checksum of the manager script.""" @@ -199,8 +230,32 @@ class PasswordManager: """Record the current time as the last user activity.""" self.last_activity = time.time() + def notify(self, message: str, level: str = "INFO") -> None: + """Enqueue a notification and set it as the active message.""" + note = Notification(message, level) + self.notifications.put(note) + self._current_notification = note + self._notification_expiry = time.time() + NOTIFICATION_DURATION + + def get_current_notification(self) -> Optional[Notification]: + """Return the active notification if it hasn't expired.""" + if not self.notifications.empty(): + latest = self.notifications.queue[-1] + if latest is not self._current_notification: + self._current_notification = latest + self._notification_expiry = time.time() + NOTIFICATION_DURATION + + if ( + self._current_notification is not None + and time.time() < self._notification_expiry + ): + return self._current_notification + return None + def lock_vault(self) -> None: """Clear sensitive information from memory.""" + if self.entry_manager is not None: + self.entry_manager.clear_cache() self.parent_seed = None self.encryption_manager = None self.entry_manager = None @@ -214,6 +269,7 @@ class PasswordManager: def unlock_vault(self) -> None: """Prompt for password and reinitialize managers.""" + start = time.perf_counter() if not self.fingerprint_dir: raise ValueError("Fingerprint directory not set") self.setup_encryption_manager(self.fingerprint_dir) @@ -221,7 +277,15 @@ class PasswordManager: self.initialize_managers() self.locked = False self.update_activity() - self.sync_index_from_nostr() + self.last_unlock_duration = time.perf_counter() - start + print( + colored( + f"Vault unlocked in {self.last_unlock_duration:.2f} seconds", + "yellow", + ) + ) + if getattr(self, "verbose_timing", False): + logger.info("Vault unlocked in %.2f seconds", self.last_unlock_duration) def initialize_fingerprint_manager(self): """ @@ -254,10 +318,18 @@ class PasswordManager: Prompts the user to select an existing fingerprint or add a new one. """ try: - print(colored("\nAvailable Seed Profiles:", "cyan")) fingerprints = self.fingerprint_manager.list_fingerprints() + current = self.fingerprint_manager.current_fingerprint + + # Auto-select when only one fingerprint exists + if len(fingerprints) == 1: + self.select_fingerprint(fingerprints[0]) + return + + print(colored("\nAvailable Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): - print(colored(f"{idx}. {fp}", "cyan")) + marker = " *" if fp == current else "" + print(colored(f"{idx}. {fp}{marker}", "cyan")) print(colored(f"{len(fingerprints)+1}. Add a new seed profile", "cyan")) @@ -330,7 +402,6 @@ class PasswordManager: # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr() print( colored( f"Seed profile {fingerprint} selected and managers initialized.", @@ -350,44 +421,69 @@ class PasswordManager: ) -> bool: """Set up encryption for the current fingerprint and load the seed.""" - try: - if password is None: - password = prompt_existing_password("Enter your master password: ") - - seed_key = derive_key_from_password(password) - seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + attempts = 0 + max_attempts = 5 + while attempts < max_attempts: try: - self.parent_seed = seed_mgr.decrypt_parent_seed() - except Exception: - msg = "Invalid password for selected seed profile." - print(colored(msg, "red")) + if password is None: + password = prompt_existing_password("Enter your master password: ") + + mode = ( + self.config_manager.get_kdf_mode() + if getattr(self, "config_manager", None) + else "pbkdf2" + ) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 50_000 + ) + print("Deriving key...") + if mode == "argon2": + seed_key = derive_key_from_password_argon2(password) + else: + seed_key = derive_key_from_password(password, iterations=iterations) + seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + print("Decrypting seed...") + try: + self.parent_seed = seed_mgr.decrypt_parent_seed() + except Exception: + msg = ( + "Invalid password for selected seed profile. Please try again." + ) + print(colored(msg, "red")) + attempts += 1 + password = None + continue + + key = derive_index_key(self.parent_seed) + + self.encryption_manager = EncryptionManager(key, fingerprint_dir) + self.vault = Vault(self.encryption_manager, fingerprint_dir) + + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) + + self.fingerprint_dir = fingerprint_dir + if not self.verify_password(password): + print(colored("Invalid password. Please try again.", "red")) + attempts += 1 + password = None + continue + return True + except KeyboardInterrupt: + raise + except Exception as e: + logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) + print(colored(f"Error: Failed to set up encryption: {e}", "red")) if exit_on_fail: sys.exit(1) return False - - key = derive_index_key(self.parent_seed) - - self.encryption_manager = EncryptionManager(key, fingerprint_dir) - self.vault = Vault(self.encryption_manager, fingerprint_dir) - - self.config_manager = ConfigManager( - vault=self.vault, - fingerprint_dir=fingerprint_dir, - ) - - self.fingerprint_dir = fingerprint_dir - if not self.verify_password(password): - print(colored("Invalid password.", "red")) - if exit_on_fail: - sys.exit(1) - return False - return True - except Exception as e: - logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) - print(colored(f"Error: Failed to set up encryption: {e}", "red")) - if exit_on_fail: - sys.exit(1) - return False + if exit_on_fail: + sys.exit(1) + return False def load_parent_seed( self, fingerprint_dir: Path, password: Optional[str] = None @@ -401,7 +497,20 @@ class PasswordManager: password = prompt_existing_password("Enter your master password: ") try: - seed_key = derive_key_from_password(password) + mode = ( + self.config_manager.get_kdf_mode() + if getattr(self, "config_manager", None) + else "pbkdf2" + ) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 50_000 + ) + if mode == "argon2": + seed_key = derive_key_from_password_argon2(password) + else: + seed_key = derive_key_from_password(password, iterations=iterations) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) self.parent_seed = seed_mgr.decrypt_parent_seed() seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() @@ -460,7 +569,7 @@ class PasswordManager: # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr() + self.start_background_sync() print(colored(f"Switched to seed profile {selected_fingerprint}.", "green")) # Re-initialize NostrClient with the new fingerprint @@ -468,6 +577,7 @@ class PasswordManager: self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, + config_manager=getattr(self, "config_manager", None), parent_seed=getattr(self, "parent_seed", None), ) logging.info( @@ -513,7 +623,7 @@ class PasswordManager: self.initialize_managers() self.locked = False self.update_activity() - self.sync_index_from_nostr_if_missing() + self.start_background_sync() def exit_managed_account(self) -> None: """Return to the parent seed profile if one is on the stack.""" @@ -533,7 +643,7 @@ class PasswordManager: self.initialize_managers() self.locked = False self.update_activity() - self.sync_index_from_nostr() + self.start_background_sync() def handle_existing_seed(self) -> None: """ @@ -545,7 +655,12 @@ class PasswordManager: password = getpass.getpass(prompt="Enter your login password: ").strip() # Derive encryption key from password - key = derive_key_from_password(password) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 50_000 + ) + key = derive_key_from_password(password, iterations=iterations) # Initialize FingerprintManager if not already initialized if not self.fingerprint_manager: @@ -608,7 +723,7 @@ class PasswordManager: Handles the setup process when no existing parent seed is found. Asks the user whether to enter an existing BIP-85 seed or generate a new one. """ - print(colored("No existing seed found. Let's set up a new one!", "yellow")) + self.notify("No existing seed found. Let's set up a new one!", level="WARNING") choice = input( "Do you want to (1) Enter an existing BIP-85 seed or (2) Generate a new BIP-85 seed? (1/2): " @@ -662,45 +777,57 @@ class PasswordManager: self.fingerprint_dir = fingerprint_dir logging.info(f"Current seed profile set to {fingerprint}") - # Initialize EncryptionManager with key and fingerprint_dir - password = prompt_for_password() - index_key = derive_index_key(parent_seed) - seed_key = derive_key_from_password(password) + try: + # Initialize EncryptionManager with key and fingerprint_dir + password = prompt_for_password() + index_key = derive_index_key(parent_seed) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 50_000 + ) + seed_key = derive_key_from_password(password, iterations=iterations) - self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) - seed_mgr = EncryptionManager(seed_key, fingerprint_dir) - self.vault = Vault(self.encryption_manager, fingerprint_dir) + self.encryption_manager = EncryptionManager( + index_key, fingerprint_dir + ) + seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + self.vault = Vault(self.encryption_manager, fingerprint_dir) - # Ensure config manager is set for the new fingerprint - self.config_manager = ConfigManager( - vault=self.vault, - fingerprint_dir=fingerprint_dir, - ) + # Ensure config manager is set for the new fingerprint + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) - # Encrypt and save the parent seed - seed_mgr.encrypt_parent_seed(parent_seed) - logging.info("Parent seed encrypted and saved successfully.") + # Encrypt and save the parent seed + seed_mgr.encrypt_parent_seed(parent_seed) + logging.info("Parent seed encrypted and saved successfully.") - # Store the hashed password - self.store_hashed_password(password) - logging.info("User password hashed and stored successfully.") + # Store the hashed password + self.store_hashed_password(password) + logging.info("User password hashed and stored successfully.") - self.parent_seed = parent_seed # Ensure this is a string - logger.debug( - f"parent_seed set to: {self.parent_seed} (type: {type(self.parent_seed)})" - ) + self.parent_seed = parent_seed # Ensure this is a string + logger.debug( + f"parent_seed set to: {self.parent_seed} (type: {type(self.parent_seed)})" + ) - self.initialize_bip85() - self.initialize_managers() - self.sync_index_from_nostr() - return fingerprint # Return the generated or added fingerprint + self.initialize_bip85() + self.initialize_managers() + self.start_background_sync() + return fingerprint # Return the generated or added fingerprint + except BaseException: + # Clean up partial profile on failure or interruption + self.fingerprint_manager.remove_fingerprint(fingerprint) + raise else: logging.error("Invalid BIP-85 seed phrase. Exiting.") print(colored("Error: Invalid BIP-85 seed phrase.", "red")) sys.exit(1) except KeyboardInterrupt: logging.info("Operation cancelled by user.") - print(colored("\nOperation cancelled by user.", "yellow")) + self.notify("Operation cancelled by user.", level="WARNING") sys.exit(0) def generate_new_seed(self) -> Optional[str]: @@ -742,11 +869,17 @@ class PasswordManager: logging.info(f"Current seed profile set to {fingerprint}") # Now, save and encrypt the seed with the fingerprint_dir - self.save_and_encrypt_seed(new_seed, fingerprint_dir) + try: + self.save_and_encrypt_seed(new_seed, fingerprint_dir) + self.start_background_sync() + except BaseException: + # Clean up partial profile on failure or interruption + self.fingerprint_manager.remove_fingerprint(fingerprint) + raise return fingerprint # Return the generated fingerprint else: - print(colored("Seed generation cancelled. Exiting.", "yellow")) + self.notify("Seed generation cancelled. Exiting.", level="WARNING") sys.exit(0) def validate_bip85_seed(self, seed: str) -> bool: @@ -806,7 +939,12 @@ class PasswordManager: password = prompt_for_password() index_key = derive_index_key(seed) - seed_key = derive_key_from_password(password) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 50_000 + ) + seed_key = derive_key_from_password(password, iterations=iterations) self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) @@ -833,7 +971,6 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr() except Exception as e: logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True) print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red")) @@ -880,35 +1017,30 @@ class PasswordManager: encryption_manager=self.encryption_manager, parent_seed=self.parent_seed, bip85=self.bip85, + policy=self.config_manager.get_password_policy(), ) # Load relay configuration and initialize NostrClient config = self.config_manager.load_config() relay_list = config.get("relays", list(DEFAULT_RELAYS)) + self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT ) self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False)) self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45)) - + self.verbose_timing = bool(config.get("verbose_timing", False)) + if not self.offline_mode: + print("Connecting to relays...") self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, relays=relay_list, + offline_mode=self.offline_mode, + config_manager=self.config_manager, parent_seed=getattr(self, "parent_seed", None), ) - if hasattr(self.nostr_client, "check_relay_health"): - healthy = self.nostr_client.check_relay_health(MIN_HEALTHY_RELAYS) - if healthy < MIN_HEALTHY_RELAYS: - print( - colored( - f"Only {healthy} relay(s) responded with your latest event." - " Consider adding more relays via Settings.", - "yellow", - ) - ) - logger.debug("Managers re-initialized for the new fingerprint.") except Exception as e: @@ -918,6 +1050,7 @@ class PasswordManager: def sync_index_from_nostr(self) -> None: """Always fetch the latest vault data from Nostr and update the local index.""" + start = time.perf_counter() try: result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) if not result: @@ -925,49 +1058,127 @@ class PasswordManager: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: - try: - version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) - if deltas: - encrypted = deltas[-1] - except ValueError: - pass + version = int(manifest.delta_since) + deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + if deltas: + encrypted = deltas[-1] current = self.vault.get_encrypted_index() if current != encrypted: self.vault.decrypt_and_save_index_from_nostr(encrypted) logger.info("Local database synchronized from Nostr.") except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") + finally: + if getattr(self, "verbose_timing", False): + duration = time.perf_counter() - start + logger.info("sync_index_from_nostr completed in %.2f seconds", duration) + + def start_background_sync(self) -> None: + """Launch a thread to synchronize the vault without blocking the UI.""" + if getattr(self, "offline_mode", False): + return + if ( + hasattr(self, "_sync_thread") + and self._sync_thread + and self._sync_thread.is_alive() + ): + return + + def _worker() -> None: + try: + if hasattr(self, "nostr_client") and hasattr(self, "vault"): + self.sync_index_from_nostr_if_missing() + if hasattr(self, "sync_index_from_nostr"): + self.sync_index_from_nostr() + except Exception as exc: + logger.warning(f"Background sync failed: {exc}") + + self._sync_thread = threading.Thread(target=_worker, daemon=True) + self._sync_thread.start() + + def start_background_relay_check(self) -> None: + """Check relay health in a background thread.""" + if ( + hasattr(self, "_relay_thread") + and self._relay_thread + and self._relay_thread.is_alive() + ): + return + + def _worker() -> None: + try: + if getattr(self, "nostr_client", None) and hasattr( + self.nostr_client, "check_relay_health" + ): + healthy = self.nostr_client.check_relay_health(MIN_HEALTHY_RELAYS) + if healthy < MIN_HEALTHY_RELAYS: + self.notify( + f"Only {healthy} relay(s) responded with your latest event. " + "Consider adding more relays via Settings.", + level="WARNING", + ) + except Exception as exc: + logger.warning(f"Relay health check failed: {exc}") + + self._relay_thread = threading.Thread(target=_worker, daemon=True) + self._relay_thread.start() + + def start_background_vault_sync(self, alt_summary: str | None = None) -> None: + """Publish the vault to Nostr in a background thread.""" + if getattr(self, "offline_mode", False): + return + + def _worker() -> None: + try: + self.sync_vault(alt_summary=alt_summary) + except Exception as exc: + logging.error(f"Background vault sync failed: {exc}", exc_info=True) + + threading.Thread(target=_worker, daemon=True).start() def sync_index_from_nostr_if_missing(self) -> None: - """Retrieve the password database from Nostr if it doesn't exist locally.""" + """Retrieve the password database from Nostr if it doesn't exist locally. + + If no valid data is found or decryption fails, initialize a fresh local + database and publish it to Nostr. + """ index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" if index_file.exists(): return + have_data = False try: result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: - try: - version = int(manifest.delta_since) - deltas = asyncio.run( - self.nostr_client.fetch_deltas_since(version) - ) - if deltas: - encrypted = deltas[-1] - except ValueError: - pass - self.vault.decrypt_and_save_index_from_nostr(encrypted) - logger.info("Initialized local database from Nostr.") + version = int(manifest.delta_since) + deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + if deltas: + encrypted = deltas[-1] + try: + self.vault.decrypt_and_save_index_from_nostr(encrypted) + logger.info("Initialized local database from Nostr.") + have_data = True + except Exception as err: + logger.warning( + f"Failed to decrypt Nostr data: {err}; treating as new account." + ) except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") + if not have_data: + self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}}) + try: + self.sync_vault() + except Exception as exc: # pragma: no cover - best effort + logger.warning(f"Unable to publish fresh database: {exc}") + def handle_add_password(self) -> None: try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Password", parent_fingerprint=parent_fp, @@ -1049,7 +1260,7 @@ class PasswordManager: # Automatically push the updated encrypted index to Nostr so the # latest changes are backed up remotely. try: - self.sync_vault() + self.start_background_vault_sync() logging.info("Encrypted index posted to Nostr after entry addition.") except Exception as nostr_error: logging.error( @@ -1067,13 +1278,14 @@ class PasswordManager: """Add a TOTP entry either derived from the seed or imported.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( - fp, - "Main Menu > Add Entry > 2FA (TOTP)", - parent_fingerprint=parent_fp, - child_fingerprint=child_fp, - ) while True: + clear_header_with_notification( + self, + fp, + "Main Menu > Add Entry > 2FA (TOTP)", + parent_fingerprint=parent_fp, + child_fingerprint=child_fp, + ) print("\nAdd TOTP:") print("1. Make 2FA (derive from seed)") print("2. Import 2FA (paste otpauth URI or secret)") @@ -1123,7 +1335,7 @@ class PasswordManager: TotpManager.print_qr_code(uri) print(color_text(f"Secret: {secret}\n", "deterministic")) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1172,7 +1384,7 @@ class PasswordManager: ) TotpManager.print_qr_code(uri) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1195,7 +1407,8 @@ class PasswordManager: """Add an SSH key pair entry and display the derived keys.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > SSH Key", parent_fingerprint=parent_fp, @@ -1224,7 +1437,7 @@ class PasswordManager: if not confirm_action( "WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): " ): - print(colored("SSH key display cancelled.", "yellow")) + self.notify("SSH key display cancelled.", level="WARNING") return print(colored(f"\n[+] SSH key entry added with ID {index}.\n", "green")) @@ -1235,7 +1448,7 @@ class PasswordManager: print(colored("Private Key:", "cyan")) print(color_text(priv_pem, "deterministic")) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1251,7 +1464,8 @@ class PasswordManager: """Add a derived BIP-39 seed phrase entry.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Seed Phrase", parent_fingerprint=parent_fp, @@ -1283,7 +1497,7 @@ class PasswordManager: if not confirm_action( "WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): " ): - print(colored("Seed phrase display cancelled.", "yellow")) + self.notify("Seed phrase display cancelled.", level="WARNING") return print( @@ -1303,7 +1517,7 @@ class PasswordManager: TotpManager.print_qr_code(encode_seedqr(phrase)) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1319,7 +1533,8 @@ class PasswordManager: """Add a PGP key entry and display the generated key.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > PGP Key", parent_fingerprint=parent_fp, @@ -1358,7 +1573,7 @@ class PasswordManager: if not confirm_action( "WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): " ): - print(colored("PGP key display cancelled.", "yellow")) + self.notify("PGP key display cancelled.", level="WARNING") return print(colored(f"\n[+] PGP key entry added with ID {index}.\n", "green")) @@ -1369,7 +1584,7 @@ class PasswordManager: print(colored(f"Fingerprint: {fingerprint}", "cyan")) print(color_text(priv_key, "deterministic")) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: # pragma: no cover - best effort logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1385,7 +1600,8 @@ class PasswordManager: """Add a Nostr key entry and display the derived keys.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Nostr Key Pair", parent_fingerprint=parent_fp, @@ -1425,7 +1641,7 @@ class PasswordManager: ): TotpManager.print_qr_code(nsec) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: # pragma: no cover - best effort logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1441,7 +1657,8 @@ class PasswordManager: """Add a generic key/value entry.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Key/Value", parent_fingerprint=parent_fp, @@ -1500,7 +1717,7 @@ class PasswordManager: else: print(color_text(f"Value: {value}", "deterministic")) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: # pragma: no cover - best effort logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1516,7 +1733,8 @@ class PasswordManager: """Add a managed account seed entry.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Managed Account", parent_fingerprint=parent_fp, @@ -1561,7 +1779,7 @@ class PasswordManager: TotpManager.print_qr_code(encode_seedqr(seed)) try: - self.sync_vault() + self.start_background_vault_sync() except Exception as nostr_error: # pragma: no cover - best effort logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1613,7 +1831,16 @@ class PasswordManager: def _entry_actions_menu(self, index: int, entry: dict) -> None: """Provide actions for a retrieved entry.""" while True: + fp, parent_fp, child_fp = self.header_fingerprint_args + clear_header_with_notification( + self, + fp, + "Entry Actions", + parent_fingerprint=parent_fp, + child_fingerprint=child_fp, + ) archived = entry.get("archived", entry.get("blacklisted", False)) + entry_type = entry.get("type", EntryType.PASSWORD.value) print(colored("\n[+] Entry Actions:", "green")) if archived: print(colored("U. Unarchive", "cyan")) @@ -1624,7 +1851,12 @@ class PasswordManager: print(colored("H. Add Hidden Field", "cyan")) print(colored("E. Edit", "cyan")) print(colored("T. Edit Tags", "cyan")) - print(colored("Q. Show QR codes", "cyan")) + if entry_type in { + EntryType.SEED.value, + EntryType.MANAGED_ACCOUNT.value, + EntryType.NOSTR.value, + }: + print(colored("Q. Show QR codes", "cyan")) choice = ( input("Select an action or press Enter to return: ").strip().lower() @@ -1692,6 +1924,14 @@ class PasswordManager: """Sub-menu for editing common entry fields.""" entry_type = entry.get("type", EntryType.PASSWORD.value) while True: + fp, parent_fp, child_fp = self.header_fingerprint_args + clear_header_with_notification( + self, + fp, + "Edit Entry", + parent_fingerprint=parent_fp, + child_fingerprint=child_fp, + ) print(colored("\n[+] Edit Menu:", "green")) print(colored("L. Edit Label", "cyan")) if entry_type == EntryType.PASSWORD.value: @@ -1761,6 +2001,14 @@ class PasswordManager: if entry_type == EntryType.NOSTR.value: while True: + fp, parent_fp, child_fp = self.header_fingerprint_args + clear_header_with_notification( + self, + fp, + "QR Codes", + parent_fingerprint=parent_fp, + child_fingerprint=child_fp, + ) print(colored("\n[+] QR Codes:", "green")) print(colored("P. Public key", "cyan")) print(colored("K. Private key", "cyan")) @@ -1787,7 +2035,7 @@ class PasswordManager: entry = self.entry_manager.retrieve_entry(index) or entry return - print(colored("No QR codes available for this entry.", "yellow")) + self.notify("No QR codes available for this entry.", level="WARNING") except Exception as e: # pragma: no cover - best effort logging.error(f"Error displaying QR menu: {e}", exc_info=True) print(colored(f"Error: Failed to display QR codes: {e}", "red")) @@ -1799,7 +2047,8 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Retrieve Entry", parent_fingerprint=parent_fp, @@ -1887,7 +2136,7 @@ class PasswordManager: if not confirm_action( "WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): " ): - print(colored("SSH key display cancelled.", "yellow")) + self.notify("SSH key display cancelled.", level="WARNING") return try: priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( @@ -1926,7 +2175,7 @@ class PasswordManager: if not confirm_action( "WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): " ): - print(colored("Seed phrase display cancelled.", "yellow")) + self.notify("Seed phrase display cancelled.", level="WARNING") return try: phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) @@ -1977,7 +2226,7 @@ class PasswordManager: if not confirm_action( "WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): " ): - print(colored("PGP key display cancelled.", "yellow")) + self.notify("PGP key display cancelled.", level="WARNING") return try: priv_key, fingerprint = self.entry_manager.get_pgp_key( @@ -2169,11 +2418,9 @@ class PasswordManager: if url: print(colored(f"URL: {url}", "cyan")) if blacklisted: - print( - colored( - f"Warning: This password is archived and should not be used.", - "yellow", - ) + self.notify( + "Warning: This password is archived and should not be used.", + level="WARNING", ) password = self.password_generator.generate_password(length, index) @@ -2252,7 +2499,8 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Modify Entry", parent_fingerprint=parent_fp, @@ -2306,8 +2554,9 @@ class PasswordManager: if period_input.isdigit(): new_period = int(period_input) else: - print( - colored("Invalid period value. Keeping current.", "yellow") + self.notify( + "Invalid period value. Keeping current.", + level="WARNING", ) digits_input = input( f"Enter new digit count (current: {digits}): " @@ -2317,11 +2566,9 @@ class PasswordManager: if digits_input.isdigit(): new_digits = int(digits_input) else: - print( - colored( - "Invalid digits value. Keeping current.", - "yellow", - ) + self.notify( + "Invalid digits value. Keeping current.", + level="WARNING", ) blacklist_input = ( input( @@ -2337,11 +2584,9 @@ class PasswordManager: elif blacklist_input == "n": new_blacklisted = False else: - print( - colored( - "Invalid input for archived status. Keeping the current status.", - "yellow", - ) + self.notify( + "Invalid input for archived status. Keeping the current status.", + level="WARNING", ) new_blacklisted = blacklisted @@ -2428,11 +2673,9 @@ class PasswordManager: elif blacklist_input == "n": new_blacklisted = False else: - print( - colored( - "Invalid input for archived status. Keeping the current status.", - "yellow", - ) + self.notify( + "Invalid input for archived status. Keeping the current status.", + level="WARNING", ) new_blacklisted = blacklisted @@ -2533,11 +2776,9 @@ class PasswordManager: elif blacklist_input == "n": new_blacklisted = False else: - print( - colored( - "Invalid input for archived status. Keeping the current status.", - "yellow", - ) + self.notify( + "Invalid input for archived status. Keeping the current status.", + level="WARNING", ) new_blacklisted = blacklisted @@ -2590,7 +2831,7 @@ class PasswordManager: # Push the updated index to Nostr so changes are backed up. try: - self.sync_vault() + self.start_background_vault_sync() logging.info( "Encrypted index posted to Nostr after entry modification." ) @@ -2613,7 +2854,8 @@ class PasswordManager: """Prompt for a query, list matches and optionally show details.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Search Entries", parent_fingerprint=parent_fp, @@ -2621,19 +2863,20 @@ class PasswordManager: ) query = input("Enter search string: ").strip() if not query: - print(colored("No search string provided.", "yellow")) + self.notify("No search string provided.", level="WARNING") pause() return results = self.entry_manager.search_entries(query) if not results: - print(colored("No matching entries found.", "yellow")) + self.notify("No matching entries found.", level="WARNING") pause() return while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Search Entries", parent_fingerprint=parent_fp, @@ -2764,7 +3007,8 @@ class PasswordManager: try: while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > List Entries", parent_fingerprint=parent_fp, @@ -2812,7 +3056,8 @@ class PasswordManager: continue while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > List Entries", parent_fingerprint=parent_fp, @@ -2852,7 +3097,7 @@ class PasswordManager: if not confirm_action( f"Are you sure you want to delete entry {index_to_delete}? (Y/N): " ): - print(colored("Deletion cancelled.", "yellow")) + self.notify("Deletion cancelled.", level="WARNING") return self.entry_manager.delete_entry(index_to_delete) @@ -2863,7 +3108,7 @@ class PasswordManager: # Push updated index to Nostr after deletion try: - self.sync_vault() + self.start_background_vault_sync() logging.info("Encrypted index posted to Nostr after entry deletion.") except Exception as nostr_error: logging.error( @@ -2899,12 +3144,13 @@ class PasswordManager: archived = self.entry_manager.list_entries(include_archived=True) archived = [e for e in archived if e[4]] if not archived: - print(colored("No archived entries found.", "yellow")) + self.notify("No archived entries found.", level="WARNING") pause() return while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Archived Entries", parent_fingerprint=parent_fp, @@ -2961,7 +3207,8 @@ class PasswordManager: """Display all stored TOTP codes with a countdown progress bar.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > 2FA Codes", parent_fingerprint=parent_fp, @@ -2980,14 +3227,15 @@ class PasswordManager: totp_list.append((label, int(idx_str), period, imported)) if not totp_list: - print(colored("No 2FA entries found.", "yellow")) + self.notify("No 2FA entries found.", level="WARNING") return totp_list.sort(key=lambda t: t[0].lower()) print(colored("Press Enter to return to the menu.", "cyan")) while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > 2FA Codes", parent_fingerprint=parent_fp, @@ -3048,7 +3296,8 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Settings > Verify Script Checksum", parent_fingerprint=parent_fp, @@ -3058,11 +3307,9 @@ class PasswordManager: try: verified = verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE) except FileNotFoundError: - print( - colored( - "Checksum file missing. Run scripts/update_checksum.py or choose 'Generate Script Checksum' in Settings.", - "yellow", - ) + self.notify( + "Checksum file missing. Run scripts/update_checksum.py or choose 'Generate Script Checksum' in Settings.", + level="WARNING", ) logging.warning("Checksum file missing during verification.") return @@ -3085,11 +3332,12 @@ class PasswordManager: def handle_update_script_checksum(self) -> None: """Generate a new checksum for the manager script.""" if not confirm_action("Generate new script checksum? (Y/N): "): - print(colored("Operation cancelled.", "yellow")) + self.notify("Operation cancelled.", level="WARNING") return try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Settings > Generate Script Checksum", parent_fingerprint=parent_fp, @@ -3154,6 +3402,8 @@ class PasswordManager: def sync_vault(self, alt_summary: str | None = None) -> str | None: """Publish the current vault contents to Nostr.""" try: + if getattr(self, "offline_mode", False): + return None encrypted = self.get_encrypted_data() if not encrypted: return None @@ -3205,7 +3455,8 @@ class PasswordManager: """Export the current database to an encrypted portable file.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Settings > Export database", parent_fingerprint=parent_fp, @@ -3228,7 +3479,8 @@ class PasswordManager: """Import a portable database file, replacing the current index.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Settings > Import database", parent_fingerprint=parent_fp, @@ -3241,6 +3493,7 @@ class PasswordManager: parent_seed=self.parent_seed, ) print(colored("Database imported successfully.", "green")) + self.sync_vault() except Exception as e: logging.error(f"Failed to import database: {e}", exc_info=True) print(colored(f"Error: Failed to import database: {e}", "red")) @@ -3249,7 +3502,8 @@ class PasswordManager: """Export all 2FA codes to a JSON file for other authenticator apps.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Settings > Export 2FA codes", parent_fingerprint=parent_fp, @@ -3281,7 +3535,7 @@ class PasswordManager: ) if not totp_entries: - print(colored("No 2FA codes to export.", "yellow")) + self.notify("No 2FA codes to export.", level="WARNING") return None dest_str = input( @@ -3293,7 +3547,8 @@ class PasswordManager: if confirm_action("Encrypt export with a password? (Y/N): "): password = prompt_new_password() - key = derive_key_from_password(password) + iterations = self.config_manager.get_kdf_iterations() + key = derive_key_from_password(password, iterations=iterations) enc_mgr = EncryptionManager(key, dest.parent) data_bytes = enc_mgr.encrypt_data(json_data.encode("utf-8")) dest = dest.with_suffix(dest.suffix + ".enc") @@ -3321,24 +3576,21 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( + self, fp, "Main Menu > Settings > Backup Parent Seed", parent_fingerprint=parent_fp, child_fingerprint=child_fp, ) print(colored("\n=== Backup Parent Seed ===", "yellow")) - print( - colored( - "Warning: Revealing your parent seed is a highly sensitive operation.", - "yellow", - ) + self.notify( + "Warning: Revealing your parent seed is a highly sensitive operation.", + level="WARNING", ) - print( - colored( - "Ensure you're in a secure, private environment and no one is watching your screen.", - "yellow", - ) + self.notify( + "Ensure you're in a secure, private environment and no one is watching your screen.", + level="WARNING", ) # Verify user's identity with secure password verification @@ -3353,7 +3605,7 @@ class PasswordManager: if not confirm_action( "Are you absolutely sure you want to reveal your parent seed? (Y/N): " ): - print(colored("Operation cancelled by user.", "yellow")) + self.notify("Operation cancelled by user.", level="WARNING") return # Reveal the parent seed @@ -3505,7 +3757,8 @@ class PasswordManager: # Create a new encryption manager with the new password new_key = derive_index_key(self.parent_seed) - seed_key = derive_key_from_password(new_password) + iterations = self.config_manager.get_kdf_iterations() + seed_key = derive_key_from_password(new_password, iterations=iterations) seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir) new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir) @@ -3526,6 +3779,7 @@ class PasswordManager: encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, relays=relay_list, + config_manager=self.config_manager, parent_seed=getattr(self, "parent_seed", None), ) @@ -3605,29 +3859,12 @@ class PasswordManager: # Nostr sync info manifest = getattr(self.nostr_client, "current_manifest", None) - if manifest is None: - try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) - if result: - manifest, _ = result - except Exception: - manifest = None - if manifest is not None: stats["chunk_count"] = len(manifest.chunks) stats["delta_since"] = manifest.delta_since - delta_count = 0 - if manifest.delta_since: - try: - version = int(manifest.delta_since) - except ValueError: - version = 0 - try: - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) - delta_count = len(deltas) - except Exception: - delta_count = 0 - stats["pending_deltas"] = delta_count + stats["pending_deltas"] = len( + getattr(self.nostr_client, "_delta_events", []) + ) else: stats["chunk_count"] = 0 stats["delta_since"] = None @@ -3675,4 +3912,6 @@ class PasswordManager: print(color_text(f"Snapshot chunks: {stats['chunk_count']}", "stats")) print(color_text(f"Pending deltas: {stats['pending_deltas']}", "stats")) if stats.get("delta_since"): - print(color_text(f"Latest delta id: {stats['delta_since']}", "stats")) + print( + color_text(f"Latest delta timestamp: {stats['delta_since']}", "stats") + ) diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index a5a3f91..b61523f 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -21,6 +21,7 @@ import random import traceback import base64 from typing import Optional +from dataclasses import dataclass from termcolor import colored from pathlib import Path import shutil @@ -48,6 +49,16 @@ from password_manager.encryption import EncryptionManager logger = logging.getLogger(__name__) +@dataclass +class PasswordPolicy: + """Minimum complexity requirements for generated passwords.""" + + min_uppercase: int = 2 + min_lowercase: int = 2 + min_digits: int = 2 + min_special: int = 2 + + class PasswordGenerator: """ PasswordGenerator Class @@ -58,7 +69,11 @@ class PasswordGenerator: """ def __init__( - self, encryption_manager: EncryptionManager, parent_seed: str, bip85: BIP85 + self, + encryption_manager: EncryptionManager, + parent_seed: str, + bip85: BIP85, + policy: PasswordPolicy | None = None, ): """ Initializes the PasswordGenerator with the encryption manager, parent seed, and BIP85 instance. @@ -72,6 +87,7 @@ class PasswordGenerator: self.encryption_manager = encryption_manager self.parent_seed = parent_seed self.bip85 = bip85 + self.policy = policy or PasswordPolicy() # Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager) self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic( @@ -224,11 +240,11 @@ class PasswordGenerator: f"Current character counts - Upper: {current_upper}, Lower: {current_lower}, Digits: {current_digits}, Special: {current_special}" ) - # Set minimum counts - min_upper = 2 - min_lower = 2 - min_digits = 2 - min_special = 2 + # Set minimum counts from policy + min_upper = self.policy.min_uppercase + min_lower = self.policy.min_lowercase + min_digits = self.policy.min_digits + min_special = self.policy.min_special # Initialize derived key index dk_index = 0 diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index 14c7dce..8731818 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -74,7 +74,7 @@ def export_backup( "created_at": int(time.time()), "fingerprint": vault.fingerprint_dir.name, "encryption_mode": PortableMode.SEED_ONLY.value, - "cipher": "fernet", + "cipher": "aes-gcm", "checksum": checksum, "payload": base64.b64encode(payload_bytes).decode("utf-8"), } @@ -90,7 +90,11 @@ def export_backup( enc_file.write_bytes(encrypted) os.chmod(enc_file, 0o600) try: - client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name) + client = NostrClient( + vault.encryption_manager, + vault.fingerprint_dir.name, + config_manager=backup_manager.config_manager, + ) asyncio.run(client.publish_snapshot(encrypted)) except Exception: logger.error("Failed to publish backup via Nostr", exc_info=True) diff --git a/src/password_manager/vault.py b/src/password_manager/vault.py index 78d8b99..e5fe002 100644 --- a/src/password_manager/vault.py +++ b/src/password_manager/vault.py @@ -30,6 +30,17 @@ class Vault: # ----- Password index helpers ----- def load_index(self) -> dict: """Return decrypted password index data as a dict, applying migrations.""" + legacy_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc" + if legacy_file.exists() and not self.index_file.exists(): + legacy_checksum = ( + self.fingerprint_dir / "seedpass_passwords_db_checksum.txt" + ) + legacy_file.rename(self.index_file) + if legacy_checksum.exists(): + legacy_checksum.rename( + self.fingerprint_dir / "seedpass_entries_db_checksum.txt" + ) + data = self.encryption_manager.load_json_data(self.index_file) from .migrations import apply_migrations, LATEST_VERSION diff --git a/src/requirements.txt b/src/requirements.txt index 1bfbef1..f3951b0 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -5,7 +5,7 @@ bip-utils>=2.5.0 bech32==1.2.0 coincurve>=18.0.0 mnemonic -aiohttp +aiohttp>=3.12.14 bcrypt pytest>=7.0 pytest-cov @@ -30,3 +30,5 @@ uvicorn>=0.35.0 httpx>=0.28.1 requests>=2.32 python-multipart +orjson +argon2-cffi diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 6827934..fdc748c 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -6,9 +6,10 @@ import os import tempfile from pathlib import Path import secrets +import queue from typing import Any, List, Optional -from fastapi import FastAPI, Header, HTTPException, Request +from fastapi import FastAPI, Header, HTTPException, Request, Response import asyncio import sys from fastapi.middleware.cors import CORSMiddleware @@ -28,6 +29,20 @@ def _check_token(auth: str | None) -> None: raise HTTPException(status_code=401, detail="Unauthorized") +def _reload_relays(relays: list[str]) -> None: + """Reload the Nostr client with a new relay list.""" + assert _pm is not None + try: + _pm.nostr_client.close_client_pool() + except Exception: + pass + try: + _pm.nostr_client.relays = relays + _pm.nostr_client.initialize_client_pool() + except Exception: + pass + + def start_server(fingerprint: str | None = None) -> str: """Initialize global state and return the API token. @@ -37,9 +52,10 @@ def start_server(fingerprint: str | None = None) -> str: Optional seed profile fingerprint to select before starting the server. """ global _pm, _token - _pm = PasswordManager() - if fingerprint: - _pm.select_fingerprint(fingerprint) + if fingerprint is None: + _pm = PasswordManager() + else: + _pm = PasswordManager(fingerprint=fingerprint) _token = secrets.token_urlsafe(16) print(f"API token: {_token}") origins = [ @@ -192,16 +208,19 @@ def update_entry( """ _check_token(authorization) assert _pm is not None - _pm.entry_manager.modify_entry( - entry_id, - username=entry.get("username"), - url=entry.get("url"), - notes=entry.get("notes"), - label=entry.get("label"), - period=entry.get("period"), - digits=entry.get("digits"), - value=entry.get("value"), - ) + try: + _pm.entry_manager.modify_entry( + entry_id, + username=entry.get("username"), + url=entry.get("url"), + notes=entry.get("notes"), + label=entry.get("label"), + period=entry.get("period"), + digits=entry.get("digits"), + value=entry.get("value"), + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) return {"status": "ok"} @@ -253,6 +272,7 @@ def update_config( "additional_backup_path": cfg.set_additional_backup_path, "secret_mode_enabled": cfg.set_secret_mode_enabled, "clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)), + "quick_unlock": cfg.set_quick_unlock, } action = mapping.get(key) @@ -360,6 +380,21 @@ def get_profile_stats(authorization: str | None = Header(None)) -> dict: return _pm.get_profile_stats() +@app.get("/api/v1/notifications") +def get_notifications(authorization: str | None = Header(None)) -> List[dict]: + """Return and clear queued notifications.""" + _check_token(authorization) + assert _pm is not None + notes = [] + while True: + try: + note = _pm.notifications.get_nowait() + except queue.Empty: + break + notes.append({"level": note.level, "message": note.message}) + return notes + + @app.get("/api/v1/parent-seed") def get_parent_seed( authorization: str | None = Header(None), file: str | None = None @@ -383,6 +418,63 @@ def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any: return {"npub": _pm.nostr_client.key_manager.get_npub()} +@app.get("/api/v1/relays") +def list_relays(authorization: str | None = Header(None)) -> dict: + """Return the configured Nostr relays.""" + _check_token(authorization) + assert _pm is not None + cfg = _pm.config_manager.load_config(require_pin=False) + return {"relays": cfg.get("relays", [])} + + +@app.post("/api/v1/relays") +def add_relay(data: dict, authorization: str | None = Header(None)) -> dict[str, str]: + """Add a relay URL to the configuration.""" + _check_token(authorization) + assert _pm is not None + url = data.get("url") + if not url: + raise HTTPException(status_code=400, detail="Missing url") + cfg = _pm.config_manager.load_config(require_pin=False) + relays = cfg.get("relays", []) + if url in relays: + raise HTTPException(status_code=400, detail="Relay already present") + relays.append(url) + _pm.config_manager.set_relays(relays, require_pin=False) + _reload_relays(relays) + return {"status": "ok"} + + +@app.delete("/api/v1/relays/{idx}") +def remove_relay(idx: int, authorization: str | None = Header(None)) -> dict[str, str]: + """Remove a relay by its index (1-based).""" + _check_token(authorization) + assert _pm is not None + cfg = _pm.config_manager.load_config(require_pin=False) + relays = cfg.get("relays", []) + if not (1 <= idx <= len(relays)): + raise HTTPException(status_code=400, detail="Invalid index") + if len(relays) == 1: + raise HTTPException(status_code=400, detail="At least one relay required") + relays.pop(idx - 1) + _pm.config_manager.set_relays(relays, require_pin=False) + _reload_relays(relays) + return {"status": "ok"} + + +@app.post("/api/v1/relays/reset") +def reset_relays(authorization: str | None = Header(None)) -> dict[str, str]: + """Reset relay list to defaults.""" + _check_token(authorization) + assert _pm is not None + from nostr.client import DEFAULT_RELAYS + + relays = list(DEFAULT_RELAYS) + _pm.config_manager.set_relays(relays, require_pin=False) + _reload_relays(relays) + return {"status": "ok"} + + @app.post("/api/v1/checksum/verify") def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]: """Verify the SeedPass script checksum.""" @@ -401,6 +493,18 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]: return {"status": "ok"} +@app.post("/api/v1/vault/export") +def export_vault(authorization: str | None = Header(None)): + """Export the vault and return the encrypted file.""" + _check_token(authorization) + assert _pm is not None + path = _pm.handle_export_database() + if path is None: + raise HTTPException(status_code=500, detail="Export failed") + data = Path(path).read_bytes() + return Response(content=data, media_type="application/octet-stream") + + @app.post("/api/v1/vault/import") async def import_vault( request: Request, authorization: str | None = Header(None) @@ -429,6 +533,23 @@ async def import_vault( if not path: raise HTTPException(status_code=400, detail="Missing file or path") _pm.handle_import_database(Path(path)) + _pm.sync_vault() + return {"status": "ok"} + + +@app.post("/api/v1/vault/backup-parent-seed") +def backup_parent_seed( + data: dict | None = None, authorization: str | None = Header(None) +) -> dict[str, str]: + """Backup and reveal the parent seed.""" + _check_token(authorization) + assert _pm is not None + path = None + if data is not None: + p = data.get("path") + if p: + path = Path(p) + _pm.handle_backup_reveal_parent_seed(path) return {"status": "ok"} diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index e04f6bd..d8df065 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -9,7 +9,12 @@ from password_manager.entry_types import EntryType import uvicorn from . import api as api_module -app = typer.Typer(help="SeedPass command line interface") +import importlib + +app = typer.Typer( + help="SeedPass command line interface", + invoke_without_command=True, +) # Global option shared across all commands fingerprint_option = typer.Option( @@ -39,18 +44,24 @@ app.add_typer(api_app, name="api") def _get_pm(ctx: typer.Context) -> PasswordManager: """Return a PasswordManager optionally selecting a fingerprint.""" - pm = PasswordManager() fp = ctx.obj.get("fingerprint") - if fp: - # `select_fingerprint` will initialize managers - pm.select_fingerprint(fp) + if fp is None: + pm = PasswordManager() + else: + pm = PasswordManager(fingerprint=fp) return pm -@app.callback() +@app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: - """SeedPass CLI entry point.""" + """SeedPass CLI entry point. + + When called without a subcommand this launches the interactive TUI. + """ ctx.obj = {"fingerprint": fingerprint} + if ctx.invoked_subcommand is None: + tui = importlib.import_module("main") + raise typer.Exit(tui.main(fingerprint=fingerprint)) @entry_app.command("list") @@ -139,6 +150,7 @@ def entry_add( pm = _get_pm(ctx) index = pm.entry_manager.add_entry(label, length, username, url) typer.echo(str(index)) + pm.sync_vault() @entry_app.command("add-totp") @@ -161,6 +173,7 @@ def entry_add_totp( digits=digits, ) typer.echo(uri) + pm.sync_vault() @entry_app.command("add-ssh") @@ -179,6 +192,7 @@ def entry_add_ssh( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-pgp") @@ -201,6 +215,7 @@ def entry_add_pgp( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-nostr") @@ -218,6 +233,7 @@ def entry_add_nostr( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-seed") @@ -238,6 +254,7 @@ def entry_add_seed( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-key-value") @@ -251,6 +268,7 @@ def entry_add_key_value( pm = _get_pm(ctx) idx = pm.entry_manager.add_key_value(label, value, notes=notes) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-managed-account") @@ -269,6 +287,7 @@ def entry_add_managed_account( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("modify") @@ -287,16 +306,21 @@ def entry_modify( ) -> None: """Modify an existing entry.""" pm = _get_pm(ctx) - pm.entry_manager.modify_entry( - entry_id, - username=username, - url=url, - notes=notes, - label=label, - period=period, - digits=digits, - value=value, - ) + try: + pm.entry_manager.modify_entry( + entry_id, + username=username, + url=url, + notes=notes, + label=label, + period=period, + digits=digits, + value=value, + ) + except ValueError as e: + typer.echo(str(e)) + raise typer.Exit(code=1) + pm.sync_vault() @entry_app.command("archive") @@ -305,6 +329,7 @@ def entry_archive(ctx: typer.Context, entry_id: int) -> None: pm = _get_pm(ctx) pm.entry_manager.archive_entry(entry_id) typer.echo(str(entry_id)) + pm.sync_vault() @entry_app.command("unarchive") @@ -313,6 +338,7 @@ def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: pm = _get_pm(ctx) pm.entry_manager.restore_entry(entry_id) typer.echo(str(entry_id)) + pm.sync_vault() @entry_app.command("totp-codes") @@ -350,6 +376,7 @@ def vault_import( """Import a vault from an encrypted JSON file.""" pm = _get_pm(ctx) pm.handle_import_database(Path(file)) + pm.sync_vault() typer.echo(str(file)) @@ -434,6 +461,21 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "relays": lambda v: cfg.set_relays( [r.strip() for r in v.split(",") if r.strip()], require_pin=False ), + "kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)), + "kdf_mode": lambda v: cfg.set_kdf_mode(v), + "backup_interval": lambda v: cfg.set_backup_interval(float(v)), + "nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)), + "nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)), + "min_uppercase": lambda v: cfg.set_min_uppercase(int(v)), + "min_lowercase": lambda v: cfg.set_min_lowercase(int(v)), + "min_digits": lambda v: cfg.set_min_digits(int(v)), + "min_special": lambda v: cfg.set_min_special(int(v)), + "quick_unlock": lambda v: cfg.set_quick_unlock( + v.lower() in ("1", "true", "yes", "y", "on") + ), + "verbose_timing": lambda v: cfg.set_verbose_timing( + v.lower() in ("1", "true", "yes", "y", "on") + ), } action = mapping.get(key) @@ -501,6 +543,41 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: typer.echo(f"Secret mode {status}.") +@config_app.command("toggle-offline") +def config_toggle_offline(ctx: typer.Context) -> None: + """Enable or disable offline mode.""" + pm = _get_pm(ctx) + cfg = pm.config_manager + try: + enabled = cfg.get_offline_mode() + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error loading settings: {exc}") + raise typer.Exit(code=1) + + typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}") + choice = ( + typer.prompt( + "Enable offline mode? (y/n, blank to keep)", default="", show_default=False + ) + .strip() + .lower() + ) + if choice in ("y", "yes"): + enabled = True + elif choice in ("n", "no"): + enabled = False + + try: + cfg.set_offline_mode(enabled) + pm.offline_mode = enabled + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + + status = "enabled" if enabled else "disabled" + typer.echo(f"Offline mode {status}.") + + @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" @@ -573,3 +650,7 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N ) except Exception as exc: # pragma: no cover - best effort typer.echo(f"Failed to stop server: {exc}") + + +if __name__ == "__main__": + app() diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 914968a..ab6f0c4 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -1,4 +1,6 @@ import sys +import time +import json from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -161,6 +163,7 @@ class DummySendResult: class DummyRelayClient: def __init__(self): self.counter = 0 + self.ts_counter = 0 self.manifests: list[DummyEvent] = [] self.chunks: dict[str, DummyEvent] = {} self.deltas: list[DummyEvent] = [] @@ -183,11 +186,19 @@ class DummyRelayClient: if isinstance(event, DummyEvent): event.id = eid if event.kind == KIND_MANIFEST: + try: + data = json.loads(event.content()) + event.delta_since = data.get("delta_since") + except Exception: + event.delta_since = None self.manifests.append(event) elif event.kind == KIND_SNAPSHOT_CHUNK: ident = event.tags[0] if event.tags else str(self.counter) self.chunks[ident] = event elif event.kind == KIND_DELTA: + if not hasattr(event, "created_at"): + self.ts_counter += 1 + event.created_at = self.ts_counter self.deltas.append(event) return DummySendResult(eid) diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 2e1f7e2..67f1b47 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -30,6 +30,7 @@ def client(monkeypatch): set_additional_backup_path=lambda v: None, set_secret_mode_enabled=lambda v: None, set_clipboard_clear_delay=lambda v: None, + set_quick_unlock=lambda v: None, ), fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]), nostr_client=SimpleNamespace( @@ -158,6 +159,22 @@ def test_update_config(client): assert res.headers.get("access-control-allow-origin") == "http://example.com" +def test_update_config_quick_unlock(client): + cl, token = client + called = {} + + api._pm.config_manager.set_quick_unlock = lambda v: called.setdefault("val", v) + headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"} + res = cl.put( + "/api/v1/config/quick_unlock", + json={"value": True}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json() == {"status": "ok"} + assert called.get("val") is True + + def test_change_password_route(client): cl, token = client called = {} diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index fc187db..dda8d0b 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -4,6 +4,8 @@ import pytest from seedpass import api from test_api import client +from helpers import dummy_nostr_client +from nostr.client import NostrClient, DEFAULT_RELAYS def test_create_and_modify_totp_entry(client): @@ -93,6 +95,19 @@ def test_create_and_modify_ssh_entry(client): assert calls["modify"][1]["notes"] == "x" +def test_update_entry_error(client): + cl, token = client + + def modify(*a, **k): + raise ValueError("nope") + + api._pm.entry_manager.modify_entry = modify + headers = {"Authorization": f"Bearer {token}"} + res = cl.put("/api/v1/entry/1", json={"username": "x"}, headers=headers) + assert res.status_code == 400 + assert res.json() == {"detail": "nope"} + + def test_update_config_secret_mode(client): cl, token = client called = {} @@ -218,6 +233,7 @@ def test_vault_import_via_path(client, tmp_path): called["path"] = path api._pm.handle_import_database = import_db + api._pm.sync_vault = lambda: called.setdefault("sync", True) file_path = tmp_path / "b.json" file_path.write_text("{}") @@ -230,6 +246,7 @@ def test_vault_import_via_path(client, tmp_path): assert res.status_code == 200 assert res.json() == {"status": "ok"} assert called["path"] == file_path + assert called.get("sync") is True def test_vault_import_via_upload(client, tmp_path): @@ -240,6 +257,7 @@ def test_vault_import_via_upload(client, tmp_path): called["path"] = path api._pm.handle_import_database = import_db + api._pm.sync_vault = lambda: called.setdefault("sync", True) file_path = tmp_path / "c.json" file_path.write_text("{}") @@ -253,6 +271,7 @@ def test_vault_import_via_upload(client, tmp_path): assert res.status_code == 200 assert res.json() == {"status": "ok"} assert isinstance(called.get("path"), Path) + assert called.get("sync") is True def test_vault_lock_endpoint(client): @@ -300,3 +319,85 @@ def test_secret_mode_endpoint(client): assert res.json() == {"status": "ok"} assert called["enabled"] is True assert called["delay"] == 12 + + +def test_vault_export_endpoint(client, tmp_path): + cl, token = client + out = tmp_path / "out.json" + out.write_text("data") + + api._pm.handle_export_database = lambda: out + + headers = {"Authorization": f"Bearer {token}"} + res = cl.post("/api/v1/vault/export", headers=headers) + assert res.status_code == 200 + assert res.content == b"data" + + +def test_backup_parent_seed_endpoint(client, tmp_path): + cl, token = client + called = {} + + def backup(path=None): + called["path"] = path + + api._pm.handle_backup_reveal_parent_seed = backup + path = tmp_path / "seed.enc" + headers = {"Authorization": f"Bearer {token}"} + res = cl.post( + "/api/v1/vault/backup-parent-seed", + json={"path": str(path)}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json() == {"status": "ok"} + assert called["path"] == path + + +def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch): + cl, token = client + nostr_client, _ = dummy_nostr_client + relays = ["wss://a", "wss://b"] + + def load_config(require_pin=False): + return {"relays": relays.copy()} + + called = {} + + def set_relays(new, require_pin=False): + called["set"] = new + + api._pm.config_manager.load_config = load_config + api._pm.config_manager.set_relays = set_relays + monkeypatch.setattr( + NostrClient, + "initialize_client_pool", + lambda self: called.setdefault("init", True), + ) + monkeypatch.setattr( + nostr_client, "close_client_pool", lambda: called.setdefault("close", True) + ) + api._pm.nostr_client = nostr_client + api._pm.nostr_client.relays = relays.copy() + + headers = {"Authorization": f"Bearer {token}"} + + res = cl.get("/api/v1/relays", headers=headers) + assert res.status_code == 200 + assert res.json() == {"relays": relays} + + res = cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers) + assert res.status_code == 200 + assert called["set"] == ["wss://a", "wss://b", "wss://c"] + + api._pm.config_manager.load_config = lambda require_pin=False: { + "relays": ["wss://a", "wss://b", "wss://c"] + } + res = cl.delete("/api/v1/relays/2", headers=headers) + assert res.status_code == 200 + assert called["set"] == ["wss://a", "wss://c"] + + res = cl.post("/api/v1/relays/reset", headers=headers) + assert res.status_code == 200 + assert called.get("init") is True + assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS) diff --git a/src/tests/test_api_notifications.py b/src/tests/test_api_notifications.py new file mode 100644 index 0000000..e0805a9 --- /dev/null +++ b/src/tests/test_api_notifications.py @@ -0,0 +1,45 @@ +from test_api import client +from types import SimpleNamespace +import queue +import seedpass.api as api + + +def test_notifications_endpoint(client): + cl, token = client + api._pm.notifications = queue.Queue() + api._pm.notifications.put(SimpleNamespace(message="m1", level="INFO")) + api._pm.notifications.put(SimpleNamespace(message="m2", level="WARNING")) + res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 200 + assert res.json() == [ + {"level": "INFO", "message": "m1"}, + {"level": "WARNING", "message": "m2"}, + ] + assert api._pm.notifications.empty() + + +def test_notifications_endpoint_clears_queue(client): + cl, token = client + api._pm.notifications = queue.Queue() + api._pm.notifications.put(SimpleNamespace(message="hi", level="INFO")) + res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 200 + assert res.json() == [{"level": "INFO", "message": "hi"}] + assert api._pm.notifications.empty() + res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}) + assert res.json() == [] + + +def test_notifications_endpoint_does_not_clear_current(client): + cl, token = client + api._pm.notifications = queue.Queue() + msg = SimpleNamespace(message="keep", level="INFO") + api._pm.notifications.put(msg) + api._pm._current_notification = msg + api._pm.get_current_notification = lambda: api._pm._current_notification + + res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 200 + assert res.json() == [{"level": "INFO", "message": "keep"}] + assert api._pm.notifications.empty() + assert api._pm.get_current_notification() is msg diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py index 779d3f3..bc094da 100644 --- a/src/tests/test_archive_from_retrieve.py +++ b/src/tests/test_archive_from_retrieve.py @@ -2,6 +2,7 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace +import queue from helpers import create_vault, TEST_SEED, TEST_PASSWORD @@ -37,6 +38,7 @@ def test_archive_entry_from_retrieve(monkeypatch): pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path pm.secret_mode_enabled = False + pm.notifications = queue.Queue() index = entry_mgr.add_entry("example.com", 8) @@ -68,6 +70,7 @@ def test_restore_entry_from_retrieve(monkeypatch): pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path pm.secret_mode_enabled = False + pm.notifications = queue.Queue() index = entry_mgr.add_entry("example.com", 8) entry_mgr.archive_entry(index) diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index 00225e1..b182866 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -2,6 +2,7 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace +import queue import pytest @@ -67,6 +68,7 @@ def test_view_archived_entries_cli(monkeypatch): pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.notifications = queue.Queue() idx = entry_mgr.add_entry("example.com", 8) @@ -98,6 +100,7 @@ def test_view_archived_entries_view_only(monkeypatch, capsys): pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.notifications = queue.Queue() idx = entry_mgr.add_entry("example.com", 8) @@ -131,6 +134,7 @@ def test_view_archived_entries_removed_after_restore(monkeypatch, capsys): pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.notifications = queue.Queue() idx = entry_mgr.add_entry("example.com", 8) @@ -145,5 +149,6 @@ def test_view_archived_entries_removed_after_restore(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *_: "") pm.handle_view_archived_entries() - out = capsys.readouterr().out - assert "No archived entries found." in out + note = pm.notifications.get_nowait() + assert note.level == "WARNING" + assert note.message == "No archived entries found." diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index 53cf949..26f0e22 100644 --- a/src/tests/test_auto_sync.py +++ b/src/tests/test_auto_sync.py @@ -22,6 +22,8 @@ def test_auto_sync_triggers_post(monkeypatch): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) called = False diff --git a/src/tests/test_background_relay_check.py b/src/tests/test_background_relay_check.py new file mode 100644 index 0000000..d537c70 --- /dev/null +++ b/src/tests/test_background_relay_check.py @@ -0,0 +1,41 @@ +import time +from types import SimpleNamespace +import queue +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.manager import PasswordManager +from constants import MIN_HEALTHY_RELAYS + + +def test_background_relay_check_runs_async(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm._current_notification = None + pm._notification_expiry = 0.0 + called = {"args": None} + pm.nostr_client = SimpleNamespace( + check_relay_health=lambda min_relays: called.__setitem__("args", min_relays) + or min_relays + ) + + pm.start_background_relay_check() + time.sleep(0.05) + + assert called["args"] == MIN_HEALTHY_RELAYS + + +def test_background_relay_check_warns_when_unhealthy(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm._current_notification = None + pm._notification_expiry = 0.0 + pm.notifications = queue.Queue() + pm.nostr_client = SimpleNamespace(check_relay_health=lambda mr: mr - 1) + + pm.start_background_relay_check() + time.sleep(0.05) + + note = pm.notifications.get_nowait() + assert note.level == "WARNING" + assert str(MIN_HEALTHY_RELAYS - 1) in note.message diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py new file mode 100644 index 0000000..84faa32 --- /dev/null +++ b/src/tests/test_background_sync_always.py @@ -0,0 +1,70 @@ +import sys +from types import SimpleNamespace +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.manager import PasswordManager +import password_manager.manager as manager_module + + +def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): + pm = PasswordManager.__new__(PasswordManager) + fingerprint = "fp1" + fm = SimpleNamespace( + list_fingerprints=lambda: [fingerprint], + current_fingerprint=None, + get_current_fingerprint_dir=lambda: tmp_path / fingerprint, + ) + pm.fingerprint_manager = fm + pm.current_fingerprint = None + pm.encryption_manager = object() + pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False) + + monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw" + ) + monkeypatch.setattr( + PasswordManager, "setup_encryption_manager", lambda *a, **k: True + ) + monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None) + monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None) + monkeypatch.setattr( + "password_manager.manager.NostrClient", lambda *a, **kw: object() + ) + + calls = {"count": 0} + + def fake_bg(self=None): + calls["count"] += 1 + + monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg) + + assert pm.handle_switch_fingerprint() + assert calls["count"] == 1 + + +def test_exit_managed_account_triggers_bg_sync(monkeypatch, tmp_path): + pm = PasswordManager.__new__(PasswordManager) + pm.profile_stack = [("rootfp", tmp_path, "seed")] + pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False) + + monkeypatch.setattr(manager_module, "derive_index_key", lambda seed: b"k") + monkeypatch.setattr( + manager_module, "EncryptionManager", lambda *a, **kw: SimpleNamespace() + ) + monkeypatch.setattr(manager_module, "Vault", lambda *a, **kw: SimpleNamespace()) + monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **kw: None) + monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **kw: None) + monkeypatch.setattr(PasswordManager, "update_activity", lambda *a, **kw: None) + + calls = {"count": 0} + + def fake_bg(self=None): + calls["count"] += 1 + + monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg) + + pm.exit_managed_account() + assert calls["count"] == 1 diff --git a/src/tests/test_backup_interval.py b/src/tests/test_backup_interval.py new file mode 100644 index 0000000..f7ce39a --- /dev/null +++ b/src/tests/test_backup_interval.py @@ -0,0 +1,34 @@ +import time +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager + + +def test_backup_interval(monkeypatch): + with TemporaryDirectory() as tmpdir: + fp_dir = Path(tmpdir) + vault, _ = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, fp_dir) + cfg_mgr.set_backup_interval(10) + backup_mgr = BackupManager(fp_dir, cfg_mgr) + + vault.save_index({"entries": {}}) + + monkeypatch.setattr(time, "time", lambda: 1000) + backup_mgr.create_backup() + first = fp_dir / "backups" / "entries_db_backup_1000.json.enc" + assert first.exists() + + monkeypatch.setattr(time, "time", lambda: 1005) + backup_mgr.create_backup() + second = fp_dir / "backups" / "entries_db_backup_1005.json.enc" + assert not second.exists() + + monkeypatch.setattr(time, "time", lambda: 1012) + backup_mgr.create_backup() + third = fp_dir / "backups" / "entries_db_backup_1012.json.enc" + assert third.exists() diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index 21e8309..6c06b0c 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -14,6 +14,12 @@ runner = CliRunner() ("secret_mode_enabled", "true", "set_secret_mode_enabled", True), ("clipboard_clear_delay", "10", "set_clipboard_clear_delay", 10), ("additional_backup_path", "", "set_additional_backup_path", None), + ("backup_interval", "5", "set_backup_interval", 5.0), + ("kdf_iterations", "123", "set_kdf_iterations", 123), + ("kdf_mode", "argon2", "set_kdf_mode", "argon2"), + ("quick_unlock", "true", "set_quick_unlock", True), + ("nostr_max_retries", "3", "set_nostr_max_retries", 3), + ("nostr_retry_delay", "1.5", "set_nostr_retry_delay", 1.5), ( "relays", "wss://a.com, wss://b.com", diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py new file mode 100644 index 0000000..44bf430 --- /dev/null +++ b/src/tests/test_cli_doc_examples.py @@ -0,0 +1,107 @@ +import re +import shlex +import sys +from pathlib import Path +from types import SimpleNamespace + +sys.path.append(str(Path(__file__).resolve().parents[1] / "src")) + +from typer.testing import CliRunner +from seedpass import cli +from password_manager.entry_types import EntryType + + +class DummyPM: + def __init__(self): + self.entry_manager = SimpleNamespace( + list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [ + (1, "Label", "user", "url", False) + ], + search_entries=lambda q: [(1, "GitHub", "user", "", False)], + retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8}, + get_totp_code=lambda idx, seed: "123456", + add_entry=lambda label, length, username, url: 1, + add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://", + add_ssh_key=lambda label, seed, index=None, notes="": 2, + add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3, + add_nostr_key=lambda label, index=None, notes="": 4, + add_seed=lambda label, seed, index=None, words_num=24, notes="": 5, + add_key_value=lambda label, value, notes="": 6, + add_managed_account=lambda label, seed, index=None, notes="": 7, + modify_entry=lambda *a, **kw: None, + archive_entry=lambda i: None, + restore_entry=lambda i: None, + export_totp_entries=lambda seed: {"entries": []}, + ) + self.password_generator = SimpleNamespace( + generate_password=lambda length, index=None: "pw" + ) + self.parent_seed = "seed" + self.handle_display_totp_codes = lambda: None + self.handle_export_database = lambda path: None + self.handle_import_database = lambda path: None + self.change_password = lambda: None + self.lock_vault = lambda: None + self.get_profile_stats = lambda: {"n": 1} + self.handle_backup_reveal_parent_seed = lambda path=None: None + self.handle_verify_checksum = lambda: None + self.handle_update_script_checksum = lambda: None + self.add_new_fingerprint = lambda: None + self.fingerprint_manager = SimpleNamespace( + list_fingerprints=lambda: ["fp"], remove_fingerprint=lambda fp: None + ) + self.nostr_client = SimpleNamespace( + key_manager=SimpleNamespace(get_npub=lambda: "npub") + ) + self.sync_vault = lambda: "event" + self.config_manager = SimpleNamespace( + load_config=lambda require_pin=False: {"inactivity_timeout": 30}, + set_inactivity_timeout=lambda v: None, + set_kdf_iterations=lambda v: None, + set_backup_interval=lambda v: None, + set_secret_mode_enabled=lambda v: None, + set_clipboard_clear_delay=lambda v: None, + set_additional_backup_path=lambda v: None, + set_relays=lambda v, require_pin=False: None, + set_nostr_max_retries=lambda v: None, + set_nostr_retry_delay=lambda v: None, + set_offline_mode=lambda v: None, + get_secret_mode_enabled=lambda: True, + get_clipboard_clear_delay=lambda: 30, + get_offline_mode=lambda: False, + ) + self.secret_mode_enabled = True + self.clipboard_clear_delay = 30 + self.select_fingerprint = lambda fp: None + + +def load_doc_commands() -> list[str]: + text = Path("docs/docs/content/01-getting-started/01-advanced_cli.md").read_text() + cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text)) + cmds = {c for c in cmds if "<" not in c and ">" not in c} + cmds.discard("vault export") + cmds.discard("vault import") + return sorted(cmds) + + +runner = CliRunner() + + +def _setup(monkeypatch): + monkeypatch.setattr(cli, "PasswordManager", lambda: DummyPM()) + monkeypatch.setattr(cli.uvicorn, "run", lambda *a, **kw: None) + monkeypatch.setattr(cli.api_module, "start_server", lambda fp: "token") + monkeypatch.setitem( + sys.modules, "requests", SimpleNamespace(post=lambda *a, **kw: None) + ) + monkeypatch.setattr(cli.typer, "prompt", lambda *a, **kw: "") + + +import pytest + + +@pytest.mark.parametrize("command", load_doc_commands()) +def test_doc_cli_examples(monkeypatch, command): + _setup(monkeypatch) + result = runner.invoke(cli.app, shlex.split(command)) + assert result.exit_code == 0 diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index b69f652..5fbeafd 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -11,6 +11,22 @@ runner = CliRunner() @pytest.mark.parametrize( "command,method,cli_args,expected_args,expected_kwargs,stdout", [ + ( + "add", + "add_entry", + [ + "Label", + "--length", + "16", + "--username", + "user", + "--url", + "https://example.com", + ], + ("Label", 16, "user", "https://example.com"), + {}, + "1", + ), ( "add-totp", "add_totp", @@ -99,10 +115,14 @@ def test_entry_add_commands( called["kwargs"] = kwargs return stdout + def sync_vault(): + called["sync"] = True + pm = SimpleNamespace( entry_manager=SimpleNamespace(**{method: func}), parent_seed="seed", select_fingerprint=lambda fp: None, + sync_vault=sync_vault, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) @@ -110,3 +130,4 @@ def test_entry_add_commands( assert stdout in result.stdout assert called["args"] == expected_args assert called["kwargs"] == expected_kwargs + assert called.get("sync") is True diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py index 5d4afef..5e268b7 100644 --- a/src/tests/test_cli_export_import.py +++ b/src/tests/test_cli_export_import.py @@ -45,7 +45,7 @@ def test_cli_export_creates_file(monkeypatch, tmp_path): } vault.save_index(data) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -83,7 +83,7 @@ def test_cli_import_round_trip(monkeypatch, tmp_path): vault.save_index({"schema_version": 4, "entries": {}}) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 589f162..0882df4 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -46,6 +46,8 @@ def _make_pm(called, locked=None): update_activity=update, lock_vault=lock, unlock_vault=unlock, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) return pm, locked diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py index e62cf57..56e437f 100644 --- a/src/tests/test_cli_subcommands.py +++ b/src/tests/test_cli_subcommands.py @@ -28,7 +28,7 @@ def make_pm(search_results, entry=None, totp_code="123456"): def test_search_command(monkeypatch, capsys): pm = make_pm([(0, "Example", "user", "", False)]) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -41,7 +41,7 @@ def test_search_command(monkeypatch, capsys): def test_get_command(monkeypatch, capsys): entry = {"type": EntryType.PASSWORD.value, "length": 8} pm = make_pm([(0, "Example", "user", "", False)], entry=entry) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -55,7 +55,7 @@ def test_totp_command(monkeypatch, capsys): entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0} pm = make_pm([(0, "Example", None, None, False)], entry=entry) called = {} - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -72,7 +72,7 @@ def test_totp_command(monkeypatch, capsys): def test_search_command_no_results(monkeypatch, capsys): pm = make_pm([]) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -85,7 +85,7 @@ def test_search_command_no_results(monkeypatch, capsys): def test_get_command_multiple_matches(monkeypatch, capsys): matches = [(0, "Example", "user", "", False), (1, "Ex2", "bob", "", False)] pm = make_pm(matches) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -98,7 +98,7 @@ def test_get_command_multiple_matches(monkeypatch, capsys): def test_get_command_wrong_type(monkeypatch, capsys): entry = {"type": EntryType.TOTP.value} pm = make_pm([(0, "Example", "user", "", False)], entry=entry) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -111,7 +111,7 @@ def test_get_command_wrong_type(monkeypatch, capsys): def test_totp_command_multiple_matches(monkeypatch, capsys): matches = [(0, "GH", None, None, False), (1, "Git", None, None, False)] pm = make_pm(matches) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -124,7 +124,7 @@ def test_totp_command_multiple_matches(monkeypatch, capsys): def test_totp_command_wrong_type(monkeypatch, capsys): entry = {"type": EntryType.PASSWORD.value, "length": 8} pm = make_pm([(0, "Example", "user", "", False)], entry=entry) - monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) @@ -132,3 +132,21 @@ def test_totp_command_wrong_type(monkeypatch, capsys): assert rc == 1 out = capsys.readouterr().out assert "Entry is not a TOTP entry" in out + + +def test_main_fingerprint_option(monkeypatch): + """Ensure the argparse CLI forwards the fingerprint to PasswordManager.""" + called = {} + + def fake_pm(fingerprint=None): + called["fp"] = fingerprint + return make_pm([]) + + monkeypatch.setattr(main, "PasswordManager", fake_pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + rc = main.main(["--fingerprint", "abc", "search", "q"]) + assert rc == 0 + assert called.get("fp") == "abc" diff --git a/src/tests/test_cli_toggle_offline_mode.py b/src/tests/test_cli_toggle_offline_mode.py new file mode 100644 index 0000000..0a46477 --- /dev/null +++ b/src/tests/test_cli_toggle_offline_mode.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from typer.testing import CliRunner + +from seedpass.cli import app +from seedpass import cli + +runner = CliRunner() + + +def _make_pm(called, enabled=False): + cfg = SimpleNamespace( + get_offline_mode=lambda: enabled, + set_offline_mode=lambda v: called.setdefault("enabled", v), + ) + pm = SimpleNamespace( + config_manager=cfg, + offline_mode=enabled, + select_fingerprint=lambda fp: None, + ) + return pm + + +def test_toggle_offline_updates(monkeypatch): + called = {} + pm = _make_pm(called) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["config", "toggle-offline"], input="y\n") + assert result.exit_code == 0 + assert called == {"enabled": True} + assert "Offline mode enabled." in result.stdout + + +def test_toggle_offline_keep(monkeypatch): + called = {} + pm = _make_pm(called, enabled=True) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["config", "toggle-offline"], input="\n") + assert result.exit_code == 0 + assert called == {"enabled": True} + assert "Offline mode enabled." in result.stdout diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index b035a6b..d26e465 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -23,6 +23,8 @@ def test_config_defaults_and_round_trip(): assert cfg["pin_hash"] == "" assert cfg["password_hash"] == "" assert cfg["additional_backup_path"] == "" + assert cfg["quick_unlock"] is False + assert cfg["kdf_iterations"] == 50_000 cfg_mgr.set_pin("1234") cfg_mgr.set_relays(["wss://example.com"], require_pin=False) @@ -146,3 +148,51 @@ def test_secret_mode_round_trip(): cfg2 = cfg_mgr.load_config(require_pin=False) assert cfg2["secret_mode_enabled"] is True assert cfg2["clipboard_clear_delay"] == 99 + + +def test_kdf_iterations_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + assert cfg_mgr.get_kdf_iterations() == 50_000 + + cfg_mgr.set_kdf_iterations(200_000) + assert cfg_mgr.get_kdf_iterations() == 200_000 + + +def test_backup_interval_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + assert cfg_mgr.get_backup_interval() == 0 + + cfg_mgr.set_backup_interval(15) + assert cfg_mgr.get_backup_interval() == 15 + + +def test_quick_unlock_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + assert cfg_mgr.get_quick_unlock() is False + + cfg_mgr.set_quick_unlock(True) + assert cfg_mgr.get_quick_unlock() is True + + +def test_nostr_retry_settings_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["nostr_max_retries"] == 2 + assert cfg["nostr_retry_delay"] == 1.0 + + cfg_mgr.set_nostr_max_retries(5) + cfg_mgr.set_nostr_retry_delay(3.5) + assert cfg_mgr.get_nostr_max_retries() == 5 + assert cfg_mgr.get_nostr_retry_delay() == 3.5 diff --git a/src/tests/test_encryption_checksum.py b/src/tests/test_encryption_checksum.py index 127f503..33d76fc 100644 --- a/src/tests/test_encryption_checksum.py +++ b/src/tests/test_encryption_checksum.py @@ -3,7 +3,8 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +import os +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -14,7 +15,7 @@ from utils.checksum import verify_and_update_checksum def test_encryption_checksum_workflow(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) manager = EncryptionManager(key, tmp_path) data = {"value": 1} diff --git a/src/tests/test_encryption_files.py b/src/tests/test_encryption_files.py index 0b7ddbe..0332f6f 100644 --- a/src/tests/test_encryption_files.py +++ b/src/tests/test_encryption_files.py @@ -3,7 +3,8 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +import os +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -12,7 +13,7 @@ from password_manager.encryption import EncryptionManager def test_json_save_and_load_round_trip(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) manager = EncryptionManager(key, Path(tmpdir)) data = {"hello": "world", "nums": [1, 2, 3]} @@ -27,7 +28,7 @@ def test_json_save_and_load_round_trip(): def test_encrypt_and_decrypt_file_binary_round_trip(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) manager = EncryptionManager(key, Path(tmpdir)) payload = b"binary secret" diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 8ea313d..1714da5 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -103,7 +103,7 @@ def test_legacy_entry_defaults_to_password(): data["entries"][str(index)].pop("type", None) enc_mgr.save_json_data(data, entry_mgr.index_file) - loaded = entry_mgr._load_index() + loaded = entry_mgr._load_index(force_reload=True) assert loaded["entries"][str(index)]["type"] == "password" diff --git a/src/tests/test_fingerprint_encryption.py b/src/tests/test_fingerprint_encryption.py index c871dea..a306c1f 100644 --- a/src/tests/test_fingerprint_encryption.py +++ b/src/tests/test_fingerprint_encryption.py @@ -3,7 +3,8 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +import os +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -24,7 +25,7 @@ def test_generate_fingerprint_deterministic(): def test_encryption_round_trip(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) manager = EncryptionManager(key, Path(tmpdir)) data = b"secret data" rel_path = Path("testfile.enc") diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py new file mode 100644 index 0000000..2787261 --- /dev/null +++ b/src/tests/test_full_sync_roundtrip.py @@ -0,0 +1,65 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager +from password_manager.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + return pm + + +def test_full_sync_roundtrip(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + # Manager A publishes initial snapshot + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].id + + # Manager B retrieves snapshot + pm_b.sync_index_from_nostr_if_missing() + entries = pm_b.entry_manager.list_entries() + assert [e[1] for e in entries] == ["site1"] + + # Manager A publishes delta with second entry + pm_a.entry_manager.add_entry("site2", 12) + delta_bytes = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta_bytes, manifest_id)) + delta_ts = relay.deltas[-1].created_at + assert relay.manifests[-1].delta_since == delta_ts + + # Manager B fetches delta and updates + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2"] diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py new file mode 100644 index 0000000..2787261 --- /dev/null +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -0,0 +1,65 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager +from password_manager.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + return pm + + +def test_full_sync_roundtrip(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + # Manager A publishes initial snapshot + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].id + + # Manager B retrieves snapshot + pm_b.sync_index_from_nostr_if_missing() + entries = pm_b.entry_manager.list_entries() + assert [e[1] for e in entries] == ["site1"] + + # Manager A publishes delta with second entry + pm_a.entry_manager.add_entry("site2", 12) + delta_bytes = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta_bytes, manifest_id)) + delta_ts = relay.deltas[-1].created_at + assert relay.manifests[-1].delta_since == delta_ts + + # Manager B fetches delta and updates + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2"] diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py new file mode 100644 index 0000000..45a35b6 --- /dev/null +++ b/src/tests/test_fuzz_key_derivation.py @@ -0,0 +1,63 @@ +import os +from pathlib import Path + +from hypothesis import given, strategies as st, settings, HealthCheck +from mnemonic import Mnemonic + +from utils.key_derivation import ( + derive_key_from_password, + derive_key_from_password_argon2, + derive_index_key, +) +from password_manager.encryption import EncryptionManager + + +cfg_values = st.one_of( + st.integers(min_value=0, max_value=100), + st.text(min_size=0, max_size=20), + st.booleans(), +) + + +@given( + password=st.text(min_size=8, max_size=32), + seed_bytes=st.binary(min_size=16, max_size=16), + config=st.dictionaries(st.text(min_size=1, max_size=10), cfg_values, max_size=5), + mode=st.sampled_from(["pbkdf2", "argon2"]), +) +@settings( + deadline=None, + max_examples=20, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_fuzz_key_round_trip(password, seed_bytes, config, mode, tmp_path: Path): + """Ensure EncryptionManager round-trips arbitrary data.""" + seed_phrase = Mnemonic("english").to_mnemonic(seed_bytes) + if mode == "argon2": + key = derive_key_from_password_argon2( + password, time_cost=1, memory_cost=8, parallelism=1 + ) + else: + key = derive_key_from_password(password, iterations=1) + + enc_mgr = EncryptionManager(key, tmp_path) + + # Parent seed round trip + enc_mgr.encrypt_parent_seed(seed_phrase) + assert enc_mgr.decrypt_parent_seed() == seed_phrase + + # JSON data round trip + enc_mgr.save_json_data(config, Path("config.enc")) + loaded = enc_mgr.load_json_data(Path("config.enc")) + assert loaded == config + + # Binary data round trip + blob = os.urandom(32) + enc_mgr.encrypt_and_save_file(blob, Path("blob.enc")) + assert enc_mgr.decrypt_file(Path("blob.enc")) == blob + + # Index key derived from seed also decrypts + index_key = derive_index_key(seed_phrase) + idx_mgr = EncryptionManager(index_key, tmp_path) + idx_mgr.save_json_data(config) + assert idx_mgr.load_json_data() == config diff --git a/src/tests/test_generate_test_profile.py b/src/tests/test_generate_test_profile.py index 6313968..8b6da8c 100644 --- a/src/tests/test_generate_test_profile.py +++ b/src/tests/test_generate_test_profile.py @@ -24,7 +24,8 @@ def test_initialize_profile_creates_directories(monkeypatch): assert spec.loader is not None spec.loader.exec_module(gtp) - seed, mgr, dir_path, fingerprint = gtp.initialize_profile("test") + seed, mgr, dir_path, fingerprint, cfg_mgr = gtp.initialize_profile("test") + assert cfg_mgr is not None assert constants.APP_DIR.exists() assert (constants.APP_DIR / "test_seed.txt").exists() diff --git a/src/tests/test_generate_test_profile_sync.py b/src/tests/test_generate_test_profile_sync.py new file mode 100644 index 0000000..216bb3d --- /dev/null +++ b/src/tests/test_generate_test_profile_sync.py @@ -0,0 +1,72 @@ +import importlib +import importlib.util +from pathlib import Path +from tempfile import TemporaryDirectory +import asyncio +import gzip + +from helpers import dummy_nostr_client + + +def load_script(): + script_path = ( + Path(__file__).resolve().parents[2] / "scripts" / "generate_test_profile.py" + ) + spec = importlib.util.spec_from_file_location("generate_test_profile", script_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_generate_test_profile_sync(monkeypatch, dummy_nostr_client): + client, _relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + constants = importlib.import_module("constants") + importlib.reload(constants) + gtp = load_script() + + monkeypatch.setattr(gtp, "NostrClient", lambda *a, **k: client) + + seed, entry_mgr, dir_path, fingerprint, cfg_mgr = gtp.initialize_profile("test") + gtp.populate(entry_mgr, seed, 5) + + encrypted = entry_mgr.vault.get_encrypted_index() + nc = gtp.NostrClient( + entry_mgr.vault.encryption_manager, + fingerprint, + parent_seed=seed, + config_manager=cfg_mgr, + ) + asyncio.run(nc.publish_snapshot(encrypted)) + + from nostr.client import NostrClient as RealClient + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + monkeypatch.setattr("nostr.client.KeyManager", DummyKeyManager) + client2 = RealClient( + entry_mgr.vault.encryption_manager, + fingerprint, + parent_seed=seed, + config_manager=cfg_mgr, + ) + result = asyncio.run(client2.fetch_latest_snapshot()) + + assert result is not None + _manifest, chunks = result + assert _manifest.delta_since is None + retrieved = gzip.decompress(b"".join(chunks)) + assert retrieved == encrypted diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py index 0c68561..32d81da 100644 --- a/src/tests/test_inactivity_lock.py +++ b/src/tests/test_inactivity_lock.py @@ -34,6 +34,8 @@ def test_inactivity_triggers_lock(monkeypatch): update_activity=update_activity, lock_vault=lock_vault, unlock_vault=unlock_vault, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) monkeypatch.setattr(main, "timed_input", lambda *_: "") @@ -70,6 +72,8 @@ def test_input_timeout_triggers_lock(monkeypatch): update_activity=update_activity, lock_vault=lock_vault, unlock_vault=unlock_vault, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) responses = iter([TimeoutError(), ""]) diff --git a/src/tests/test_index_cache.py b/src/tests/test_index_cache.py new file mode 100644 index 0000000..e4a054b --- /dev/null +++ b/src/tests/test_index_cache.py @@ -0,0 +1,33 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager + + +def test_index_caching(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + # create initial entry so the index file exists + entry_mgr.add_entry("init", 8) + entry_mgr.clear_cache() + + with patch.object(vault, "load_index", wraps=vault.load_index) as mocked: + idx = entry_mgr.add_entry("example.com", 8) + assert mocked.call_count == 1 + + entry = entry_mgr.retrieve_entry(idx) + assert entry["label"] == "example.com" + assert mocked.call_count == 1 + + entry_mgr.clear_cache() + entry = entry_mgr.retrieve_entry(idx) + assert entry["label"] == "example.com" + assert mocked.call_count == 2 diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py new file mode 100644 index 0000000..ab453de --- /dev/null +++ b/src/tests/test_kdf_modes.py @@ -0,0 +1,75 @@ +import bcrypt +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from utils.key_derivation import ( + derive_key_from_password, + derive_key_from_password_argon2, + derive_index_key, +) +from password_manager.encryption import EncryptionManager +from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager +from password_manager.manager import PasswordManager, EncryptionMode + +TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +TEST_PASSWORD = "pw" + + +def _setup_profile(tmp: Path, mode: str): + argon_kwargs = dict(time_cost=1, memory_cost=8, parallelism=1) + if mode == "argon2": + seed_key = derive_key_from_password_argon2(TEST_PASSWORD, **argon_kwargs) + else: + seed_key = derive_key_from_password(TEST_PASSWORD, iterations=1) + EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED) + + index_key = derive_index_key(TEST_SEED) + enc_mgr = EncryptionManager(index_key, tmp) + vault = Vault(enc_mgr, tmp) + cfg_mgr = ConfigManager(vault, tmp) + cfg = cfg_mgr.load_config(require_pin=False) + cfg["password_hash"] = bcrypt.hashpw( + TEST_PASSWORD.encode(), bcrypt.gensalt() + ).decode() + cfg["kdf_mode"] = mode + cfg["kdf_iterations"] = 1 + cfg_mgr.save_config(cfg) + return cfg_mgr + + +def _make_pm(tmp: Path, cfg: ConfigManager): + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.config_manager = cfg + pm.fingerprint_dir = tmp + pm.current_fingerprint = "fp" + pm.verify_password = lambda pw: True + return pm + + +def test_setup_encryption_manager_kdf_modes(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + argon_kwargs = dict(time_cost=1, memory_cost=8, parallelism=1) + for mode in ("pbkdf2", "argon2"): + path = tmp / mode + path.mkdir() + cfg = _setup_profile(path, mode) + pm = _make_pm(path, cfg) + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", + lambda *_: TEST_PASSWORD, + ) + if mode == "argon2": + monkeypatch.setattr( + "password_manager.manager.derive_key_from_password_argon2", + lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs), + ) + monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) + monkeypatch.setattr( + PasswordManager, "initialize_managers", lambda self: None + ) + assert pm.setup_encryption_manager(path, exit_on_fail=False) + assert pm.parent_seed == TEST_SEED diff --git a/src/tests/test_key_derivation.py b/src/tests/test_key_derivation.py index a1ea90f..bfa0c65 100644 --- a/src/tests/test_key_derivation.py +++ b/src/tests/test_key_derivation.py @@ -2,6 +2,7 @@ import logging import pytest from utils.key_derivation import ( derive_key_from_password, + derive_key_from_password_argon2, derive_index_key_seed_only, derive_index_key, ) @@ -33,3 +34,11 @@ def test_seed_only_key_deterministic(): def test_derive_index_key_seed_only(): seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" assert derive_index_key(seed) == derive_index_key_seed_only(seed) + + +def test_argon2_key_deterministic(): + pw = "correct horse battery staple" + k1 = derive_key_from_password_argon2(pw, time_cost=1, memory_cost=8, parallelism=1) + k2 = derive_key_from_password_argon2(pw, time_cost=1, memory_cost=8, parallelism=1) + assert k1 == k2 + assert len(k1) == 44 diff --git a/src/tests/test_last_used_fingerprint.py b/src/tests/test_last_used_fingerprint.py new file mode 100644 index 0000000..b097c4a --- /dev/null +++ b/src/tests/test_last_used_fingerprint.py @@ -0,0 +1,60 @@ +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory + +import constants +import password_manager.manager as manager_module +from utils.fingerprint_manager import FingerprintManager +from password_manager.manager import EncryptionMode + +from helpers import TEST_SEED + + +def test_last_used_fingerprint(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + assert fm.current_fingerprint == fp + + # Ensure persistence on reload + fm2 = FingerprintManager(constants.APP_DIR) + assert fm2.current_fingerprint == fp + + def init_fm(self): + self.fingerprint_manager = fm2 + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_fingerprint_manager", init_fm + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "setup_encryption_manager", + lambda *a, **k: True, + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "sync_index_from_nostr_if_missing", + lambda self: None, + ) + monkeypatch.setattr( + manager_module.PasswordManager, "verify_password", lambda *a, **k: True + ) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + + pm = manager_module.PasswordManager() + assert pm.current_fingerprint == fp diff --git a/src/tests/test_legacy_migration.py b/src/tests/test_legacy_migration.py new file mode 100644 index 0000000..87cd3ed --- /dev/null +++ b/src/tests/test_legacy_migration.py @@ -0,0 +1,42 @@ +import json +import hashlib +from pathlib import Path + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD +from utils.key_derivation import derive_index_key +from cryptography.fernet import Fernet + + +def test_legacy_index_migrates(tmp_path: Path): + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + + key = derive_index_key(TEST_SEED) + data = { + "schema_version": 4, + "entries": { + "0": { + "label": "a", + "length": 8, + "type": "password", + "kind": "password", + "notes": "", + "custom_fields": [], + "origin": "", + "tags": [], + } + }, + } + enc = Fernet(key).encrypt(json.dumps(data).encode()) + legacy_file = tmp_path / "seedpass_passwords_db.json.enc" + legacy_file.write_bytes(enc) + (tmp_path / "seedpass_passwords_db_checksum.txt").write_text( + hashlib.sha256(enc).hexdigest() + ) + + loaded = vault.load_index() + assert loaded == data + + new_file = tmp_path / "seedpass_entries_db.json.enc" + assert new_file.exists() + assert not legacy_file.exists() + assert not (tmp_path / "seedpass_passwords_db_checksum.txt").exists() diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index c011d3e..3dd140a 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -52,7 +52,9 @@ def test_handle_add_totp(monkeypatch, capsys): ] ) monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) - monkeypatch.setattr(pm, "sync_vault", lambda: None) + monkeypatch.setattr( + pm, "start_background_vault_sync", lambda *a, **k: pm.sync_vault(*a, **k) + ) pm.handle_add_totp() out = capsys.readouterr().out diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py index 4a74ba0..cfba90c 100644 --- a/src/tests/test_manager_checksum_backup.py +++ b/src/tests/test_manager_checksum_backup.py @@ -4,6 +4,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.manager import PasswordManager, EncryptionMode +import queue class FakeBackupManager: @@ -20,6 +21,7 @@ class FakeBackupManager: def _make_pm(): pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.notifications = queue.Queue() return pm @@ -56,8 +58,9 @@ def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys): monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) pm.handle_verify_checksum() - out = capsys.readouterr().out.lower() - assert "generate script checksum" in out + note = pm.notifications.get_nowait() + assert note.level == "WARNING" + assert "generate script checksum" in note.message.lower() def test_backup_and_restore_database(monkeypatch, capsys): diff --git a/src/tests/test_manager_current_notification.py b/src/tests/test_manager_current_notification.py new file mode 100644 index 0000000..ab94d9e --- /dev/null +++ b/src/tests/test_manager_current_notification.py @@ -0,0 +1,45 @@ +import queue + +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.manager import PasswordManager, Notification +from constants import NOTIFICATION_DURATION + + +def _make_pm(): + pm = PasswordManager.__new__(PasswordManager) + pm.notifications = queue.Queue() + pm._current_notification = None + pm._notification_expiry = 0.0 + return pm + + +def test_notify_sets_current(monkeypatch): + pm = _make_pm() + current = {"val": 100.0} + monkeypatch.setattr("password_manager.manager.time.time", lambda: current["val"]) + pm.notify("hello") + note = pm._current_notification + assert hasattr(note, "message") + assert note.message == "hello" + assert pm._notification_expiry == 100.0 + NOTIFICATION_DURATION + assert pm.notifications.qsize() == 1 + + +def test_get_current_notification_ttl(monkeypatch): + pm = _make_pm() + now = {"val": 0.0} + monkeypatch.setattr("password_manager.manager.time.time", lambda: now["val"]) + pm.notify("note1") + + assert pm.get_current_notification().message == "note1" + assert pm.notifications.qsize() == 1 + + now["val"] += NOTIFICATION_DURATION - 1 + assert pm.get_current_notification().message == "note1" + + now["val"] += 2 + assert pm.get_current_notification() is None diff --git a/src/tests/test_manager_warning_notifications.py b/src/tests/test_manager_warning_notifications.py new file mode 100644 index 0000000..55ad85c --- /dev/null +++ b/src/tests/test_manager_warning_notifications.py @@ -0,0 +1,45 @@ +import queue +from types import SimpleNamespace +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from helpers import create_vault, TEST_SEED, TEST_PASSWORD +from password_manager.config_manager import ConfigManager + + +def _make_pm(tmp_path: Path) -> PasswordManager: + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.notifications = queue.Queue() + return pm + + +def test_handle_search_entries_no_query(monkeypatch, tmp_path): + pm = _make_pm(tmp_path) + monkeypatch.setattr( + "password_manager.manager.clear_header_with_notification", lambda *a, **k: None + ) + monkeypatch.setattr("password_manager.manager.pause", lambda: None) + monkeypatch.setattr("builtins.input", lambda *_: "") + + pm.handle_search_entries() + note = pm.notifications.get_nowait() + assert note.level == "WARNING" + assert note.message == "No search string provided." diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index 5d2dbd0..a5046b2 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -71,6 +71,12 @@ def test_manager_workflow(monkeypatch): ) monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) + monkeypatch.setattr( + pm, + "start_background_vault_sync", + lambda *a, **k: pm.sync_vault(*a, **k), + ) + pm.handle_add_password() assert pm.is_dirty is False backups = list((tmp_path / "backups").glob("entries_db_backup_*.json.enc")) diff --git a/src/tests/test_menu_navigation.py b/src/tests/test_menu_navigation.py index 8ab7bae..f7ef100 100644 --- a/src/tests/test_menu_navigation.py +++ b/src/tests/test_menu_navigation.py @@ -30,6 +30,8 @@ def _make_pm(calls): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) diff --git a/src/tests/test_menu_notifications.py b/src/tests/test_menu_notifications.py new file mode 100644 index 0000000..402d4da --- /dev/null +++ b/src/tests/test_menu_notifications.py @@ -0,0 +1,79 @@ +import time +import queue +from types import SimpleNamespace +from pathlib import Path +import sys +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def _make_pm(msg): + q = queue.Queue() + if msg is not None: + q.put(SimpleNamespace(message=msg, level="INFO")) + return SimpleNamespace( + notifications=q, + get_current_notification=lambda: q.queue[-1] if not q.empty() else None, + _current_notification=None, + _notification_expiry=0.0, + is_dirty=False, + last_update=time.time(), + last_activity=time.time(), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + handle_add_password=lambda: None, + handle_retrieve_entry=lambda: None, + handle_search_entries=lambda: None, + handle_list_entries=lambda: None, + handle_modify_entry=lambda: None, + handle_display_totp_codes=lambda: None, + update_activity=lambda: None, + lock_vault=lambda: None, + unlock_vault=lambda: None, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, + profile_stack=[], + current_fingerprint="fp", + ) + + +def test_display_menu_prints_notifications(monkeypatch, capsys): + pm = _make_pm("hello") + monkeypatch.setattr(main, "_display_live_stats", lambda *_: None) + monkeypatch.setattr( + main, + "clear_header_with_notification", + lambda pm, *a, **k: ( + print("HEADER"), + print(main.get_notification_text(pm)), + ), + ) + monkeypatch.setattr(main, "timed_input", lambda *a, **k: "") + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + out = capsys.readouterr().out + assert out.splitlines()[0] == "HEADER" + assert out.splitlines()[1] == "hello" + + +def test_display_menu_reuses_notification_line(monkeypatch, capsys): + pm = _make_pm(None) + msgs = iter(["first", "second"]) + monkeypatch.setattr(main, "_display_live_stats", lambda *_: None) + monkeypatch.setattr( + main, + "clear_header_with_notification", + lambda _pm, *a, **k: (print("HEADER"), print(next(msgs, ""))), + ) + inputs = iter(["9", ""]) + monkeypatch.setattr(main, "timed_input", lambda *a, **k: next(inputs)) + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + out = capsys.readouterr().out + lines = out.splitlines() + assert lines[0] == "HEADER" + assert out.count("first") == 1 + assert out.count("second") == 1 + assert out.count("Select an option:") == 2 diff --git a/src/tests/test_menu_options.py b/src/tests/test_menu_options.py index ff8e7cf..d70b609 100644 --- a/src/tests/test_menu_options.py +++ b/src/tests/test_menu_options.py @@ -24,6 +24,8 @@ def _make_pm(calls): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) diff --git a/src/tests/test_menu_search.py b/src/tests/test_menu_search.py index 0e1d439..e1f78c8 100644 --- a/src/tests/test_menu_search.py +++ b/src/tests/test_menu_search.py @@ -23,6 +23,8 @@ def _make_pm(called): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) return pm diff --git a/src/tests/test_modify_totp_entry.py b/src/tests/test_modify_totp_entry.py index b1cb825..8e038d6 100644 --- a/src/tests/test_modify_totp_entry.py +++ b/src/tests/test_modify_totp_entry.py @@ -1,4 +1,5 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD +import pytest from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager @@ -18,3 +19,14 @@ def test_modify_totp_entry_period_digits_and_archive(tmp_path): assert entry["period"] == 60 assert entry["digits"] == 8 assert entry["archived"] is True + + +def test_modify_totp_entry_invalid_field(tmp_path): + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + em = EntryManager(vault, backup_mgr) + + em.add_totp("Example", TEST_SEED) + with pytest.raises(ValueError): + em.modify_entry(0, username="alice") diff --git a/src/tests/test_multiple_fingerprint_prompt.py b/src/tests/test_multiple_fingerprint_prompt.py new file mode 100644 index 0000000..f065ac6 --- /dev/null +++ b/src/tests/test_multiple_fingerprint_prompt.py @@ -0,0 +1,64 @@ +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory + +import constants +import password_manager.manager as manager_module +from utils.fingerprint_manager import FingerprintManager + +from helpers import TEST_SEED + +OTHER_SEED = ( + "legal winner thank year wave sausage worth useful legal winner thank yellow" +) + + +def test_prompt_when_multiple_fingerprints(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp1 = fm.add_fingerprint(TEST_SEED) + fm.add_fingerprint(OTHER_SEED) + + def init_fm(self): + self.fingerprint_manager = fm + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_fingerprint_manager", init_fm + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "setup_encryption_manager", + lambda *a, **k: True, + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "sync_index_from_nostr_if_missing", + lambda self: None, + ) + monkeypatch.setattr( + manager_module.PasswordManager, "verify_password", lambda *a, **k: True + ) + + calls = {"count": 0} + + def fake_input(*args, **kwargs): + calls["count"] += 1 + return "1" # select first fingerprint + + monkeypatch.setattr("builtins.input", fake_input) + + pm = manager_module.PasswordManager() + assert calls["count"] == 1 + assert pm.current_fingerprint == fp1 diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 310ab4b..c3a6e9a 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -4,7 +4,8 @@ from tempfile import TemporaryDirectory from unittest.mock import patch import json import asyncio -from cryptography.fernet import Fernet +import os +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -15,7 +16,7 @@ import nostr.client as nostr_client def test_nostr_client_uses_custom_relays(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) enc_mgr = EncryptionManager(key, Path(tmpdir)) custom_relays = ["wss://relay1", "wss://relay2"] @@ -73,7 +74,7 @@ class FakeWebSocket: def _setup_client(tmpdir, fake_cls): - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) enc_mgr = EncryptionManager(key, Path(tmpdir)) with patch("nostr.client.Client", fake_cls), patch( @@ -88,6 +89,7 @@ def _setup_client(tmpdir, fake_cls): def test_initialize_client_pool_add_relays_used(tmp_path): client = _setup_client(tmp_path, FakeAddRelaysClient) fc = client.client + client.connect() assert fc.added == [client.relays] assert fc.connected is True @@ -95,6 +97,7 @@ def test_initialize_client_pool_add_relays_used(tmp_path): def test_initialize_client_pool_add_relay_fallback(tmp_path): client = _setup_client(tmp_path, FakeAddRelayClient) fc = client.client + client.connect() assert fc.added == client.relays assert fc.connected is True @@ -128,3 +131,23 @@ def test_ping_relay_accepts_eose(tmp_path, monkeypatch): result = asyncio.run(client._ping_relay("wss://relay", timeout=0.1)) assert result is True + + +def test_update_relays_reinitializes_pool(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + monkeypatch.setattr(nostr_client, "Client", FakeAddRelaysClient) + + called = {"ran": False} + + def fake_init(self): + called["ran"] = True + + monkeypatch.setattr(NostrClient, "initialize_client_pool", fake_init) + + new_relays = ["wss://relay1"] + client.update_relays(new_relays) + + assert called["ran"] is True + assert isinstance(client.client, FakeAddRelaysClient) + assert client.relays == new_relays diff --git a/src/tests/test_nostr_contract.py b/src/tests/test_nostr_contract.py index 29a974c..34ce289 100644 --- a/src/tests/test_nostr_contract.py +++ b/src/tests/test_nostr_contract.py @@ -3,7 +3,8 @@ from pathlib import Path from unittest.mock import patch import asyncio import gzip -from cryptography.fernet import Fernet +import os +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -61,7 +62,7 @@ class MockClient: def setup_client(tmp_path, server): - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) enc_mgr = EncryptionManager(key, tmp_path) with patch("nostr.client.Client", lambda signer: MockClient(server)), patch( diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 89bc250..5cdfddb 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -49,6 +49,10 @@ def test_publish_and_fetch_deltas(dummy_nostr_client): d1 = b"d1" d2 = b"d2" asyncio.run(client.publish_delta(d1, manifest_id)) + first_ts = relay.deltas[-1].created_at asyncio.run(client.publish_delta(d2, manifest_id)) + second_ts = relay.deltas[-1].created_at + assert second_ts > first_ts + assert relay.manifests[-1].delta_since == second_ts deltas = asyncio.run(client.fetch_deltas_since(0)) assert deltas == [d1, d2] diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 442330f..4277a76 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -10,7 +10,8 @@ import uuid import pytest -from cryptography.fernet import Fernet +import base64 +import os sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -32,7 +33,7 @@ def test_nostr_index_size_limits(pytestconfig: pytest.Config): ) results = [] with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) enc_mgr = EncryptionManager(key, Path(tmpdir)) with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed): client = NostrClient( diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index 97b466b..0226626 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -9,7 +9,7 @@ import gzip import uuid import pytest -from cryptography.fernet import Fernet +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -25,7 +25,9 @@ def test_nostr_publish_and_retrieve(): "abandon abandon abandon abandon about" ) with TemporaryDirectory() as tmpdir: - enc_mgr = EncryptionManager(Fernet.generate_key(), Path(tmpdir)) + enc_mgr = EncryptionManager( + base64.urlsafe_b64encode(os.urandom(32)), Path(tmpdir) + ) with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed): client = NostrClient( enc_mgr, diff --git a/src/tests/test_nostr_snapshot.py b/src/tests/test_nostr_snapshot.py index 3d60560..32fcbdb 100644 --- a/src/tests/test_nostr_snapshot.py +++ b/src/tests/test_nostr_snapshot.py @@ -3,8 +3,8 @@ import json import gzip from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet import base64 +import os import asyncio from unittest.mock import patch @@ -82,7 +82,9 @@ def test_fetch_latest_snapshot(): client = DummyClient(events) with TemporaryDirectory() as tmpdir: - enc_mgr = EncryptionManager(Fernet.generate_key(), Path(tmpdir)) + enc_mgr = EncryptionManager( + base64.urlsafe_b64encode(os.urandom(32)), Path(tmpdir) + ) with patch("nostr.client.Client", lambda signer: client), patch( "nostr.client.KeyManager" ) as MockKM, patch.object(NostrClient, "initialize_client_pool"), patch.object( diff --git a/src/tests/test_offline_mode_behavior.py b/src/tests/test_offline_mode_behavior.py new file mode 100644 index 0000000..0480207 --- /dev/null +++ b/src/tests/test_offline_mode_behavior.py @@ -0,0 +1,27 @@ +import time +from types import SimpleNamespace + +from password_manager.manager import PasswordManager + + +def test_sync_vault_skips_network(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = True + pm.get_encrypted_data = lambda: b"data" + called = {"nostr": False} + pm.nostr_client = SimpleNamespace( + publish_snapshot=lambda *a, **kw: called.__setitem__("nostr", True) + ) + result = PasswordManager.sync_vault(pm) + assert result is None + assert called["nostr"] is False + + +def test_start_background_sync_offline(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = True + called = {"sync": False} + pm.sync_index_from_nostr = lambda: called.__setitem__("sync", True) + PasswordManager.start_background_sync(pm) + time.sleep(0.05) + assert called["sync"] is False diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py index 728f8b0..ff379a6 100644 --- a/src/tests/test_parent_seed_backup.py +++ b/src/tests/test_parent_seed_backup.py @@ -2,6 +2,7 @@ import builtins import sys from pathlib import Path from types import SimpleNamespace +import queue sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -16,6 +17,7 @@ def _make_pm(tmp_path: Path) -> PasswordManager: pm.fingerprint_dir = tmp_path pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=lambda *a, **k: None) pm.verify_password = lambda pw: True + pm.notifications = queue.Queue() return pm diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py new file mode 100644 index 0000000..5384075 --- /dev/null +++ b/src/tests/test_password_generation_policy.py @@ -0,0 +1,68 @@ +import string +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.password_generation import PasswordGenerator, PasswordPolicy + + +class DummyEnc: + def derive_seed_from_mnemonic(self, mnemonic): + return b"\x00" * 32 + + +class DummyBIP85: + def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(bytes_len)) + + +def make_generator(policy=None): + pg = PasswordGenerator.__new__(PasswordGenerator) + pg.encryption_manager = DummyEnc() + pg.bip85 = DummyBIP85() + pg.policy = policy or PasswordPolicy() + return pg + + +def count_types(pw: str): + return ( + sum(c.isupper() for c in pw), + sum(c.islower() for c in pw), + sum(c.isdigit() for c in pw), + sum(c in string.punctuation for c in pw), + ) + + +def test_zero_policy_preserves_length(): + policy = PasswordPolicy(0, 0, 0, 0) + pg = make_generator(policy) + alphabet = string.ascii_lowercase + dk = bytes(range(32)) + result = pg._enforce_complexity("a" * 32, alphabet, dk) + assert len(result) == 32 + + +def test_custom_policy_applied(): + policy = PasswordPolicy( + min_uppercase=4, min_lowercase=1, min_digits=3, min_special=2 + ) + pg = make_generator(policy) + alphabet = string.ascii_letters + string.digits + string.punctuation + dk = bytes(range(32)) + result = pg._enforce_complexity("a" * 32, alphabet, dk) + counts = count_types(result) + assert counts[0] >= 4 + assert counts[1] >= 1 + assert counts[2] >= 3 + assert counts[3] >= 2 + + +def test_generate_password_respects_policy(): + policy = PasswordPolicy( + min_uppercase=3, min_lowercase=3, min_digits=3, min_special=3 + ) + pg = make_generator(policy) + pw = pg.generate_password(length=16, index=1) + counts = count_types(pw) + assert all(c >= 3 for c in counts) diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index 9253130..d6f661c 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -1,5 +1,5 @@ import string -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: @@ -16,6 +16,7 @@ def make_generator(): pg = PasswordGenerator.__new__(PasswordGenerator) pg.encryption_manager = DummyEnc() pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() return pg diff --git a/src/tests/test_password_length_constraints.py b/src/tests/test_password_length_constraints.py index db38702..eaa4941 100644 --- a/src/tests/test_password_length_constraints.py +++ b/src/tests/test_password_length_constraints.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import PasswordGenerator, PasswordPolicy from constants import MIN_PASSWORD_LENGTH @@ -22,6 +22,7 @@ def make_generator(): pg = PasswordGenerator.__new__(PasswordGenerator) pg.encryption_manager = DummyEnc() pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() return pg diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index f51dd5f..60fce89 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -5,7 +5,7 @@ from hypothesis import given, strategies as st, settings sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import PasswordGenerator, PasswordPolicy from password_manager.entry_types import EntryType @@ -23,6 +23,7 @@ def make_generator(): pg = PasswordGenerator.__new__(PasswordGenerator) pg.encryption_manager = DummyEnc() pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() return pg diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 8c89fae..dc8910b 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -43,6 +43,8 @@ def test_round_trip(monkeypatch): path = export_backup(vault, backup, parent_seed=SEED) assert path.exists() + wrapper = json.loads(path.read_text()) + assert wrapper.get("cipher") == "aes-gcm" vault.save_index({"pw": 0}) import_backup(vault, backup, path, parent_seed=SEED) diff --git a/src/tests/test_profile_cleanup.py b/src/tests/test_profile_cleanup.py new file mode 100644 index 0000000..1959489 --- /dev/null +++ b/src/tests/test_profile_cleanup.py @@ -0,0 +1,49 @@ +import sys +import importlib +import json +from pathlib import Path +from tempfile import TemporaryDirectory +import pytest +from unittest.mock import patch + +sys.path.append(str(Path(__file__).resolve().parents[1])) + + +def setup_pm(tmp_path): + import constants + import password_manager.manager as manager_module + + importlib.reload(constants) + importlib.reload(manager_module) + + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + pm.encryption_mode = manager_module.EncryptionMode.SEED_ONLY + pm.fingerprint_manager = manager_module.FingerprintManager(constants.APP_DIR) + pm.current_fingerprint = None + return pm, constants, manager_module + + +def test_generate_seed_cleanup_on_failure(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + pm, const, mgr = setup_pm(tmp_path) + + with patch("password_manager.manager.confirm_action", return_value=True): + monkeypatch.setattr( + pm, + "save_and_encrypt_seed", + lambda seed, d: (_ for _ in ()).throw(RuntimeError("fail")), + ) + with pytest.raises(RuntimeError): + pm.generate_new_seed() + + # fingerprint list should be empty and only fingerprints.json should remain + assert pm.fingerprint_manager.list_fingerprints() == [] + contents = list(const.APP_DIR.iterdir()) + assert len(contents) == 1 and contents[0].name == "fingerprints.json" + fp_file = pm.fingerprint_manager.fingerprints_file + with open(fp_file) as f: + data = json.load(f) + assert data.get("fingerprints") == [] diff --git a/src/tests/test_profile_init_integration.py b/src/tests/test_profile_init_integration.py new file mode 100644 index 0000000..62c291a --- /dev/null +++ b/src/tests/test_profile_init_integration.py @@ -0,0 +1,48 @@ +import importlib +import importlib.util +from pathlib import Path +from tempfile import TemporaryDirectory + +from password_manager.manager import PasswordManager, EncryptionMode + + +def load_script(): + script_path = ( + Path(__file__).resolve().parents[2] / "scripts" / "generate_test_profile.py" + ) + spec = importlib.util.spec_from_file_location("generate_test_profile", script_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_initialize_profile_and_manager(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + gtp = load_script() + + seed, _mgr, dir_path, fingerprint, cfg_mgr = gtp.initialize_profile("test") + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.config_manager = cfg_mgr + pm.fingerprint_dir = dir_path + pm.current_fingerprint = fingerprint + + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", + lambda *_: gtp.DEFAULT_PASSWORD, + ) + monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) + monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) + + assert pm.setup_encryption_manager(dir_path, exit_on_fail=False) + assert pm.parent_seed == seed + + index = pm.vault.load_index() + config = pm.config_manager.load_config(require_pin=False) + assert "entries" in index + assert config["password_hash"] diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index ae5dcce..bbb52de 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -74,6 +74,11 @@ def test_add_and_delete_entry(monkeypatch): inputs = iter([str(index)]) monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(inputs)) + monkeypatch.setattr( + pm, + "start_background_vault_sync", + lambda *a, **k: pm.sync_vault(*a, **k), + ) pm.delete_entry() diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index 176c968..0abc648 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -4,7 +4,8 @@ from tempfile import TemporaryDirectory from unittest.mock import patch import asyncio import pytest -from cryptography.fernet import Fernet +import os +import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -13,7 +14,7 @@ from nostr.client import NostrClient, Manifest def setup_client(tmp_path): - key = Fernet.generate_key() + key = base64.urlsafe_b64encode(os.urandom(32)) enc_mgr = EncryptionManager(key, tmp_path) with patch("nostr.client.ClientBuilder"), patch( diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py new file mode 100644 index 0000000..a273be3 --- /dev/null +++ b/src/tests/test_seed_migration.py @@ -0,0 +1,29 @@ +import sys +from pathlib import Path +from cryptography.fernet import Fernet + +from helpers import TEST_PASSWORD, TEST_SEED +from utils.key_derivation import derive_key_from_password + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.encryption import EncryptionManager + + +def test_parent_seed_migrates_from_fernet(tmp_path: Path) -> None: + key = derive_key_from_password(TEST_PASSWORD) + fernet = Fernet(key) + encrypted = fernet.encrypt(TEST_SEED.encode()) + legacy_file = tmp_path / "parent_seed.enc" + legacy_file.write_bytes(encrypted) + + manager = EncryptionManager(key, tmp_path) + decrypted = manager.decrypt_parent_seed() + + assert decrypted == TEST_SEED + + new_file = tmp_path / "parent_seed.enc" + + assert new_file.exists() + assert new_file.read_bytes() != encrypted + assert new_file.read_bytes().startswith(b"V2:") diff --git a/src/tests/test_stats_screen.py b/src/tests/test_stats_screen.py new file mode 100644 index 0000000..f65d72d --- /dev/null +++ b/src/tests/test_stats_screen.py @@ -0,0 +1,38 @@ +import sys +from types import SimpleNamespace +from pathlib import Path +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def _make_pm(): + return SimpleNamespace(display_stats=lambda: print("stats")) + + +def test_live_stats_shows_message(monkeypatch, capsys): + pm = _make_pm() + monkeypatch.setattr(main, "get_notification_text", lambda *_: "") + monkeypatch.setattr( + main, + "timed_input", + lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + main._display_live_stats(pm) + out = capsys.readouterr().out + assert "Press Enter to continue." in out + + +def test_live_stats_shows_notification(monkeypatch, capsys): + pm = _make_pm() + monkeypatch.setattr(main, "get_notification_text", lambda *_: "note") + monkeypatch.setattr( + main, + "timed_input", + lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + main._display_live_stats(pm) + out = capsys.readouterr().out + assert "note" in out diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 21feb6c..b64a2d1 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -88,7 +88,9 @@ def test_vault_import(monkeypatch, tmp_path): called["path"] = path pm = SimpleNamespace( - handle_import_database=import_db, select_fingerprint=lambda fp: None + handle_import_database=import_db, + select_fingerprint=lambda fp: None, + sync_vault=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) in_path = tmp_path / "in.json" @@ -98,6 +100,29 @@ def test_vault_import(monkeypatch, tmp_path): assert called["path"] == in_path +def test_vault_import_triggers_sync(monkeypatch, tmp_path): + called = {} + + def import_db(path): + called["path"] = path + + def sync(): + called["sync"] = True + + pm = SimpleNamespace( + handle_import_database=import_db, + sync_vault=sync, + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + in_path = tmp_path / "in.json" + in_path.write_text("{}") + result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) + assert result.exit_code == 0 + assert called["path"] == in_path + assert called.get("sync") is True + + def test_vault_change_password(monkeypatch): called = {} @@ -307,6 +332,21 @@ def test_api_start_passes_fingerprint(monkeypatch): assert called.get("fp") == "abc" +def test_entry_list_passes_fingerprint(monkeypatch): + """Ensure entry commands receive the fingerprint.""" + called = {} + + class PM: + def __init__(self, fingerprint=None): + called["fp"] = fingerprint + self.entry_manager = SimpleNamespace(list_entries=lambda *a, **k: []) + + monkeypatch.setattr(cli, "PasswordManager", PM) + result = runner.invoke(app, ["--fingerprint", "abc", "entry", "list"]) + assert result.exit_code == 0 + assert called.get("fp") == "abc" + + def test_entry_add(monkeypatch): called = {} @@ -317,6 +357,7 @@ def test_entry_add(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(add_entry=add_entry), select_fingerprint=lambda fp: None, + sync_vault=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke( @@ -347,6 +388,7 @@ def test_entry_modify(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, + sync_vault=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -354,6 +396,21 @@ def test_entry_modify(monkeypatch): assert called["args"][:5] == (1, "alice", None, None, None) +def test_entry_modify_invalid(monkeypatch): + def modify_entry(*a, **k): + raise ValueError("bad") + + pm = SimpleNamespace( + entry_manager=SimpleNamespace(modify_entry=modify_entry), + select_fingerprint=lambda fp: None, + sync_vault=lambda: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) + assert result.exit_code == 1 + assert "bad" in result.stdout + + def test_entry_archive(monkeypatch): called = {} @@ -363,6 +420,7 @@ def test_entry_archive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(archive_entry=archive_entry), select_fingerprint=lambda fp: None, + sync_vault=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "archive", "3"]) @@ -380,6 +438,7 @@ def test_entry_unarchive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(restore_entry=restore_entry), select_fingerprint=lambda fp: None, + sync_vault=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "unarchive", "4"]) @@ -447,3 +506,21 @@ def test_update_checksum_command(monkeypatch): result = runner.invoke(app, ["util", "update-checksum"]) assert result.exit_code == 0 assert called.get("called") is True + + +def test_tui_forward_fingerprint(monkeypatch): + """Ensure --fingerprint is forwarded when launching the TUI.""" + called = {} + + def fake_main(*, fingerprint=None): + called["fp"] = fingerprint + return 0 + + fake_mod = SimpleNamespace(main=fake_main) + monkeypatch.setattr( + cli, "importlib", SimpleNamespace(import_module=lambda n: fake_mod) + ) + + result = runner.invoke(app, ["--fingerprint", "abc"]) + assert result.exit_code == 0 + assert called.get("fp") == "abc" diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index 7eef4c5..d618974 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -6,6 +6,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.manager import PasswordManager +from password_manager import manager as manager_module def test_unlock_triggers_sync(monkeypatch, tmp_path): @@ -22,5 +23,34 @@ def test_unlock_triggers_sync(monkeypatch, tmp_path): monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync) pm.unlock_vault() + pm.start_background_sync() + time.sleep(0.05) assert called["sync"] + + +def test_quick_unlock_background_sync(monkeypatch, tmp_path): + pm = PasswordManager.__new__(PasswordManager) + pm.profile_stack = [("rootfp", tmp_path, "seed")] + pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: True) + + monkeypatch.setattr(manager_module, "derive_index_key", lambda s: b"k") + monkeypatch.setattr( + manager_module, "EncryptionManager", lambda *a, **k: SimpleNamespace() + ) + monkeypatch.setattr(manager_module, "Vault", lambda *a, **k: SimpleNamespace()) + + pm.initialize_bip85 = lambda: None + pm.initialize_managers = lambda: None + pm.update_activity = lambda: None + + called = {"bg": False} + + def fake_bg(): + called["bg"] = True + + pm.start_background_sync = fake_bg + + pm.exit_managed_account() + + assert called["bg"] diff --git a/src/tests/test_v2_prefix_fallback.py b/src/tests/test_v2_prefix_fallback.py new file mode 100644 index 0000000..0d23cbf --- /dev/null +++ b/src/tests/test_v2_prefix_fallback.py @@ -0,0 +1,38 @@ +import logging +import os +from pathlib import Path + +import pytest +from cryptography.fernet import InvalidToken + +from helpers import TEST_SEED +from utils.key_derivation import derive_index_key +from password_manager.encryption import EncryptionManager + + +def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: + key = derive_index_key(TEST_SEED) + manager = EncryptionManager(key, tmp_path) + + original = b"legacy data" + token = manager.fernet.encrypt(original) + payload = b"V2:" + token + + caplog.set_level(logging.WARNING, logger="password_manager.encryption") + decrypted = manager.decrypt_data(payload) + + assert decrypted == original + assert "incorrect 'V2:' header" in caplog.text + + +def test_aesgcm_payload_too_short(tmp_path: Path, caplog) -> None: + key = derive_index_key(TEST_SEED) + manager = EncryptionManager(key, tmp_path) + + payload = b"V2:" + os.urandom(12) + b"short" + + caplog.set_level(logging.ERROR, logger="password_manager.encryption") + with pytest.raises(InvalidToken, match="AES-GCM payload too short"): + manager.decrypt_data(payload) + + assert "AES-GCM payload too short" in caplog.text diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py new file mode 100644 index 0000000..79cd5ca --- /dev/null +++ b/src/tests/test_verbose_timing.py @@ -0,0 +1,32 @@ +import asyncio +import logging + +from password_manager.manager import PasswordManager +from helpers import dummy_nostr_client + + +def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): + pm = PasswordManager.__new__(PasswordManager) + pm.fingerprint_dir = tmp_path + pm.setup_encryption_manager = lambda path: None + pm.initialize_bip85 = lambda: None + pm.initialize_managers = lambda: None + pm.update_activity = lambda: None + pm.verbose_timing = True + caplog.set_level(logging.INFO, logger="password_manager.manager") + times = iter([0.0, 1.0]) + monkeypatch.setattr( + "password_manager.manager.time.perf_counter", lambda: next(times) + ) + pm.unlock_vault() + assert "Vault unlocked in 1.00 seconds" in caplog.text + + +def test_publish_snapshot_logs_time(dummy_nostr_client, monkeypatch, caplog): + client, _relay = dummy_nostr_client + client.verbose_timing = True + caplog.set_level(logging.INFO, logger="nostr.client") + times = iter([0.0, 1.0]) + monkeypatch.setattr("nostr.client.time.perf_counter", lambda: next(times)) + asyncio.run(client.publish_snapshot(b"data")) + assert "publish_snapshot completed in 1.00 seconds" in caplog.text diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 25a2ca0..01e058c 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -28,7 +28,12 @@ try: from .input_utils import timed_input from .memory_protection import InMemorySecret from .clipboard import copy_to_clipboard - from .terminal_utils import clear_screen, pause, clear_and_print_fingerprint + from .terminal_utils import ( + clear_screen, + pause, + clear_and_print_fingerprint, + clear_header_with_notification, + ) if logger.isEnabledFor(logging.DEBUG): logger.info("Modules imported successfully.") @@ -58,5 +63,6 @@ __all__ = [ "copy_to_clipboard", "clear_screen", "clear_and_print_fingerprint", + "clear_header_with_notification", "pause", ] diff --git a/src/utils/color_scheme.py b/src/utils/color_scheme.py index db6798f..96dccfd 100644 --- a/src/utils/color_scheme.py +++ b/src/utils/color_scheme.py @@ -20,6 +20,9 @@ _COLOR_MAP = { "index": "yellow", "menu": "cyan", "stats": "green", + "info": "white", + "warning": "yellow", + "error": "red", "default": "white", } @@ -29,4 +32,5 @@ def color_text(text: str, category: str = "default") -> str: color = _COLOR_MAP.get(category, "white") if color == "orange": return _apply_orange(text) - return colored(text, color) + attrs = ["bold"] if category in {"info", "warning", "error"} else None + return colored(text, color, attrs=attrs) diff --git a/src/utils/fingerprint_manager.py b/src/utils/fingerprint_manager.py index a47a942..34ee4a0 100644 --- a/src/utils/fingerprint_manager.py +++ b/src/utils/fingerprint_manager.py @@ -34,8 +34,7 @@ class FingerprintManager: self.app_dir = app_dir self.fingerprints_file = self.app_dir / "fingerprints.json" self._ensure_app_directory() - self.fingerprints = self._load_fingerprints() - self.current_fingerprint: Optional[str] = None + self.fingerprints, self.current_fingerprint = self._load_fingerprints() def get_current_fingerprint_dir(self) -> Optional[Path]: """ @@ -63,28 +62,25 @@ class FingerprintManager: ) raise - def _load_fingerprints(self) -> List[str]: - """ - Loads the list of fingerprints from the fingerprints.json file. - - Returns: - List[str]: A list of fingerprint strings. - """ + def _load_fingerprints(self) -> tuple[list[str], Optional[str]]: + """Return stored fingerprints and the last used fingerprint.""" try: if self.fingerprints_file.exists(): with open(self.fingerprints_file, "r") as f: data = json.load(f) - fingerprints = data.get("fingerprints", []) - logger.debug(f"Loaded fingerprints: {fingerprints}") - return fingerprints - else: + fingerprints = data.get("fingerprints", []) + current = data.get("last_used") logger.debug( - "fingerprints.json not found. Initializing empty fingerprint list." + f"Loaded fingerprints: {fingerprints} (last used: {current})" ) - return [] + return fingerprints, current + logger.debug( + "fingerprints.json not found. Initializing empty fingerprint list." + ) + return [], None except Exception as e: logger.error(f"Failed to load fingerprints: {e}", exc_info=True) - return [] + return [], None def _save_fingerprints(self): """ @@ -92,8 +88,17 @@ class FingerprintManager: """ try: with open(self.fingerprints_file, "w") as f: - json.dump({"fingerprints": self.fingerprints}, f, indent=4) - logger.debug(f"Fingerprints saved: {self.fingerprints}") + json.dump( + { + "fingerprints": self.fingerprints, + "last_used": self.current_fingerprint, + }, + f, + indent=4, + ) + logger.debug( + f"Fingerprints saved: {self.fingerprints} (last used: {self.current_fingerprint})" + ) except Exception as e: logger.error(f"Failed to save fingerprints: {e}", exc_info=True) raise @@ -111,6 +116,7 @@ class FingerprintManager: fingerprint = generate_fingerprint(seed_phrase) if fingerprint and fingerprint not in self.fingerprints: self.fingerprints.append(fingerprint) + self.current_fingerprint = fingerprint self._save_fingerprints() logger.info(f"Fingerprint {fingerprint} added successfully.") # Create fingerprint directory @@ -138,6 +144,10 @@ class FingerprintManager: if fingerprint in self.fingerprints: try: self.fingerprints.remove(fingerprint) + if self.current_fingerprint == fingerprint: + self.current_fingerprint = ( + self.fingerprints[0] if self.fingerprints else None + ) self._save_fingerprints() # Remove fingerprint directory fingerprint_dir = self.app_dir / fingerprint @@ -181,6 +191,7 @@ class FingerprintManager: """ if fingerprint in self.fingerprints: self.current_fingerprint = fingerprint + self._save_fingerprints() logger.info(f"Fingerprint {fingerprint} selected.") return True else: diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index d71b26c..091ef46 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -97,6 +97,42 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: raise +def derive_key_from_password_argon2( + password: str, + *, + time_cost: int = 2, + memory_cost: int = 64 * 1024, + parallelism: int = 8, +) -> bytes: + """Derive an encryption key from a password using Argon2id. + + The defaults follow recommended parameters but omit a salt for deterministic + output. Smaller values may be supplied for testing. + """ + + if not password: + logger.error("Password cannot be empty.") + raise ValueError("Password cannot be empty.") + + normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8") + try: + from argon2.low_level import hash_secret_raw, Type + + key = hash_secret_raw( + secret=normalized, + salt=b"\x00" * 16, + time_cost=time_cost, + memory_cost=memory_cost, + parallelism=parallelism, + hash_len=32, + type=Type.ID, + ) + return base64.urlsafe_b64encode(key) + except Exception as e: # pragma: no cover - pass through errors + logger.error(f"Error deriving key with Argon2id: {e}", exc_info=True) + raise + + def derive_key_from_parent_seed(parent_seed: str, fingerprint: str = None) -> bytes: """ Derives a 32-byte cryptographic key from a BIP-39 parent seed using HKDF. diff --git a/src/utils/password_prompt.py b/src/utils/password_prompt.py index de380d3..065ea0a 100644 --- a/src/utils/password_prompt.py +++ b/src/utils/password_prompt.py @@ -106,44 +106,55 @@ def prompt_new_password() -> str: raise PasswordPromptError("Maximum password attempts exceeded") -def prompt_existing_password(prompt_message: str = "Enter your password: ") -> str: +def prompt_existing_password( + prompt_message: str = "Enter your password: ", max_retries: int = 5 +) -> str: """ - Prompts the user to enter an existing password, typically used for decryption purposes. + Prompt the user for an existing password. - This function ensures that the password is entered securely without echoing it to the terminal. + The user will be reprompted on empty input up to ``max_retries`` times. Parameters: - prompt_message (str): The message displayed to prompt the user. Defaults to "Enter your password: ". + prompt_message (str): Message displayed when prompting for the password. + max_retries (int): Number of attempts allowed before aborting. Returns: - str: The password entered by the user. + str: The password provided by the user. Raises: - PasswordPromptError: If the user interrupts the operation. + PasswordPromptError: If the user interrupts the operation or exceeds + ``max_retries`` attempts. """ - try: - password = getpass.getpass(prompt=prompt_message).strip() + attempts = 0 + while attempts < max_retries: + try: + password = getpass.getpass(prompt=prompt_message).strip() - if not password: - print(colored("Error: Password cannot be empty.", "red")) - logging.warning("User attempted to enter an empty password.") - raise PasswordPromptError("Password cannot be empty") + if not password: + print( + colored("Error: Password cannot be empty. Please try again.", "red") + ) + logging.warning("User attempted to enter an empty password.") + attempts += 1 + continue - # Normalize the password to NFKD form - normalized_password = unicodedata.normalize("NFKD", password) - logging.debug("User entered an existing password for decryption.") - return normalized_password + normalized_password = unicodedata.normalize("NFKD", password) + logging.debug("User entered an existing password for decryption.") + return normalized_password - except KeyboardInterrupt: - print(colored("\nOperation cancelled by user.", "yellow")) - logging.info("Existing password prompt interrupted by user.") - raise PasswordPromptError("Operation cancelled by user") - except Exception as e: - logging.error( - f"Unexpected error during existing password prompt: {e}", exc_info=True - ) - print(colored(f"Error: {e}", "red")) - raise PasswordPromptError(str(e)) + except KeyboardInterrupt: + print(colored("\nOperation cancelled by user.", "yellow")) + logging.info("Existing password prompt interrupted by user.") + raise PasswordPromptError("Operation cancelled by user") + except Exception as e: + logging.error( + f"Unexpected error during existing password prompt: {e}", + exc_info=True, + ) + print(colored(f"Error: {e}", "red")) + attempts += 1 + + raise PasswordPromptError("Maximum password attempts exceeded") def confirm_action( diff --git a/src/utils/terminal_utils.py b/src/utils/terminal_utils.py index 8c856ed..f1e05e7 100644 --- a/src/utils/terminal_utils.py +++ b/src/utils/terminal_utils.py @@ -5,6 +5,8 @@ import sys from termcolor import colored +from utils.color_scheme import color_text + def clear_screen() -> None: """Clear the terminal screen using an ANSI escape code.""" @@ -49,6 +51,46 @@ def clear_and_print_profile_chain( print(colored(header, "green")) +def clear_header_with_notification( + pm, + fingerprint: str | None = None, + breadcrumb: str | None = None, + parent_fingerprint: str | None = None, + child_fingerprint: str | None = None, +) -> None: + """Clear the screen, print the header, then show the current notification.""" + + clear_screen() + header_fp = None + if parent_fingerprint and child_fingerprint: + header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}" + elif fingerprint: + header_fp = fingerprint + elif parent_fingerprint or child_fingerprint: + header_fp = parent_fingerprint or child_fingerprint + if header_fp: + header = f"Seed Profile: {header_fp}" + if breadcrumb: + header += f" > {breadcrumb}" + print(colored(header, "green")) + + note = None + if hasattr(pm, "get_current_notification"): + try: + note = pm.get_current_notification() + except Exception: + note = None + + line = "" + if note: + category = getattr(note, "level", "info").lower() + if category not in ("info", "warning", "error"): + category = "info" + line = color_text(getattr(note, "message", ""), category) + + print(line) + + def pause(message: str = "Press Enter to continue...") -> None: """Wait for the user to press Enter before proceeding.""" if not sys.stdin or not sys.stdin.isatty(): diff --git a/totp.json b/totp.json new file mode 100644 index 0000000..c43cb32 --- /dev/null +++ b/totp.json @@ -0,0 +1,3 @@ +{ + "entries": [] +} \ No newline at end of file