From 39ec8026dbdec4d4bf7ddd8fb651e6e19c7f0390 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:21:29 -0400 Subject: [PATCH 001/120] Add vault export and relay management API --- docs/api_reference.md | 6 ++ src/seedpass/api.py | 101 +++++++++++++++++++++++++++- src/tests/test_api_new_endpoints.py | 74 ++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/docs/api_reference.md b/docs/api_reference.md index ec6619d..1be8d34 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -37,7 +37,13 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `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. diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 6827934..c1482e1 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -8,7 +8,7 @@ from pathlib import Path import secrets 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 +28,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. @@ -383,6 +397,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 +472,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) @@ -432,6 +515,22 @@ async def import_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"} + + @app.post("/api/v1/change-password") def change_password(authorization: str | None = Header(None)) -> dict[str, str]: """Change the master password for the active profile.""" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index fc187db..f8ac6f0 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -300,3 +300,77 @@ 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): + cl, token = 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 + api._pm.nostr_client = SimpleNamespace( + close_client_pool=lambda: called.setdefault("close", True), + initialize_client_pool=lambda: called.setdefault("init", True), + relays=relays, + ) + + 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 From 11bdbb9962d9870326c8ee498da09f7c17dd1b80 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:43:12 -0400 Subject: [PATCH 002/120] Document new CLI commands and API endpoints --- README.md | 7 +++ docs/advanced_cli.md | 2 + docs/api_reference.md | 101 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 92cb53c..8ead95c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,13 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security. - **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. ## Prerequisites diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 0148aa5..cbc2511 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -74,6 +74,7 @@ Manage the entire vault for a profile. | 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 @@ -161,6 +162,7 @@ Code: 123456 - **`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 diff --git a/docs/api_reference.md b/docs/api_reference.md index 1be8d34..64e4c7a 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -121,7 +121,106 @@ 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 +``` + +### 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 From 050e8ec78217a930c47d9e44c01e2f8f5c8be5b4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:08:31 -0400 Subject: [PATCH 003/120] Add Read the Docs style documentation --- landing/docs/README.html | 114 ++++++ landing/docs/advanced_cli.html | 613 ++++++++++++++++++++++++++++ landing/docs/api_reference.html | 320 +++++++++++++++ landing/docs/index.html | 26 ++ landing/docs/json_entries.html | 703 ++++++++++++++++++++++++++++++++ landing/docs/migrations.html | 51 +++ landing/docs/theme.css | 4 + landing/index.html | 2 + 8 files changed, 1833 insertions(+) create mode 100644 landing/docs/README.html create mode 100644 landing/docs/advanced_cli.html create mode 100644 landing/docs/api_reference.html create mode 100644 landing/docs/index.html create mode 100644 landing/docs/json_entries.html create mode 100644 landing/docs/migrations.html create mode 100644 landing/docs/theme.css diff --git a/landing/docs/README.html b/landing/docs/README.html new file mode 100644 index 0000000..761a7a8 --- /dev/null +++ b/landing/docs/README.html @@ -0,0 +1,114 @@ + + + + + + + README + + + + +

SeedPass Documentation

+

This directory contains supplementary guides for using SeedPass.

+

Quick Example: Get a TOTP +Code

+

Run seedpass entry get <query> to retrieve a +time-based one-time password (TOTP). The <query> can +be a label, title, or index. A progress bar shows the remaining seconds +in the current period.

+
$ seedpass entry get "email"
+[##########----------] 15s
+Code: 123456
+

To show all stored TOTP codes with their countdown timers, run:

+
$ seedpass entry totp-codes
+

CLI and API Reference

+

See advanced_cli.html for a list of +command examples. Detailed information about the REST API is available +in api_reference.html. When starting the +API, set SEEDPASS_CORS_ORIGINS if you need to allow +requests from specific web origins.

+ + diff --git a/landing/docs/advanced_cli.html b/landing/docs/advanced_cli.html new file mode 100644 index 0000000..29163bd --- /dev/null +++ b/landing/docs/advanced_cli.html @@ -0,0 +1,613 @@ + + + + + + + advanced_cli + + + + +

SeedPass Advanced +CLI and API Documentation

+

Overview

+

Welcome to the Advanced CLI and API Documentation +for SeedPass, a secure, deterministic password manager +built on Bitcoin’s BIP‑85 standard. This guide is designed for power +users, developers, and system administrators who wish to leverage the +full capabilities of SeedPass through the command line for scripting, +automation, and integration.

+

SeedPass uses a noun-verb command structure (e.g., +seedpass entry get <query>) for a clear, scalable, +and discoverable interface. You can explore the available actions for +any command group with the --help flag (for example, +seedpass entry --help).

+

The commands in this document reflect the Typer-based CLI shipped +with SeedPass. Each command accepts the optional +--fingerprint flag to operate on a specific seed +profile.

+
+

Table of Contents

+
    +
  1. Global Options
  2. +
  3. Command Group Reference +
  4. +
  5. Detailed Command +Descriptions
  6. +
  7. API Integration
  8. +
  9. Usage Guidelines
  10. +
+
+

Global Options

+

These options can be used with any command.

+ ++++ + + + + + + + + + + + + + + + + +
FlagDescription
--fingerprint <fp>Specify which seed profile to use. If +omitted, the most recently used profile is selected.
--help, -hDisplay help information for a command or +subcommand.
+
+

Command Group Reference

+

Entry Commands

+

Manage individual entries within a vault.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
List entriesentry listseedpass entry list --sort label
Search for entriesentry searchseedpass entry search "GitHub"
Retrieve an entry’s secret (password or +TOTP code)entry getseedpass entry get "GitHub"
Add a password entryentry addseedpass entry add Example --length 16
Add a TOTP entryentry add-totpseedpass entry add-totp Email --secret JBSW...
Add an SSH key entryentry add-sshseedpass entry add-ssh Server --index 0
Add a PGP key entryentry add-pgpseedpass entry add-pgp Personal --user-id me@example.com
Add a Nostr key entryentry add-nostrseedpass entry add-nostr Chat
Add a seed phrase entryentry add-seedseedpass entry add-seed Backup --words 24
Add a key/value entryentry add-key-valueseedpass entry add-key-value "API Token" --value abc123
Add a managed account entryentry add-managed-accountseedpass entry add-managed-account Trading
Modify an entryentry modifyseedpass entry modify 1 --username alice
Archive an entryentry archiveseedpass entry archive 1
Unarchive an entryentry unarchiveseedpass entry unarchive 1
Export all TOTP secretsentry export-totpseedpass entry export-totp --file totp.json
Show all TOTP codesentry totp-codesseedpass entry totp-codes
+

Vault Commands

+

Manage the entire vault for a profile.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
Export the vaultvault exportseedpass vault export --file backup.json
Import a vaultvault importseedpass vault import --file backup.json
Change the master passwordvault change-passwordseedpass vault change-password
Lock the vaultvault lockseedpass vault lock
Show profile statisticsvault statsseedpass vault stats
Reveal or back up the parent seedvault reveal-parent-seedseedpass vault reveal-parent-seed --file backup.enc
+

Nostr Commands

+

Interact with the Nostr network for backup and synchronization.

+ + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
Sync with relaysnostr syncseedpass nostr sync
Get public keynostr get-pubkeyseedpass nostr get-pubkey
+

Config Commands

+

Manage profile‑specific settings.

+ +++++ + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
Get a setting valueconfig getseedpass config get inactivity_timeout
Set a setting valueconfig setseedpass config set inactivity_timeout 300
+

Fingerprint Commands

+

Manage seed profiles (fingerprints).

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
List all profilesfingerprint listseedpass fingerprint list
Add a profilefingerprint addseedpass fingerprint add
Remove a profilefingerprint removeseedpass fingerprint remove <fp>
Switch profilefingerprint switchseedpass fingerprint switch <fp>
+

Utility Commands

+

Miscellaneous helper commands.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
Generate a passwordutil generate-passwordseedpass util generate-password --length 24
Verify script checksumutil verify-checksumseedpass util verify-checksum
Update script checksumutil update-checksumseedpass util update-checksum
+

API Commands

+

Run or stop the local HTTP API.

+ +++++ + + + + + + + + + + + + + + + + + + + +
ActionCommandExamples
Start the APIapi startseedpass api start --host 0.0.0.0 --port 8000
Stop the APIapi stopseedpass api stop
+
+

Detailed Command +Descriptions

+

entry Commands

+
    +
  • seedpass entry list – List entries in +the vault, optionally sorted or filtered.
  • +
  • seedpass entry search <query> – +Search across labels, usernames, URLs and notes.
  • +
  • seedpass entry get <query> – +Retrieve the password or TOTP code for one matching entry, depending on +the entry’s type.
  • +
  • seedpass entry add <label> – +Create a new password entry. Use --length to set the +password length and optional --username/--url +values.
  • +
  • seedpass entry add-totp <label> +– Create a TOTP entry. Use --secret to import an existing +secret or --index to derive from the seed.
  • +
  • seedpass entry add-ssh <label> – +Create an SSH key entry derived from the seed.
  • +
  • seedpass entry add-pgp <label> – +Create a PGP key entry. Provide --user-id and +--key-type as needed.
  • +
  • seedpass entry add-nostr <label> +– Create a Nostr key entry for decentralised chat.
  • +
  • seedpass entry add-seed <label> +– Store a derived seed phrase. Use --words to set the word +count.
  • +
  • seedpass entry add-key-value <label> +– Store arbitrary data with --value.
  • +
  • seedpass entry add-managed-account <label> +– Store a BIP‑85 derived account seed.
  • +
  • seedpass entry modify <id> – +Update an entry’s label, username, URL or notes.
  • +
  • seedpass entry archive <id> – +Mark an entry as archived so it is hidden from normal lists.
  • +
  • seedpass entry unarchive <id> – +Restore an archived entry.
  • +
  • seedpass entry export-totp --file <path> +– Export all stored TOTP secrets to a JSON file.
  • +
  • seedpass entry totp-codes – Display +all current TOTP codes with remaining time.
  • +
+

Example retrieving a TOTP code:

+
$ seedpass entry get "email"
+[##########----------] 15s
+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 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

+
    +
  • seedpass nostr sync – Perform a +two‑way sync with configured Nostr relays.
  • +
  • seedpass nostr get-pubkey – Display +the Nostr public key for the active profile.
  • +
+

config Commands

+
    +
  • seedpass config get <key> – +Retrieve a configuration value such as inactivity_timeout, +secret_mode, or auto_sync.
  • +
  • seedpass config set <key> <value> +– Update a configuration option. Example: +seedpass config set inactivity_timeout 300.
  • +
  • seedpass config toggle-secret-mode – +Interactively enable or disable Secret Mode and set the clipboard +delay.
  • +
+

fingerprint Commands

+
    +
  • seedpass fingerprint list – List +available profiles by fingerprint.
  • +
  • seedpass fingerprint add – Create a +new seed profile.
  • +
  • seedpass fingerprint remove <fp> +– Delete the specified profile.
  • +
  • seedpass fingerprint switch <fp> +– Switch the active profile.
  • +
+

util Commands

+
    +
  • seedpass util generate-password – +Generate a strong password of the requested length.
  • +
  • seedpass util verify-checksum – Verify +the SeedPass script checksum.
  • +
  • seedpass util update-checksum – +Regenerate the script checksum file.
  • +
+
+

API Integration

+

SeedPass provides a small REST API for automation. Run +seedpass api start to launch the server. The command prints +a one‑time token which clients must include in the +Authorization header.

+

Set the SEEDPASS_CORS_ORIGINS environment variable to a +comma‑separated list of allowed origins when you need cross‑origin +requests:

+
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
+

Shut down the server with seedpass api stop.

+
+

Usage Guidelines

+
    +
  • 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.
  • +
  • entry get is script‑friendly and can be piped into +other commands.
  • +
+ + diff --git a/landing/docs/api_reference.html b/landing/docs/api_reference.html new file mode 100644 index 0000000..d5e24e6 --- /dev/null +++ b/landing/docs/api_reference.html @@ -0,0 +1,320 @@ + + + + + + + api_reference + + + + +

SeedPass REST API Reference

+

This guide covers how to start the SeedPass API, authenticate +requests, and interact with the available endpoints.

+

Starting the API

+

Run seedpass api start from your terminal. The command +prints a one‑time token used for authentication:

+
$ seedpass api start
+API token: abcdef1234567890
+

Keep this token secret. Every request must include it in the +Authorization header using the Bearer +scheme.

+

Endpoints

+
    +
  • GET /api/v1/entry?query=<text> – Search entries +matching a query.
  • +
  • GET /api/v1/entry/{id} – Retrieve a single entry by its +index.
  • +
  • POST /api/v1/entry – Create a new entry of any +supported type.
  • +
  • PUT /api/v1/entry/{id} – Modify an existing entry.
  • +
  • PUT /api/v1/config/{key} – Update a configuration +value.
  • +
  • POST /api/v1/secret-mode – Enable or disable Secret +Mode and set the clipboard delay.
  • +
  • POST /api/v1/entry/{id}/archive – Archive an +entry.
  • +
  • POST /api/v1/entry/{id}/unarchive – Unarchive an +entry.
  • +
  • GET /api/v1/config/{key} – Return the value for a +configuration key.
  • +
  • GET /api/v1/fingerprint – List available seed +fingerprints.
  • +
  • POST /api/v1/fingerprint – Add a new seed +fingerprint.
  • +
  • DELETE /api/v1/fingerprint/{fp} – Remove a +fingerprint.
  • +
  • POST /api/v1/fingerprint/select – Switch the active +fingerprint.
  • +
  • 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/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.

+

Example Requests

+

Send requests with the token in the header:

+
curl -H "Authorization: Bearer <token>" \
+     "http://127.0.0.1:8000/api/v1/entry?query=email"
+

Creating an Entry

+

POST /api/v1/entry accepts a JSON body with at least a +label field. Set type (or kind) +to choose the entry variant (password, totp, +ssh, pgp, nostr, +seed, key_value, or +managed_account). Additional fields vary by type:

+
    +
  • passwordlength, optional +username, url and notes
  • +
  • totpsecret or index, +optional period, digits, notes, +archived
  • +
  • ssh/nostr/seed/managed_account – +index, optional notes, +archived
  • +
  • pgpindex, key_type, +user_id, optional notes, +archived
  • +
  • key_valuevalue, optional +notes
  • +
+

Example creating a TOTP entry:

+
curl -X POST http://127.0.0.1:8000/api/v1/entry \
+     -H "Authorization: Bearer <token>" \
+     -H "Content-Type: application/json" \
+     -d '{"type": "totp", "label": "Email", "secret": "JBSW..."}'
+

Updating an Entry

+

Use PUT /api/v1/entry/{id} to change fields such as +label, username, url, +notes, period, digits or +value depending on the entry type.

+
curl -X PUT http://127.0.0.1:8000/api/v1/entry/1 \
+     -H "Authorization: Bearer <token>" \
+     -H "Content-Type: application/json" \
+     -d '{"username": "alice"}'
+

Updating Configuration

+

Send a JSON body containing a value field to +PUT /api/v1/config/{key}:

+
curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
+     -H "Authorization: Bearer <token>" \
+     -H "Content-Type: application/json" \
+     -d '{"value": 300}'
+

Toggling Secret Mode

+

Send both enabled and delay values to +/api/v1/secret-mode:

+
curl -X POST http://127.0.0.1:8000/api/v1/secret-mode \
+     -H "Authorization: Bearer <token>" \
+     -H "Content-Type: application/json" \
+     -d '{"enabled": true, "delay": 20}'
+

Switching Fingerprints

+

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 <token>" \
+     -H "Content-Type: application/json" \
+    -d '{"fingerprint": "abc123"}'
+

Exporting the Vault

+

Download an encrypted vault backup via +POST /api/v1/vault/export:

+
curl -X POST http://127.0.0.1:8000/api/v1/vault/export \
+     -H "Authorization: Bearer <token>" \
+     -o backup.json
+

Importing a Vault

+

Restore a backup with POST /api/v1/vault/import. Use +-F to upload a file:

+
curl -X POST http://127.0.0.1:8000/api/v1/vault/import \
+     -H "Authorization: Bearer <token>" \
+     -F file=@backup.json
+

Locking the Vault

+

Clear sensitive data from memory using +/api/v1/vault/lock:

+
curl -X POST http://127.0.0.1:8000/api/v1/vault/lock \
+     -H "Authorization: Bearer <token>"
+

Backing Up the Parent Seed

+

Trigger an encrypted seed backup with +/api/v1/vault/backup-parent-seed:

+
curl -X POST http://127.0.0.1:8000/api/v1/vault/backup-parent-seed \
+     -H "Authorization: Bearer <token>" \
+     -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:

+
curl -H "Authorization: Bearer <token>" \
+     http://127.0.0.1:8000/api/v1/stats
+

Changing the Master Password

+

Update the vault password via +POST /api/v1/change-password:

+
curl -X POST http://127.0.0.1:8000/api/v1/change-password \
+     -H "Authorization: Bearer <token>"
+

Verifying the Script +Checksum

+

Check that the running script matches the stored checksum:

+
curl -X POST http://127.0.0.1:8000/api/v1/checksum/verify \
+     -H "Authorization: Bearer <token>"
+

Updating the Script Checksum

+

Regenerate the stored checksum using +/api/v1/checksum/update:

+
curl -X POST http://127.0.0.1:8000/api/v1/checksum/update \
+     -H "Authorization: Bearer <token>"
+

Managing Relays

+

List, add, or remove Nostr relays:

+
# list
+curl -H "Authorization: Bearer <token>" 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 <token>" \
+     -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 <token>"
+
+# reset to defaults
+curl -X POST http://127.0.0.1:8000/api/v1/relays/reset \
+     -H "Authorization: Bearer <token>"
+

Enabling CORS

+

Cross‑origin requests are disabled by default. Set +SEEDPASS_CORS_ORIGINS to a comma‑separated list of allowed +origins before starting the API:

+
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
+

Browsers can then call the API from the specified origins, for +example using JavaScript:

+
fetch('http://127.0.0.1:8000/api/v1/entry?query=email', {
+  headers: { Authorization: 'Bearer <token>' }
+});
+

Without CORS enabled, only same‑origin or command‑line tools like +curl can access the API.

+ + diff --git a/landing/docs/index.html b/landing/docs/index.html new file mode 100644 index 0000000..d759e7e --- /dev/null +++ b/landing/docs/index.html @@ -0,0 +1,26 @@ + + + + + + SeedPass Documentation + + + +
+ +
+

SeedPass Documentation

+

Select a topic from the sidebar.

+
+
+ + diff --git a/landing/docs/json_entries.html b/landing/docs/json_entries.html new file mode 100644 index 0000000..fc28e0a --- /dev/null +++ b/landing/docs/json_entries.html @@ -0,0 +1,703 @@ + + + + + + + json_entries + + + + +

SeedPass JSON +Entry Management and Extensibility

+

Table of Contents

+ +
+

Introduction

+

SeedPass is a secure password generator and manager +leveraging Bitcoin’s BIP-85 standard and integrating +with the Nostr network for decentralized +synchronization. Instead of pushing one large index file, SeedPass posts +snapshot chunks of the index followed by lightweight +delta events whenever changes occur. This chunked +approach improves reliability and keeps bandwidth usage minimal. To +enhance modularity, scalability, and security, SeedPass stores all +entries in a single encrypted index file named +seedpass_entries_db.json.enc. This document outlines the +entry management system, ensuring that new kind types can +be added seamlessly without disrupting existing functionalities.

+
+

Index File Format

+

All entries belonging to a seed profile are stored in an encrypted +file named seedpass_entries_db.json.enc. This index uses +schema_version 3 and contains an +entries object keyed by numeric identifiers.

+
{
+  "schema_version": 3,
+  "entries": {
+    "0": {
+      "label": "example.com",
+      "length": 8,
+      "username": "user",
+      "url": "https://example.com",
+      "archived": false,
+      "type": "password",
+      "kind": "password",
+      "notes": "",
+      "custom_fields": [],
+      "origin": ""
+    }
+  }
+}
+
+

JSON Schema for Individual +Entries

+

Each entry is stored within seedpass_entries_db.json.enc +under the entries dictionary. The structure supports +diverse entry types (kind) and allows for future +expansions.

+

General Structure

+
{
+  "label": "Example",
+  "length": 8,
+  "username": "user@example.com",
+  "url": "https://example.com",
+  "archived": false,
+  "type": "password",
+  "kind": "password",
+  "notes": "",
+  "custom_fields": [],
+  "origin": "",
+  "tags": [],
+  "index": 0
+}
+

Field Descriptions

+
    +
  • label (string): Descriptive name +for the entry (e.g., website or service).

  • +
  • length (integer, optional): Desired +password length for generated passwords.

  • +
  • username (string, optional): +Username associated with the entry.

  • +
  • url (string, optional): Website or +service URL.

  • +
  • archived (boolean): Marks the entry +as archived when true.

  • +
  • type (string): The entry type +(password, totp, ssh, +seed, pgp, nostr, +note, key_value).

  • +
  • kind (string): Synonym for +type kept for backward compatibility.

  • +
  • notes (string): Free-form +notes.

  • +
  • custom_fields (array, optional): +Additional user-defined fields.

  • +
  • origin (string, optional): Source +identifier for imported data.

  • +
  • value (string, optional): For +key_value entries, stores the secret value.

  • +
  • index (integer, optional): BIP-85 +derivation index for entries that derive material from a seed.

  • +
  • word_count (integer, +managed_account only): Number of words in the child seed. Managed +accounts always use 12.

  • +
  • fingerprint (string, +managed_account only): Identifier of the child profile, used for its +directory name.

  • +
  • tags (array, optional): Category +labels to aid in organization and search. Example:

    +
    "custom_fields": [
    +  {"name": "account_id", "value": "123"},
    +  {"name": "recovery_hint", "value": "mother's maiden name"}
    +]
  • +
+

Example Entries

+

1. Generated Password

+
{
+  "entry_num": 0,
+  "index_num": 0,
+  "fingerprint": "a1b2c3d4",
+  "kind": "generated_password",
+  "data": {
+    "title": "Example Website",
+    "username": "user@example.com",
+    "email": "user@example.com",
+    "url": "https://example.com",
+    "password": "<encrypted_password>"
+  },
+  "custom_fields": [
+    {"name": "department", "value": "finance"}
+  ],
+  "tags": ["work"],
+  "timestamp": "2024-04-27T12:34:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:34:56Z",
+    "updated_at": "2024-04-27T12:34:56Z",
+    "checksum": "abc123def456"
+  }
+}
+

2. Stored Password

+
{
+  "entry_num": 1,
+  "index_num": "q1wec4d426fs",
+  "fingerprint": "a1b2c3d4",
+  "kind": "stored_password",
+  "data": {
+    "title": "Another Service",
+    "username": "another_user",
+    "password": "<encrypted_password>"
+  },
+  "timestamp": "2024-04-27T12:35:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:35:56Z",
+    "updated_at": "2024-04-27T12:35:56Z",
+    "checksum": "def789ghi012"
+  }
+}
+

3. Managed User

+
{
+  "entry_num": 2,
+  "index_num": "a1b2c3d4e5f6",
+  "fingerprint": "a1b2c3d4",
+  "kind": "managed_user",
+  "data": {
+    "users_password": "<encrypted_users_password>"
+  },
+  "timestamp": "2024-04-27T12:36:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:36:56Z",
+    "updated_at": "2024-04-27T12:36:56Z",
+    "checksum": "ghi345jkl678"
+  }
+}
+

4. 12 Word Seed

+
{
+  "entry_num": 3,
+  "index_num": "f7g8h9i0j1k2",
+  "fingerprint": "a1b2c3d4",
+  "kind": "12_word_seed",
+  "data": {
+    "seed_phrase": "<encrypted_seed_phrase>"
+  },
+  "timestamp": "2024-04-27T12:37:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:37:56Z",
+    "updated_at": "2024-04-27T12:37:56Z",
+    "checksum": "jkl901mno234"
+  }
+}
+

5. Nostr Keys

+
{
+  "entry_num": 4,
+  "index_num": "l3m4n5o6p7q8",
+  "fingerprint": "a1b2c3d4",
+  "kind": "nostr_keys",
+  "data": {
+    "public_key": "<public_key>",
+    "private_key": "<encrypted_private_key>"
+  },
+  "timestamp": "2024-04-27T12:38:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:38:56Z",
+    "updated_at": "2024-04-27T12:38:56Z",
+    "checksum": "mno567pqr890"
+  }
+}
+

6. Note

+
{
+  "entry_num": 5,
+  "index_num": "r9s0t1u2v3w4",
+  "fingerprint": "a1b2c3d4",
+  "kind": "note",
+  "data": {
+    "content": "This is a secure note.",
+    "tags": ["personal", "secure"]
+  },
+  "timestamp": "2024-04-27T12:39:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:39:56Z",
+    "updated_at": "2024-04-27T12:39:56Z",
+    "checksum": "pqr345stu678"
+  }
+}
+

7. Key/Value

+
{
+  "entry_num": 6,
+  "fingerprint": "a1b2c3d4",
+  "kind": "key_value",
+  "data": {
+    "key": "api_key",
+    "value": "<encrypted_value>"
+  },
+  "tags": ["api"],
+  "timestamp": "2024-04-27T12:40:56Z"
+}
+

8. Managed Account

+
{
+  "entry_num": 7,
+  "fingerprint": "a1b2c3d4",
+  "kind": "managed_account",
+  "data": {
+    "account": "alice@example.com",
+    "password": "<encrypted_password>"
+  },
+  "timestamp": "2024-04-27T12:41:56Z"
+}
+

Managed accounts store a child seed derived from the parent profile. +The entry is saved under +.seedpass/<parent_fp>/accounts/<child_fp> where +<child_fp> is the managed account’s fingerprint. When +loaded, the CLI displays a breadcrumb like +<parent_fp> > Managed Account > <child_fp>. +Press Enter on the main menu to exit back to the parent +profile.

+

The key field is purely descriptive, while +value holds the sensitive string such as an API token. +Notes and custom fields may also be included alongside the standard +metadata.

+
+

Handling +kind Types and Extensibility

+

Extensible JSON Schema +Design

+

The JSON schema is designed to be extensible and +forward-compatible, allowing new kind +types to be added without impacting existing functionalities.

+

a. Core Structure

+

Each entry is encapsulated in its own JSON file with a standardized +structure:

+
{
+  "entry_num": 0,
+  "index_num": 0,
+  "fingerprint": "a1b2c3d4",
+  "kind": "generated_password",
+  "data": {
+    // Fields specific to the kind
+  },
+  "timestamp": "2024-04-27T12:34:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:34:56Z",
+    "updated_at": "2024-04-27T12:34:56Z",
+    "checksum": "<checksum_value>"
+  }
+}
+

b. The kind Field

+
    +
  • Purpose: Specifies the type of entry.
  • +
  • Flexibility: As a simple string identifier, new +kind values can be introduced without altering the existing +schema.
  • +
+

Example:

+
"kind": "cryptocurrency_wallet"
+

c. The data Object

+
    +
  • Purpose: Contains fields specific to the +kind.
  • +
  • Extensibility: Each kind can define +its unique set of fields without affecting others.
  • +
+

Example for a New Kind +(cryptocurrency_wallet):

+
"data": {
+  "wallet_name": "My Bitcoin Wallet",
+  "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
+  "private_key": "<encrypted_private_key>"
+}
+

Ensuring Backward +Compatibility

+

To maintain compatibility as new kind types are +introduced, implement the following practices:

+

a. Graceful Handling of +Unknown Kinds

+
    +
  • Implementation: When encountering an unrecognized +kind, handle it gracefully by ignoring the entry, logging a +warning, or providing a default handling mechanism.
  • +
  • Benefit: Prevents the application from crashing or +misbehaving due to unrecognized kind types.
  • +
+

Pseudo-Code Example:

+
def process_entry(entry):
+    kind = entry.get("kind")
+    data = entry.get("data")
+    fingerprint = entry.get("fingerprint")
+    
+    if kind == "generated_password":
+        handle_generated_password(data, fingerprint)
+    elif kind == "stored_password":
+        handle_stored_password(data, fingerprint)
+    # ... other known kinds ...
+    else:
+        log_warning(f"Unknown kind: {kind}. Skipping entry.")
+

b. Versioning the Schema

+
    +
  • Implementation: Introduce a +schema_version or seedpass_version field to +indicate the version of the JSON schema being used.
  • +
  • Benefit: Facilitates future updates and migrations +by clearly identifying the schema version.
  • +
+

Example:

+
"seedpass_version": "1.0.0"
+

c. Documentation and +Standards

+
    +
  • Maintain Clear Documentation: Keep comprehensive +documentation for each kind, detailing required and +optional fields.
  • +
  • Adhere to Standards: Follow consistent naming +conventions and data types to ensure uniformity across different +kind types.
  • +
+

Best Practices for Adding +New Kinds

+

To ensure seamless integration of new kind types in the +future, consider the following best practices:

+

a. Consistent Naming +Conventions

+
    +
  • Use Clear and Descriptive Names: Aids in +readability and maintenance.
  • +
  • Avoid Reserved Keywords: Ensure kind +names do not clash with existing or future reserved keywords within the +application or JSON standards.
  • +
+

b. Modular Code Architecture

+
    +
  • Separate Handlers: Implement separate functions or +modules for handling each kind. Promotes code modularity +and easier maintenance.
  • +
+

Example:

+
# handlers.py
+
+def handle_generated_password(data, fingerprint):
+    # Implementation
+
+def handle_stored_password(data, fingerprint):
+    # Implementation
+
+def handle_cryptocurrency_wallet(data, fingerprint):
+    # Implementation
+

c. Validation and Error +Handling

+
    +
  • Validate Data Fields: Ensure each kind +has the necessary fields before processing.
  • +
  • Handle Missing or Extra Fields: Implement logic to +manage incomplete or unexpected data gracefully.
  • +
+

Example:

+
def handle_cryptocurrency_wallet(data, fingerprint):
+    required_fields = ["wallet_name", "address", "private_key"]
+    for field in required_fields:
+        if field not in data:
+            raise ValueError(f"Missing required field '{field}' in cryptocurrency_wallet entry.")
+    # Proceed with processing
+

d. Backward Compatibility +Testing

+
    +
  • Automated Tests: Develop tests that verify the +application’s ability to handle both existing and new kind +types.
  • +
  • Regression Testing: Ensure adding new kinds does +not inadvertently affect existing functionalities.
  • +
+
+

Adding New kind Types

+

Adding new kind types is straightforward due to the +extensible JSON schema design. Below is a step-by-step guide to adding a +new kind without affecting existing functionalities.

+

Example: Adding +cryptocurrency_wallet

+

a. Define the New Kind +Structure

+

Create a JSON file following the standardized structure with the new +kind value.

+
{
+  "entry_num": 6,
+  "index_num": "x1y2z3a4b5c6",
+  "fingerprint": "a1b2c3d4",
+  "kind": "cryptocurrency_wallet",
+  "data": {
+    "wallet_name": "My Bitcoin Wallet",
+    "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
+    "private_key": "<encrypted_private_key>"
+  },
+  "timestamp": "2024-04-27T12:40:56Z",
+  "metadata": {
+    "created_at": "2024-04-27T12:40:56Z",
+    "updated_at": "2024-04-27T12:40:56Z",
+    "checksum": "stu901vwx234"
+  }
+}
+

b. Update the +Application to Handle the New Kind

+

Implement Handler Function:

+
def handle_cryptocurrency_wallet(data, fingerprint):
+    wallet_name = data.get("wallet_name")
+    address = data.get("address")
+    private_key = decrypt(data.get("private_key"))
+    # Process the cryptocurrency wallet entry
+    # e.g., store in memory, display to user, etc.
+

Integrate the Handler:

+
def process_entry(entry):
+    kind = entry.get("kind")
+    data = entry.get("data")
+    fingerprint = entry.get("fingerprint")
+    
+    if kind == "generated_password":
+        handle_generated_password(data, fingerprint)
+    elif kind == "stored_password":
+        handle_stored_password(data, fingerprint)
+    elif kind == "cryptocurrency_wallet":
+        handle_cryptocurrency_wallet(data, fingerprint)
+    # ... other known kinds ...
+    else:
+        log_warning(f"Unknown kind: {kind}. Skipping entry.")
+

c. No Impact on Existing +Kinds

+

Existing kinds such as generated_password, +stored_password, etc., continue to operate without any +changes. The introduction of cryptocurrency_wallet is +additive and does not interfere with the processing of other kinds.

+
+

Backup and Rollback +Mechanism

+

To ensure data integrity and provide recovery options, SeedPass +implements a robust backup and rollback system within the +Fingerprint-Based Backup and Local Storage +framework.

+

Backup Directory Structure

+

All backups are organized based on fingerprints, ensuring that each +seed’s data remains isolated and secure.

+
~/.seedpass/
+├── a1b2c3d4/
+│   ├── entries/
+│   │   ├── entry_0.json
+│   │   ├── entry_1.json
+│   │   └── ...
+│   ├── backups/
+│   │   ├── entry_0_v1.json
+│   │   ├── entry_0_v2.json
+│   │   ├── entry_1_v1.json
+│   │   └── ...
+│   ├── parent_seed.enc
+│   ├── seedpass_entries_db_checksum.txt
+│   └── seedpass_entries_db.json
+├── b5c6d7e8/
+│   ├── entries/
+│   │   ├── entry_0.json
+│   │   ├── entry_1.json
+│   │   └── ...
+│   ├── backups/
+│   │   ├── entry_0_v1.json
+│   │   ├── entry_0_v2.json
+│   │   ├── entry_1_v1.json
+│   │   └── ...
+│   ├── parent_seed.enc
+│   ├── seedpass_entries_db_checksum.txt
+│   └── seedpass_entries_db.json
+└── ...
+

Backup Process

+
    +
  1. Upon Modifying an Entry: +
      +
    • The current version of the entry is copied to the +backups/ directory within the corresponding fingerprint +folder with a version suffix (e.g., entry_0_v1.json).
    • +
    • The modified entry is saved in the entries/ directory +within the same fingerprint folder.
    • +
  2. +
  3. Versioning: +
      +
    • Each backup file includes a version number to track changes over +time.
    • +
  4. +
+

Rollback Functionality

+
    +
  • Restoring an Entry: +
      +
    • Users can select a backup version from the backups/ +directory within the specific fingerprint folder.
    • +
    • The selected backup file is copied back to the entries/ +directory, replacing the current version.
    • +
  • +
+

Example Command:

+
seedpass rollback --fingerprint a1b2c3d4 --file entry_0_v1.json
+

Example Directory Structure After Rollback:

+
~/.seedpass/
+├── a1b2c3d4/
+│   ├── entries/
+│   │   ├── entry_0.json  # Restored from entry_0_v1.json
+│   │   ├── entry_1.json
+│   │   └── ...
+│   ├── backups/
+│   │   ├── entry_0_v1.json
+│   │   ├── entry_0_v2.json
+│   │   ├── entry_1_v1.json
+│   │   └── ...
+│   ├── parent_seed.enc
+│   ├── seedpass_script_checksum.txt
+│   ├── seedpass_entries_db_checksum.txt
+│   └── seedpass_entries_db.json
+├── ...
+

Note: Restoring a backup overwrites the current entry. Ensure +that you intend to revert to the selected backup before +proceeding.

+ + diff --git a/landing/docs/migrations.html b/landing/docs/migrations.html new file mode 100644 index 0000000..593789d --- /dev/null +++ b/landing/docs/migrations.html @@ -0,0 +1,51 @@ + + + + + + + migrations + + + + +

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. +
  3. apply_migrations() applies any necessary steps, such as +injecting the schema_version field on first upgrade.
  4. +
  5. After migration, the updated index is saved back to disk.
  6. +
+

This process happens automatically; users only need to open their +vault to upgrade older indices.

+ + diff --git a/landing/docs/theme.css b/landing/docs/theme.css new file mode 100644 index 0000000..a88467c --- /dev/null +++ b/landing/docs/theme.css @@ -0,0 +1,4 @@ +html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden],audio:not([controls]){display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;text-decoration:none}ins,mark{color:#000}mark{background:#ff0;font-style:italic;font-weight:700}.rst-content code,.rst-content tt,code,kbd,pre,samp{font-family:monospace,serif;_font-family:courier new,monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:after,q:before{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,ol,ul{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure,form{margin:0}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}textarea{resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none!important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{body,html,section{background:none!important}*{box-shadow:none!important;text-shadow:none!important;filter:none!important;-ms-filter:none!important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}@page{margin:.5cm}.rst-content .toctree-wrapper>p.caption,h2,h3,p{orphans:3;widows:3}.rst-content .toctree-wrapper>p.caption,h2,h3{page-break-after:avoid}}.btn,.fa:before,.icon:before,.rst-content .admonition,.rst-content .admonition-title:before,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .code-block-caption .headerlink:before,.rst-content .danger,.rst-content .eqno .headerlink:before,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-alert,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before,input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:FontAwesome;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-pull-left.icon,.fa.fa-pull-left,.rst-content .code-block-caption .fa-pull-left.headerlink,.rst-content .eqno .fa-pull-left.headerlink,.rst-content .fa-pull-left.admonition-title,.rst-content code.download span.fa-pull-left:first-child,.rst-content dl dt .fa-pull-left.headerlink,.rst-content h1 .fa-pull-left.headerlink,.rst-content h2 .fa-pull-left.headerlink,.rst-content h3 .fa-pull-left.headerlink,.rst-content h4 .fa-pull-left.headerlink,.rst-content h5 .fa-pull-left.headerlink,.rst-content h6 .fa-pull-left.headerlink,.rst-content p .fa-pull-left.headerlink,.rst-content table>caption .fa-pull-left.headerlink,.rst-content tt.download span.fa-pull-left:first-child,.wy-menu-vertical li.current>a button.fa-pull-left.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-left.toctree-expand,.wy-menu-vertical li button.fa-pull-left.toctree-expand{margin-right:.3em}.fa-pull-right.icon,.fa.fa-pull-right,.rst-content .code-block-caption .fa-pull-right.headerlink,.rst-content .eqno .fa-pull-right.headerlink,.rst-content .fa-pull-right.admonition-title,.rst-content code.download span.fa-pull-right:first-child,.rst-content dl dt .fa-pull-right.headerlink,.rst-content h1 .fa-pull-right.headerlink,.rst-content h2 .fa-pull-right.headerlink,.rst-content h3 .fa-pull-right.headerlink,.rst-content h4 .fa-pull-right.headerlink,.rst-content h5 .fa-pull-right.headerlink,.rst-content h6 .fa-pull-right.headerlink,.rst-content p .fa-pull-right.headerlink,.rst-content table>caption .fa-pull-right.headerlink,.rst-content tt.download span.fa-pull-right:first-child,.wy-menu-vertical li.current>a button.fa-pull-right.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-right.toctree-expand,.wy-menu-vertical li button.fa-pull-right.toctree-expand{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.pull-left.icon,.rst-content .code-block-caption .pull-left.headerlink,.rst-content .eqno .pull-left.headerlink,.rst-content .pull-left.admonition-title,.rst-content code.download span.pull-left:first-child,.rst-content dl dt .pull-left.headerlink,.rst-content h1 .pull-left.headerlink,.rst-content h2 .pull-left.headerlink,.rst-content h3 .pull-left.headerlink,.rst-content h4 .pull-left.headerlink,.rst-content h5 .pull-left.headerlink,.rst-content h6 .pull-left.headerlink,.rst-content p .pull-left.headerlink,.rst-content table>caption .pull-left.headerlink,.rst-content tt.download span.pull-left:first-child,.wy-menu-vertical li.current>a button.pull-left.toctree-expand,.wy-menu-vertical li.on a button.pull-left.toctree-expand,.wy-menu-vertical li button.pull-left.toctree-expand{margin-right:.3em}.fa.pull-right,.pull-right.icon,.rst-content .code-block-caption .pull-right.headerlink,.rst-content .eqno .pull-right.headerlink,.rst-content .pull-right.admonition-title,.rst-content code.download span.pull-right:first-child,.rst-content dl dt .pull-right.headerlink,.rst-content h1 .pull-right.headerlink,.rst-content h2 .pull-right.headerlink,.rst-content h3 .pull-right.headerlink,.rst-content h4 .pull-right.headerlink,.rst-content h5 .pull-right.headerlink,.rst-content h6 .pull-right.headerlink,.rst-content p .pull-right.headerlink,.rst-content table>caption .pull-right.headerlink,.rst-content tt.download span.pull-right:first-child,.wy-menu-vertical li.current>a button.pull-right.toctree-expand,.wy-menu-vertical li.on a button.pull-right.toctree-expand,.wy-menu-vertical li button.pull-right.toctree-expand{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);-ms-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);-ms-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before,.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.rst-content .admonition-title:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.icon-caret-down:before,.wy-dropdown .caret:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before,.wy-menu-vertical li button.toctree-expand:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.fa-reddit-alien:before{content:""}.fa-edge:before{content:""}.fa-credit-card-alt:before{content:""}.fa-codiepie:before{content:""}.fa-modx:before{content:""}.fa-fort-awesome:before{content:""}.fa-usb:before{content:""}.fa-product-hunt:before{content:""}.fa-mixcloud:before{content:""}.fa-scribd:before{content:""}.fa-pause-circle:before{content:""}.fa-pause-circle-o:before{content:""}.fa-stop-circle:before{content:""}.fa-stop-circle-o:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-hashtag:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-percent:before{content:""}.fa-gitlab:before,.icon-gitlab:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpforms:before{content:""}.fa-envira:before{content:""}.fa-universal-access:before{content:""}.fa-wheelchair-alt:before{content:""}.fa-question-circle-o:before{content:""}.fa-blind:before{content:""}.fa-audio-description:before{content:""}.fa-volume-control-phone:before{content:""}.fa-braille:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:""}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-sign-language:before,.fa-signing:before{content:""}.fa-low-vision:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-pied-piper:before{content:""}.fa-first-order:before{content:""}.fa-yoast:before{content:""}.fa-themeisle:before{content:""}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:""}.fa-fa:before,.fa-font-awesome:before{content:""}.fa-handshake-o:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-o:before{content:""}.fa-linode:before{content:""}.fa-address-book:before{content:""}.fa-address-book-o:before{content:""}.fa-address-card:before,.fa-vcard:before{content:""}.fa-address-card-o:before,.fa-vcard-o:before{content:""}.fa-user-circle:before{content:""}.fa-user-circle-o:before{content:""}.fa-user-o:before{content:""}.fa-id-badge:before{content:""}.fa-drivers-license:before,.fa-id-card:before{content:""}.fa-drivers-license-o:before,.fa-id-card-o:before{content:""}.fa-quora:before{content:""}.fa-free-code-camp:before{content:""}.fa-telegram:before{content:""}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:""}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:""}.fa-thermometer-2:before,.fa-thermometer-half:before{content:""}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:""}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:""}.fa-shower:before{content:""}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:""}.fa-podcast:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-times-rectangle:before,.fa-window-close:before{content:""}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:""}.fa-bandcamp:before{content:""}.fa-grav:before{content:""}.fa-etsy:before{content:""}.fa-imdb:before{content:""}.fa-ravelry:before{content:""}.fa-eercast:before{content:""}.fa-microchip:before{content:""}.fa-snowflake-o:before{content:""}.fa-superpowers:before{content:""}.fa-wpexplorer:before{content:""}.fa-meetup:before{content:""}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{font-family:inherit}.fa:before,.icon:before,.rst-content .admonition-title:before,.rst-content .code-block-caption .headerlink:before,.rst-content .eqno .headerlink:before,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before{font-family:FontAwesome;display:inline-block;font-style:normal;font-weight:400;line-height:1;text-decoration:inherit}.rst-content .code-block-caption a .headerlink,.rst-content .eqno a .headerlink,.rst-content a .admonition-title,.rst-content code.download a span:first-child,.rst-content dl dt a .headerlink,.rst-content h1 a .headerlink,.rst-content h2 a .headerlink,.rst-content h3 a .headerlink,.rst-content h4 a .headerlink,.rst-content h5 a .headerlink,.rst-content h6 a .headerlink,.rst-content p.caption a .headerlink,.rst-content p a .headerlink,.rst-content table>caption a .headerlink,.rst-content tt.download a span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li a button.toctree-expand,a .fa,a .icon,a .rst-content .admonition-title,a .rst-content .code-block-caption .headerlink,a .rst-content .eqno .headerlink,a .rst-content code.download span:first-child,a .rst-content dl dt .headerlink,a .rst-content h1 .headerlink,a .rst-content h2 .headerlink,a .rst-content h3 .headerlink,a .rst-content h4 .headerlink,a .rst-content h5 .headerlink,a .rst-content h6 .headerlink,a .rst-content p.caption .headerlink,a .rst-content p .headerlink,a .rst-content table>caption .headerlink,a .rst-content tt.download span:first-child,a .wy-menu-vertical li button.toctree-expand{display:inline-block;text-decoration:inherit}.btn .fa,.btn .icon,.btn .rst-content .admonition-title,.btn .rst-content .code-block-caption .headerlink,.btn .rst-content .eqno .headerlink,.btn .rst-content code.download span:first-child,.btn .rst-content dl dt .headerlink,.btn .rst-content h1 .headerlink,.btn .rst-content h2 .headerlink,.btn .rst-content h3 .headerlink,.btn .rst-content h4 .headerlink,.btn .rst-content h5 .headerlink,.btn .rst-content h6 .headerlink,.btn .rst-content p .headerlink,.btn .rst-content table>caption .headerlink,.btn .rst-content tt.download span:first-child,.btn .wy-menu-vertical li.current>a button.toctree-expand,.btn .wy-menu-vertical li.on a button.toctree-expand,.btn .wy-menu-vertical li button.toctree-expand,.nav .fa,.nav .icon,.nav .rst-content .admonition-title,.nav .rst-content .code-block-caption .headerlink,.nav .rst-content .eqno .headerlink,.nav .rst-content code.download span:first-child,.nav .rst-content dl dt .headerlink,.nav .rst-content h1 .headerlink,.nav .rst-content h2 .headerlink,.nav .rst-content h3 .headerlink,.nav .rst-content h4 .headerlink,.nav .rst-content h5 .headerlink,.nav .rst-content h6 .headerlink,.nav .rst-content p .headerlink,.nav .rst-content table>caption .headerlink,.nav .rst-content tt.download span:first-child,.nav .wy-menu-vertical li.current>a button.toctree-expand,.nav .wy-menu-vertical li.on a button.toctree-expand,.nav .wy-menu-vertical li button.toctree-expand,.rst-content .btn .admonition-title,.rst-content .code-block-caption .btn .headerlink,.rst-content .code-block-caption .nav .headerlink,.rst-content .eqno .btn .headerlink,.rst-content .eqno .nav .headerlink,.rst-content .nav .admonition-title,.rst-content code.download .btn span:first-child,.rst-content code.download .nav span:first-child,.rst-content dl dt .btn .headerlink,.rst-content dl dt .nav .headerlink,.rst-content h1 .btn .headerlink,.rst-content h1 .nav .headerlink,.rst-content h2 .btn .headerlink,.rst-content h2 .nav .headerlink,.rst-content h3 .btn .headerlink,.rst-content h3 .nav .headerlink,.rst-content h4 .btn .headerlink,.rst-content h4 .nav .headerlink,.rst-content h5 .btn .headerlink,.rst-content h5 .nav .headerlink,.rst-content h6 .btn .headerlink,.rst-content h6 .nav .headerlink,.rst-content p .btn .headerlink,.rst-content p .nav .headerlink,.rst-content table>caption .btn .headerlink,.rst-content table>caption .nav .headerlink,.rst-content tt.download .btn span:first-child,.rst-content tt.download .nav span:first-child,.wy-menu-vertical li .btn button.toctree-expand,.wy-menu-vertical li.current>a .btn button.toctree-expand,.wy-menu-vertical li.current>a .nav button.toctree-expand,.wy-menu-vertical li .nav button.toctree-expand,.wy-menu-vertical li.on a .btn button.toctree-expand,.wy-menu-vertical li.on a .nav button.toctree-expand{display:inline}.btn .fa-large.icon,.btn .fa.fa-large,.btn .rst-content .code-block-caption .fa-large.headerlink,.btn .rst-content .eqno .fa-large.headerlink,.btn .rst-content .fa-large.admonition-title,.btn .rst-content code.download span.fa-large:first-child,.btn .rst-content dl dt .fa-large.headerlink,.btn .rst-content h1 .fa-large.headerlink,.btn .rst-content h2 .fa-large.headerlink,.btn .rst-content h3 .fa-large.headerlink,.btn .rst-content h4 .fa-large.headerlink,.btn .rst-content h5 .fa-large.headerlink,.btn .rst-content h6 .fa-large.headerlink,.btn .rst-content p .fa-large.headerlink,.btn .rst-content table>caption .fa-large.headerlink,.btn .rst-content tt.download span.fa-large:first-child,.btn .wy-menu-vertical li button.fa-large.toctree-expand,.nav .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .code-block-caption .fa-large.headerlink,.nav .rst-content .eqno .fa-large.headerlink,.nav .rst-content .fa-large.admonition-title,.nav .rst-content code.download span.fa-large:first-child,.nav .rst-content dl dt .fa-large.headerlink,.nav .rst-content h1 .fa-large.headerlink,.nav .rst-content h2 .fa-large.headerlink,.nav .rst-content h3 .fa-large.headerlink,.nav .rst-content h4 .fa-large.headerlink,.nav .rst-content h5 .fa-large.headerlink,.nav .rst-content h6 .fa-large.headerlink,.nav .rst-content p .fa-large.headerlink,.nav .rst-content table>caption .fa-large.headerlink,.nav .rst-content tt.download span.fa-large:first-child,.nav .wy-menu-vertical li button.fa-large.toctree-expand,.rst-content .btn .fa-large.admonition-title,.rst-content .code-block-caption .btn .fa-large.headerlink,.rst-content .code-block-caption .nav .fa-large.headerlink,.rst-content .eqno .btn .fa-large.headerlink,.rst-content .eqno .nav .fa-large.headerlink,.rst-content .nav .fa-large.admonition-title,.rst-content code.download .btn span.fa-large:first-child,.rst-content code.download .nav span.fa-large:first-child,.rst-content dl dt .btn .fa-large.headerlink,.rst-content dl dt .nav .fa-large.headerlink,.rst-content h1 .btn .fa-large.headerlink,.rst-content h1 .nav .fa-large.headerlink,.rst-content h2 .btn .fa-large.headerlink,.rst-content h2 .nav .fa-large.headerlink,.rst-content h3 .btn .fa-large.headerlink,.rst-content h3 .nav .fa-large.headerlink,.rst-content h4 .btn .fa-large.headerlink,.rst-content h4 .nav .fa-large.headerlink,.rst-content h5 .btn .fa-large.headerlink,.rst-content h5 .nav .fa-large.headerlink,.rst-content h6 .btn .fa-large.headerlink,.rst-content h6 .nav .fa-large.headerlink,.rst-content p .btn .fa-large.headerlink,.rst-content p .nav .fa-large.headerlink,.rst-content table>caption .btn .fa-large.headerlink,.rst-content table>caption .nav .fa-large.headerlink,.rst-content tt.download .btn span.fa-large:first-child,.rst-content tt.download .nav span.fa-large:first-child,.wy-menu-vertical li .btn button.fa-large.toctree-expand,.wy-menu-vertical li .nav button.fa-large.toctree-expand{line-height:.9em}.btn .fa-spin.icon,.btn .fa.fa-spin,.btn .rst-content .code-block-caption .fa-spin.headerlink,.btn .rst-content .eqno .fa-spin.headerlink,.btn .rst-content .fa-spin.admonition-title,.btn .rst-content code.download span.fa-spin:first-child,.btn .rst-content dl dt .fa-spin.headerlink,.btn .rst-content h1 .fa-spin.headerlink,.btn .rst-content h2 .fa-spin.headerlink,.btn .rst-content h3 .fa-spin.headerlink,.btn .rst-content h4 .fa-spin.headerlink,.btn .rst-content h5 .fa-spin.headerlink,.btn .rst-content h6 .fa-spin.headerlink,.btn .rst-content p .fa-spin.headerlink,.btn .rst-content table>caption .fa-spin.headerlink,.btn .rst-content tt.download span.fa-spin:first-child,.btn .wy-menu-vertical li button.fa-spin.toctree-expand,.nav .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .code-block-caption .fa-spin.headerlink,.nav .rst-content .eqno .fa-spin.headerlink,.nav .rst-content .fa-spin.admonition-title,.nav .rst-content code.download span.fa-spin:first-child,.nav .rst-content dl dt .fa-spin.headerlink,.nav .rst-content h1 .fa-spin.headerlink,.nav .rst-content h2 .fa-spin.headerlink,.nav .rst-content h3 .fa-spin.headerlink,.nav .rst-content h4 .fa-spin.headerlink,.nav .rst-content h5 .fa-spin.headerlink,.nav .rst-content h6 .fa-spin.headerlink,.nav .rst-content p .fa-spin.headerlink,.nav .rst-content table>caption .fa-spin.headerlink,.nav .rst-content tt.download span.fa-spin:first-child,.nav .wy-menu-vertical li button.fa-spin.toctree-expand,.rst-content .btn .fa-spin.admonition-title,.rst-content .code-block-caption .btn .fa-spin.headerlink,.rst-content .code-block-caption .nav .fa-spin.headerlink,.rst-content .eqno .btn .fa-spin.headerlink,.rst-content .eqno .nav .fa-spin.headerlink,.rst-content .nav .fa-spin.admonition-title,.rst-content code.download .btn span.fa-spin:first-child,.rst-content code.download .nav span.fa-spin:first-child,.rst-content dl dt .btn .fa-spin.headerlink,.rst-content dl dt .nav .fa-spin.headerlink,.rst-content h1 .btn .fa-spin.headerlink,.rst-content h1 .nav .fa-spin.headerlink,.rst-content h2 .btn .fa-spin.headerlink,.rst-content h2 .nav .fa-spin.headerlink,.rst-content h3 .btn .fa-spin.headerlink,.rst-content h3 .nav .fa-spin.headerlink,.rst-content h4 .btn .fa-spin.headerlink,.rst-content h4 .nav .fa-spin.headerlink,.rst-content h5 .btn .fa-spin.headerlink,.rst-content h5 .nav .fa-spin.headerlink,.rst-content h6 .btn .fa-spin.headerlink,.rst-content h6 .nav .fa-spin.headerlink,.rst-content p .btn .fa-spin.headerlink,.rst-content p .nav .fa-spin.headerlink,.rst-content table>caption .btn .fa-spin.headerlink,.rst-content table>caption .nav .fa-spin.headerlink,.rst-content tt.download .btn span.fa-spin:first-child,.rst-content tt.download .nav span.fa-spin:first-child,.wy-menu-vertical li .btn button.fa-spin.toctree-expand,.wy-menu-vertical li .nav button.fa-spin.toctree-expand{display:inline-block}.btn.fa:before,.btn.icon:before,.rst-content .btn.admonition-title:before,.rst-content .code-block-caption .btn.headerlink:before,.rst-content .eqno .btn.headerlink:before,.rst-content code.download span.btn:first-child:before,.rst-content dl dt .btn.headerlink:before,.rst-content h1 .btn.headerlink:before,.rst-content h2 .btn.headerlink:before,.rst-content h3 .btn.headerlink:before,.rst-content h4 .btn.headerlink:before,.rst-content h5 .btn.headerlink:before,.rst-content h6 .btn.headerlink:before,.rst-content p .btn.headerlink:before,.rst-content table>caption .btn.headerlink:before,.rst-content tt.download span.btn:first-child:before,.wy-menu-vertical li button.btn.toctree-expand:before{opacity:.5;-webkit-transition:opacity .05s ease-in;-moz-transition:opacity .05s ease-in;transition:opacity .05s ease-in}.btn.fa:hover:before,.btn.icon:hover:before,.rst-content .btn.admonition-title:hover:before,.rst-content .code-block-caption .btn.headerlink:hover:before,.rst-content .eqno .btn.headerlink:hover:before,.rst-content code.download span.btn:first-child:hover:before,.rst-content dl dt .btn.headerlink:hover:before,.rst-content h1 .btn.headerlink:hover:before,.rst-content h2 .btn.headerlink:hover:before,.rst-content h3 .btn.headerlink:hover:before,.rst-content h4 .btn.headerlink:hover:before,.rst-content h5 .btn.headerlink:hover:before,.rst-content h6 .btn.headerlink:hover:before,.rst-content p .btn.headerlink:hover:before,.rst-content table>caption .btn.headerlink:hover:before,.rst-content tt.download span.btn:first-child:hover:before,.wy-menu-vertical li button.btn.toctree-expand:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .icon:before,.btn-mini .rst-content .admonition-title:before,.btn-mini .rst-content .code-block-caption .headerlink:before,.btn-mini .rst-content .eqno .headerlink:before,.btn-mini .rst-content code.download span:first-child:before,.btn-mini .rst-content dl dt .headerlink:before,.btn-mini .rst-content h1 .headerlink:before,.btn-mini .rst-content h2 .headerlink:before,.btn-mini .rst-content h3 .headerlink:before,.btn-mini .rst-content h4 .headerlink:before,.btn-mini .rst-content h5 .headerlink:before,.btn-mini .rst-content h6 .headerlink:before,.btn-mini .rst-content p .headerlink:before,.btn-mini .rst-content table>caption .headerlink:before,.btn-mini .rst-content tt.download span:first-child:before,.btn-mini .wy-menu-vertical li button.toctree-expand:before,.rst-content .btn-mini .admonition-title:before,.rst-content .code-block-caption .btn-mini .headerlink:before,.rst-content .eqno .btn-mini .headerlink:before,.rst-content code.download .btn-mini span:first-child:before,.rst-content dl dt .btn-mini .headerlink:before,.rst-content h1 .btn-mini .headerlink:before,.rst-content h2 .btn-mini .headerlink:before,.rst-content h3 .btn-mini .headerlink:before,.rst-content h4 .btn-mini .headerlink:before,.rst-content h5 .btn-mini .headerlink:before,.rst-content h6 .btn-mini .headerlink:before,.rst-content p .btn-mini .headerlink:before,.rst-content table>caption .btn-mini .headerlink:before,.rst-content tt.download .btn-mini span:first-child:before,.wy-menu-vertical li .btn-mini button.toctree-expand:before{font-size:14px;vertical-align:-15%}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.wy-alert{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.rst-content .admonition-title,.wy-alert-title{font-weight:700;display:block;color:#fff;background:#6ab0de;padding:6px 12px;margin:-12px -12px 12px}.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.admonition,.rst-content .wy-alert-danger.admonition-todo,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.wy-alert.wy-alert-danger{background:#fdf3f2}.rst-content .danger .admonition-title,.rst-content .danger .wy-alert-title,.rst-content .error .admonition-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.rst-content .wy-alert-danger.admonition .admonition-title,.rst-content .wy-alert-danger.admonition .wy-alert-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.wy-alert.wy-alert-danger .wy-alert-title{background:#f29f97}.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .warning,.rst-content .wy-alert-warning.admonition,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.note,.rst-content .wy-alert-warning.seealso,.rst-content .wy-alert-warning.tip,.wy-alert.wy-alert-warning{background:#ffedcc}.rst-content .admonition-todo .admonition-title,.rst-content .admonition-todo .wy-alert-title,.rst-content .attention .admonition-title,.rst-content .attention .wy-alert-title,.rst-content .caution .admonition-title,.rst-content .caution .wy-alert-title,.rst-content .warning .admonition-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.admonition .admonition-title,.rst-content .wy-alert-warning.admonition .wy-alert-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.wy-alert.wy-alert-warning .wy-alert-title{background:#f0b37e}.rst-content .note,.rst-content .seealso,.rst-content .wy-alert-info.admonition,.rst-content .wy-alert-info.admonition-todo,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.wy-alert.wy-alert-info{background:#e7f2fa}.rst-content .note .admonition-title,.rst-content .note .wy-alert-title,.rst-content .seealso .admonition-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .admonition-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.rst-content .wy-alert-info.admonition .admonition-title,.rst-content .wy-alert-info.admonition .wy-alert-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.wy-alert.wy-alert-info .wy-alert-title{background:#6ab0de}.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.admonition,.rst-content .wy-alert-success.admonition-todo,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.warning,.wy-alert.wy-alert-success{background:#dbfaf4}.rst-content .hint .admonition-title,.rst-content .hint .wy-alert-title,.rst-content .important .admonition-title,.rst-content .important .wy-alert-title,.rst-content .tip .admonition-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .admonition-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.rst-content .wy-alert-success.admonition .admonition-title,.rst-content .wy-alert-success.admonition .wy-alert-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.wy-alert.wy-alert-success .wy-alert-title{background:#1abc9c}.rst-content .wy-alert-neutral.admonition,.rst-content .wy-alert-neutral.admonition-todo,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.wy-alert.wy-alert-neutral{background:#f3f6f6}.rst-content .wy-alert-neutral.admonition-todo .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.rst-content .wy-alert-neutral.admonition .admonition-title,.rst-content .wy-alert-neutral.admonition .wy-alert-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.wy-alert.wy-alert-neutral .wy-alert-title{color:#404040;background:#e1e4e5}.rst-content .wy-alert-neutral.admonition-todo a,.rst-content .wy-alert-neutral.admonition a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.wy-alert.wy-alert-neutral a{color:#2980b9}.rst-content .admonition-todo p:last-child,.rst-content .admonition p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .note p:last-child,.rst-content .seealso p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.wy-alert p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all .3s ease-in;-moz-transition:all .3s ease-in;transition:all .3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27ae60}.wy-tray-container li.wy-tray-item-info{background:#2980b9}.wy-tray-container li.wy-tray-item-warning{background:#e67e22}.wy-tray-container li.wy-tray-item-danger{background:#e74c3c}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width:768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px;color:#fff;border:1px solid rgba(0,0,0,.1);background-color:#27ae60;text-decoration:none;font-weight:400;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 2px -1px hsla(0,0%,100%,.5),inset 0 -2px 0 0 rgba(0,0,0,.1);outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all .1s linear;-moz-transition:all .1s linear;transition:all .1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:inset 0 -1px 0 0 rgba(0,0,0,.05),inset 0 2px 0 0 rgba(0,0,0,.1);padding:8px 12px 6px}.btn:visited{color:#fff}.btn-disabled,.btn-disabled:active,.btn-disabled:focus,.btn-disabled:hover,.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980b9!important}.btn-info:hover{background-color:#2e8ece!important}.btn-neutral{background-color:#f3f6f6!important;color:#404040!important}.btn-neutral:hover{background-color:#e5ebeb!important;color:#404040}.btn-neutral:visited{color:#404040!important}.btn-success{background-color:#27ae60!important}.btn-success:hover{background-color:#295!important}.btn-danger{background-color:#e74c3c!important}.btn-danger:hover{background-color:#ea6153!important}.btn-warning{background-color:#e67e22!important}.btn-warning:hover{background-color:#e98b39!important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f!important}.btn-link{background-color:transparent!important;color:#2980b9;box-shadow:none;border-color:transparent!important}.btn-link:active,.btn-link:hover{background-color:transparent!important;color:#409ad5!important;box-shadow:none}.btn-link:visited{color:#9b59b6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:after,.wy-btn-group:before{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:1px solid #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980b9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:1px solid #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type=search]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980b9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned .wy-help-inline,.wy-form-aligned input,.wy-form-aligned label,.wy-form-aligned select,.wy-form-aligned textarea{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{margin:0}fieldset,legend{border:0;padding:0}legend{width:100%;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label,legend{display:block}label{margin:0 0 .3125em;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;max-width:1200px;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:after,.wy-control-group:before{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#e74c3c}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full input[type=color],.wy-control-group .wy-form-full input[type=date],.wy-control-group .wy-form-full input[type=datetime-local],.wy-control-group .wy-form-full input[type=datetime],.wy-control-group .wy-form-full input[type=email],.wy-control-group .wy-form-full input[type=month],.wy-control-group .wy-form-full input[type=number],.wy-control-group .wy-form-full input[type=password],.wy-control-group .wy-form-full input[type=search],.wy-control-group .wy-form-full input[type=tel],.wy-control-group .wy-form-full input[type=text],.wy-control-group .wy-form-full input[type=time],.wy-control-group .wy-form-full input[type=url],.wy-control-group .wy-form-full input[type=week],.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves input[type=color],.wy-control-group .wy-form-halves input[type=date],.wy-control-group .wy-form-halves input[type=datetime-local],.wy-control-group .wy-form-halves input[type=datetime],.wy-control-group .wy-form-halves input[type=email],.wy-control-group .wy-form-halves input[type=month],.wy-control-group .wy-form-halves input[type=number],.wy-control-group .wy-form-halves input[type=password],.wy-control-group .wy-form-halves input[type=search],.wy-control-group .wy-form-halves input[type=tel],.wy-control-group .wy-form-halves input[type=text],.wy-control-group .wy-form-halves input[type=time],.wy-control-group .wy-form-halves input[type=url],.wy-control-group .wy-form-halves input[type=week],.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds input[type=color],.wy-control-group .wy-form-thirds input[type=date],.wy-control-group .wy-form-thirds input[type=datetime-local],.wy-control-group .wy-form-thirds input[type=datetime],.wy-control-group .wy-form-thirds input[type=email],.wy-control-group .wy-form-thirds input[type=month],.wy-control-group .wy-form-thirds input[type=number],.wy-control-group .wy-form-thirds input[type=password],.wy-control-group .wy-form-thirds input[type=search],.wy-control-group .wy-form-thirds input[type=tel],.wy-control-group .wy-form-thirds input[type=text],.wy-control-group .wy-form-thirds input[type=time],.wy-control-group .wy-form-thirds input[type=url],.wy-control-group .wy-form-thirds input[type=week],.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full{float:left;display:block;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child,.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(odd){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child,.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control,.wy-control-no-input{margin:6px 0 0;font-size:90%}.wy-control-no-input{display:inline-block}.wy-control-group.fluid-input input[type=color],.wy-control-group.fluid-input input[type=date],.wy-control-group.fluid-input input[type=datetime-local],.wy-control-group.fluid-input input[type=datetime],.wy-control-group.fluid-input input[type=email],.wy-control-group.fluid-input input[type=month],.wy-control-group.fluid-input input[type=number],.wy-control-group.fluid-input input[type=password],.wy-control-group.fluid-input input[type=search],.wy-control-group.fluid-input input[type=tel],.wy-control-group.fluid-input input[type=text],.wy-control-group.fluid-input input[type=time],.wy-control-group.fluid-input input[type=url],.wy-control-group.fluid-input input[type=week]{width:100%}.wy-form-message-inline{padding-left:.3em;color:#666;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;*overflow:visible}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}input[type=datetime-local]{padding:.34375em .625em}input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{padding:0;margin-right:.3125em;*height:13px;*width:13px}input[type=checkbox],input[type=radio],input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus,input[type=week]:focus{outline:0;outline:thin dotted\9;border-color:#333}input.no-focus:focus{border-color:#ccc!important}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:thin dotted #333;outline:1px auto #129fea}input[type=color][disabled],input[type=date][disabled],input[type=datetime-local][disabled],input[type=datetime][disabled],input[type=email][disabled],input[type=month][disabled],input[type=number][disabled],input[type=password][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=text][disabled],input[type=time][disabled],input[type=url][disabled],input[type=week][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,select:focus:invalid,textarea:focus:invalid{color:#e74c3c;border:1px solid #e74c3c}input:focus:invalid:focus,select:focus:invalid:focus,textarea:focus:invalid:focus{border-color:#e74c3c}input[type=checkbox]:focus:invalid:focus,input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus{outline-color:#e74c3c}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif}select,textarea{padding:.5em .625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}input[readonly],select[disabled],select[readonly],textarea[disabled],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type=checkbox][disabled],input[type=radio][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:1px solid #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{position:relative;display:block;height:24px;margin-top:12px;cursor:pointer}.wy-switch:before{left:0;top:0;width:36px;height:12px;background:#ccc}.wy-switch:after,.wy-switch:before{position:absolute;content:"";display:block;border-radius:4px;-webkit-transition:all .2s ease-in-out;-moz-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.wy-switch:after{width:18px;height:18px;background:#999;left:-3px;top:-3px}.wy-switch span{position:absolute;left:48px;display:block;font-size:12px;color:#ccc;line-height:1}.wy-switch.active:before{background:#1e8449}.wy-switch.active:after{left:24px;background:#27ae60}.wy-switch.disabled{cursor:not-allowed;opacity:.8}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#e74c3c}.wy-control-group.wy-control-group-error input[type=color],.wy-control-group.wy-control-group-error input[type=date],.wy-control-group.wy-control-group-error input[type=datetime-local],.wy-control-group.wy-control-group-error input[type=datetime],.wy-control-group.wy-control-group-error input[type=email],.wy-control-group.wy-control-group-error input[type=month],.wy-control-group.wy-control-group-error input[type=number],.wy-control-group.wy-control-group-error input[type=password],.wy-control-group.wy-control-group-error input[type=search],.wy-control-group.wy-control-group-error input[type=tel],.wy-control-group.wy-control-group-error input[type=text],.wy-control-group.wy-control-group-error input[type=time],.wy-control-group.wy-control-group-error input[type=url],.wy-control-group.wy-control-group-error input[type=week],.wy-control-group.wy-control-group-error textarea{border:1px solid #e74c3c}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:.5em .625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27ae60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#e74c3c}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#e67e22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980b9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width:480px){.wy-form button[type=submit]{margin:.7em 0 0}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=text],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week],.wy-form label{margin-bottom:.3em;display:block}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0}.wy-form-message,.wy-form-message-inline,.wy-form .wy-help-inline{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width:768px){.tablet-hide{display:none}}@media screen and (max-width:480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.rst-content table.docutils,.rst-content table.field-list,.wy-table{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.rst-content table.docutils caption,.rst-content table.field-list caption,.wy-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.rst-content table.docutils td,.rst-content table.docutils th,.rst-content table.field-list td,.rst-content table.field-list th,.wy-table td,.wy-table th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.rst-content table.docutils td:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list td:first-child,.rst-content table.field-list th:first-child,.wy-table td:first-child,.wy-table th:first-child{border-left-width:0}.rst-content table.docutils thead,.rst-content table.field-list thead,.wy-table thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.rst-content table.docutils thead th,.rst-content table.field-list thead th,.wy-table thead th{font-weight:700;border-bottom:2px solid #e1e4e5}.rst-content table.docutils td,.rst-content table.field-list td,.wy-table td{background-color:transparent;vertical-align:middle}.rst-content table.docutils td p,.rst-content table.field-list td p,.wy-table td p{line-height:18px}.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child,.wy-table td p:last-child{margin-bottom:0}.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min,.wy-table .wy-table-cell-min{width:1%;padding-right:0}.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:grey;font-size:90%}.wy-table-tertiary{color:grey;font-size:80%}.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td,.wy-table-backed,.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td{background-color:#f3f6f6}.rst-content table.docutils,.wy-table-bordered-all{border:1px solid #e1e4e5}.rst-content table.docutils td,.wy-table-bordered-all td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.rst-content table.docutils tbody>tr:last-child td,.wy-table-bordered-all tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0!important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980b9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9b59b6}html{height:100%}body,html{overflow-x:hidden}body{font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-weight:400;color:#404040;min-height:100%;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#e67e22!important}a.wy-text-warning:hover{color:#eb9950!important}.wy-text-info{color:#2980b9!important}a.wy-text-info:hover{color:#409ad5!important}.wy-text-success{color:#27ae60!important}a.wy-text-success:hover{color:#36d278!important}.wy-text-danger{color:#e74c3c!important}a.wy-text-danger:hover{color:#ed7669!important}.wy-text-neutral{color:#404040!important}a.wy-text-neutral:hover{color:#595959!important}.rst-content .toctree-wrapper>p.caption,h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif}p{line-height:24px;font-size:16px;margin:0 0 24px}h1{font-size:175%}.rst-content .toctree-wrapper>p.caption,h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}.rst-content code,.rst-content tt,code{white-space:nowrap;max-width:100%;background:#fff;border:1px solid #e1e4e5;font-size:75%;padding:0 5px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#e74c3c;overflow-x:auto}.rst-content tt.code-large,code.code-large{font-size:90%}.rst-content .section ul,.rst-content .toctree-wrapper ul,.rst-content section ul,.wy-plain-list-disc,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.rst-content .section ul li,.rst-content .toctree-wrapper ul li,.rst-content section ul li,.wy-plain-list-disc li,article ul li{list-style:disc;margin-left:24px}.rst-content .section ul li p:last-child,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li p:last-child,.rst-content .toctree-wrapper ul li ul,.rst-content section ul li p:last-child,.rst-content section ul li ul,.wy-plain-list-disc li p:last-child,.wy-plain-list-disc li ul,article ul li p:last-child,article ul li ul{margin-bottom:0}.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,.rst-content section ul li li,.wy-plain-list-disc li li,article ul li li{list-style:circle}.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,.rst-content section ul li li li,.wy-plain-list-disc li li li,article ul li li li{list-style:square}.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,.rst-content section ul li ol li,.wy-plain-list-disc li ol li,article ul li ol li{list-style:decimal}.rst-content .section ol,.rst-content .section ol.arabic,.rst-content .toctree-wrapper ol,.rst-content .toctree-wrapper ol.arabic,.rst-content section ol,.rst-content section ol.arabic,.wy-plain-list-decimal,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.rst-content .section ol.arabic li,.rst-content .section ol li,.rst-content .toctree-wrapper ol.arabic li,.rst-content .toctree-wrapper ol li,.rst-content section ol.arabic li,.rst-content section ol li,.wy-plain-list-decimal li,article ol li{list-style:decimal;margin-left:24px}.rst-content .section ol.arabic li ul,.rst-content .section ol li p:last-child,.rst-content .section ol li ul,.rst-content .toctree-wrapper ol.arabic li ul,.rst-content .toctree-wrapper ol li p:last-child,.rst-content .toctree-wrapper ol li ul,.rst-content section ol.arabic li ul,.rst-content section ol li p:last-child,.rst-content section ol li ul,.wy-plain-list-decimal li p:last-child,.wy-plain-list-decimal li ul,article ol li p:last-child,article ol li ul{margin-bottom:0}.rst-content .section ol.arabic li ul li,.rst-content .section ol li ul li,.rst-content .toctree-wrapper ol.arabic li ul li,.rst-content .toctree-wrapper ol li ul li,.rst-content section ol.arabic li ul li,.rst-content section ol li ul li,.wy-plain-list-decimal li ul li,article ol li ul li{list-style:disc}.wy-breadcrumbs{*zoom:1}.wy-breadcrumbs:after,.wy-breadcrumbs:before{display:table;content:""}.wy-breadcrumbs:after{clear:both}.wy-breadcrumbs>li{display:inline-block;padding-top:5px}.wy-breadcrumbs>li.wy-breadcrumbs-aside{float:right}.rst-content .wy-breadcrumbs>li code,.rst-content .wy-breadcrumbs>li tt,.wy-breadcrumbs>li .rst-content tt,.wy-breadcrumbs>li code{all:inherit;color:inherit}.breadcrumb-item:before{content:"/";color:#bbb;font-size:13px;padding:0 6px 0 3px}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width:480px){.wy-breadcrumbs-extra,.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}html{font-size:16px}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:after,.wy-menu-horiz:before{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz li,.wy-menu-horiz ul{display:inline-block}.wy-menu-horiz li:hover{background:hsla(0,0%,100%,.1)}.wy-menu-horiz li.divide-left{border-left:1px solid #404040}.wy-menu-horiz li.divide-right{border-right:1px solid #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical{width:300px}.wy-menu-vertical header,.wy-menu-vertical p.caption{color:#55a5d9;height:32px;line-height:32px;padding:0 1.618em;margin:12px 0 0;display:block;font-weight:700;text-transform:uppercase;font-size:85%;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:1px solid #404040}.wy-menu-vertical li.divide-bottom{border-bottom:1px solid #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:grey;border-right:1px solid #c9c9c9;padding:.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.rst-content .wy-menu-vertical li tt,.wy-menu-vertical li .rst-content tt,.wy-menu-vertical li code{border:none;background:inherit;color:inherit;padding-left:0;padding-right:0}.wy-menu-vertical li button.toctree-expand{display:block;float:left;margin-left:-1.2em;line-height:18px;color:#4d4d4d;border:none;background:none;padding:0}.wy-menu-vertical li.current>a,.wy-menu-vertical li.on a{color:#404040;font-weight:700;position:relative;background:#fcfcfc;border:none;padding:.4045em 1.618em}.wy-menu-vertical li.current>a:hover,.wy-menu-vertical li.on a:hover{background:#fcfcfc}.wy-menu-vertical li.current>a:hover button.toctree-expand,.wy-menu-vertical li.on a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand{display:block;line-height:18px;color:#333}.wy-menu-vertical li.toctree-l1.current>a{border-bottom:1px solid #c9c9c9;border-top:1px solid #c9c9c9}.wy-menu-vertical .toctree-l1.current .toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .toctree-l11>ul{display:none}.wy-menu-vertical .toctree-l1.current .current.toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .current.toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .current.toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .current.toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .current.toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .current.toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .current.toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .current.toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .current.toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .current.toctree-l11>ul{display:block}.wy-menu-vertical li.toctree-l3,.wy-menu-vertical li.toctree-l4{font-size:.9em}.wy-menu-vertical li.toctree-l2 a,.wy-menu-vertical li.toctree-l3 a,.wy-menu-vertical li.toctree-l4 a,.wy-menu-vertical li.toctree-l5 a,.wy-menu-vertical li.toctree-l6 a,.wy-menu-vertical li.toctree-l7 a,.wy-menu-vertical li.toctree-l8 a,.wy-menu-vertical li.toctree-l9 a,.wy-menu-vertical li.toctree-l10 a{color:#404040}.wy-menu-vertical li.toctree-l2 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l3 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l4 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l5 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l6 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l7 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l8 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l9 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l10 a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a,.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a,.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a,.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a,.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a,.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a,.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a,.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{display:block}.wy-menu-vertical li.toctree-l2.current>a{padding:.4045em 2.427em}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{padding:.4045em 1.618em .4045em 4.045em}.wy-menu-vertical li.toctree-l3.current>a{padding:.4045em 4.045em}.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{padding:.4045em 1.618em .4045em 5.663em}.wy-menu-vertical li.toctree-l4.current>a{padding:.4045em 5.663em}.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a{padding:.4045em 1.618em .4045em 7.281em}.wy-menu-vertical li.toctree-l5.current>a{padding:.4045em 7.281em}.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a{padding:.4045em 1.618em .4045em 8.899em}.wy-menu-vertical li.toctree-l6.current>a{padding:.4045em 8.899em}.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a{padding:.4045em 1.618em .4045em 10.517em}.wy-menu-vertical li.toctree-l7.current>a{padding:.4045em 10.517em}.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a{padding:.4045em 1.618em .4045em 12.135em}.wy-menu-vertical li.toctree-l8.current>a{padding:.4045em 12.135em}.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a{padding:.4045em 1.618em .4045em 13.753em}.wy-menu-vertical li.toctree-l9.current>a{padding:.4045em 13.753em}.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a{padding:.4045em 1.618em .4045em 15.371em}.wy-menu-vertical li.toctree-l10.current>a{padding:.4045em 15.371em}.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{padding:.4045em 1.618em .4045em 16.989em}.wy-menu-vertical li.toctree-l2.current>a,.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{background:#c9c9c9}.wy-menu-vertical li.toctree-l2 button.toctree-expand{color:#a3a3a3}.wy-menu-vertical li.toctree-l3.current>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{background:#bdbdbd}.wy-menu-vertical li.toctree-l3 button.toctree-expand{color:#969696}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical li ul li a{margin-bottom:0;color:#d9d9d9;font-weight:400}.wy-menu-vertical a{line-height:18px;padding:.4045em 1.618em;display:block;position:relative;font-size:90%;color:#d9d9d9}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:hover button.toctree-expand{color:#d9d9d9}.wy-menu-vertical a:active{background-color:#2980b9;cursor:pointer;color:#fff}.wy-menu-vertical a:active button.toctree-expand{color:#fff}.wy-side-nav-search{display:block;width:300px;padding:.809em;margin-bottom:.809em;z-index:200;background-color:#2980b9;text-align:center;color:#fcfcfc}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto .809em;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-side-nav-search .wy-dropdown>a,.wy-side-nav-search>a{color:#fcfcfc;font-size:100%;font-weight:700;display:inline-block;padding:4px 6px;margin-bottom:.809em;max-width:100%}.wy-side-nav-search .wy-dropdown>a:hover,.wy-side-nav-search .wy-dropdown>aactive,.wy-side-nav-search .wy-dropdown>afocus,.wy-side-nav-search>a:hover,.wy-side-nav-search>aactive,.wy-side-nav-search>afocus{background:hsla(0,0%,100%,.1)}.wy-side-nav-search .wy-dropdown>a img.logo,.wy-side-nav-search>a img.logo{display:block;margin:0 auto;height:auto;width:auto;border-radius:0;max-width:100%;background:transparent}.wy-side-nav-search .wy-dropdown>a.icon,.wy-side-nav-search>a.icon{display:block}.wy-side-nav-search .wy-dropdown>a.icon img.logo,.wy-side-nav-search>a.icon img.logo{margin-top:.85em}.wy-side-nav-search>div.switch-menus{position:relative;display:block;margin-top:-.4045em;margin-bottom:.809em;font-weight:400;color:hsla(0,0%,100%,.3)}.wy-side-nav-search>div.switch-menus>div.language-switch,.wy-side-nav-search>div.switch-menus>div.version-switch{display:inline-block;padding:.2em}.wy-side-nav-search>div.switch-menus>div.language-switch select,.wy-side-nav-search>div.switch-menus>div.version-switch select{display:inline-block;margin-right:-2rem;padding-right:2rem;max-width:240px;text-align-last:center;background:none;border:none;border-radius:0;box-shadow:none;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-size:1em;font-weight:400;color:hsla(0,0%,100%,.3);cursor:pointer;appearance:none;-webkit-appearance:none;-moz-appearance:none}.wy-side-nav-search>div.switch-menus>div.language-switch select:active,.wy-side-nav-search>div.switch-menus>div.language-switch select:focus,.wy-side-nav-search>div.switch-menus>div.language-switch select:hover,.wy-side-nav-search>div.switch-menus>div.version-switch select:active,.wy-side-nav-search>div.switch-menus>div.version-switch select:focus,.wy-side-nav-search>div.switch-menus>div.version-switch select:hover{background:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.wy-side-nav-search>div.switch-menus>div.language-switch select option,.wy-side-nav-search>div.switch-menus>div.version-switch select option{color:#000}.wy-side-nav-search>div.switch-menus>div.language-switch:has(>select):after,.wy-side-nav-search>div.switch-menus>div.version-switch:has(>select):after{display:inline-block;width:1.5em;height:100%;padding:.1em;content:"\f0d7";font-size:1em;line-height:1.2em;font-family:FontAwesome;text-align:center;pointer-events:none;box-sizing:border-box}.wy-nav .wy-menu-vertical header{color:#2980b9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980b9;color:#fff}[data-menu-wrap]{-webkit-transition:all .2s ease-in;-moz-transition:all .2s ease-in;transition:all .2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:#fcfcfc}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:fixed;top:0;bottom:0;left:0;padding-bottom:2em;width:300px;overflow-x:hidden;overflow-y:hidden;min-height:100%;color:#9b9b9b;background:#343131;z-index:200}.wy-side-scroll{width:320px;position:relative;overflow-x:hidden;overflow-y:scroll;height:100%}.wy-nav-top{display:none;background:#2980b9;color:#fff;padding:.4045em .809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:after,.wy-nav-top:before{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:700}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer;padding-top:inherit}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:grey}footer p{margin-bottom:12px}.rst-content footer span.commit tt,footer span.commit .rst-content tt,footer span.commit code{padding:0;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:1em;background:none;border:none;color:grey}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:after,.rst-footer-buttons:before{width:100%;display:table;content:""}.rst-footer-buttons:after{clear:both}.rst-breadcrumbs-buttons{margin-top:12px;*zoom:1}.rst-breadcrumbs-buttons:after,.rst-breadcrumbs-buttons:before{display:table;content:""}.rst-breadcrumbs-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:1px solid #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:1px solid #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:grey;font-size:90%}.genindextable li>ul{margin-left:24px}@media screen and (max-width:768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-menu.wy-menu-vertical,.wy-side-nav-search,.wy-side-scroll{width:auto}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width:1100px){.wy-nav-content-wrap{background:rgba(0,0,0,.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,.wy-nav-side,footer{display:none}.wy-nav-content-wrap{margin-left:0}}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:after,.rst-versions .rst-current-version:before{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-content .code-block-caption .rst-versions .rst-current-version .headerlink,.rst-content .eqno .rst-versions .rst-current-version .headerlink,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-content code.download .rst-versions .rst-current-version span:first-child,.rst-content dl dt .rst-versions .rst-current-version .headerlink,.rst-content h1 .rst-versions .rst-current-version .headerlink,.rst-content h2 .rst-versions .rst-current-version .headerlink,.rst-content h3 .rst-versions .rst-current-version .headerlink,.rst-content h4 .rst-versions .rst-current-version .headerlink,.rst-content h5 .rst-versions .rst-current-version .headerlink,.rst-content h6 .rst-versions .rst-current-version .headerlink,.rst-content p .rst-versions .rst-current-version .headerlink,.rst-content table>caption .rst-versions .rst-current-version .headerlink,.rst-content tt.download .rst-versions .rst-current-version span:first-child,.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .icon,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-versions .rst-current-version .rst-content .code-block-caption .headerlink,.rst-versions .rst-current-version .rst-content .eqno .headerlink,.rst-versions .rst-current-version .rst-content code.download span:first-child,.rst-versions .rst-current-version .rst-content dl dt .headerlink,.rst-versions .rst-current-version .rst-content h1 .headerlink,.rst-versions .rst-current-version .rst-content h2 .headerlink,.rst-versions .rst-current-version .rst-content h3 .headerlink,.rst-versions .rst-current-version .rst-content h4 .headerlink,.rst-versions .rst-current-version .rst-content h5 .headerlink,.rst-versions .rst-current-version .rst-content h6 .headerlink,.rst-versions .rst-current-version .rst-content p .headerlink,.rst-versions .rst-current-version .rst-content table>caption .headerlink,.rst-versions .rst-current-version .rst-content tt.download span:first-child,.rst-versions .rst-current-version .wy-menu-vertical li button.toctree-expand,.wy-menu-vertical li .rst-versions .rst-current-version button.toctree-expand{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions .rst-other-versions .rtd-current-item{font-weight:700}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}#flyout-search-form{padding:6px}.rst-content .toctree-wrapper>p.caption,.rst-content h1,.rst-content h2,.rst-content h3,.rst-content h4,.rst-content h5,.rst-content h6{margin-bottom:24px}.rst-content img{max-width:100%;height:auto}.rst-content div.figure,.rst-content figure{margin-bottom:24px}.rst-content div.figure .caption-text,.rst-content figure .caption-text{font-style:italic}.rst-content div.figure p:last-child.caption,.rst-content figure p:last-child.caption{margin-bottom:0}.rst-content div.figure.align-center,.rst-content figure.align-center{text-align:center}.rst-content .section>a>img,.rst-content .section>img,.rst-content section>a>img,.rst-content section>img{margin-bottom:24px}.rst-content abbr[title]{text-decoration:none}.rst-content.style-external-links a.reference.external:after{font-family:FontAwesome;content:"\f08e";color:#b3b3b3;vertical-align:super;font-size:60%;margin:0 .2em}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content pre.literal-block{white-space:pre;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;display:block;overflow:auto}.rst-content div[class^=highlight],.rst-content pre.literal-block{border:1px solid #e1e4e5;overflow-x:auto;margin:1px 0 24px}.rst-content div[class^=highlight] div[class^=highlight],.rst-content pre.literal-block div[class^=highlight]{padding:0;border:none;margin:0}.rst-content div[class^=highlight] td.code{width:100%}.rst-content .linenodiv pre{border-right:1px solid #e6e9ea;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;user-select:none;pointer-events:none}.rst-content div[class^=highlight] pre{white-space:pre;margin:0;padding:12px;display:block;overflow:auto}.rst-content div[class^=highlight] pre .hll{display:block;margin:0 -12px;padding:0 12px}.rst-content .linenodiv pre,.rst-content div[class^=highlight] pre,.rst-content pre.literal-block{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:12px;line-height:1.4}.rst-content div.highlight .gp,.rst-content div.highlight span.linenos{user-select:none;pointer-events:none}.rst-content div.highlight span.linenos{display:inline-block;padding-left:0;padding-right:12px;margin-right:12px;border-right:1px solid #e6e9ea}.rst-content .code-block-caption{font-style:italic;font-size:85%;line-height:1;padding:1em 0;text-align:center}@media print{.rst-content .codeblock,.rst-content div[class^=highlight],.rst-content div[class^=highlight] pre{white-space:pre-wrap}}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning{clear:both}.rst-content .admonition-todo .last,.rst-content .admonition-todo>:last-child,.rst-content .admonition .last,.rst-content .admonition>:last-child,.rst-content .attention .last,.rst-content .attention>:last-child,.rst-content .caution .last,.rst-content .caution>:last-child,.rst-content .danger .last,.rst-content .danger>:last-child,.rst-content .error .last,.rst-content .error>:last-child,.rst-content .hint .last,.rst-content .hint>:last-child,.rst-content .important .last,.rst-content .important>:last-child,.rst-content .note .last,.rst-content .note>:last-child,.rst-content .seealso .last,.rst-content .seealso>:last-child,.rst-content .tip .last,.rst-content .tip>:last-child,.rst-content .warning .last,.rst-content .warning>:last-child{margin-bottom:0}.rst-content .admonition-title:before{margin-right:4px}.rst-content .admonition table{border-color:rgba(0,0,0,.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent!important;border-color:rgba(0,0,0,.1)!important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha>li,.rst-content .toctree-wrapper ol.loweralpha,.rst-content .toctree-wrapper ol.loweralpha>li,.rst-content section ol.loweralpha,.rst-content section ol.loweralpha>li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha>li,.rst-content .toctree-wrapper ol.upperalpha,.rst-content .toctree-wrapper ol.upperalpha>li,.rst-content section ol.upperalpha,.rst-content section ol.upperalpha>li{list-style:upper-alpha}.rst-content .section ol li>*,.rst-content .section ul li>*,.rst-content .toctree-wrapper ol li>*,.rst-content .toctree-wrapper ul li>*,.rst-content section ol li>*,.rst-content section ul li>*{margin-top:12px;margin-bottom:12px}.rst-content .section ol li>:first-child,.rst-content .section ul li>:first-child,.rst-content .toctree-wrapper ol li>:first-child,.rst-content .toctree-wrapper ul li>:first-child,.rst-content section ol li>:first-child,.rst-content section ul li>:first-child{margin-top:0}.rst-content .section ol li>p,.rst-content .section ol li>p:last-child,.rst-content .section ul li>p,.rst-content .section ul li>p:last-child,.rst-content .toctree-wrapper ol li>p,.rst-content .toctree-wrapper ol li>p:last-child,.rst-content .toctree-wrapper ul li>p,.rst-content .toctree-wrapper ul li>p:last-child,.rst-content section ol li>p,.rst-content section ol li>p:last-child,.rst-content section ul li>p,.rst-content section ul li>p:last-child{margin-bottom:12px}.rst-content .section ol li>p:only-child,.rst-content .section ol li>p:only-child:last-child,.rst-content .section ul li>p:only-child,.rst-content .section ul li>p:only-child:last-child,.rst-content .toctree-wrapper ol li>p:only-child,.rst-content .toctree-wrapper ol li>p:only-child:last-child,.rst-content .toctree-wrapper ul li>p:only-child,.rst-content .toctree-wrapper ul li>p:only-child:last-child,.rst-content section ol li>p:only-child,.rst-content section ol li>p:only-child:last-child,.rst-content section ul li>p:only-child,.rst-content section ul li>p:only-child:last-child{margin-bottom:0}.rst-content .section ol li>ol,.rst-content .section ol li>ul,.rst-content .section ul li>ol,.rst-content .section ul li>ul,.rst-content .toctree-wrapper ol li>ol,.rst-content .toctree-wrapper ol li>ul,.rst-content .toctree-wrapper ul li>ol,.rst-content .toctree-wrapper ul li>ul,.rst-content section ol li>ol,.rst-content section ol li>ul,.rst-content section ul li>ol,.rst-content section ul li>ul{margin-bottom:12px}.rst-content .section ol.simple li>*,.rst-content .section ol.simple li ol,.rst-content .section ol.simple li ul,.rst-content .section ul.simple li>*,.rst-content .section ul.simple li ol,.rst-content .section ul.simple li ul,.rst-content .toctree-wrapper ol.simple li>*,.rst-content .toctree-wrapper ol.simple li ol,.rst-content .toctree-wrapper ol.simple li ul,.rst-content .toctree-wrapper ul.simple li>*,.rst-content .toctree-wrapper ul.simple li ol,.rst-content .toctree-wrapper ul.simple li ul,.rst-content section ol.simple li>*,.rst-content section ol.simple li ol,.rst-content section ol.simple li ul,.rst-content section ul.simple li>*,.rst-content section ul.simple li ol,.rst-content section ul.simple li ul{margin-top:0;margin-bottom:0}.rst-content .line-block{margin-left:0;margin-bottom:24px;line-height:24px}.rst-content .line-block .line-block{margin-left:24px;margin-bottom:0}.rst-content .topic-title{font-weight:700;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0 0 24px 24px}.rst-content .align-left{float:left;margin:0 24px 24px 0}.rst-content .align-center{margin:auto}.rst-content .align-center:not(table){display:block}.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink{opacity:0;font-size:14px;font-family:FontAwesome;margin-left:.5em}.rst-content .code-block-caption .headerlink:focus,.rst-content .code-block-caption:hover .headerlink,.rst-content .eqno .headerlink:focus,.rst-content .eqno:hover .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink:focus,.rst-content .toctree-wrapper>p.caption:hover .headerlink,.rst-content dl dt .headerlink:focus,.rst-content dl dt:hover .headerlink,.rst-content h1 .headerlink:focus,.rst-content h1:hover .headerlink,.rst-content h2 .headerlink:focus,.rst-content h2:hover .headerlink,.rst-content h3 .headerlink:focus,.rst-content h3:hover .headerlink,.rst-content h4 .headerlink:focus,.rst-content h4:hover .headerlink,.rst-content h5 .headerlink:focus,.rst-content h5:hover .headerlink,.rst-content h6 .headerlink:focus,.rst-content h6:hover .headerlink,.rst-content p.caption .headerlink:focus,.rst-content p.caption:hover .headerlink,.rst-content p .headerlink:focus,.rst-content p:hover .headerlink,.rst-content table>caption .headerlink:focus,.rst-content table>caption:hover .headerlink{opacity:1}.rst-content p a{overflow-wrap:anywhere}.rst-content .wy-table td p,.rst-content .wy-table td ul,.rst-content .wy-table th p,.rst-content .wy-table th ul,.rst-content table.docutils td p,.rst-content table.docutils td ul,.rst-content table.docutils th p,.rst-content table.docutils th ul,.rst-content table.field-list td p,.rst-content table.field-list td ul,.rst-content table.field-list th p,.rst-content table.field-list th ul{font-size:inherit}.rst-content .btn:focus{outline:2px solid}.rst-content table>caption .headerlink:after{font-size:12px}.rst-content .centered{text-align:center}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:1px solid #e1e4e5}.rst-content .sidebar dl,.rst-content .sidebar p,.rst-content .sidebar ul{font-size:90%}.rst-content .sidebar .last,.rst-content .sidebar>:last-child{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif;font-weight:700;background:#e1e4e5;padding:6px 12px;margin:-24px -24px 24px;font-size:100%}.rst-content .highlighted{background:#f1c40f;box-shadow:0 0 0 2px #f1c40f;display:inline;font-weight:700}.rst-content .citation-reference,.rst-content .footnote-reference{vertical-align:baseline;position:relative;top:-.4em;line-height:0;font-size:90%}.rst-content .citation-reference>span.fn-bracket,.rst-content .footnote-reference>span.fn-bracket{display:none}.rst-content .hlist{width:100%}.rst-content dl dt span.classifier:before{content:" : "}.rst-content dl dt span.classifier-delimiter{display:none!important}html.writer-html4 .rst-content table.docutils.citation,html.writer-html4 .rst-content table.docutils.footnote{background:none;border:none}html.writer-html4 .rst-content table.docutils.citation td,html.writer-html4 .rst-content table.docutils.citation tr,html.writer-html4 .rst-content table.docutils.footnote td,html.writer-html4 .rst-content table.docutils.footnote tr{border:none;background-color:transparent!important;white-space:normal}html.writer-html4 .rst-content table.docutils.citation td.label,html.writer-html4 .rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{display:grid;grid-template-columns:auto minmax(80%,95%)}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{display:inline-grid;grid-template-columns:max-content auto}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{display:grid;grid-template-columns:auto auto minmax(.65rem,auto) minmax(40%,95%)}html.writer-html5 .rst-content aside.citation>span.label,html.writer-html5 .rst-content aside.footnote>span.label,html.writer-html5 .rst-content div.citation>span.label{grid-column-start:1;grid-column-end:2}html.writer-html5 .rst-content aside.citation>span.backrefs,html.writer-html5 .rst-content aside.footnote>span.backrefs,html.writer-html5 .rst-content div.citation>span.backrefs{grid-column-start:2;grid-column-end:3;grid-row-start:1;grid-row-end:3}html.writer-html5 .rst-content aside.citation>p,html.writer-html5 .rst-content aside.footnote>p,html.writer-html5 .rst-content div.citation>p{grid-column-start:4;grid-column-end:5}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{margin-bottom:24px}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{padding-left:1rem}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dd,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dd,html.writer-html5 .rst-content dl.footnote>dt{margin-bottom:0}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{font-size:.9rem}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.footnote>dt{margin:0 .5rem .5rem 0;line-height:1.2rem;word-break:break-all;font-weight:400}html.writer-html5 .rst-content dl.citation>dt>span.brackets:before,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:before{content:"["}html.writer-html5 .rst-content dl.citation>dt>span.brackets:after,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:after{content:"]"}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a{word-break:keep-all}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a:not(:first-child):before,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.footnote>dd{margin:0 0 .5rem;line-height:1.2rem}html.writer-html5 .rst-content dl.citation>dd p,html.writer-html5 .rst-content dl.footnote>dd p{font-size:.9rem}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{padding-left:1rem;padding-right:1rem;font-size:.9rem;line-height:1.2rem}html.writer-html5 .rst-content aside.citation p,html.writer-html5 .rst-content aside.footnote p,html.writer-html5 .rst-content div.citation p{font-size:.9rem;line-height:1.2rem;margin-bottom:12px}html.writer-html5 .rst-content aside.citation span.backrefs,html.writer-html5 .rst-content aside.footnote span.backrefs,html.writer-html5 .rst-content div.citation span.backrefs{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content aside.citation span.backrefs>a,html.writer-html5 .rst-content aside.footnote span.backrefs>a,html.writer-html5 .rst-content div.citation span.backrefs>a{word-break:keep-all}html.writer-html5 .rst-content aside.citation span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content aside.footnote span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content div.citation span.backrefs>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content aside.citation span.label,html.writer-html5 .rst-content aside.footnote span.label,html.writer-html5 .rst-content div.citation span.label{line-height:1.2rem}html.writer-html5 .rst-content aside.citation-list,html.writer-html5 .rst-content aside.footnote-list,html.writer-html5 .rst-content div.citation-list{margin-bottom:24px}html.writer-html5 .rst-content dl.option-list kbd{font-size:.9rem}.rst-content table.docutils.footnote,html.writer-html4 .rst-content table.docutils.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content aside.footnote-list aside.footnote,html.writer-html5 .rst-content div.citation-list>div.citation,html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{color:grey}.rst-content table.docutils.footnote code,.rst-content table.docutils.footnote tt,html.writer-html4 .rst-content table.docutils.citation code,html.writer-html4 .rst-content table.docutils.citation tt,html.writer-html5 .rst-content aside.footnote-list aside.footnote code,html.writer-html5 .rst-content aside.footnote-list aside.footnote tt,html.writer-html5 .rst-content aside.footnote code,html.writer-html5 .rst-content aside.footnote tt,html.writer-html5 .rst-content div.citation-list>div.citation code,html.writer-html5 .rst-content div.citation-list>div.citation tt,html.writer-html5 .rst-content dl.citation code,html.writer-html5 .rst-content dl.citation tt,html.writer-html5 .rst-content dl.footnote code,html.writer-html5 .rst-content dl.footnote tt{color:#555}.rst-content .wy-table-responsive.citation,.rst-content .wy-table-responsive.footnote{margin-bottom:0}.rst-content .wy-table-responsive.citation+:not(.citation),.rst-content .wy-table-responsive.footnote+:not(.footnote){margin-top:24px}.rst-content .wy-table-responsive.citation:last-child,.rst-content .wy-table-responsive.footnote:last-child{margin-bottom:24px}.rst-content table.docutils th{border-color:#e1e4e5}html.writer-html5 .rst-content table.docutils th{border:1px solid #e1e4e5}html.writer-html5 .rst-content table.docutils td>p,html.writer-html5 .rst-content table.docutils th>p{line-height:1rem;margin-bottom:0;font-size:.9rem}.rst-content table.docutils td .last,.rst-content table.docutils td .last>:last-child{margin-bottom:0}.rst-content table.field-list,.rst-content table.field-list td{border:none}.rst-content table.field-list td p{line-height:inherit}.rst-content table.field-list td>strong{display:inline-block}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left}.rst-content code,.rst-content tt{color:#000;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;padding:2px 5px}.rst-content code big,.rst-content code em,.rst-content tt big,.rst-content tt em{font-size:100%!important;line-height:normal}.rst-content code.literal,.rst-content tt.literal{color:#e74c3c;white-space:normal}.rst-content code.xref,.rst-content tt.xref,a .rst-content code,a .rst-content tt{font-weight:700;color:#404040;overflow-wrap:normal}.rst-content kbd,.rst-content pre,.rst-content samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace}.rst-content a code,.rst-content a tt{color:#2980b9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:700;margin-bottom:12px}.rst-content dl ol,.rst-content dl p,.rst-content dl table,.rst-content dl ul{margin-bottom:12px}.rst-content dl dd{margin:0 0 12px 24px;line-height:24px}.rst-content dl dd>ol:last-child,.rst-content dl dd>p:last-child,.rst-content dl dd>table:last-child,.rst-content dl dd>ul:last-child{margin-bottom:0}html.writer-html4 .rst-content dl:not(.docutils),html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple){margin-bottom:24px}html.writer-html4 .rst-content dl:not(.docutils)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{display:table;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980b9;border-top:3px solid #6ab0de;padding:6px;position:relative}html.writer-html4 .rst-content dl:not(.docutils)>dt:before,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:before{color:#6ab0de}html.writer-html4 .rst-content dl:not(.docutils)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{margin-bottom:6px;border:none;border-left:3px solid #ccc;background:#f0f0f0;color:#555}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils)>dt:first-child,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:first-child{margin-top:0}html.writer-html4 .rst-content dl:not(.docutils) code.descclassname,html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descclassname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{background-color:transparent;border:none;padding:0;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .optional,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .property,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .property{display:inline-block;padding-right:8px;max-width:100%}html.writer-html4 .rst-content dl:not(.docutils) .k,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .k{font-style:italic}html.writer-html4 .rst-content dl:not(.docutils) .descclassname,html.writer-html4 .rst-content dl:not(.docutils) .descname,html.writer-html4 .rst-content dl:not(.docutils) .sig-name,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .sig-name{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#000}.rst-content .viewcode-back,.rst-content .viewcode-link{display:inline-block;color:#27ae60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:700}.rst-content code.download,.rst-content tt.download{background:inherit;padding:inherit;font-weight:400;font-family:inherit;font-size:inherit;color:inherit;border:inherit;white-space:inherit}.rst-content code.download span:first-child,.rst-content tt.download span:first-child{-webkit-font-smoothing:subpixel-antialiased}.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{margin-right:4px}.rst-content .guilabel,.rst-content .menuselection{font-size:80%;font-weight:700;border-radius:4px;padding:2.4px 6px;margin:auto 2px}.rst-content .guilabel,.rst-content .menuselection{border:1px solid #7fbbe3;background:#e7f2fa}.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>.kbd,.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>kbd{color:inherit;font-size:80%;background-color:#fff;border:1px solid #a6a6a6;border-radius:4px;box-shadow:0 2px grey;padding:2.4px 6px;margin:auto 0}.rst-content .versionmodified{font-style:italic}@media screen and (max-width:480px){.rst-content .sidebar{width:100%;float:none;margin-left:0}}span[id*=MathJax-Span]{color:#404040}.math{text-align:center}@font-face{font-family:Lato;src:url(fonts/lato-normal.woff2?bd03a2cc277bbbc338d464e679fe9942) format("woff2"),url(fonts/lato-normal.woff?27bd77b9162d388cb8d4c4217c7c5e2a) format("woff");font-weight:400;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold.woff2?cccb897485813c7c256901dbca54ecf2) format("woff2"),url(fonts/lato-bold.woff?d878b6c29b10beca227e9eef4246111b) format("woff");font-weight:700;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold-italic.woff2?0b6bb6725576b072c5d0b02ecdd1900d) format("woff2"),url(fonts/lato-bold-italic.woff?9c7e4e9eb485b4a121c760e61bc3707c) format("woff");font-weight:700;font-style:italic;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-normal-italic.woff2?4eb103b4d12be57cb1d040ed5e162e9d) format("woff2"),url(fonts/lato-normal-italic.woff?f28f2d6482446544ef1ea1ccc6dd5892) format("woff");font-weight:400;font-style:italic;font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:400;src:url(fonts/Roboto-Slab-Regular.woff2?7abf5b8d04d26a2cafea937019bca958) format("woff2"),url(fonts/Roboto-Slab-Regular.woff?c1be9284088d487c5e3ff0a10a92e58c) format("woff");font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:700;src:url(fonts/Roboto-Slab-Bold.woff2?9984f4a9bda09be08e83f2506954adbe) format("woff2"),url(fonts/Roboto-Slab-Bold.woff?bed5564a116b05148e3b3bea6fb1162a) format("woff");font-display:block} \ No newline at end of file diff --git a/landing/index.html b/landing/index.html index fa5cd60..3b6af51 100644 --- a/landing/index.html +++ b/landing/index.html @@ -40,6 +40,8 @@
  • Disclaimer
  • +
  • Docs +
  • From 40d16101e05b9f9f88027133c80ad5a58c410250 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 19:37:30 -0400 Subject: [PATCH 004/120] update --- docs/.gitattributes | 2 + docs/.github/workflows/ci.yml | 17 + docs/.gitignore | 2 + docs/README.md | 56 +- docs/__tests__/buildNav.test.js | 34 + docs/__tests__/loadConfig.test.js | 13 + docs/__tests__/pluginHooks.test.js | 23 + docs/__tests__/renderMarkdown.test.js | 77 + docs/__tests__/responsive.test.js | 128 + docs/assets/lunr.js | 3475 +++++++++ docs/assets/theme.css | 160 + docs/assets/theme.js | 107 + docs/bin/create-archivox.js | 45 + docs/build-docs.js | 15 + docs/docs/config.yaml | 13 + .../01-getting-started/01-advanced_cli.md} | 0 .../01-getting-started/02-api_reference.md} | 0 .../01-getting-started/03-json_entries.md} | 0 .../01-getting-started/04-migrations.md} | 0 docs/docs/content/01-getting-started/index.md | 3 + docs/docs/content/index.md | 15 + docs/docs/package.json | 11 + docs/netlify.toml | 3 + docs/package-lock.json | 6357 +++++++++++++++++ docs/package.json | 25 + docs/plugins/analytics.js | 7 + docs/src/config/loadConfig.js | 70 + docs/src/config/loadPlugins.js | 24 + docs/src/generator/index.js | 235 + docs/starter/config.yaml | 6 + .../content/01-getting-started/01-install.md | 3 + .../content/01-getting-started/index.md | 3 + docs/starter/content/index.md | 3 + docs/starter/package.json | 11 + docs/templates/layout.njk | 23 + docs/templates/partials/footer.njk | 14 + docs/templates/partials/header.njk | 7 + docs/templates/partials/sidebar.njk | 29 + 38 files changed, 11003 insertions(+), 13 deletions(-) create mode 100644 docs/.gitattributes create mode 100644 docs/.github/workflows/ci.yml create mode 100644 docs/.gitignore create mode 100644 docs/__tests__/buildNav.test.js create mode 100644 docs/__tests__/loadConfig.test.js create mode 100644 docs/__tests__/pluginHooks.test.js create mode 100644 docs/__tests__/renderMarkdown.test.js create mode 100644 docs/__tests__/responsive.test.js create mode 100644 docs/assets/lunr.js create mode 100644 docs/assets/theme.css create mode 100644 docs/assets/theme.js create mode 100755 docs/bin/create-archivox.js create mode 100755 docs/build-docs.js create mode 100644 docs/docs/config.yaml rename docs/{advanced_cli.md => docs/content/01-getting-started/01-advanced_cli.md} (100%) rename docs/{api_reference.md => docs/content/01-getting-started/02-api_reference.md} (100%) rename docs/{json_entries.md => docs/content/01-getting-started/03-json_entries.md} (100%) rename docs/{migrations.md => docs/content/01-getting-started/04-migrations.md} (100%) create mode 100644 docs/docs/content/01-getting-started/index.md create mode 100644 docs/docs/content/index.md create mode 100644 docs/docs/package.json create mode 100644 docs/netlify.toml create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/plugins/analytics.js create mode 100644 docs/src/config/loadConfig.js create mode 100644 docs/src/config/loadPlugins.js create mode 100644 docs/src/generator/index.js create mode 100644 docs/starter/config.yaml create mode 100644 docs/starter/content/01-getting-started/01-install.md create mode 100644 docs/starter/content/01-getting-started/index.md create mode 100644 docs/starter/content/index.md create mode 100644 docs/starter/package.json create mode 100644 docs/templates/layout.njk create mode 100644 docs/templates/partials/footer.njk create mode 100644 docs/templates/partials/header.njk create mode 100644 docs/templates/partials/sidebar.njk 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..dc39613 --- /dev/null +++ b/docs/docs/config.yaml @@ -0,0 +1,13 @@ +site: + title: "Archivox Docs" + description: "Simple static docs." + +navigation: + search: true + +footer: + links: + - text: "Custom Link 1" + url: "https://example.com" + - text: "Custom Link 2" + url: "https://example.com/other" diff --git a/docs/advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md similarity index 100% rename from docs/advanced_cli.md rename to docs/docs/content/01-getting-started/01-advanced_cli.md diff --git a/docs/api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md similarity index 100% rename from docs/api_reference.md rename to docs/docs/content/01-getting-started/02-api_reference.md 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/migrations.md b/docs/docs/content/01-getting-started/04-migrations.md similarity index 100% rename from docs/migrations.md rename to docs/docs/content/01-getting-started/04-migrations.md diff --git a/docs/docs/content/01-getting-started/index.md b/docs/docs/content/01-getting-started/index.md new file mode 100644 index 0000000..2a196f8 --- /dev/null +++ b/docs/docs/content/01-getting-started/index.md @@ -0,0 +1,3 @@ +# Getting Started + +Welcome to SeedPass! diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md new file mode 100644 index 0000000..a7fbdfd --- /dev/null +++ b/docs/docs/content/index.md @@ -0,0 +1,15 @@ +# Archivox Documentation + +Welcome to the official documentation for **Archivox**, a lightweight static site generator designed for "Read the Docs" style websites. + +## Quick Start + +```bash +npm install +npm run dev # start local server at http://localhost:8080 +npm run build # generate the _site/ folder +``` + +Archivox converts Markdown files inside a `content/` folder into a full documentation site with search, navigation, and responsive design. + +Check the **Getting Started** section to learn how to run Archivox locally and the **Project Integration** guide to drop Archivox into an existing codebase. 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/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 %} + + From 7129e82110215fd5b12dbd2182784b9d94bede7a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 19:44:39 -0400 Subject: [PATCH 005/120] update --- docs/docs/config.yaml | 11 +- docs/docs/content/index.md | 506 ++++++++++++++++++++++++++++++++++++- 2 files changed, 504 insertions(+), 13 deletions(-) diff --git a/docs/docs/config.yaml b/docs/docs/config.yaml index dc39613..952a12a 100644 --- a/docs/docs/config.yaml +++ b/docs/docs/config.yaml @@ -1,13 +1,12 @@ site: - title: "Archivox Docs" - description: "Simple static docs." + title: "SeedPass Docs" + description: "One seed to rule them all." navigation: search: true footer: links: - - text: "Custom Link 1" - url: "https://example.com" - - text: "Custom Link 2" - url: "https://example.com/other" + - text: "SeedPass" + url: "https://seedpass.me/" + diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index a7fbdfd..8ead95c 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -1,15 +1,507 @@ -# Archivox Documentation +# SeedPass -Welcome to the official documentation for **Archivox**, a lightweight static site generator designed for "Read the Docs" style websites. +![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. + +[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. +- **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. +- **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. + +## 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. +*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 +``` + +#### 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 and activating your virtual environment, launch +SeedPass and create a backup: + ```bash -npm install -npm run dev # start local server at http://localhost:8080 -npm run build # generate the _site/ folder +# Start the application +python src/main.py + +# Export your index +seedpass export --file "~/seedpass_backup.json" + +# Later you can restore it +seedpass import --file "~/seedpass_backup.json" + +# 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. ``` -Archivox converts Markdown files inside a `content/` folder into a full documentation site with search, navigation, and responsive design. +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). -Check the **Getting Started** section to learn how to run Archivox locally and the **Project Integration** guide to drop Archivox into an existing codebase. +### 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, you can run SeedPass using the following command: + +```bash +python src/main.py +``` + +You can also use the new Typer-based CLI: +```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 + python src/main.py + ``` + +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. +* 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. + +## 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 +``` + +### 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 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. + +### 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. + +## 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!* + +--- From 5238a62b102a28ab48df956539cdd38d04ca06bb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 19:53:45 -0400 Subject: [PATCH 006/120] update --- landing/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/index.html b/landing/index.html index 3b6af51..49d89ff 100644 --- a/landing/index.html +++ b/landing/index.html @@ -40,7 +40,7 @@
  • Disclaimer
  • -
  • Docs +
  • Docs
  • From daa3a0c3776673deb74bf522399228fe4eba439e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 19:55:28 -0400 Subject: [PATCH 007/120] update --- docs/docs/content/{ => 01-Introduction}/index.md | 0 docs/docs/content/01-getting-started/index.md | 3 --- .../01-advanced_cli.md | 0 .../02-api_reference.md | 0 .../03-json_entries.md | 0 .../04-migrations.md | 0 6 files changed, 3 deletions(-) rename docs/docs/content/{ => 01-Introduction}/index.md (100%) delete mode 100644 docs/docs/content/01-getting-started/index.md rename docs/docs/content/{01-getting-started => 02-getting-started}/01-advanced_cli.md (100%) rename docs/docs/content/{01-getting-started => 02-getting-started}/02-api_reference.md (100%) rename docs/docs/content/{01-getting-started => 02-getting-started}/03-json_entries.md (100%) rename docs/docs/content/{01-getting-started => 02-getting-started}/04-migrations.md (100%) diff --git a/docs/docs/content/index.md b/docs/docs/content/01-Introduction/index.md similarity index 100% rename from docs/docs/content/index.md rename to docs/docs/content/01-Introduction/index.md diff --git a/docs/docs/content/01-getting-started/index.md b/docs/docs/content/01-getting-started/index.md deleted file mode 100644 index 2a196f8..0000000 --- a/docs/docs/content/01-getting-started/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# Getting Started - -Welcome to SeedPass! diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/02-getting-started/01-advanced_cli.md similarity index 100% rename from docs/docs/content/01-getting-started/01-advanced_cli.md rename to docs/docs/content/02-getting-started/01-advanced_cli.md diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/02-getting-started/02-api_reference.md similarity index 100% rename from docs/docs/content/01-getting-started/02-api_reference.md rename to docs/docs/content/02-getting-started/02-api_reference.md diff --git a/docs/docs/content/01-getting-started/03-json_entries.md b/docs/docs/content/02-getting-started/03-json_entries.md similarity index 100% rename from docs/docs/content/01-getting-started/03-json_entries.md rename to docs/docs/content/02-getting-started/03-json_entries.md diff --git a/docs/docs/content/01-getting-started/04-migrations.md b/docs/docs/content/02-getting-started/04-migrations.md similarity index 100% rename from docs/docs/content/01-getting-started/04-migrations.md rename to docs/docs/content/02-getting-started/04-migrations.md From 5caf3166444dd476a715bf4cf3011b4826dd3d8c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:01:29 -0400 Subject: [PATCH 008/120] update --- docs/docs/content/index.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/docs/content/index.md diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md new file mode 100644 index 0000000..4d78717 --- /dev/null +++ b/docs/docs/content/index.md @@ -0,0 +1 @@ +# Getting Started With SeedPass \ No newline at end of file From 6052a1543bc7c4dfe8683da5f0689377db0af9f2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:05:13 -0400 Subject: [PATCH 009/120] update --- docs/docs/content/01-Introduction/index.md | 507 ------------------ .../01-advanced_cli.md | 0 .../02-api_reference.md | 0 .../03-json_entries.md | 0 .../04-migrations.md | 0 docs/docs/content/index.md | 506 ++++++++++++++++- 6 files changed, 505 insertions(+), 508 deletions(-) delete mode 100644 docs/docs/content/01-Introduction/index.md rename docs/docs/content/{02-getting-started => 01-getting-started}/01-advanced_cli.md (100%) rename docs/docs/content/{02-getting-started => 01-getting-started}/02-api_reference.md (100%) rename docs/docs/content/{02-getting-started => 01-getting-started}/03-json_entries.md (100%) rename docs/docs/content/{02-getting-started => 01-getting-started}/04-migrations.md (100%) diff --git a/docs/docs/content/01-Introduction/index.md b/docs/docs/content/01-Introduction/index.md deleted file mode 100644 index 8ead95c..0000000 --- a/docs/docs/content/01-Introduction/index.md +++ /dev/null @@ -1,507 +0,0 @@ -# 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. - -[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. -- **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. -- **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. - -## 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. -*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 -``` - -#### 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 and activating your virtual environment, launch -SeedPass and create a backup: - -```bash -# Start the application -python src/main.py - -# Export your index -seedpass export --file "~/seedpass_backup.json" - -# Later you can restore it -seedpass import --file "~/seedpass_backup.json" - -# 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, you can run SeedPass using the following command: - -```bash -python src/main.py -``` - -You can also use the new Typer-based CLI: -```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 - python src/main.py - ``` - -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. -* 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. - -## 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 -``` - -### 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 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. - -### 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. - -## 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/content/02-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md similarity index 100% rename from docs/docs/content/02-getting-started/01-advanced_cli.md rename to docs/docs/content/01-getting-started/01-advanced_cli.md diff --git a/docs/docs/content/02-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md similarity index 100% rename from docs/docs/content/02-getting-started/02-api_reference.md rename to docs/docs/content/01-getting-started/02-api_reference.md diff --git a/docs/docs/content/02-getting-started/03-json_entries.md b/docs/docs/content/01-getting-started/03-json_entries.md similarity index 100% rename from docs/docs/content/02-getting-started/03-json_entries.md rename to docs/docs/content/01-getting-started/03-json_entries.md diff --git a/docs/docs/content/02-getting-started/04-migrations.md b/docs/docs/content/01-getting-started/04-migrations.md similarity index 100% rename from docs/docs/content/02-getting-started/04-migrations.md rename to docs/docs/content/01-getting-started/04-migrations.md diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 4d78717..df611df 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -1 +1,505 @@ -# Getting Started With SeedPass \ No newline at end of file +# 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. +- **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. +- **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. + +## 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. +*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 +``` + +#### 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 and activating your virtual environment, launch +SeedPass and create a backup: + +```bash +# Start the application +python src/main.py + +# Export your index +seedpass export --file "~/seedpass_backup.json" + +# Later you can restore it +seedpass import --file "~/seedpass_backup.json" + +# 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, you can run SeedPass using the following command: + +```bash +python src/main.py +``` + +You can also use the new Typer-based CLI: +```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 + python src/main.py + ``` + +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. +* 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. + +## 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 +``` + +### 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 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. + +### 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. + +## 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!* + +--- From d1d11a46ac2dfcb6c773df8b4e7257c6fd58a3ed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:53:54 -0400 Subject: [PATCH 010/120] test: verify advanced CLI examples --- src/tests/test_cli_doc_examples.py | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/tests/test_cli_doc_examples.py diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py new file mode 100644 index 0000000..84223ca --- /dev/null +++ b/src/tests/test_cli_doc_examples.py @@ -0,0 +1,101 @@ +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_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, + get_secret_mode_enabled=lambda: True, + get_clipboard_clear_delay=lambda: 30, + ) + 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 From b6a8604b1f142b5371c648e0feb2d6a724b58a37 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:06:14 -0400 Subject: [PATCH 011/120] update --- requirements.lock | 1 + src/requirements.txt | 1 + totp.json | 3 +++ 3 files changed, 5 insertions(+) create mode 100644 totp.json diff --git a/requirements.lock b/requirements.lock index 0318410..aeaaca9 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,3 +1,4 @@ +-e . aiohappyeyeballs==2.6.1 aiohttp==3.12.13 aiosignal==1.3.2 diff --git a/src/requirements.txt b/src/requirements.txt index 1bfbef1..c199d05 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,4 @@ +-e . colorama>=0.4.6 termcolor>=1.1.0 cryptography>=40.0.2 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 From 10bb880a4d89299cd0e9778e21b13b3756c837ef Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:07:26 -0400 Subject: [PATCH 012/120] update --- src/seedpass.egg-info/PKG-INFO | 5 + src/seedpass.egg-info/SOURCES.txt | 151 +++++++++++++++++++++ src/seedpass.egg-info/dependency_links.txt | 1 + src/seedpass.egg-info/entry_points.txt | 2 + src/seedpass.egg-info/top_level.txt | 8 ++ 5 files changed, 167 insertions(+) create mode 100644 src/seedpass.egg-info/PKG-INFO create mode 100644 src/seedpass.egg-info/SOURCES.txt create mode 100644 src/seedpass.egg-info/dependency_links.txt create mode 100644 src/seedpass.egg-info/entry_points.txt create mode 100644 src/seedpass.egg-info/top_level.txt diff --git a/src/seedpass.egg-info/PKG-INFO b/src/seedpass.egg-info/PKG-INFO new file mode 100644 index 0000000..3906997 --- /dev/null +++ b/src/seedpass.egg-info/PKG-INFO @@ -0,0 +1,5 @@ +Metadata-Version: 2.4 +Name: seedpass +Version: 0.1.0 +License-File: LICENSE +Dynamic: license-file diff --git a/src/seedpass.egg-info/SOURCES.txt b/src/seedpass.egg-info/SOURCES.txt new file mode 100644 index 0000000..95443e5 --- /dev/null +++ b/src/seedpass.egg-info/SOURCES.txt @@ -0,0 +1,151 @@ +LICENSE +README.md +pyproject.toml +src/constants.py +src/main.py +src/local_bip85/__init__.py +src/local_bip85/bip85.py +src/nostr/__init__.py +src/nostr/backup_models.py +src/nostr/client.py +src/nostr/coincurve_keys.py +src/nostr/event_handler.py +src/nostr/key_manager.py +src/nostr/logging_config.py +src/nostr/utils.py +src/password_manager/__init__.py +src/password_manager/backup.py +src/password_manager/config_manager.py +src/password_manager/encryption.py +src/password_manager/entry_management.py +src/password_manager/entry_types.py +src/password_manager/manager.py +src/password_manager/migrations.py +src/password_manager/password_generation.py +src/password_manager/portable_backup.py +src/password_manager/seedqr.py +src/password_manager/totp.py +src/password_manager/vault.py +src/seedpass/__init__.py +src/seedpass/api.py +src/seedpass/cli.py +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 +src/tests/conftest.py +src/tests/helpers.py +src/tests/test_add_tags_from_retrieve.py +src/tests/test_additional_backup.py +src/tests/test_api.py +src/tests/test_api_new_endpoints.py +src/tests/test_api_profile_stats.py +src/tests/test_archive_from_retrieve.py +src/tests/test_archive_nonpassword.py +src/tests/test_archive_restore.py +src/tests/test_auto_sync.py +src/tests/test_backup_restore.py +src/tests/test_bip85_vectors.py +src/tests/test_checksum_utils.py +src/tests/test_cli_config_set_extra.py +src/tests/test_cli_doc_examples.py +src/tests/test_cli_entry_add_commands.py +src/tests/test_cli_export_import.py +src/tests/test_cli_invalid_input.py +src/tests/test_cli_subcommands.py +src/tests/test_cli_toggle_secret_mode.py +src/tests/test_cli_vault_stats.py +src/tests/test_clipboard_utils.py +src/tests/test_concurrency_stress.py +src/tests/test_config_manager.py +src/tests/test_custom_fields_display.py +src/tests/test_default_encryption_mode.py +src/tests/test_edit_tags_from_retrieve.py +src/tests/test_encryption_checksum.py +src/tests/test_encryption_files.py +src/tests/test_entries_empty.py +src/tests/test_entry_add.py +src/tests/test_entry_management_checksum_path.py +src/tests/test_event_handler.py +src/tests/test_export_totp_codes.py +src/tests/test_file_lock.py +src/tests/test_file_locking.py +src/tests/test_fingerprint_encryption.py +src/tests/test_fingerprint_manager_utils.py +src/tests/test_generate_test_profile.py +src/tests/test_import.py +src/tests/test_inactivity_lock.py +src/tests/test_index_import_export.py +src/tests/test_key_derivation.py +src/tests/test_key_manager_helpers.py +src/tests/test_key_value_entry.py +src/tests/test_list_entries_sort_filter.py +src/tests/test_managed_account.py +src/tests/test_managed_account_entry.py +src/tests/test_manager_add_totp.py +src/tests/test_manager_checksum_backup.py +src/tests/test_manager_display_totp_codes.py +src/tests/test_manager_edit_totp.py +src/tests/test_manager_list_entries.py +src/tests/test_manager_retrieve_totp.py +src/tests/test_manager_search_display.py +src/tests/test_manager_workflow.py +src/tests/test_memory_protection.py +src/tests/test_menu_navigation.py +src/tests/test_menu_options.py +src/tests/test_menu_search.py +src/tests/test_migrations.py +src/tests/test_modify_totp_entry.py +src/tests/test_nostr_backup.py +src/tests/test_nostr_client.py +src/tests/test_nostr_contract.py +src/tests/test_nostr_dummy_client.py +src/tests/test_nostr_entry.py +src/tests/test_nostr_index_size.py +src/tests/test_nostr_qr.py +src/tests/test_nostr_real.py +src/tests/test_nostr_sdk_workflow.py +src/tests/test_nostr_snapshot.py +src/tests/test_parent_seed_backup.py +src/tests/test_password_change.py +src/tests/test_password_helpers.py +src/tests/test_password_length_constraints.py +src/tests/test_password_prompt.py +src/tests/test_password_properties.py +src/tests/test_password_unlock_after_change.py +src/tests/test_pgp_entry.py +src/tests/test_portable_backup.py +src/tests/test_post_sync_messages.py +src/tests/test_profile_management.py +src/tests/test_profiles.py +src/tests/test_publish_json_result.py +src/tests/test_search_entries.py +src/tests/test_secret_mode.py +src/tests/test_seed_entry.py +src/tests/test_seed_generation.py +src/tests/test_seed_import.py +src/tests/test_seedqr_encoding.py +src/tests/test_settings_menu.py +src/tests/test_ssh_entry.py +src/tests/test_ssh_entry_valid.py +src/tests/test_tag_persistence.py +src/tests/test_totp.py +src/tests/test_totp_entry.py +src/tests/test_totp_uri.py +src/tests/test_typer_cli.py +src/tests/test_unlock_sync.py +src/tests/test_vault_initialization.py +src/utils/__init__.py +src/utils/checksum.py +src/utils/clipboard.py +src/utils/color_scheme.py +src/utils/file_lock.py +src/utils/fingerprint.py +src/utils/fingerprint_manager.py +src/utils/imghdr_stub.py +src/utils/input_utils.py +src/utils/key_derivation.py +src/utils/memory_protection.py +src/utils/password_prompt.py +src/utils/terminal_utils.py \ No newline at end of file diff --git a/src/seedpass.egg-info/dependency_links.txt b/src/seedpass.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/seedpass.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/seedpass.egg-info/entry_points.txt b/src/seedpass.egg-info/entry_points.txt new file mode 100644 index 0000000..87af5e3 --- /dev/null +++ b/src/seedpass.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +seedpass = seedpass.cli:app diff --git a/src/seedpass.egg-info/top_level.txt b/src/seedpass.egg-info/top_level.txt new file mode 100644 index 0000000..dcb1ab3 --- /dev/null +++ b/src/seedpass.egg-info/top_level.txt @@ -0,0 +1,8 @@ +constants +local_bip85 +main +nostr +password_manager +seedpass +tests +utils From c8a96a292a3338f6e74d7dcc82d8a299540f9a99 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:37:40 -0400 Subject: [PATCH 013/120] update --- requirements.lock | 1 - src/requirements.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/requirements.lock b/requirements.lock index aeaaca9..0318410 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,4 +1,3 @@ --e . aiohappyeyeballs==2.6.1 aiohttp==3.12.13 aiosignal==1.3.2 diff --git a/src/requirements.txt b/src/requirements.txt index c199d05..1bfbef1 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,4 +1,3 @@ --e . colorama>=0.4.6 termcolor>=1.1.0 cryptography>=40.0.2 From 4e184f9e7d8b7cb35de0d0d3e3e5c7ca6fc8ba80 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:45:27 -0400 Subject: [PATCH 014/120] docs: update advanced CLI config options --- docs/docs/content/01-getting-started/01-advanced_cli.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index cbc2511..70c2395 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -92,7 +92,7 @@ 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` | +| Set a setting value | `config set` | `seedpass config set secret_mode_enabled true` | ### Fingerprint Commands @@ -171,8 +171,8 @@ 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 `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, or `relays`. +- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set secret_mode_enabled true`. - **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay. ### `fingerprint` Commands @@ -208,5 +208,5 @@ 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 `inactivity_timeout` or `secret_mode_enabled` through the `config` commands. - `entry get` is script‑friendly and can be piped into other commands. From fd1db2cdde2545901cdbb0626a746684ccfd4d60 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 22:09:49 -0400 Subject: [PATCH 015/120] Update installer scripts and docs for Typer CLI --- README.md | 19 ++++++++++++++++--- docs/docs/content/index.md | 9 ++++++--- scripts/install.ps1 | 7 ++++++- scripts/install.sh | 3 ++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8ead95c..ab78254 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ When upgrading pip, use `python -m pip` inside the virtual environment so that p ```bash python -m pip install --upgrade pip python -m pip install -r src/requirements.txt +python -m pip install -e . ``` #### Linux Clipboard Support @@ -166,8 +167,14 @@ 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 +python -m pip install -e . +``` + +You can then launch SeedPass and create a backup: ```bash # Start the application @@ -219,7 +226,13 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers ## 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, you can run SeedPass using the following command: ```bash python src/main.py diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index df611df..d9f63c1 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -150,6 +150,7 @@ When upgrading pip, use `python -m pip` inside the virtual environment so that p ```bash python -m pip install --upgrade pip python -m pip install -r src/requirements.txt +python -m pip install -e . ``` #### Linux Clipboard Support @@ -164,8 +165,9 @@ sudo apt-get install xclip ## Quick Start -After installing dependencies and activating your virtual environment, launch -SeedPass and create a backup: +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 @@ -223,7 +225,8 @@ After successfully installing the dependencies, you can run SeedPass using the f python src/main.py ``` -You can also use the new Typer-based CLI: +You can also use the Typer-based command line interface once the +package is installed: ```bash seedpass --help ``` diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7ac147c..d45cbbe 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,7 +268,7 @@ $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 diff --git a/scripts/install.sh b/scripts/install.sh index 5cea79c..8765dbe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -119,6 +119,7 @@ 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 @@ -127,7 +128,7 @@ main() { cat > "$LAUNCHER_PATH" << EOF2 #!/bin/bash source "$VENV_DIR/bin/activate" -exec python3 "$INSTALL_DIR/src/main.py" "\$@" +exec "$VENV_DIR/bin/python" -m seedpass.cli "\$@" EOF2 chmod +x "$LAUNCHER_PATH" From 26030611b1e166a4f1f112ea1a7d0ee59b3dd045 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 22:13:46 -0400 Subject: [PATCH 016/120] update --- .gitignore | 8 ++ src/seedpass.egg-info/PKG-INFO | 5 - src/seedpass.egg-info/SOURCES.txt | 151 --------------------- src/seedpass.egg-info/dependency_links.txt | 1 - src/seedpass.egg-info/entry_points.txt | 2 - src/seedpass.egg-info/top_level.txt | 8 -- 6 files changed, 8 insertions(+), 167 deletions(-) delete mode 100644 src/seedpass.egg-info/PKG-INFO delete mode 100644 src/seedpass.egg-info/SOURCES.txt delete mode 100644 src/seedpass.egg-info/dependency_links.txt delete mode 100644 src/seedpass.egg-info/entry_points.txt delete mode 100644 src/seedpass.egg-info/top_level.txt 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/src/seedpass.egg-info/PKG-INFO b/src/seedpass.egg-info/PKG-INFO deleted file mode 100644 index 3906997..0000000 --- a/src/seedpass.egg-info/PKG-INFO +++ /dev/null @@ -1,5 +0,0 @@ -Metadata-Version: 2.4 -Name: seedpass -Version: 0.1.0 -License-File: LICENSE -Dynamic: license-file diff --git a/src/seedpass.egg-info/SOURCES.txt b/src/seedpass.egg-info/SOURCES.txt deleted file mode 100644 index 95443e5..0000000 --- a/src/seedpass.egg-info/SOURCES.txt +++ /dev/null @@ -1,151 +0,0 @@ -LICENSE -README.md -pyproject.toml -src/constants.py -src/main.py -src/local_bip85/__init__.py -src/local_bip85/bip85.py -src/nostr/__init__.py -src/nostr/backup_models.py -src/nostr/client.py -src/nostr/coincurve_keys.py -src/nostr/event_handler.py -src/nostr/key_manager.py -src/nostr/logging_config.py -src/nostr/utils.py -src/password_manager/__init__.py -src/password_manager/backup.py -src/password_manager/config_manager.py -src/password_manager/encryption.py -src/password_manager/entry_management.py -src/password_manager/entry_types.py -src/password_manager/manager.py -src/password_manager/migrations.py -src/password_manager/password_generation.py -src/password_manager/portable_backup.py -src/password_manager/seedqr.py -src/password_manager/totp.py -src/password_manager/vault.py -src/seedpass/__init__.py -src/seedpass/api.py -src/seedpass/cli.py -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 -src/tests/conftest.py -src/tests/helpers.py -src/tests/test_add_tags_from_retrieve.py -src/tests/test_additional_backup.py -src/tests/test_api.py -src/tests/test_api_new_endpoints.py -src/tests/test_api_profile_stats.py -src/tests/test_archive_from_retrieve.py -src/tests/test_archive_nonpassword.py -src/tests/test_archive_restore.py -src/tests/test_auto_sync.py -src/tests/test_backup_restore.py -src/tests/test_bip85_vectors.py -src/tests/test_checksum_utils.py -src/tests/test_cli_config_set_extra.py -src/tests/test_cli_doc_examples.py -src/tests/test_cli_entry_add_commands.py -src/tests/test_cli_export_import.py -src/tests/test_cli_invalid_input.py -src/tests/test_cli_subcommands.py -src/tests/test_cli_toggle_secret_mode.py -src/tests/test_cli_vault_stats.py -src/tests/test_clipboard_utils.py -src/tests/test_concurrency_stress.py -src/tests/test_config_manager.py -src/tests/test_custom_fields_display.py -src/tests/test_default_encryption_mode.py -src/tests/test_edit_tags_from_retrieve.py -src/tests/test_encryption_checksum.py -src/tests/test_encryption_files.py -src/tests/test_entries_empty.py -src/tests/test_entry_add.py -src/tests/test_entry_management_checksum_path.py -src/tests/test_event_handler.py -src/tests/test_export_totp_codes.py -src/tests/test_file_lock.py -src/tests/test_file_locking.py -src/tests/test_fingerprint_encryption.py -src/tests/test_fingerprint_manager_utils.py -src/tests/test_generate_test_profile.py -src/tests/test_import.py -src/tests/test_inactivity_lock.py -src/tests/test_index_import_export.py -src/tests/test_key_derivation.py -src/tests/test_key_manager_helpers.py -src/tests/test_key_value_entry.py -src/tests/test_list_entries_sort_filter.py -src/tests/test_managed_account.py -src/tests/test_managed_account_entry.py -src/tests/test_manager_add_totp.py -src/tests/test_manager_checksum_backup.py -src/tests/test_manager_display_totp_codes.py -src/tests/test_manager_edit_totp.py -src/tests/test_manager_list_entries.py -src/tests/test_manager_retrieve_totp.py -src/tests/test_manager_search_display.py -src/tests/test_manager_workflow.py -src/tests/test_memory_protection.py -src/tests/test_menu_navigation.py -src/tests/test_menu_options.py -src/tests/test_menu_search.py -src/tests/test_migrations.py -src/tests/test_modify_totp_entry.py -src/tests/test_nostr_backup.py -src/tests/test_nostr_client.py -src/tests/test_nostr_contract.py -src/tests/test_nostr_dummy_client.py -src/tests/test_nostr_entry.py -src/tests/test_nostr_index_size.py -src/tests/test_nostr_qr.py -src/tests/test_nostr_real.py -src/tests/test_nostr_sdk_workflow.py -src/tests/test_nostr_snapshot.py -src/tests/test_parent_seed_backup.py -src/tests/test_password_change.py -src/tests/test_password_helpers.py -src/tests/test_password_length_constraints.py -src/tests/test_password_prompt.py -src/tests/test_password_properties.py -src/tests/test_password_unlock_after_change.py -src/tests/test_pgp_entry.py -src/tests/test_portable_backup.py -src/tests/test_post_sync_messages.py -src/tests/test_profile_management.py -src/tests/test_profiles.py -src/tests/test_publish_json_result.py -src/tests/test_search_entries.py -src/tests/test_secret_mode.py -src/tests/test_seed_entry.py -src/tests/test_seed_generation.py -src/tests/test_seed_import.py -src/tests/test_seedqr_encoding.py -src/tests/test_settings_menu.py -src/tests/test_ssh_entry.py -src/tests/test_ssh_entry_valid.py -src/tests/test_tag_persistence.py -src/tests/test_totp.py -src/tests/test_totp_entry.py -src/tests/test_totp_uri.py -src/tests/test_typer_cli.py -src/tests/test_unlock_sync.py -src/tests/test_vault_initialization.py -src/utils/__init__.py -src/utils/checksum.py -src/utils/clipboard.py -src/utils/color_scheme.py -src/utils/file_lock.py -src/utils/fingerprint.py -src/utils/fingerprint_manager.py -src/utils/imghdr_stub.py -src/utils/input_utils.py -src/utils/key_derivation.py -src/utils/memory_protection.py -src/utils/password_prompt.py -src/utils/terminal_utils.py \ No newline at end of file diff --git a/src/seedpass.egg-info/dependency_links.txt b/src/seedpass.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/seedpass.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/seedpass.egg-info/entry_points.txt b/src/seedpass.egg-info/entry_points.txt deleted file mode 100644 index 87af5e3..0000000 --- a/src/seedpass.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -seedpass = seedpass.cli:app diff --git a/src/seedpass.egg-info/top_level.txt b/src/seedpass.egg-info/top_level.txt deleted file mode 100644 index dcb1ab3..0000000 --- a/src/seedpass.egg-info/top_level.txt +++ /dev/null @@ -1,8 +0,0 @@ -constants -local_bip85 -main -nostr -password_manager -seedpass -tests -utils From b1d58b206b9e03aba426b46a0b8ef84256bf6340 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 22:38:24 -0400 Subject: [PATCH 017/120] docs: clarify CLI install --- README.md | 9 +++++++++ scripts/install.ps1 | 6 ++++++ scripts/install.sh | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index ab78254..2d808c3 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,11 @@ python -m pip install -r src/requirements.txt python -m pip install -e . ``` +If you previously installed an older version of SeedPass, the `seedpass` +command might still point to the legacy `main.py` script. Run +`pip uninstall seedpass` and then reinstall with the command above to +register the new Typer-based CLI. + #### Linux Clipboard Support On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. @@ -242,6 +247,10 @@ You can also use the new Typer-based CLI: ```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 +`python -m pip install -e .`. 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 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d45cbbe..b98a52e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -273,6 +273,12 @@ 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") diff --git a/scripts/install.sh b/scripts/install.sh index 8765dbe..0f0394d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -132,6 +132,12 @@ exec "$VENV_DIR/bin/python" -m seedpass.cli "\$@" 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" From 2609064016a48eaae25614a18bd79ee4f144aaf2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 22:47:23 -0400 Subject: [PATCH 018/120] Clarify CLI reinstall steps --- README.md | 18 ++++++++++++++++++ scripts/install.ps1 | 7 +++++++ scripts/install.sh | 7 +++++++ 3 files changed, 32 insertions(+) diff --git a/README.md b/README.md index ab78254..068e16e 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,15 @@ python -m pip install -r src/requirements.txt python -m pip install -e . ``` +If you previously installed an older version of SeedPass, the `seedpass` +command might still point to the legacy `main.py` script. Run +`pip uninstall seedpass` and then reinstall with the command above to +register the new Typer-based CLI. + +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`. @@ -242,6 +251,15 @@ You can also use the new Typer-based CLI: ```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 +`python -m pip install -e .`. +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 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d45cbbe..b1cd64b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -273,6 +273,12 @@ 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") @@ -287,3 +293,4 @@ 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 "'seedpass' resolves to: $(Get-Command seedpass | Select-Object -ExpandProperty Source)" diff --git a/scripts/install.sh b/scripts/install.sh index 8765dbe..4eee90b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -132,9 +132,16 @@ exec "$VENV_DIR/bin/python" -m seedpass.cli "\$@" 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 "'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." From a32bfd45233af1c67c69ffda6ef50d8625a50e64 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:37:07 -0400 Subject: [PATCH 019/120] Add uninstall scripts for all platforms --- README.md | 13 +++++++++++ docs/docs/content/index.md | 14 ++++++++++++ scripts/uninstall.ps1 | 41 ++++++++++++++++++++++++++++++++++ scripts/uninstall.sh | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 scripts/uninstall.ps1 create mode 100644 scripts/uninstall.sh diff --git a/README.md b/README.md index 6768656..0ed38ec 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,19 @@ Before running the script, install **Python 3.11** or **3.12** from [python.org] 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)" +``` + +**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)) +``` ### Manual Setup diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index d9f63c1..81aaab2 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -94,6 +94,20 @@ isn't on your PATH. If these tools are unavailable you'll see a link to download 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)" +``` + +**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 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..d7872b3 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,45 @@ +#!/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"; } + +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 + + print_info "Attempting to uninstall any global 'seedpass' package with pip..." + if command -v pip &> /dev/null; then + pip uninstall -y seedpass >/dev/null 2>&1 || true + elif command -v pip3 &> /dev/null; then + pip3 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 "$@" + From fb27d49ad79a73afc89cdde057344d895ac289db Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:28:48 -0400 Subject: [PATCH 020/120] enhance uninstall script to clean old executables --- scripts/uninstall.sh | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index d7872b3..6a02d4c 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -15,6 +15,18 @@ 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'..." + rm -f "$candidate" || true + fi + done +} + main() { if [ -d "$INSTALL_DIR" ]; then print_info "Removing installation directory '$INSTALL_DIR'..." @@ -23,6 +35,7 @@ main() { print_info "Installation directory not found." fi + if [ -f "$LAUNCHER_PATH" ]; then print_info "Removing launcher script '$LAUNCHER_PATH'..." rm -f "$LAUNCHER_PATH" @@ -30,11 +43,16 @@ main() { print_info "Launcher script not found." fi + remove_stale_executables + print_info "Attempting to uninstall any global 'seedpass' package with pip..." - if command -v pip &> /dev/null; then + 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 - elif command -v pip3 &> /dev/null; then - pip3 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." From 8d64a94d83e023e422fda40b2c0cbd0723283026 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:41:33 -0400 Subject: [PATCH 021/120] Handle rm failure in uninstall --- README.md | 1 + docs/docs/content/index.md | 1 + scripts/uninstall.sh | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ed38ec..598bf03 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Run the matching uninstaller if you need to remove a previous installation or cl ```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 diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 81aaab2..345b1fa 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -102,6 +102,7 @@ Run the matching uninstaller if you need to remove a previous installation or cl ```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 diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 6a02d4c..45c24b0 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -22,7 +22,14 @@ remove_stale_executables() { candidate="$dir/seedpass" if [ -f "$candidate" ] && [ "$candidate" != "$LAUNCHER_PATH" ]; then print_info "Removing old executable '$candidate'..." - rm -f "$candidate" || true + 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 } From 29b06d5b40ececf9332cda61faf859944a460c72 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:24:24 -0400 Subject: [PATCH 022/120] Fix CLI launcher and module entry --- scripts/install.sh | 4 ++-- src/seedpass/cli.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 4eee90b..412974f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -125,10 +125,10 @@ main() { # 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 "$VENV_DIR/bin/python" -m seedpass.cli "\$@" +exec "$VENV_DIR/bin/seedpass" "\$@" EOF2 chmod +x "$LAUNCHER_PATH" diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index e04f6bd..e2639f1 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -573,3 +573,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() From 172314b86bbc37c9ae85b6c4fa9486421761b84a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:26:04 -0400 Subject: [PATCH 023/120] Make seedpass command launch TUI by default --- src/seedpass/cli.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index e2639f1..afe1f7b 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( @@ -47,10 +52,16 @@ def _get_pm(ctx: typer.Context) -> PasswordManager: 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()) @entry_app.command("list") From fc56ec169f3bd5ddefd6de2a387f778a0c2a119a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:41:31 -0400 Subject: [PATCH 024/120] docs: describe launching TUI --- README.md | 18 +++++++++++++----- docs/docs/content/index.md | 16 +++++++++++----- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 598bf03..28bed77 100644 --- a/README.md +++ b/README.md @@ -160,11 +160,12 @@ After installing dependencies and activating your virtual environment, install t python -m pip install -e . ``` + You can then launch SeedPass and create a backup: ```bash -# Start the application -python src/main.py +# Start the application (interactive TUI) +seedpass # Export your index seedpass export --file "~/seedpass_backup.json" @@ -216,13 +217,19 @@ After successfully installing the dependencies, install the package with: python -m pip install -e . ``` -Once installed, you can run SeedPass using the following command: +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 @@ -247,8 +254,9 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th 1. **Start the Application:** ```bash - python src/main.py + seedpass ``` + *(or `python src/main.py` when running directly from the repository)* 2. **Follow the Prompts:** diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 345b1fa..b071d38 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -186,7 +186,7 @@ create a backup: ```bash # Start the application -python src/main.py +seedpass # Export your index seedpass export --file "~/seedpass_backup.json" @@ -234,14 +234,19 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers ## Usage -After successfully installing the dependencies, you can run SeedPass using the following command: +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 also use the Typer-based command line interface once the -package is installed: +You can explore other CLI commands using: ```bash seedpass --help ``` @@ -252,8 +257,9 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th 1. **Start the Application:** ```bash - python src/main.py + seedpass ``` + *(or `python src/main.py` if running directly from the repository)* 2. **Follow the Prompts:** diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b1cd64b..d843b8b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -292,5 +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 412974f..cb07099 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -140,7 +140,7 @@ EOF2 # 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." From afefb5415b982730759e58c4fc7ffaa8b841bcb3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 14:19:16 -0400 Subject: [PATCH 025/120] cli: avoid fingerprint prompt when option provided --- src/password_manager/manager.py | 25 ++++++++++++++++++------- src/seedpass/api.py | 7 ++++--- src/seedpass/cli.py | 8 ++++---- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 56a151a..f44e94b 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -102,8 +102,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 @@ -131,11 +137,16 @@ class PasswordManager: # 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.""" diff --git a/src/seedpass/api.py b/src/seedpass/api.py index c1482e1..e87c45a 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -51,9 +51,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 = [ diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index afe1f7b..cc98ace 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -44,11 +44,11 @@ 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 From b46943f7f8070aaa29ce9ddc64dde9c8ba534462 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:23:31 -0400 Subject: [PATCH 026/120] Support fingerprint option in legacy CLI --- src/main.py | 15 ++++++++++--- src/seedpass/cli.py | 2 +- src/tests/test_cli_export_import.py | 4 ++-- src/tests/test_cli_subcommands.py | 34 ++++++++++++++++++++++------- src/tests/test_typer_cli.py | 33 ++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/main.py b/src/main.py index 3767a37..8085c53 100644 --- a/src/main.py +++ b/src/main.py @@ -919,8 +919,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 +936,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 +957,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/seedpass/cli.py b/src/seedpass/cli.py index cc98ace..3dffb39 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -61,7 +61,7 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> ctx.obj = {"fingerprint": fingerprint} if ctx.invoked_subcommand is None: tui = importlib.import_module("main") - raise typer.Exit(tui.main()) + raise typer.Exit(tui.main(fingerprint=fingerprint)) @entry_app.command("list") 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_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_typer_cli.py b/src/tests/test_typer_cli.py index 21feb6c..ea4ad87 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -307,6 +307,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 = {} @@ -447,3 +462,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" From d261a244a0894241970190ea78619eaf6110dfd4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:26:48 -0400 Subject: [PATCH 027/120] update --- .../02-examples/01-entry_management_demo.py | 0 .../02-examples/02-password_manager_demo.py | 0 landing/docs/README.html | 114 --- landing/docs/advanced_cli.html | 613 --------------- landing/docs/api_reference.html | 320 -------- landing/docs/index.html | 26 - landing/docs/json_entries.html | 703 ------------------ landing/docs/migrations.html | 51 -- landing/docs/theme.css | 4 - 9 files changed, 1831 deletions(-) rename examples/entry_management_demo.py => docs/docs/content/02-examples/01-entry_management_demo.py (100%) rename examples/password_manager_demo.py => docs/docs/content/02-examples/02-password_manager_demo.py (100%) delete mode 100644 landing/docs/README.html delete mode 100644 landing/docs/advanced_cli.html delete mode 100644 landing/docs/api_reference.html delete mode 100644 landing/docs/index.html delete mode 100644 landing/docs/json_entries.html delete mode 100644 landing/docs/migrations.html delete mode 100644 landing/docs/theme.css diff --git a/examples/entry_management_demo.py b/docs/docs/content/02-examples/01-entry_management_demo.py similarity index 100% rename from examples/entry_management_demo.py rename to docs/docs/content/02-examples/01-entry_management_demo.py diff --git a/examples/password_manager_demo.py b/docs/docs/content/02-examples/02-password_manager_demo.py similarity index 100% rename from examples/password_manager_demo.py rename to docs/docs/content/02-examples/02-password_manager_demo.py diff --git a/landing/docs/README.html b/landing/docs/README.html deleted file mode 100644 index 761a7a8..0000000 --- a/landing/docs/README.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - README - - - - -

    SeedPass Documentation

    -

    This directory contains supplementary guides for using SeedPass.

    -

    Quick Example: Get a TOTP -Code

    -

    Run seedpass entry get <query> to retrieve a -time-based one-time password (TOTP). The <query> can -be a label, title, or index. A progress bar shows the remaining seconds -in the current period.

    -
    $ seedpass entry get "email"
    -[##########----------] 15s
    -Code: 123456
    -

    To show all stored TOTP codes with their countdown timers, run:

    -
    $ seedpass entry totp-codes
    -

    CLI and API Reference

    -

    See advanced_cli.html for a list of -command examples. Detailed information about the REST API is available -in api_reference.html. When starting the -API, set SEEDPASS_CORS_ORIGINS if you need to allow -requests from specific web origins.

    - - diff --git a/landing/docs/advanced_cli.html b/landing/docs/advanced_cli.html deleted file mode 100644 index 29163bd..0000000 --- a/landing/docs/advanced_cli.html +++ /dev/null @@ -1,613 +0,0 @@ - - - - - - - advanced_cli - - - - -

    SeedPass Advanced -CLI and API Documentation

    -

    Overview

    -

    Welcome to the Advanced CLI and API Documentation -for SeedPass, a secure, deterministic password manager -built on Bitcoin’s BIP‑85 standard. This guide is designed for power -users, developers, and system administrators who wish to leverage the -full capabilities of SeedPass through the command line for scripting, -automation, and integration.

    -

    SeedPass uses a noun-verb command structure (e.g., -seedpass entry get <query>) for a clear, scalable, -and discoverable interface. You can explore the available actions for -any command group with the --help flag (for example, -seedpass entry --help).

    -

    The commands in this document reflect the Typer-based CLI shipped -with SeedPass. Each command accepts the optional ---fingerprint flag to operate on a specific seed -profile.

    -
    -

    Table of Contents

    -
      -
    1. Global Options
    2. -
    3. Command Group Reference -
    4. -
    5. Detailed Command -Descriptions
    6. -
    7. API Integration
    8. -
    9. Usage Guidelines
    10. -
    -
    -

    Global Options

    -

    These options can be used with any command.

    - ---- - - - - - - - - - - - - - - - - -
    FlagDescription
    --fingerprint <fp>Specify which seed profile to use. If -omitted, the most recently used profile is selected.
    --help, -hDisplay help information for a command or -subcommand.
    -
    -

    Command Group Reference

    -

    Entry Commands

    -

    Manage individual entries within a vault.

    - ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    List entriesentry listseedpass entry list --sort label
    Search for entriesentry searchseedpass entry search "GitHub"
    Retrieve an entry’s secret (password or -TOTP code)entry getseedpass entry get "GitHub"
    Add a password entryentry addseedpass entry add Example --length 16
    Add a TOTP entryentry add-totpseedpass entry add-totp Email --secret JBSW...
    Add an SSH key entryentry add-sshseedpass entry add-ssh Server --index 0
    Add a PGP key entryentry add-pgpseedpass entry add-pgp Personal --user-id me@example.com
    Add a Nostr key entryentry add-nostrseedpass entry add-nostr Chat
    Add a seed phrase entryentry add-seedseedpass entry add-seed Backup --words 24
    Add a key/value entryentry add-key-valueseedpass entry add-key-value "API Token" --value abc123
    Add a managed account entryentry add-managed-accountseedpass entry add-managed-account Trading
    Modify an entryentry modifyseedpass entry modify 1 --username alice
    Archive an entryentry archiveseedpass entry archive 1
    Unarchive an entryentry unarchiveseedpass entry unarchive 1
    Export all TOTP secretsentry export-totpseedpass entry export-totp --file totp.json
    Show all TOTP codesentry totp-codesseedpass entry totp-codes
    -

    Vault Commands

    -

    Manage the entire vault for a profile.

    - ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    Export the vaultvault exportseedpass vault export --file backup.json
    Import a vaultvault importseedpass vault import --file backup.json
    Change the master passwordvault change-passwordseedpass vault change-password
    Lock the vaultvault lockseedpass vault lock
    Show profile statisticsvault statsseedpass vault stats
    Reveal or back up the parent seedvault reveal-parent-seedseedpass vault reveal-parent-seed --file backup.enc
    -

    Nostr Commands

    -

    Interact with the Nostr network for backup and synchronization.

    - - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    Sync with relaysnostr syncseedpass nostr sync
    Get public keynostr get-pubkeyseedpass nostr get-pubkey
    -

    Config Commands

    -

    Manage profile‑specific settings.

    - ----- - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    Get a setting valueconfig getseedpass config get inactivity_timeout
    Set a setting valueconfig setseedpass config set inactivity_timeout 300
    -

    Fingerprint Commands

    -

    Manage seed profiles (fingerprints).

    - ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    List all profilesfingerprint listseedpass fingerprint list
    Add a profilefingerprint addseedpass fingerprint add
    Remove a profilefingerprint removeseedpass fingerprint remove <fp>
    Switch profilefingerprint switchseedpass fingerprint switch <fp>
    -

    Utility Commands

    -

    Miscellaneous helper commands.

    - ----- - - - - - - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    Generate a passwordutil generate-passwordseedpass util generate-password --length 24
    Verify script checksumutil verify-checksumseedpass util verify-checksum
    Update script checksumutil update-checksumseedpass util update-checksum
    -

    API Commands

    -

    Run or stop the local HTTP API.

    - ----- - - - - - - - - - - - - - - - - - - - -
    ActionCommandExamples
    Start the APIapi startseedpass api start --host 0.0.0.0 --port 8000
    Stop the APIapi stopseedpass api stop
    -
    -

    Detailed Command -Descriptions

    -

    entry Commands

    -
      -
    • seedpass entry list – List entries in -the vault, optionally sorted or filtered.
    • -
    • seedpass entry search <query> – -Search across labels, usernames, URLs and notes.
    • -
    • seedpass entry get <query> – -Retrieve the password or TOTP code for one matching entry, depending on -the entry’s type.
    • -
    • seedpass entry add <label> – -Create a new password entry. Use --length to set the -password length and optional --username/--url -values.
    • -
    • seedpass entry add-totp <label> -– Create a TOTP entry. Use --secret to import an existing -secret or --index to derive from the seed.
    • -
    • seedpass entry add-ssh <label> – -Create an SSH key entry derived from the seed.
    • -
    • seedpass entry add-pgp <label> – -Create a PGP key entry. Provide --user-id and ---key-type as needed.
    • -
    • seedpass entry add-nostr <label> -– Create a Nostr key entry for decentralised chat.
    • -
    • seedpass entry add-seed <label> -– Store a derived seed phrase. Use --words to set the word -count.
    • -
    • seedpass entry add-key-value <label> -– Store arbitrary data with --value.
    • -
    • seedpass entry add-managed-account <label> -– Store a BIP‑85 derived account seed.
    • -
    • seedpass entry modify <id> – -Update an entry’s label, username, URL or notes.
    • -
    • seedpass entry archive <id> – -Mark an entry as archived so it is hidden from normal lists.
    • -
    • seedpass entry unarchive <id> – -Restore an archived entry.
    • -
    • seedpass entry export-totp --file <path> -– Export all stored TOTP secrets to a JSON file.
    • -
    • seedpass entry totp-codes – Display -all current TOTP codes with remaining time.
    • -
    -

    Example retrieving a TOTP code:

    -
    $ seedpass entry get "email"
    -[##########----------] 15s
    -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 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

    -
      -
    • seedpass nostr sync – Perform a -two‑way sync with configured Nostr relays.
    • -
    • seedpass nostr get-pubkey – Display -the Nostr public key for the active profile.
    • -
    -

    config Commands

    -
      -
    • seedpass config get <key> – -Retrieve a configuration value such as inactivity_timeout, -secret_mode, or auto_sync.
    • -
    • seedpass config set <key> <value> -– Update a configuration option. Example: -seedpass config set inactivity_timeout 300.
    • -
    • seedpass config toggle-secret-mode – -Interactively enable or disable Secret Mode and set the clipboard -delay.
    • -
    -

    fingerprint Commands

    -
      -
    • seedpass fingerprint list – List -available profiles by fingerprint.
    • -
    • seedpass fingerprint add – Create a -new seed profile.
    • -
    • seedpass fingerprint remove <fp> -– Delete the specified profile.
    • -
    • seedpass fingerprint switch <fp> -– Switch the active profile.
    • -
    -

    util Commands

    -
      -
    • seedpass util generate-password – -Generate a strong password of the requested length.
    • -
    • seedpass util verify-checksum – Verify -the SeedPass script checksum.
    • -
    • seedpass util update-checksum – -Regenerate the script checksum file.
    • -
    -
    -

    API Integration

    -

    SeedPass provides a small REST API for automation. Run -seedpass api start to launch the server. The command prints -a one‑time token which clients must include in the -Authorization header.

    -

    Set the SEEDPASS_CORS_ORIGINS environment variable to a -comma‑separated list of allowed origins when you need cross‑origin -requests:

    -
    SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
    -

    Shut down the server with seedpass api stop.

    -
    -

    Usage Guidelines

    -
      -
    • 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.
    • -
    • entry get is script‑friendly and can be piped into -other commands.
    • -
    - - diff --git a/landing/docs/api_reference.html b/landing/docs/api_reference.html deleted file mode 100644 index d5e24e6..0000000 --- a/landing/docs/api_reference.html +++ /dev/null @@ -1,320 +0,0 @@ - - - - - - - api_reference - - - - -

    SeedPass REST API Reference

    -

    This guide covers how to start the SeedPass API, authenticate -requests, and interact with the available endpoints.

    -

    Starting the API

    -

    Run seedpass api start from your terminal. The command -prints a one‑time token used for authentication:

    -
    $ seedpass api start
    -API token: abcdef1234567890
    -

    Keep this token secret. Every request must include it in the -Authorization header using the Bearer -scheme.

    -

    Endpoints

    -
      -
    • GET /api/v1/entry?query=<text> – Search entries -matching a query.
    • -
    • GET /api/v1/entry/{id} – Retrieve a single entry by its -index.
    • -
    • POST /api/v1/entry – Create a new entry of any -supported type.
    • -
    • PUT /api/v1/entry/{id} – Modify an existing entry.
    • -
    • PUT /api/v1/config/{key} – Update a configuration -value.
    • -
    • POST /api/v1/secret-mode – Enable or disable Secret -Mode and set the clipboard delay.
    • -
    • POST /api/v1/entry/{id}/archive – Archive an -entry.
    • -
    • POST /api/v1/entry/{id}/unarchive – Unarchive an -entry.
    • -
    • GET /api/v1/config/{key} – Return the value for a -configuration key.
    • -
    • GET /api/v1/fingerprint – List available seed -fingerprints.
    • -
    • POST /api/v1/fingerprint – Add a new seed -fingerprint.
    • -
    • DELETE /api/v1/fingerprint/{fp} – Remove a -fingerprint.
    • -
    • POST /api/v1/fingerprint/select – Switch the active -fingerprint.
    • -
    • 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/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.

    -

    Example Requests

    -

    Send requests with the token in the header:

    -
    curl -H "Authorization: Bearer <token>" \
    -     "http://127.0.0.1:8000/api/v1/entry?query=email"
    -

    Creating an Entry

    -

    POST /api/v1/entry accepts a JSON body with at least a -label field. Set type (or kind) -to choose the entry variant (password, totp, -ssh, pgp, nostr, -seed, key_value, or -managed_account). Additional fields vary by type:

    -
      -
    • passwordlength, optional -username, url and notes
    • -
    • totpsecret or index, -optional period, digits, notes, -archived
    • -
    • ssh/nostr/seed/managed_account – -index, optional notes, -archived
    • -
    • pgpindex, key_type, -user_id, optional notes, -archived
    • -
    • key_valuevalue, optional -notes
    • -
    -

    Example creating a TOTP entry:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/entry \
    -     -H "Authorization: Bearer <token>" \
    -     -H "Content-Type: application/json" \
    -     -d '{"type": "totp", "label": "Email", "secret": "JBSW..."}'
    -

    Updating an Entry

    -

    Use PUT /api/v1/entry/{id} to change fields such as -label, username, url, -notes, period, digits or -value depending on the entry type.

    -
    curl -X PUT http://127.0.0.1:8000/api/v1/entry/1 \
    -     -H "Authorization: Bearer <token>" \
    -     -H "Content-Type: application/json" \
    -     -d '{"username": "alice"}'
    -

    Updating Configuration

    -

    Send a JSON body containing a value field to -PUT /api/v1/config/{key}:

    -
    curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
    -     -H "Authorization: Bearer <token>" \
    -     -H "Content-Type: application/json" \
    -     -d '{"value": 300}'
    -

    Toggling Secret Mode

    -

    Send both enabled and delay values to -/api/v1/secret-mode:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/secret-mode \
    -     -H "Authorization: Bearer <token>" \
    -     -H "Content-Type: application/json" \
    -     -d '{"enabled": true, "delay": 20}'
    -

    Switching Fingerprints

    -

    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 <token>" \
    -     -H "Content-Type: application/json" \
    -    -d '{"fingerprint": "abc123"}'
    -

    Exporting the Vault

    -

    Download an encrypted vault backup via -POST /api/v1/vault/export:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/vault/export \
    -     -H "Authorization: Bearer <token>" \
    -     -o backup.json
    -

    Importing a Vault

    -

    Restore a backup with POST /api/v1/vault/import. Use --F to upload a file:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/vault/import \
    -     -H "Authorization: Bearer <token>" \
    -     -F file=@backup.json
    -

    Locking the Vault

    -

    Clear sensitive data from memory using -/api/v1/vault/lock:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/vault/lock \
    -     -H "Authorization: Bearer <token>"
    -

    Backing Up the Parent Seed

    -

    Trigger an encrypted seed backup with -/api/v1/vault/backup-parent-seed:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/vault/backup-parent-seed \
    -     -H "Authorization: Bearer <token>" \
    -     -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:

    -
    curl -H "Authorization: Bearer <token>" \
    -     http://127.0.0.1:8000/api/v1/stats
    -

    Changing the Master Password

    -

    Update the vault password via -POST /api/v1/change-password:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/change-password \
    -     -H "Authorization: Bearer <token>"
    -

    Verifying the Script -Checksum

    -

    Check that the running script matches the stored checksum:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/checksum/verify \
    -     -H "Authorization: Bearer <token>"
    -

    Updating the Script Checksum

    -

    Regenerate the stored checksum using -/api/v1/checksum/update:

    -
    curl -X POST http://127.0.0.1:8000/api/v1/checksum/update \
    -     -H "Authorization: Bearer <token>"
    -

    Managing Relays

    -

    List, add, or remove Nostr relays:

    -
    # list
    -curl -H "Authorization: Bearer <token>" 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 <token>" \
    -     -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 <token>"
    -
    -# reset to defaults
    -curl -X POST http://127.0.0.1:8000/api/v1/relays/reset \
    -     -H "Authorization: Bearer <token>"
    -

    Enabling CORS

    -

    Cross‑origin requests are disabled by default. Set -SEEDPASS_CORS_ORIGINS to a comma‑separated list of allowed -origins before starting the API:

    -
    SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
    -

    Browsers can then call the API from the specified origins, for -example using JavaScript:

    -
    fetch('http://127.0.0.1:8000/api/v1/entry?query=email', {
    -  headers: { Authorization: 'Bearer <token>' }
    -});
    -

    Without CORS enabled, only same‑origin or command‑line tools like -curl can access the API.

    - - diff --git a/landing/docs/index.html b/landing/docs/index.html deleted file mode 100644 index d759e7e..0000000 --- a/landing/docs/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - SeedPass Documentation - - - -
    - -
    -

    SeedPass Documentation

    -

    Select a topic from the sidebar.

    -
    -
    - - diff --git a/landing/docs/json_entries.html b/landing/docs/json_entries.html deleted file mode 100644 index fc28e0a..0000000 --- a/landing/docs/json_entries.html +++ /dev/null @@ -1,703 +0,0 @@ - - - - - - - json_entries - - - - -

    SeedPass JSON -Entry Management and Extensibility

    -

    Table of Contents

    - -
    -

    Introduction

    -

    SeedPass is a secure password generator and manager -leveraging Bitcoin’s BIP-85 standard and integrating -with the Nostr network for decentralized -synchronization. Instead of pushing one large index file, SeedPass posts -snapshot chunks of the index followed by lightweight -delta events whenever changes occur. This chunked -approach improves reliability and keeps bandwidth usage minimal. To -enhance modularity, scalability, and security, SeedPass stores all -entries in a single encrypted index file named -seedpass_entries_db.json.enc. This document outlines the -entry management system, ensuring that new kind types can -be added seamlessly without disrupting existing functionalities.

    -
    -

    Index File Format

    -

    All entries belonging to a seed profile are stored in an encrypted -file named seedpass_entries_db.json.enc. This index uses -schema_version 3 and contains an -entries object keyed by numeric identifiers.

    -
    {
    -  "schema_version": 3,
    -  "entries": {
    -    "0": {
    -      "label": "example.com",
    -      "length": 8,
    -      "username": "user",
    -      "url": "https://example.com",
    -      "archived": false,
    -      "type": "password",
    -      "kind": "password",
    -      "notes": "",
    -      "custom_fields": [],
    -      "origin": ""
    -    }
    -  }
    -}
    -
    -

    JSON Schema for Individual -Entries

    -

    Each entry is stored within seedpass_entries_db.json.enc -under the entries dictionary. The structure supports -diverse entry types (kind) and allows for future -expansions.

    -

    General Structure

    -
    {
    -  "label": "Example",
    -  "length": 8,
    -  "username": "user@example.com",
    -  "url": "https://example.com",
    -  "archived": false,
    -  "type": "password",
    -  "kind": "password",
    -  "notes": "",
    -  "custom_fields": [],
    -  "origin": "",
    -  "tags": [],
    -  "index": 0
    -}
    -

    Field Descriptions

    -
      -
    • label (string): Descriptive name -for the entry (e.g., website or service).

    • -
    • length (integer, optional): Desired -password length for generated passwords.

    • -
    • username (string, optional): -Username associated with the entry.

    • -
    • url (string, optional): Website or -service URL.

    • -
    • archived (boolean): Marks the entry -as archived when true.

    • -
    • type (string): The entry type -(password, totp, ssh, -seed, pgp, nostr, -note, key_value).

    • -
    • kind (string): Synonym for -type kept for backward compatibility.

    • -
    • notes (string): Free-form -notes.

    • -
    • custom_fields (array, optional): -Additional user-defined fields.

    • -
    • origin (string, optional): Source -identifier for imported data.

    • -
    • value (string, optional): For -key_value entries, stores the secret value.

    • -
    • index (integer, optional): BIP-85 -derivation index for entries that derive material from a seed.

    • -
    • word_count (integer, -managed_account only): Number of words in the child seed. Managed -accounts always use 12.

    • -
    • fingerprint (string, -managed_account only): Identifier of the child profile, used for its -directory name.

    • -
    • tags (array, optional): Category -labels to aid in organization and search. Example:

      -
      "custom_fields": [
      -  {"name": "account_id", "value": "123"},
      -  {"name": "recovery_hint", "value": "mother's maiden name"}
      -]
    • -
    -

    Example Entries

    -

    1. Generated Password

    -
    {
    -  "entry_num": 0,
    -  "index_num": 0,
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "generated_password",
    -  "data": {
    -    "title": "Example Website",
    -    "username": "user@example.com",
    -    "email": "user@example.com",
    -    "url": "https://example.com",
    -    "password": "<encrypted_password>"
    -  },
    -  "custom_fields": [
    -    {"name": "department", "value": "finance"}
    -  ],
    -  "tags": ["work"],
    -  "timestamp": "2024-04-27T12:34:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:34:56Z",
    -    "updated_at": "2024-04-27T12:34:56Z",
    -    "checksum": "abc123def456"
    -  }
    -}
    -

    2. Stored Password

    -
    {
    -  "entry_num": 1,
    -  "index_num": "q1wec4d426fs",
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "stored_password",
    -  "data": {
    -    "title": "Another Service",
    -    "username": "another_user",
    -    "password": "<encrypted_password>"
    -  },
    -  "timestamp": "2024-04-27T12:35:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:35:56Z",
    -    "updated_at": "2024-04-27T12:35:56Z",
    -    "checksum": "def789ghi012"
    -  }
    -}
    -

    3. Managed User

    -
    {
    -  "entry_num": 2,
    -  "index_num": "a1b2c3d4e5f6",
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "managed_user",
    -  "data": {
    -    "users_password": "<encrypted_users_password>"
    -  },
    -  "timestamp": "2024-04-27T12:36:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:36:56Z",
    -    "updated_at": "2024-04-27T12:36:56Z",
    -    "checksum": "ghi345jkl678"
    -  }
    -}
    -

    4. 12 Word Seed

    -
    {
    -  "entry_num": 3,
    -  "index_num": "f7g8h9i0j1k2",
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "12_word_seed",
    -  "data": {
    -    "seed_phrase": "<encrypted_seed_phrase>"
    -  },
    -  "timestamp": "2024-04-27T12:37:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:37:56Z",
    -    "updated_at": "2024-04-27T12:37:56Z",
    -    "checksum": "jkl901mno234"
    -  }
    -}
    -

    5. Nostr Keys

    -
    {
    -  "entry_num": 4,
    -  "index_num": "l3m4n5o6p7q8",
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "nostr_keys",
    -  "data": {
    -    "public_key": "<public_key>",
    -    "private_key": "<encrypted_private_key>"
    -  },
    -  "timestamp": "2024-04-27T12:38:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:38:56Z",
    -    "updated_at": "2024-04-27T12:38:56Z",
    -    "checksum": "mno567pqr890"
    -  }
    -}
    -

    6. Note

    -
    {
    -  "entry_num": 5,
    -  "index_num": "r9s0t1u2v3w4",
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "note",
    -  "data": {
    -    "content": "This is a secure note.",
    -    "tags": ["personal", "secure"]
    -  },
    -  "timestamp": "2024-04-27T12:39:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:39:56Z",
    -    "updated_at": "2024-04-27T12:39:56Z",
    -    "checksum": "pqr345stu678"
    -  }
    -}
    -

    7. Key/Value

    -
    {
    -  "entry_num": 6,
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "key_value",
    -  "data": {
    -    "key": "api_key",
    -    "value": "<encrypted_value>"
    -  },
    -  "tags": ["api"],
    -  "timestamp": "2024-04-27T12:40:56Z"
    -}
    -

    8. Managed Account

    -
    {
    -  "entry_num": 7,
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "managed_account",
    -  "data": {
    -    "account": "alice@example.com",
    -    "password": "<encrypted_password>"
    -  },
    -  "timestamp": "2024-04-27T12:41:56Z"
    -}
    -

    Managed accounts store a child seed derived from the parent profile. -The entry is saved under -.seedpass/<parent_fp>/accounts/<child_fp> where -<child_fp> is the managed account’s fingerprint. When -loaded, the CLI displays a breadcrumb like -<parent_fp> > Managed Account > <child_fp>. -Press Enter on the main menu to exit back to the parent -profile.

    -

    The key field is purely descriptive, while -value holds the sensitive string such as an API token. -Notes and custom fields may also be included alongside the standard -metadata.

    -
    -

    Handling -kind Types and Extensibility

    -

    Extensible JSON Schema -Design

    -

    The JSON schema is designed to be extensible and -forward-compatible, allowing new kind -types to be added without impacting existing functionalities.

    -

    a. Core Structure

    -

    Each entry is encapsulated in its own JSON file with a standardized -structure:

    -
    {
    -  "entry_num": 0,
    -  "index_num": 0,
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "generated_password",
    -  "data": {
    -    // Fields specific to the kind
    -  },
    -  "timestamp": "2024-04-27T12:34:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:34:56Z",
    -    "updated_at": "2024-04-27T12:34:56Z",
    -    "checksum": "<checksum_value>"
    -  }
    -}
    -

    b. The kind Field

    -
      -
    • Purpose: Specifies the type of entry.
    • -
    • Flexibility: As a simple string identifier, new -kind values can be introduced without altering the existing -schema.
    • -
    -

    Example:

    -
    "kind": "cryptocurrency_wallet"
    -

    c. The data Object

    -
      -
    • Purpose: Contains fields specific to the -kind.
    • -
    • Extensibility: Each kind can define -its unique set of fields without affecting others.
    • -
    -

    Example for a New Kind -(cryptocurrency_wallet):

    -
    "data": {
    -  "wallet_name": "My Bitcoin Wallet",
    -  "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
    -  "private_key": "<encrypted_private_key>"
    -}
    -

    Ensuring Backward -Compatibility

    -

    To maintain compatibility as new kind types are -introduced, implement the following practices:

    -

    a. Graceful Handling of -Unknown Kinds

    -
      -
    • Implementation: When encountering an unrecognized -kind, handle it gracefully by ignoring the entry, logging a -warning, or providing a default handling mechanism.
    • -
    • Benefit: Prevents the application from crashing or -misbehaving due to unrecognized kind types.
    • -
    -

    Pseudo-Code Example:

    -
    def process_entry(entry):
    -    kind = entry.get("kind")
    -    data = entry.get("data")
    -    fingerprint = entry.get("fingerprint")
    -    
    -    if kind == "generated_password":
    -        handle_generated_password(data, fingerprint)
    -    elif kind == "stored_password":
    -        handle_stored_password(data, fingerprint)
    -    # ... other known kinds ...
    -    else:
    -        log_warning(f"Unknown kind: {kind}. Skipping entry.")
    -

    b. Versioning the Schema

    -
      -
    • Implementation: Introduce a -schema_version or seedpass_version field to -indicate the version of the JSON schema being used.
    • -
    • Benefit: Facilitates future updates and migrations -by clearly identifying the schema version.
    • -
    -

    Example:

    -
    "seedpass_version": "1.0.0"
    -

    c. Documentation and -Standards

    -
      -
    • Maintain Clear Documentation: Keep comprehensive -documentation for each kind, detailing required and -optional fields.
    • -
    • Adhere to Standards: Follow consistent naming -conventions and data types to ensure uniformity across different -kind types.
    • -
    -

    Best Practices for Adding -New Kinds

    -

    To ensure seamless integration of new kind types in the -future, consider the following best practices:

    -

    a. Consistent Naming -Conventions

    -
      -
    • Use Clear and Descriptive Names: Aids in -readability and maintenance.
    • -
    • Avoid Reserved Keywords: Ensure kind -names do not clash with existing or future reserved keywords within the -application or JSON standards.
    • -
    -

    b. Modular Code Architecture

    -
      -
    • Separate Handlers: Implement separate functions or -modules for handling each kind. Promotes code modularity -and easier maintenance.
    • -
    -

    Example:

    -
    # handlers.py
    -
    -def handle_generated_password(data, fingerprint):
    -    # Implementation
    -
    -def handle_stored_password(data, fingerprint):
    -    # Implementation
    -
    -def handle_cryptocurrency_wallet(data, fingerprint):
    -    # Implementation
    -

    c. Validation and Error -Handling

    -
      -
    • Validate Data Fields: Ensure each kind -has the necessary fields before processing.
    • -
    • Handle Missing or Extra Fields: Implement logic to -manage incomplete or unexpected data gracefully.
    • -
    -

    Example:

    -
    def handle_cryptocurrency_wallet(data, fingerprint):
    -    required_fields = ["wallet_name", "address", "private_key"]
    -    for field in required_fields:
    -        if field not in data:
    -            raise ValueError(f"Missing required field '{field}' in cryptocurrency_wallet entry.")
    -    # Proceed with processing
    -

    d. Backward Compatibility -Testing

    -
      -
    • Automated Tests: Develop tests that verify the -application’s ability to handle both existing and new kind -types.
    • -
    • Regression Testing: Ensure adding new kinds does -not inadvertently affect existing functionalities.
    • -
    -
    -

    Adding New kind Types

    -

    Adding new kind types is straightforward due to the -extensible JSON schema design. Below is a step-by-step guide to adding a -new kind without affecting existing functionalities.

    -

    Example: Adding -cryptocurrency_wallet

    -

    a. Define the New Kind -Structure

    -

    Create a JSON file following the standardized structure with the new -kind value.

    -
    {
    -  "entry_num": 6,
    -  "index_num": "x1y2z3a4b5c6",
    -  "fingerprint": "a1b2c3d4",
    -  "kind": "cryptocurrency_wallet",
    -  "data": {
    -    "wallet_name": "My Bitcoin Wallet",
    -    "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
    -    "private_key": "<encrypted_private_key>"
    -  },
    -  "timestamp": "2024-04-27T12:40:56Z",
    -  "metadata": {
    -    "created_at": "2024-04-27T12:40:56Z",
    -    "updated_at": "2024-04-27T12:40:56Z",
    -    "checksum": "stu901vwx234"
    -  }
    -}
    -

    b. Update the -Application to Handle the New Kind

    -

    Implement Handler Function:

    -
    def handle_cryptocurrency_wallet(data, fingerprint):
    -    wallet_name = data.get("wallet_name")
    -    address = data.get("address")
    -    private_key = decrypt(data.get("private_key"))
    -    # Process the cryptocurrency wallet entry
    -    # e.g., store in memory, display to user, etc.
    -

    Integrate the Handler:

    -
    def process_entry(entry):
    -    kind = entry.get("kind")
    -    data = entry.get("data")
    -    fingerprint = entry.get("fingerprint")
    -    
    -    if kind == "generated_password":
    -        handle_generated_password(data, fingerprint)
    -    elif kind == "stored_password":
    -        handle_stored_password(data, fingerprint)
    -    elif kind == "cryptocurrency_wallet":
    -        handle_cryptocurrency_wallet(data, fingerprint)
    -    # ... other known kinds ...
    -    else:
    -        log_warning(f"Unknown kind: {kind}. Skipping entry.")
    -

    c. No Impact on Existing -Kinds

    -

    Existing kinds such as generated_password, -stored_password, etc., continue to operate without any -changes. The introduction of cryptocurrency_wallet is -additive and does not interfere with the processing of other kinds.

    -
    -

    Backup and Rollback -Mechanism

    -

    To ensure data integrity and provide recovery options, SeedPass -implements a robust backup and rollback system within the -Fingerprint-Based Backup and Local Storage -framework.

    -

    Backup Directory Structure

    -

    All backups are organized based on fingerprints, ensuring that each -seed’s data remains isolated and secure.

    -
    ~/.seedpass/
    -├── a1b2c3d4/
    -│   ├── entries/
    -│   │   ├── entry_0.json
    -│   │   ├── entry_1.json
    -│   │   └── ...
    -│   ├── backups/
    -│   │   ├── entry_0_v1.json
    -│   │   ├── entry_0_v2.json
    -│   │   ├── entry_1_v1.json
    -│   │   └── ...
    -│   ├── parent_seed.enc
    -│   ├── seedpass_entries_db_checksum.txt
    -│   └── seedpass_entries_db.json
    -├── b5c6d7e8/
    -│   ├── entries/
    -│   │   ├── entry_0.json
    -│   │   ├── entry_1.json
    -│   │   └── ...
    -│   ├── backups/
    -│   │   ├── entry_0_v1.json
    -│   │   ├── entry_0_v2.json
    -│   │   ├── entry_1_v1.json
    -│   │   └── ...
    -│   ├── parent_seed.enc
    -│   ├── seedpass_entries_db_checksum.txt
    -│   └── seedpass_entries_db.json
    -└── ...
    -

    Backup Process

    -
      -
    1. Upon Modifying an Entry: -
        -
      • The current version of the entry is copied to the -backups/ directory within the corresponding fingerprint -folder with a version suffix (e.g., entry_0_v1.json).
      • -
      • The modified entry is saved in the entries/ directory -within the same fingerprint folder.
      • -
    2. -
    3. Versioning: -
        -
      • Each backup file includes a version number to track changes over -time.
      • -
    4. -
    -

    Rollback Functionality

    -
      -
    • Restoring an Entry: -
        -
      • Users can select a backup version from the backups/ -directory within the specific fingerprint folder.
      • -
      • The selected backup file is copied back to the entries/ -directory, replacing the current version.
      • -
    • -
    -

    Example Command:

    -
    seedpass rollback --fingerprint a1b2c3d4 --file entry_0_v1.json
    -

    Example Directory Structure After Rollback:

    -
    ~/.seedpass/
    -├── a1b2c3d4/
    -│   ├── entries/
    -│   │   ├── entry_0.json  # Restored from entry_0_v1.json
    -│   │   ├── entry_1.json
    -│   │   └── ...
    -│   ├── backups/
    -│   │   ├── entry_0_v1.json
    -│   │   ├── entry_0_v2.json
    -│   │   ├── entry_1_v1.json
    -│   │   └── ...
    -│   ├── parent_seed.enc
    -│   ├── seedpass_script_checksum.txt
    -│   ├── seedpass_entries_db_checksum.txt
    -│   └── seedpass_entries_db.json
    -├── ...
    -

    Note: Restoring a backup overwrites the current entry. Ensure -that you intend to revert to the selected backup before -proceeding.

    - - diff --git a/landing/docs/migrations.html b/landing/docs/migrations.html deleted file mode 100644 index 593789d..0000000 --- a/landing/docs/migrations.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - migrations - - - - -

    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. -
    3. apply_migrations() applies any necessary steps, such as -injecting the schema_version field on first upgrade.
    4. -
    5. After migration, the updated index is saved back to disk.
    6. -
    -

    This process happens automatically; users only need to open their -vault to upgrade older indices.

    - - diff --git a/landing/docs/theme.css b/landing/docs/theme.css deleted file mode 100644 index a88467c..0000000 --- a/landing/docs/theme.css +++ /dev/null @@ -1,4 +0,0 @@ -html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden],audio:not([controls]){display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;text-decoration:none}ins,mark{color:#000}mark{background:#ff0;font-style:italic;font-weight:700}.rst-content code,.rst-content tt,code,kbd,pre,samp{font-family:monospace,serif;_font-family:courier new,monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:after,q:before{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,ol,ul{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure,form{margin:0}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}textarea{resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none!important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{body,html,section{background:none!important}*{box-shadow:none!important;text-shadow:none!important;filter:none!important;-ms-filter:none!important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}@page{margin:.5cm}.rst-content .toctree-wrapper>p.caption,h2,h3,p{orphans:3;widows:3}.rst-content .toctree-wrapper>p.caption,h2,h3{page-break-after:avoid}}.btn,.fa:before,.icon:before,.rst-content .admonition,.rst-content .admonition-title:before,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .code-block-caption .headerlink:before,.rst-content .danger,.rst-content .eqno .headerlink:before,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-alert,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before,input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */@font-face{font-family:FontAwesome;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-pull-left.icon,.fa.fa-pull-left,.rst-content .code-block-caption .fa-pull-left.headerlink,.rst-content .eqno .fa-pull-left.headerlink,.rst-content .fa-pull-left.admonition-title,.rst-content code.download span.fa-pull-left:first-child,.rst-content dl dt .fa-pull-left.headerlink,.rst-content h1 .fa-pull-left.headerlink,.rst-content h2 .fa-pull-left.headerlink,.rst-content h3 .fa-pull-left.headerlink,.rst-content h4 .fa-pull-left.headerlink,.rst-content h5 .fa-pull-left.headerlink,.rst-content h6 .fa-pull-left.headerlink,.rst-content p .fa-pull-left.headerlink,.rst-content table>caption .fa-pull-left.headerlink,.rst-content tt.download span.fa-pull-left:first-child,.wy-menu-vertical li.current>a button.fa-pull-left.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-left.toctree-expand,.wy-menu-vertical li button.fa-pull-left.toctree-expand{margin-right:.3em}.fa-pull-right.icon,.fa.fa-pull-right,.rst-content .code-block-caption .fa-pull-right.headerlink,.rst-content .eqno .fa-pull-right.headerlink,.rst-content .fa-pull-right.admonition-title,.rst-content code.download span.fa-pull-right:first-child,.rst-content dl dt .fa-pull-right.headerlink,.rst-content h1 .fa-pull-right.headerlink,.rst-content h2 .fa-pull-right.headerlink,.rst-content h3 .fa-pull-right.headerlink,.rst-content h4 .fa-pull-right.headerlink,.rst-content h5 .fa-pull-right.headerlink,.rst-content h6 .fa-pull-right.headerlink,.rst-content p .fa-pull-right.headerlink,.rst-content table>caption .fa-pull-right.headerlink,.rst-content tt.download span.fa-pull-right:first-child,.wy-menu-vertical li.current>a button.fa-pull-right.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-right.toctree-expand,.wy-menu-vertical li button.fa-pull-right.toctree-expand{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.pull-left.icon,.rst-content .code-block-caption .pull-left.headerlink,.rst-content .eqno .pull-left.headerlink,.rst-content .pull-left.admonition-title,.rst-content code.download span.pull-left:first-child,.rst-content dl dt .pull-left.headerlink,.rst-content h1 .pull-left.headerlink,.rst-content h2 .pull-left.headerlink,.rst-content h3 .pull-left.headerlink,.rst-content h4 .pull-left.headerlink,.rst-content h5 .pull-left.headerlink,.rst-content h6 .pull-left.headerlink,.rst-content p .pull-left.headerlink,.rst-content table>caption .pull-left.headerlink,.rst-content tt.download span.pull-left:first-child,.wy-menu-vertical li.current>a button.pull-left.toctree-expand,.wy-menu-vertical li.on a button.pull-left.toctree-expand,.wy-menu-vertical li button.pull-left.toctree-expand{margin-right:.3em}.fa.pull-right,.pull-right.icon,.rst-content .code-block-caption .pull-right.headerlink,.rst-content .eqno .pull-right.headerlink,.rst-content .pull-right.admonition-title,.rst-content code.download span.pull-right:first-child,.rst-content dl dt .pull-right.headerlink,.rst-content h1 .pull-right.headerlink,.rst-content h2 .pull-right.headerlink,.rst-content h3 .pull-right.headerlink,.rst-content h4 .pull-right.headerlink,.rst-content h5 .pull-right.headerlink,.rst-content h6 .pull-right.headerlink,.rst-content p .pull-right.headerlink,.rst-content table>caption .pull-right.headerlink,.rst-content tt.download span.pull-right:first-child,.wy-menu-vertical li.current>a button.pull-right.toctree-expand,.wy-menu-vertical li.on a button.pull-right.toctree-expand,.wy-menu-vertical li button.pull-right.toctree-expand{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);-ms-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);-ms-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before,.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.rst-content .admonition-title:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.icon-caret-down:before,.wy-dropdown .caret:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before,.wy-menu-vertical li button.toctree-expand:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.fa-reddit-alien:before{content:""}.fa-edge:before{content:""}.fa-credit-card-alt:before{content:""}.fa-codiepie:before{content:""}.fa-modx:before{content:""}.fa-fort-awesome:before{content:""}.fa-usb:before{content:""}.fa-product-hunt:before{content:""}.fa-mixcloud:before{content:""}.fa-scribd:before{content:""}.fa-pause-circle:before{content:""}.fa-pause-circle-o:before{content:""}.fa-stop-circle:before{content:""}.fa-stop-circle-o:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-hashtag:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-percent:before{content:""}.fa-gitlab:before,.icon-gitlab:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpforms:before{content:""}.fa-envira:before{content:""}.fa-universal-access:before{content:""}.fa-wheelchair-alt:before{content:""}.fa-question-circle-o:before{content:""}.fa-blind:before{content:""}.fa-audio-description:before{content:""}.fa-volume-control-phone:before{content:""}.fa-braille:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:""}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-sign-language:before,.fa-signing:before{content:""}.fa-low-vision:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-pied-piper:before{content:""}.fa-first-order:before{content:""}.fa-yoast:before{content:""}.fa-themeisle:before{content:""}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:""}.fa-fa:before,.fa-font-awesome:before{content:""}.fa-handshake-o:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-o:before{content:""}.fa-linode:before{content:""}.fa-address-book:before{content:""}.fa-address-book-o:before{content:""}.fa-address-card:before,.fa-vcard:before{content:""}.fa-address-card-o:before,.fa-vcard-o:before{content:""}.fa-user-circle:before{content:""}.fa-user-circle-o:before{content:""}.fa-user-o:before{content:""}.fa-id-badge:before{content:""}.fa-drivers-license:before,.fa-id-card:before{content:""}.fa-drivers-license-o:before,.fa-id-card-o:before{content:""}.fa-quora:before{content:""}.fa-free-code-camp:before{content:""}.fa-telegram:before{content:""}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:""}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:""}.fa-thermometer-2:before,.fa-thermometer-half:before{content:""}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:""}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:""}.fa-shower:before{content:""}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:""}.fa-podcast:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-times-rectangle:before,.fa-window-close:before{content:""}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:""}.fa-bandcamp:before{content:""}.fa-grav:before{content:""}.fa-etsy:before{content:""}.fa-imdb:before{content:""}.fa-ravelry:before{content:""}.fa-eercast:before{content:""}.fa-microchip:before{content:""}.fa-snowflake-o:before{content:""}.fa-superpowers:before{content:""}.fa-wpexplorer:before{content:""}.fa-meetup:before{content:""}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{font-family:inherit}.fa:before,.icon:before,.rst-content .admonition-title:before,.rst-content .code-block-caption .headerlink:before,.rst-content .eqno .headerlink:before,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before{font-family:FontAwesome;display:inline-block;font-style:normal;font-weight:400;line-height:1;text-decoration:inherit}.rst-content .code-block-caption a .headerlink,.rst-content .eqno a .headerlink,.rst-content a .admonition-title,.rst-content code.download a span:first-child,.rst-content dl dt a .headerlink,.rst-content h1 a .headerlink,.rst-content h2 a .headerlink,.rst-content h3 a .headerlink,.rst-content h4 a .headerlink,.rst-content h5 a .headerlink,.rst-content h6 a .headerlink,.rst-content p.caption a .headerlink,.rst-content p a .headerlink,.rst-content table>caption a .headerlink,.rst-content tt.download a span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li a button.toctree-expand,a .fa,a .icon,a .rst-content .admonition-title,a .rst-content .code-block-caption .headerlink,a .rst-content .eqno .headerlink,a .rst-content code.download span:first-child,a .rst-content dl dt .headerlink,a .rst-content h1 .headerlink,a .rst-content h2 .headerlink,a .rst-content h3 .headerlink,a .rst-content h4 .headerlink,a .rst-content h5 .headerlink,a .rst-content h6 .headerlink,a .rst-content p.caption .headerlink,a .rst-content p .headerlink,a .rst-content table>caption .headerlink,a .rst-content tt.download span:first-child,a .wy-menu-vertical li button.toctree-expand{display:inline-block;text-decoration:inherit}.btn .fa,.btn .icon,.btn .rst-content .admonition-title,.btn .rst-content .code-block-caption .headerlink,.btn .rst-content .eqno .headerlink,.btn .rst-content code.download span:first-child,.btn .rst-content dl dt .headerlink,.btn .rst-content h1 .headerlink,.btn .rst-content h2 .headerlink,.btn .rst-content h3 .headerlink,.btn .rst-content h4 .headerlink,.btn .rst-content h5 .headerlink,.btn .rst-content h6 .headerlink,.btn .rst-content p .headerlink,.btn .rst-content table>caption .headerlink,.btn .rst-content tt.download span:first-child,.btn .wy-menu-vertical li.current>a button.toctree-expand,.btn .wy-menu-vertical li.on a button.toctree-expand,.btn .wy-menu-vertical li button.toctree-expand,.nav .fa,.nav .icon,.nav .rst-content .admonition-title,.nav .rst-content .code-block-caption .headerlink,.nav .rst-content .eqno .headerlink,.nav .rst-content code.download span:first-child,.nav .rst-content dl dt .headerlink,.nav .rst-content h1 .headerlink,.nav .rst-content h2 .headerlink,.nav .rst-content h3 .headerlink,.nav .rst-content h4 .headerlink,.nav .rst-content h5 .headerlink,.nav .rst-content h6 .headerlink,.nav .rst-content p .headerlink,.nav .rst-content table>caption .headerlink,.nav .rst-content tt.download span:first-child,.nav .wy-menu-vertical li.current>a button.toctree-expand,.nav .wy-menu-vertical li.on a button.toctree-expand,.nav .wy-menu-vertical li button.toctree-expand,.rst-content .btn .admonition-title,.rst-content .code-block-caption .btn .headerlink,.rst-content .code-block-caption .nav .headerlink,.rst-content .eqno .btn .headerlink,.rst-content .eqno .nav .headerlink,.rst-content .nav .admonition-title,.rst-content code.download .btn span:first-child,.rst-content code.download .nav span:first-child,.rst-content dl dt .btn .headerlink,.rst-content dl dt .nav .headerlink,.rst-content h1 .btn .headerlink,.rst-content h1 .nav .headerlink,.rst-content h2 .btn .headerlink,.rst-content h2 .nav .headerlink,.rst-content h3 .btn .headerlink,.rst-content h3 .nav .headerlink,.rst-content h4 .btn .headerlink,.rst-content h4 .nav .headerlink,.rst-content h5 .btn .headerlink,.rst-content h5 .nav .headerlink,.rst-content h6 .btn .headerlink,.rst-content h6 .nav .headerlink,.rst-content p .btn .headerlink,.rst-content p .nav .headerlink,.rst-content table>caption .btn .headerlink,.rst-content table>caption .nav .headerlink,.rst-content tt.download .btn span:first-child,.rst-content tt.download .nav span:first-child,.wy-menu-vertical li .btn button.toctree-expand,.wy-menu-vertical li.current>a .btn button.toctree-expand,.wy-menu-vertical li.current>a .nav button.toctree-expand,.wy-menu-vertical li .nav button.toctree-expand,.wy-menu-vertical li.on a .btn button.toctree-expand,.wy-menu-vertical li.on a .nav button.toctree-expand{display:inline}.btn .fa-large.icon,.btn .fa.fa-large,.btn .rst-content .code-block-caption .fa-large.headerlink,.btn .rst-content .eqno .fa-large.headerlink,.btn .rst-content .fa-large.admonition-title,.btn .rst-content code.download span.fa-large:first-child,.btn .rst-content dl dt .fa-large.headerlink,.btn .rst-content h1 .fa-large.headerlink,.btn .rst-content h2 .fa-large.headerlink,.btn .rst-content h3 .fa-large.headerlink,.btn .rst-content h4 .fa-large.headerlink,.btn .rst-content h5 .fa-large.headerlink,.btn .rst-content h6 .fa-large.headerlink,.btn .rst-content p .fa-large.headerlink,.btn .rst-content table>caption .fa-large.headerlink,.btn .rst-content tt.download span.fa-large:first-child,.btn .wy-menu-vertical li button.fa-large.toctree-expand,.nav .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .code-block-caption .fa-large.headerlink,.nav .rst-content .eqno .fa-large.headerlink,.nav .rst-content .fa-large.admonition-title,.nav .rst-content code.download span.fa-large:first-child,.nav .rst-content dl dt .fa-large.headerlink,.nav .rst-content h1 .fa-large.headerlink,.nav .rst-content h2 .fa-large.headerlink,.nav .rst-content h3 .fa-large.headerlink,.nav .rst-content h4 .fa-large.headerlink,.nav .rst-content h5 .fa-large.headerlink,.nav .rst-content h6 .fa-large.headerlink,.nav .rst-content p .fa-large.headerlink,.nav .rst-content table>caption .fa-large.headerlink,.nav .rst-content tt.download span.fa-large:first-child,.nav .wy-menu-vertical li button.fa-large.toctree-expand,.rst-content .btn .fa-large.admonition-title,.rst-content .code-block-caption .btn .fa-large.headerlink,.rst-content .code-block-caption .nav .fa-large.headerlink,.rst-content .eqno .btn .fa-large.headerlink,.rst-content .eqno .nav .fa-large.headerlink,.rst-content .nav .fa-large.admonition-title,.rst-content code.download .btn span.fa-large:first-child,.rst-content code.download .nav span.fa-large:first-child,.rst-content dl dt .btn .fa-large.headerlink,.rst-content dl dt .nav .fa-large.headerlink,.rst-content h1 .btn .fa-large.headerlink,.rst-content h1 .nav .fa-large.headerlink,.rst-content h2 .btn .fa-large.headerlink,.rst-content h2 .nav .fa-large.headerlink,.rst-content h3 .btn .fa-large.headerlink,.rst-content h3 .nav .fa-large.headerlink,.rst-content h4 .btn .fa-large.headerlink,.rst-content h4 .nav .fa-large.headerlink,.rst-content h5 .btn .fa-large.headerlink,.rst-content h5 .nav .fa-large.headerlink,.rst-content h6 .btn .fa-large.headerlink,.rst-content h6 .nav .fa-large.headerlink,.rst-content p .btn .fa-large.headerlink,.rst-content p .nav .fa-large.headerlink,.rst-content table>caption .btn .fa-large.headerlink,.rst-content table>caption .nav .fa-large.headerlink,.rst-content tt.download .btn span.fa-large:first-child,.rst-content tt.download .nav span.fa-large:first-child,.wy-menu-vertical li .btn button.fa-large.toctree-expand,.wy-menu-vertical li .nav button.fa-large.toctree-expand{line-height:.9em}.btn .fa-spin.icon,.btn .fa.fa-spin,.btn .rst-content .code-block-caption .fa-spin.headerlink,.btn .rst-content .eqno .fa-spin.headerlink,.btn .rst-content .fa-spin.admonition-title,.btn .rst-content code.download span.fa-spin:first-child,.btn .rst-content dl dt .fa-spin.headerlink,.btn .rst-content h1 .fa-spin.headerlink,.btn .rst-content h2 .fa-spin.headerlink,.btn .rst-content h3 .fa-spin.headerlink,.btn .rst-content h4 .fa-spin.headerlink,.btn .rst-content h5 .fa-spin.headerlink,.btn .rst-content h6 .fa-spin.headerlink,.btn .rst-content p .fa-spin.headerlink,.btn .rst-content table>caption .fa-spin.headerlink,.btn .rst-content tt.download span.fa-spin:first-child,.btn .wy-menu-vertical li button.fa-spin.toctree-expand,.nav .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .code-block-caption .fa-spin.headerlink,.nav .rst-content .eqno .fa-spin.headerlink,.nav .rst-content .fa-spin.admonition-title,.nav .rst-content code.download span.fa-spin:first-child,.nav .rst-content dl dt .fa-spin.headerlink,.nav .rst-content h1 .fa-spin.headerlink,.nav .rst-content h2 .fa-spin.headerlink,.nav .rst-content h3 .fa-spin.headerlink,.nav .rst-content h4 .fa-spin.headerlink,.nav .rst-content h5 .fa-spin.headerlink,.nav .rst-content h6 .fa-spin.headerlink,.nav .rst-content p .fa-spin.headerlink,.nav .rst-content table>caption .fa-spin.headerlink,.nav .rst-content tt.download span.fa-spin:first-child,.nav .wy-menu-vertical li button.fa-spin.toctree-expand,.rst-content .btn .fa-spin.admonition-title,.rst-content .code-block-caption .btn .fa-spin.headerlink,.rst-content .code-block-caption .nav .fa-spin.headerlink,.rst-content .eqno .btn .fa-spin.headerlink,.rst-content .eqno .nav .fa-spin.headerlink,.rst-content .nav .fa-spin.admonition-title,.rst-content code.download .btn span.fa-spin:first-child,.rst-content code.download .nav span.fa-spin:first-child,.rst-content dl dt .btn .fa-spin.headerlink,.rst-content dl dt .nav .fa-spin.headerlink,.rst-content h1 .btn .fa-spin.headerlink,.rst-content h1 .nav .fa-spin.headerlink,.rst-content h2 .btn .fa-spin.headerlink,.rst-content h2 .nav .fa-spin.headerlink,.rst-content h3 .btn .fa-spin.headerlink,.rst-content h3 .nav .fa-spin.headerlink,.rst-content h4 .btn .fa-spin.headerlink,.rst-content h4 .nav .fa-spin.headerlink,.rst-content h5 .btn .fa-spin.headerlink,.rst-content h5 .nav .fa-spin.headerlink,.rst-content h6 .btn .fa-spin.headerlink,.rst-content h6 .nav .fa-spin.headerlink,.rst-content p .btn .fa-spin.headerlink,.rst-content p .nav .fa-spin.headerlink,.rst-content table>caption .btn .fa-spin.headerlink,.rst-content table>caption .nav .fa-spin.headerlink,.rst-content tt.download .btn span.fa-spin:first-child,.rst-content tt.download .nav span.fa-spin:first-child,.wy-menu-vertical li .btn button.fa-spin.toctree-expand,.wy-menu-vertical li .nav button.fa-spin.toctree-expand{display:inline-block}.btn.fa:before,.btn.icon:before,.rst-content .btn.admonition-title:before,.rst-content .code-block-caption .btn.headerlink:before,.rst-content .eqno .btn.headerlink:before,.rst-content code.download span.btn:first-child:before,.rst-content dl dt .btn.headerlink:before,.rst-content h1 .btn.headerlink:before,.rst-content h2 .btn.headerlink:before,.rst-content h3 .btn.headerlink:before,.rst-content h4 .btn.headerlink:before,.rst-content h5 .btn.headerlink:before,.rst-content h6 .btn.headerlink:before,.rst-content p .btn.headerlink:before,.rst-content table>caption .btn.headerlink:before,.rst-content tt.download span.btn:first-child:before,.wy-menu-vertical li button.btn.toctree-expand:before{opacity:.5;-webkit-transition:opacity .05s ease-in;-moz-transition:opacity .05s ease-in;transition:opacity .05s ease-in}.btn.fa:hover:before,.btn.icon:hover:before,.rst-content .btn.admonition-title:hover:before,.rst-content .code-block-caption .btn.headerlink:hover:before,.rst-content .eqno .btn.headerlink:hover:before,.rst-content code.download span.btn:first-child:hover:before,.rst-content dl dt .btn.headerlink:hover:before,.rst-content h1 .btn.headerlink:hover:before,.rst-content h2 .btn.headerlink:hover:before,.rst-content h3 .btn.headerlink:hover:before,.rst-content h4 .btn.headerlink:hover:before,.rst-content h5 .btn.headerlink:hover:before,.rst-content h6 .btn.headerlink:hover:before,.rst-content p .btn.headerlink:hover:before,.rst-content table>caption .btn.headerlink:hover:before,.rst-content tt.download span.btn:first-child:hover:before,.wy-menu-vertical li button.btn.toctree-expand:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .icon:before,.btn-mini .rst-content .admonition-title:before,.btn-mini .rst-content .code-block-caption .headerlink:before,.btn-mini .rst-content .eqno .headerlink:before,.btn-mini .rst-content code.download span:first-child:before,.btn-mini .rst-content dl dt .headerlink:before,.btn-mini .rst-content h1 .headerlink:before,.btn-mini .rst-content h2 .headerlink:before,.btn-mini .rst-content h3 .headerlink:before,.btn-mini .rst-content h4 .headerlink:before,.btn-mini .rst-content h5 .headerlink:before,.btn-mini .rst-content h6 .headerlink:before,.btn-mini .rst-content p .headerlink:before,.btn-mini .rst-content table>caption .headerlink:before,.btn-mini .rst-content tt.download span:first-child:before,.btn-mini .wy-menu-vertical li button.toctree-expand:before,.rst-content .btn-mini .admonition-title:before,.rst-content .code-block-caption .btn-mini .headerlink:before,.rst-content .eqno .btn-mini .headerlink:before,.rst-content code.download .btn-mini span:first-child:before,.rst-content dl dt .btn-mini .headerlink:before,.rst-content h1 .btn-mini .headerlink:before,.rst-content h2 .btn-mini .headerlink:before,.rst-content h3 .btn-mini .headerlink:before,.rst-content h4 .btn-mini .headerlink:before,.rst-content h5 .btn-mini .headerlink:before,.rst-content h6 .btn-mini .headerlink:before,.rst-content p .btn-mini .headerlink:before,.rst-content table>caption .btn-mini .headerlink:before,.rst-content tt.download .btn-mini span:first-child:before,.wy-menu-vertical li .btn-mini button.toctree-expand:before{font-size:14px;vertical-align:-15%}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.wy-alert{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.rst-content .admonition-title,.wy-alert-title{font-weight:700;display:block;color:#fff;background:#6ab0de;padding:6px 12px;margin:-12px -12px 12px}.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.admonition,.rst-content .wy-alert-danger.admonition-todo,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.wy-alert.wy-alert-danger{background:#fdf3f2}.rst-content .danger .admonition-title,.rst-content .danger .wy-alert-title,.rst-content .error .admonition-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.rst-content .wy-alert-danger.admonition .admonition-title,.rst-content .wy-alert-danger.admonition .wy-alert-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.wy-alert.wy-alert-danger .wy-alert-title{background:#f29f97}.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .warning,.rst-content .wy-alert-warning.admonition,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.note,.rst-content .wy-alert-warning.seealso,.rst-content .wy-alert-warning.tip,.wy-alert.wy-alert-warning{background:#ffedcc}.rst-content .admonition-todo .admonition-title,.rst-content .admonition-todo .wy-alert-title,.rst-content .attention .admonition-title,.rst-content .attention .wy-alert-title,.rst-content .caution .admonition-title,.rst-content .caution .wy-alert-title,.rst-content .warning .admonition-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.admonition .admonition-title,.rst-content .wy-alert-warning.admonition .wy-alert-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.wy-alert.wy-alert-warning .wy-alert-title{background:#f0b37e}.rst-content .note,.rst-content .seealso,.rst-content .wy-alert-info.admonition,.rst-content .wy-alert-info.admonition-todo,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.wy-alert.wy-alert-info{background:#e7f2fa}.rst-content .note .admonition-title,.rst-content .note .wy-alert-title,.rst-content .seealso .admonition-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .admonition-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.rst-content .wy-alert-info.admonition .admonition-title,.rst-content .wy-alert-info.admonition .wy-alert-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.wy-alert.wy-alert-info .wy-alert-title{background:#6ab0de}.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.admonition,.rst-content .wy-alert-success.admonition-todo,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.warning,.wy-alert.wy-alert-success{background:#dbfaf4}.rst-content .hint .admonition-title,.rst-content .hint .wy-alert-title,.rst-content .important .admonition-title,.rst-content .important .wy-alert-title,.rst-content .tip .admonition-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .admonition-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.rst-content .wy-alert-success.admonition .admonition-title,.rst-content .wy-alert-success.admonition .wy-alert-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.wy-alert.wy-alert-success .wy-alert-title{background:#1abc9c}.rst-content .wy-alert-neutral.admonition,.rst-content .wy-alert-neutral.admonition-todo,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.wy-alert.wy-alert-neutral{background:#f3f6f6}.rst-content .wy-alert-neutral.admonition-todo .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.rst-content .wy-alert-neutral.admonition .admonition-title,.rst-content .wy-alert-neutral.admonition .wy-alert-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.wy-alert.wy-alert-neutral .wy-alert-title{color:#404040;background:#e1e4e5}.rst-content .wy-alert-neutral.admonition-todo a,.rst-content .wy-alert-neutral.admonition a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.wy-alert.wy-alert-neutral a{color:#2980b9}.rst-content .admonition-todo p:last-child,.rst-content .admonition p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .note p:last-child,.rst-content .seealso p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.wy-alert p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all .3s ease-in;-moz-transition:all .3s ease-in;transition:all .3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27ae60}.wy-tray-container li.wy-tray-item-info{background:#2980b9}.wy-tray-container li.wy-tray-item-warning{background:#e67e22}.wy-tray-container li.wy-tray-item-danger{background:#e74c3c}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width:768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px;color:#fff;border:1px solid rgba(0,0,0,.1);background-color:#27ae60;text-decoration:none;font-weight:400;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 2px -1px hsla(0,0%,100%,.5),inset 0 -2px 0 0 rgba(0,0,0,.1);outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all .1s linear;-moz-transition:all .1s linear;transition:all .1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:inset 0 -1px 0 0 rgba(0,0,0,.05),inset 0 2px 0 0 rgba(0,0,0,.1);padding:8px 12px 6px}.btn:visited{color:#fff}.btn-disabled,.btn-disabled:active,.btn-disabled:focus,.btn-disabled:hover,.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980b9!important}.btn-info:hover{background-color:#2e8ece!important}.btn-neutral{background-color:#f3f6f6!important;color:#404040!important}.btn-neutral:hover{background-color:#e5ebeb!important;color:#404040}.btn-neutral:visited{color:#404040!important}.btn-success{background-color:#27ae60!important}.btn-success:hover{background-color:#295!important}.btn-danger{background-color:#e74c3c!important}.btn-danger:hover{background-color:#ea6153!important}.btn-warning{background-color:#e67e22!important}.btn-warning:hover{background-color:#e98b39!important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f!important}.btn-link{background-color:transparent!important;color:#2980b9;box-shadow:none;border-color:transparent!important}.btn-link:active,.btn-link:hover{background-color:transparent!important;color:#409ad5!important;box-shadow:none}.btn-link:visited{color:#9b59b6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:after,.wy-btn-group:before{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:1px solid #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980b9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:1px solid #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type=search]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980b9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned .wy-help-inline,.wy-form-aligned input,.wy-form-aligned label,.wy-form-aligned select,.wy-form-aligned textarea{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{margin:0}fieldset,legend{border:0;padding:0}legend{width:100%;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label,legend{display:block}label{margin:0 0 .3125em;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;max-width:1200px;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:after,.wy-control-group:before{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#e74c3c}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full input[type=color],.wy-control-group .wy-form-full input[type=date],.wy-control-group .wy-form-full input[type=datetime-local],.wy-control-group .wy-form-full input[type=datetime],.wy-control-group .wy-form-full input[type=email],.wy-control-group .wy-form-full input[type=month],.wy-control-group .wy-form-full input[type=number],.wy-control-group .wy-form-full input[type=password],.wy-control-group .wy-form-full input[type=search],.wy-control-group .wy-form-full input[type=tel],.wy-control-group .wy-form-full input[type=text],.wy-control-group .wy-form-full input[type=time],.wy-control-group .wy-form-full input[type=url],.wy-control-group .wy-form-full input[type=week],.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves input[type=color],.wy-control-group .wy-form-halves input[type=date],.wy-control-group .wy-form-halves input[type=datetime-local],.wy-control-group .wy-form-halves input[type=datetime],.wy-control-group .wy-form-halves input[type=email],.wy-control-group .wy-form-halves input[type=month],.wy-control-group .wy-form-halves input[type=number],.wy-control-group .wy-form-halves input[type=password],.wy-control-group .wy-form-halves input[type=search],.wy-control-group .wy-form-halves input[type=tel],.wy-control-group .wy-form-halves input[type=text],.wy-control-group .wy-form-halves input[type=time],.wy-control-group .wy-form-halves input[type=url],.wy-control-group .wy-form-halves input[type=week],.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds input[type=color],.wy-control-group .wy-form-thirds input[type=date],.wy-control-group .wy-form-thirds input[type=datetime-local],.wy-control-group .wy-form-thirds input[type=datetime],.wy-control-group .wy-form-thirds input[type=email],.wy-control-group .wy-form-thirds input[type=month],.wy-control-group .wy-form-thirds input[type=number],.wy-control-group .wy-form-thirds input[type=password],.wy-control-group .wy-form-thirds input[type=search],.wy-control-group .wy-form-thirds input[type=tel],.wy-control-group .wy-form-thirds input[type=text],.wy-control-group .wy-form-thirds input[type=time],.wy-control-group .wy-form-thirds input[type=url],.wy-control-group .wy-form-thirds input[type=week],.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full{float:left;display:block;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child,.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(odd){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child,.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control,.wy-control-no-input{margin:6px 0 0;font-size:90%}.wy-control-no-input{display:inline-block}.wy-control-group.fluid-input input[type=color],.wy-control-group.fluid-input input[type=date],.wy-control-group.fluid-input input[type=datetime-local],.wy-control-group.fluid-input input[type=datetime],.wy-control-group.fluid-input input[type=email],.wy-control-group.fluid-input input[type=month],.wy-control-group.fluid-input input[type=number],.wy-control-group.fluid-input input[type=password],.wy-control-group.fluid-input input[type=search],.wy-control-group.fluid-input input[type=tel],.wy-control-group.fluid-input input[type=text],.wy-control-group.fluid-input input[type=time],.wy-control-group.fluid-input input[type=url],.wy-control-group.fluid-input input[type=week]{width:100%}.wy-form-message-inline{padding-left:.3em;color:#666;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;*overflow:visible}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}input[type=datetime-local]{padding:.34375em .625em}input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{padding:0;margin-right:.3125em;*height:13px;*width:13px}input[type=checkbox],input[type=radio],input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus,input[type=week]:focus{outline:0;outline:thin dotted\9;border-color:#333}input.no-focus:focus{border-color:#ccc!important}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:thin dotted #333;outline:1px auto #129fea}input[type=color][disabled],input[type=date][disabled],input[type=datetime-local][disabled],input[type=datetime][disabled],input[type=email][disabled],input[type=month][disabled],input[type=number][disabled],input[type=password][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=text][disabled],input[type=time][disabled],input[type=url][disabled],input[type=week][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,select:focus:invalid,textarea:focus:invalid{color:#e74c3c;border:1px solid #e74c3c}input:focus:invalid:focus,select:focus:invalid:focus,textarea:focus:invalid:focus{border-color:#e74c3c}input[type=checkbox]:focus:invalid:focus,input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus{outline-color:#e74c3c}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif}select,textarea{padding:.5em .625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}input[readonly],select[disabled],select[readonly],textarea[disabled],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type=checkbox][disabled],input[type=radio][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:1px solid #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{position:relative;display:block;height:24px;margin-top:12px;cursor:pointer}.wy-switch:before{left:0;top:0;width:36px;height:12px;background:#ccc}.wy-switch:after,.wy-switch:before{position:absolute;content:"";display:block;border-radius:4px;-webkit-transition:all .2s ease-in-out;-moz-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.wy-switch:after{width:18px;height:18px;background:#999;left:-3px;top:-3px}.wy-switch span{position:absolute;left:48px;display:block;font-size:12px;color:#ccc;line-height:1}.wy-switch.active:before{background:#1e8449}.wy-switch.active:after{left:24px;background:#27ae60}.wy-switch.disabled{cursor:not-allowed;opacity:.8}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#e74c3c}.wy-control-group.wy-control-group-error input[type=color],.wy-control-group.wy-control-group-error input[type=date],.wy-control-group.wy-control-group-error input[type=datetime-local],.wy-control-group.wy-control-group-error input[type=datetime],.wy-control-group.wy-control-group-error input[type=email],.wy-control-group.wy-control-group-error input[type=month],.wy-control-group.wy-control-group-error input[type=number],.wy-control-group.wy-control-group-error input[type=password],.wy-control-group.wy-control-group-error input[type=search],.wy-control-group.wy-control-group-error input[type=tel],.wy-control-group.wy-control-group-error input[type=text],.wy-control-group.wy-control-group-error input[type=time],.wy-control-group.wy-control-group-error input[type=url],.wy-control-group.wy-control-group-error input[type=week],.wy-control-group.wy-control-group-error textarea{border:1px solid #e74c3c}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:.5em .625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27ae60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#e74c3c}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#e67e22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980b9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width:480px){.wy-form button[type=submit]{margin:.7em 0 0}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=text],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week],.wy-form label{margin-bottom:.3em;display:block}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0}.wy-form-message,.wy-form-message-inline,.wy-form .wy-help-inline{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width:768px){.tablet-hide{display:none}}@media screen and (max-width:480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.rst-content table.docutils,.rst-content table.field-list,.wy-table{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.rst-content table.docutils caption,.rst-content table.field-list caption,.wy-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.rst-content table.docutils td,.rst-content table.docutils th,.rst-content table.field-list td,.rst-content table.field-list th,.wy-table td,.wy-table th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.rst-content table.docutils td:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list td:first-child,.rst-content table.field-list th:first-child,.wy-table td:first-child,.wy-table th:first-child{border-left-width:0}.rst-content table.docutils thead,.rst-content table.field-list thead,.wy-table thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.rst-content table.docutils thead th,.rst-content table.field-list thead th,.wy-table thead th{font-weight:700;border-bottom:2px solid #e1e4e5}.rst-content table.docutils td,.rst-content table.field-list td,.wy-table td{background-color:transparent;vertical-align:middle}.rst-content table.docutils td p,.rst-content table.field-list td p,.wy-table td p{line-height:18px}.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child,.wy-table td p:last-child{margin-bottom:0}.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min,.wy-table .wy-table-cell-min{width:1%;padding-right:0}.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:grey;font-size:90%}.wy-table-tertiary{color:grey;font-size:80%}.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td,.wy-table-backed,.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td{background-color:#f3f6f6}.rst-content table.docutils,.wy-table-bordered-all{border:1px solid #e1e4e5}.rst-content table.docutils td,.wy-table-bordered-all td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.rst-content table.docutils tbody>tr:last-child td,.wy-table-bordered-all tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0!important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980b9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9b59b6}html{height:100%}body,html{overflow-x:hidden}body{font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-weight:400;color:#404040;min-height:100%;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#e67e22!important}a.wy-text-warning:hover{color:#eb9950!important}.wy-text-info{color:#2980b9!important}a.wy-text-info:hover{color:#409ad5!important}.wy-text-success{color:#27ae60!important}a.wy-text-success:hover{color:#36d278!important}.wy-text-danger{color:#e74c3c!important}a.wy-text-danger:hover{color:#ed7669!important}.wy-text-neutral{color:#404040!important}a.wy-text-neutral:hover{color:#595959!important}.rst-content .toctree-wrapper>p.caption,h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif}p{line-height:24px;font-size:16px;margin:0 0 24px}h1{font-size:175%}.rst-content .toctree-wrapper>p.caption,h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}.rst-content code,.rst-content tt,code{white-space:nowrap;max-width:100%;background:#fff;border:1px solid #e1e4e5;font-size:75%;padding:0 5px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#e74c3c;overflow-x:auto}.rst-content tt.code-large,code.code-large{font-size:90%}.rst-content .section ul,.rst-content .toctree-wrapper ul,.rst-content section ul,.wy-plain-list-disc,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.rst-content .section ul li,.rst-content .toctree-wrapper ul li,.rst-content section ul li,.wy-plain-list-disc li,article ul li{list-style:disc;margin-left:24px}.rst-content .section ul li p:last-child,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li p:last-child,.rst-content .toctree-wrapper ul li ul,.rst-content section ul li p:last-child,.rst-content section ul li ul,.wy-plain-list-disc li p:last-child,.wy-plain-list-disc li ul,article ul li p:last-child,article ul li ul{margin-bottom:0}.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,.rst-content section ul li li,.wy-plain-list-disc li li,article ul li li{list-style:circle}.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,.rst-content section ul li li li,.wy-plain-list-disc li li li,article ul li li li{list-style:square}.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,.rst-content section ul li ol li,.wy-plain-list-disc li ol li,article ul li ol li{list-style:decimal}.rst-content .section ol,.rst-content .section ol.arabic,.rst-content .toctree-wrapper ol,.rst-content .toctree-wrapper ol.arabic,.rst-content section ol,.rst-content section ol.arabic,.wy-plain-list-decimal,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.rst-content .section ol.arabic li,.rst-content .section ol li,.rst-content .toctree-wrapper ol.arabic li,.rst-content .toctree-wrapper ol li,.rst-content section ol.arabic li,.rst-content section ol li,.wy-plain-list-decimal li,article ol li{list-style:decimal;margin-left:24px}.rst-content .section ol.arabic li ul,.rst-content .section ol li p:last-child,.rst-content .section ol li ul,.rst-content .toctree-wrapper ol.arabic li ul,.rst-content .toctree-wrapper ol li p:last-child,.rst-content .toctree-wrapper ol li ul,.rst-content section ol.arabic li ul,.rst-content section ol li p:last-child,.rst-content section ol li ul,.wy-plain-list-decimal li p:last-child,.wy-plain-list-decimal li ul,article ol li p:last-child,article ol li ul{margin-bottom:0}.rst-content .section ol.arabic li ul li,.rst-content .section ol li ul li,.rst-content .toctree-wrapper ol.arabic li ul li,.rst-content .toctree-wrapper ol li ul li,.rst-content section ol.arabic li ul li,.rst-content section ol li ul li,.wy-plain-list-decimal li ul li,article ol li ul li{list-style:disc}.wy-breadcrumbs{*zoom:1}.wy-breadcrumbs:after,.wy-breadcrumbs:before{display:table;content:""}.wy-breadcrumbs:after{clear:both}.wy-breadcrumbs>li{display:inline-block;padding-top:5px}.wy-breadcrumbs>li.wy-breadcrumbs-aside{float:right}.rst-content .wy-breadcrumbs>li code,.rst-content .wy-breadcrumbs>li tt,.wy-breadcrumbs>li .rst-content tt,.wy-breadcrumbs>li code{all:inherit;color:inherit}.breadcrumb-item:before{content:"/";color:#bbb;font-size:13px;padding:0 6px 0 3px}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width:480px){.wy-breadcrumbs-extra,.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}html{font-size:16px}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:after,.wy-menu-horiz:before{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz li,.wy-menu-horiz ul{display:inline-block}.wy-menu-horiz li:hover{background:hsla(0,0%,100%,.1)}.wy-menu-horiz li.divide-left{border-left:1px solid #404040}.wy-menu-horiz li.divide-right{border-right:1px solid #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical{width:300px}.wy-menu-vertical header,.wy-menu-vertical p.caption{color:#55a5d9;height:32px;line-height:32px;padding:0 1.618em;margin:12px 0 0;display:block;font-weight:700;text-transform:uppercase;font-size:85%;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:1px solid #404040}.wy-menu-vertical li.divide-bottom{border-bottom:1px solid #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:grey;border-right:1px solid #c9c9c9;padding:.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.rst-content .wy-menu-vertical li tt,.wy-menu-vertical li .rst-content tt,.wy-menu-vertical li code{border:none;background:inherit;color:inherit;padding-left:0;padding-right:0}.wy-menu-vertical li button.toctree-expand{display:block;float:left;margin-left:-1.2em;line-height:18px;color:#4d4d4d;border:none;background:none;padding:0}.wy-menu-vertical li.current>a,.wy-menu-vertical li.on a{color:#404040;font-weight:700;position:relative;background:#fcfcfc;border:none;padding:.4045em 1.618em}.wy-menu-vertical li.current>a:hover,.wy-menu-vertical li.on a:hover{background:#fcfcfc}.wy-menu-vertical li.current>a:hover button.toctree-expand,.wy-menu-vertical li.on a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand{display:block;line-height:18px;color:#333}.wy-menu-vertical li.toctree-l1.current>a{border-bottom:1px solid #c9c9c9;border-top:1px solid #c9c9c9}.wy-menu-vertical .toctree-l1.current .toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .toctree-l11>ul{display:none}.wy-menu-vertical .toctree-l1.current .current.toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .current.toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .current.toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .current.toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .current.toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .current.toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .current.toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .current.toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .current.toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .current.toctree-l11>ul{display:block}.wy-menu-vertical li.toctree-l3,.wy-menu-vertical li.toctree-l4{font-size:.9em}.wy-menu-vertical li.toctree-l2 a,.wy-menu-vertical li.toctree-l3 a,.wy-menu-vertical li.toctree-l4 a,.wy-menu-vertical li.toctree-l5 a,.wy-menu-vertical li.toctree-l6 a,.wy-menu-vertical li.toctree-l7 a,.wy-menu-vertical li.toctree-l8 a,.wy-menu-vertical li.toctree-l9 a,.wy-menu-vertical li.toctree-l10 a{color:#404040}.wy-menu-vertical li.toctree-l2 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l3 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l4 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l5 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l6 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l7 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l8 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l9 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l10 a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a,.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a,.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a,.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a,.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a,.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a,.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a,.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{display:block}.wy-menu-vertical li.toctree-l2.current>a{padding:.4045em 2.427em}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{padding:.4045em 1.618em .4045em 4.045em}.wy-menu-vertical li.toctree-l3.current>a{padding:.4045em 4.045em}.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{padding:.4045em 1.618em .4045em 5.663em}.wy-menu-vertical li.toctree-l4.current>a{padding:.4045em 5.663em}.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a{padding:.4045em 1.618em .4045em 7.281em}.wy-menu-vertical li.toctree-l5.current>a{padding:.4045em 7.281em}.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a{padding:.4045em 1.618em .4045em 8.899em}.wy-menu-vertical li.toctree-l6.current>a{padding:.4045em 8.899em}.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a{padding:.4045em 1.618em .4045em 10.517em}.wy-menu-vertical li.toctree-l7.current>a{padding:.4045em 10.517em}.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a{padding:.4045em 1.618em .4045em 12.135em}.wy-menu-vertical li.toctree-l8.current>a{padding:.4045em 12.135em}.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a{padding:.4045em 1.618em .4045em 13.753em}.wy-menu-vertical li.toctree-l9.current>a{padding:.4045em 13.753em}.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a{padding:.4045em 1.618em .4045em 15.371em}.wy-menu-vertical li.toctree-l10.current>a{padding:.4045em 15.371em}.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{padding:.4045em 1.618em .4045em 16.989em}.wy-menu-vertical li.toctree-l2.current>a,.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{background:#c9c9c9}.wy-menu-vertical li.toctree-l2 button.toctree-expand{color:#a3a3a3}.wy-menu-vertical li.toctree-l3.current>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{background:#bdbdbd}.wy-menu-vertical li.toctree-l3 button.toctree-expand{color:#969696}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical li ul li a{margin-bottom:0;color:#d9d9d9;font-weight:400}.wy-menu-vertical a{line-height:18px;padding:.4045em 1.618em;display:block;position:relative;font-size:90%;color:#d9d9d9}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:hover button.toctree-expand{color:#d9d9d9}.wy-menu-vertical a:active{background-color:#2980b9;cursor:pointer;color:#fff}.wy-menu-vertical a:active button.toctree-expand{color:#fff}.wy-side-nav-search{display:block;width:300px;padding:.809em;margin-bottom:.809em;z-index:200;background-color:#2980b9;text-align:center;color:#fcfcfc}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto .809em;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-side-nav-search .wy-dropdown>a,.wy-side-nav-search>a{color:#fcfcfc;font-size:100%;font-weight:700;display:inline-block;padding:4px 6px;margin-bottom:.809em;max-width:100%}.wy-side-nav-search .wy-dropdown>a:hover,.wy-side-nav-search .wy-dropdown>aactive,.wy-side-nav-search .wy-dropdown>afocus,.wy-side-nav-search>a:hover,.wy-side-nav-search>aactive,.wy-side-nav-search>afocus{background:hsla(0,0%,100%,.1)}.wy-side-nav-search .wy-dropdown>a img.logo,.wy-side-nav-search>a img.logo{display:block;margin:0 auto;height:auto;width:auto;border-radius:0;max-width:100%;background:transparent}.wy-side-nav-search .wy-dropdown>a.icon,.wy-side-nav-search>a.icon{display:block}.wy-side-nav-search .wy-dropdown>a.icon img.logo,.wy-side-nav-search>a.icon img.logo{margin-top:.85em}.wy-side-nav-search>div.switch-menus{position:relative;display:block;margin-top:-.4045em;margin-bottom:.809em;font-weight:400;color:hsla(0,0%,100%,.3)}.wy-side-nav-search>div.switch-menus>div.language-switch,.wy-side-nav-search>div.switch-menus>div.version-switch{display:inline-block;padding:.2em}.wy-side-nav-search>div.switch-menus>div.language-switch select,.wy-side-nav-search>div.switch-menus>div.version-switch select{display:inline-block;margin-right:-2rem;padding-right:2rem;max-width:240px;text-align-last:center;background:none;border:none;border-radius:0;box-shadow:none;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-size:1em;font-weight:400;color:hsla(0,0%,100%,.3);cursor:pointer;appearance:none;-webkit-appearance:none;-moz-appearance:none}.wy-side-nav-search>div.switch-menus>div.language-switch select:active,.wy-side-nav-search>div.switch-menus>div.language-switch select:focus,.wy-side-nav-search>div.switch-menus>div.language-switch select:hover,.wy-side-nav-search>div.switch-menus>div.version-switch select:active,.wy-side-nav-search>div.switch-menus>div.version-switch select:focus,.wy-side-nav-search>div.switch-menus>div.version-switch select:hover{background:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.wy-side-nav-search>div.switch-menus>div.language-switch select option,.wy-side-nav-search>div.switch-menus>div.version-switch select option{color:#000}.wy-side-nav-search>div.switch-menus>div.language-switch:has(>select):after,.wy-side-nav-search>div.switch-menus>div.version-switch:has(>select):after{display:inline-block;width:1.5em;height:100%;padding:.1em;content:"\f0d7";font-size:1em;line-height:1.2em;font-family:FontAwesome;text-align:center;pointer-events:none;box-sizing:border-box}.wy-nav .wy-menu-vertical header{color:#2980b9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980b9;color:#fff}[data-menu-wrap]{-webkit-transition:all .2s ease-in;-moz-transition:all .2s ease-in;transition:all .2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:#fcfcfc}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:fixed;top:0;bottom:0;left:0;padding-bottom:2em;width:300px;overflow-x:hidden;overflow-y:hidden;min-height:100%;color:#9b9b9b;background:#343131;z-index:200}.wy-side-scroll{width:320px;position:relative;overflow-x:hidden;overflow-y:scroll;height:100%}.wy-nav-top{display:none;background:#2980b9;color:#fff;padding:.4045em .809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:after,.wy-nav-top:before{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:700}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer;padding-top:inherit}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:grey}footer p{margin-bottom:12px}.rst-content footer span.commit tt,footer span.commit .rst-content tt,footer span.commit code{padding:0;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:1em;background:none;border:none;color:grey}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:after,.rst-footer-buttons:before{width:100%;display:table;content:""}.rst-footer-buttons:after{clear:both}.rst-breadcrumbs-buttons{margin-top:12px;*zoom:1}.rst-breadcrumbs-buttons:after,.rst-breadcrumbs-buttons:before{display:table;content:""}.rst-breadcrumbs-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:1px solid #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:1px solid #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:grey;font-size:90%}.genindextable li>ul{margin-left:24px}@media screen and (max-width:768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-menu.wy-menu-vertical,.wy-side-nav-search,.wy-side-scroll{width:auto}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width:1100px){.wy-nav-content-wrap{background:rgba(0,0,0,.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,.wy-nav-side,footer{display:none}.wy-nav-content-wrap{margin-left:0}}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:after,.rst-versions .rst-current-version:before{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-content .code-block-caption .rst-versions .rst-current-version .headerlink,.rst-content .eqno .rst-versions .rst-current-version .headerlink,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-content code.download .rst-versions .rst-current-version span:first-child,.rst-content dl dt .rst-versions .rst-current-version .headerlink,.rst-content h1 .rst-versions .rst-current-version .headerlink,.rst-content h2 .rst-versions .rst-current-version .headerlink,.rst-content h3 .rst-versions .rst-current-version .headerlink,.rst-content h4 .rst-versions .rst-current-version .headerlink,.rst-content h5 .rst-versions .rst-current-version .headerlink,.rst-content h6 .rst-versions .rst-current-version .headerlink,.rst-content p .rst-versions .rst-current-version .headerlink,.rst-content table>caption .rst-versions .rst-current-version .headerlink,.rst-content tt.download .rst-versions .rst-current-version span:first-child,.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .icon,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-versions .rst-current-version .rst-content .code-block-caption .headerlink,.rst-versions .rst-current-version .rst-content .eqno .headerlink,.rst-versions .rst-current-version .rst-content code.download span:first-child,.rst-versions .rst-current-version .rst-content dl dt .headerlink,.rst-versions .rst-current-version .rst-content h1 .headerlink,.rst-versions .rst-current-version .rst-content h2 .headerlink,.rst-versions .rst-current-version .rst-content h3 .headerlink,.rst-versions .rst-current-version .rst-content h4 .headerlink,.rst-versions .rst-current-version .rst-content h5 .headerlink,.rst-versions .rst-current-version .rst-content h6 .headerlink,.rst-versions .rst-current-version .rst-content p .headerlink,.rst-versions .rst-current-version .rst-content table>caption .headerlink,.rst-versions .rst-current-version .rst-content tt.download span:first-child,.rst-versions .rst-current-version .wy-menu-vertical li button.toctree-expand,.wy-menu-vertical li .rst-versions .rst-current-version button.toctree-expand{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions .rst-other-versions .rtd-current-item{font-weight:700}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}#flyout-search-form{padding:6px}.rst-content .toctree-wrapper>p.caption,.rst-content h1,.rst-content h2,.rst-content h3,.rst-content h4,.rst-content h5,.rst-content h6{margin-bottom:24px}.rst-content img{max-width:100%;height:auto}.rst-content div.figure,.rst-content figure{margin-bottom:24px}.rst-content div.figure .caption-text,.rst-content figure .caption-text{font-style:italic}.rst-content div.figure p:last-child.caption,.rst-content figure p:last-child.caption{margin-bottom:0}.rst-content div.figure.align-center,.rst-content figure.align-center{text-align:center}.rst-content .section>a>img,.rst-content .section>img,.rst-content section>a>img,.rst-content section>img{margin-bottom:24px}.rst-content abbr[title]{text-decoration:none}.rst-content.style-external-links a.reference.external:after{font-family:FontAwesome;content:"\f08e";color:#b3b3b3;vertical-align:super;font-size:60%;margin:0 .2em}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content pre.literal-block{white-space:pre;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;display:block;overflow:auto}.rst-content div[class^=highlight],.rst-content pre.literal-block{border:1px solid #e1e4e5;overflow-x:auto;margin:1px 0 24px}.rst-content div[class^=highlight] div[class^=highlight],.rst-content pre.literal-block div[class^=highlight]{padding:0;border:none;margin:0}.rst-content div[class^=highlight] td.code{width:100%}.rst-content .linenodiv pre{border-right:1px solid #e6e9ea;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;user-select:none;pointer-events:none}.rst-content div[class^=highlight] pre{white-space:pre;margin:0;padding:12px;display:block;overflow:auto}.rst-content div[class^=highlight] pre .hll{display:block;margin:0 -12px;padding:0 12px}.rst-content .linenodiv pre,.rst-content div[class^=highlight] pre,.rst-content pre.literal-block{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:12px;line-height:1.4}.rst-content div.highlight .gp,.rst-content div.highlight span.linenos{user-select:none;pointer-events:none}.rst-content div.highlight span.linenos{display:inline-block;padding-left:0;padding-right:12px;margin-right:12px;border-right:1px solid #e6e9ea}.rst-content .code-block-caption{font-style:italic;font-size:85%;line-height:1;padding:1em 0;text-align:center}@media print{.rst-content .codeblock,.rst-content div[class^=highlight],.rst-content div[class^=highlight] pre{white-space:pre-wrap}}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning{clear:both}.rst-content .admonition-todo .last,.rst-content .admonition-todo>:last-child,.rst-content .admonition .last,.rst-content .admonition>:last-child,.rst-content .attention .last,.rst-content .attention>:last-child,.rst-content .caution .last,.rst-content .caution>:last-child,.rst-content .danger .last,.rst-content .danger>:last-child,.rst-content .error .last,.rst-content .error>:last-child,.rst-content .hint .last,.rst-content .hint>:last-child,.rst-content .important .last,.rst-content .important>:last-child,.rst-content .note .last,.rst-content .note>:last-child,.rst-content .seealso .last,.rst-content .seealso>:last-child,.rst-content .tip .last,.rst-content .tip>:last-child,.rst-content .warning .last,.rst-content .warning>:last-child{margin-bottom:0}.rst-content .admonition-title:before{margin-right:4px}.rst-content .admonition table{border-color:rgba(0,0,0,.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent!important;border-color:rgba(0,0,0,.1)!important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha>li,.rst-content .toctree-wrapper ol.loweralpha,.rst-content .toctree-wrapper ol.loweralpha>li,.rst-content section ol.loweralpha,.rst-content section ol.loweralpha>li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha>li,.rst-content .toctree-wrapper ol.upperalpha,.rst-content .toctree-wrapper ol.upperalpha>li,.rst-content section ol.upperalpha,.rst-content section ol.upperalpha>li{list-style:upper-alpha}.rst-content .section ol li>*,.rst-content .section ul li>*,.rst-content .toctree-wrapper ol li>*,.rst-content .toctree-wrapper ul li>*,.rst-content section ol li>*,.rst-content section ul li>*{margin-top:12px;margin-bottom:12px}.rst-content .section ol li>:first-child,.rst-content .section ul li>:first-child,.rst-content .toctree-wrapper ol li>:first-child,.rst-content .toctree-wrapper ul li>:first-child,.rst-content section ol li>:first-child,.rst-content section ul li>:first-child{margin-top:0}.rst-content .section ol li>p,.rst-content .section ol li>p:last-child,.rst-content .section ul li>p,.rst-content .section ul li>p:last-child,.rst-content .toctree-wrapper ol li>p,.rst-content .toctree-wrapper ol li>p:last-child,.rst-content .toctree-wrapper ul li>p,.rst-content .toctree-wrapper ul li>p:last-child,.rst-content section ol li>p,.rst-content section ol li>p:last-child,.rst-content section ul li>p,.rst-content section ul li>p:last-child{margin-bottom:12px}.rst-content .section ol li>p:only-child,.rst-content .section ol li>p:only-child:last-child,.rst-content .section ul li>p:only-child,.rst-content .section ul li>p:only-child:last-child,.rst-content .toctree-wrapper ol li>p:only-child,.rst-content .toctree-wrapper ol li>p:only-child:last-child,.rst-content .toctree-wrapper ul li>p:only-child,.rst-content .toctree-wrapper ul li>p:only-child:last-child,.rst-content section ol li>p:only-child,.rst-content section ol li>p:only-child:last-child,.rst-content section ul li>p:only-child,.rst-content section ul li>p:only-child:last-child{margin-bottom:0}.rst-content .section ol li>ol,.rst-content .section ol li>ul,.rst-content .section ul li>ol,.rst-content .section ul li>ul,.rst-content .toctree-wrapper ol li>ol,.rst-content .toctree-wrapper ol li>ul,.rst-content .toctree-wrapper ul li>ol,.rst-content .toctree-wrapper ul li>ul,.rst-content section ol li>ol,.rst-content section ol li>ul,.rst-content section ul li>ol,.rst-content section ul li>ul{margin-bottom:12px}.rst-content .section ol.simple li>*,.rst-content .section ol.simple li ol,.rst-content .section ol.simple li ul,.rst-content .section ul.simple li>*,.rst-content .section ul.simple li ol,.rst-content .section ul.simple li ul,.rst-content .toctree-wrapper ol.simple li>*,.rst-content .toctree-wrapper ol.simple li ol,.rst-content .toctree-wrapper ol.simple li ul,.rst-content .toctree-wrapper ul.simple li>*,.rst-content .toctree-wrapper ul.simple li ol,.rst-content .toctree-wrapper ul.simple li ul,.rst-content section ol.simple li>*,.rst-content section ol.simple li ol,.rst-content section ol.simple li ul,.rst-content section ul.simple li>*,.rst-content section ul.simple li ol,.rst-content section ul.simple li ul{margin-top:0;margin-bottom:0}.rst-content .line-block{margin-left:0;margin-bottom:24px;line-height:24px}.rst-content .line-block .line-block{margin-left:24px;margin-bottom:0}.rst-content .topic-title{font-weight:700;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0 0 24px 24px}.rst-content .align-left{float:left;margin:0 24px 24px 0}.rst-content .align-center{margin:auto}.rst-content .align-center:not(table){display:block}.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink{opacity:0;font-size:14px;font-family:FontAwesome;margin-left:.5em}.rst-content .code-block-caption .headerlink:focus,.rst-content .code-block-caption:hover .headerlink,.rst-content .eqno .headerlink:focus,.rst-content .eqno:hover .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink:focus,.rst-content .toctree-wrapper>p.caption:hover .headerlink,.rst-content dl dt .headerlink:focus,.rst-content dl dt:hover .headerlink,.rst-content h1 .headerlink:focus,.rst-content h1:hover .headerlink,.rst-content h2 .headerlink:focus,.rst-content h2:hover .headerlink,.rst-content h3 .headerlink:focus,.rst-content h3:hover .headerlink,.rst-content h4 .headerlink:focus,.rst-content h4:hover .headerlink,.rst-content h5 .headerlink:focus,.rst-content h5:hover .headerlink,.rst-content h6 .headerlink:focus,.rst-content h6:hover .headerlink,.rst-content p.caption .headerlink:focus,.rst-content p.caption:hover .headerlink,.rst-content p .headerlink:focus,.rst-content p:hover .headerlink,.rst-content table>caption .headerlink:focus,.rst-content table>caption:hover .headerlink{opacity:1}.rst-content p a{overflow-wrap:anywhere}.rst-content .wy-table td p,.rst-content .wy-table td ul,.rst-content .wy-table th p,.rst-content .wy-table th ul,.rst-content table.docutils td p,.rst-content table.docutils td ul,.rst-content table.docutils th p,.rst-content table.docutils th ul,.rst-content table.field-list td p,.rst-content table.field-list td ul,.rst-content table.field-list th p,.rst-content table.field-list th ul{font-size:inherit}.rst-content .btn:focus{outline:2px solid}.rst-content table>caption .headerlink:after{font-size:12px}.rst-content .centered{text-align:center}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:1px solid #e1e4e5}.rst-content .sidebar dl,.rst-content .sidebar p,.rst-content .sidebar ul{font-size:90%}.rst-content .sidebar .last,.rst-content .sidebar>:last-child{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif;font-weight:700;background:#e1e4e5;padding:6px 12px;margin:-24px -24px 24px;font-size:100%}.rst-content .highlighted{background:#f1c40f;box-shadow:0 0 0 2px #f1c40f;display:inline;font-weight:700}.rst-content .citation-reference,.rst-content .footnote-reference{vertical-align:baseline;position:relative;top:-.4em;line-height:0;font-size:90%}.rst-content .citation-reference>span.fn-bracket,.rst-content .footnote-reference>span.fn-bracket{display:none}.rst-content .hlist{width:100%}.rst-content dl dt span.classifier:before{content:" : "}.rst-content dl dt span.classifier-delimiter{display:none!important}html.writer-html4 .rst-content table.docutils.citation,html.writer-html4 .rst-content table.docutils.footnote{background:none;border:none}html.writer-html4 .rst-content table.docutils.citation td,html.writer-html4 .rst-content table.docutils.citation tr,html.writer-html4 .rst-content table.docutils.footnote td,html.writer-html4 .rst-content table.docutils.footnote tr{border:none;background-color:transparent!important;white-space:normal}html.writer-html4 .rst-content table.docutils.citation td.label,html.writer-html4 .rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{display:grid;grid-template-columns:auto minmax(80%,95%)}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{display:inline-grid;grid-template-columns:max-content auto}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{display:grid;grid-template-columns:auto auto minmax(.65rem,auto) minmax(40%,95%)}html.writer-html5 .rst-content aside.citation>span.label,html.writer-html5 .rst-content aside.footnote>span.label,html.writer-html5 .rst-content div.citation>span.label{grid-column-start:1;grid-column-end:2}html.writer-html5 .rst-content aside.citation>span.backrefs,html.writer-html5 .rst-content aside.footnote>span.backrefs,html.writer-html5 .rst-content div.citation>span.backrefs{grid-column-start:2;grid-column-end:3;grid-row-start:1;grid-row-end:3}html.writer-html5 .rst-content aside.citation>p,html.writer-html5 .rst-content aside.footnote>p,html.writer-html5 .rst-content div.citation>p{grid-column-start:4;grid-column-end:5}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{margin-bottom:24px}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{padding-left:1rem}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dd,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dd,html.writer-html5 .rst-content dl.footnote>dt{margin-bottom:0}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{font-size:.9rem}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.footnote>dt{margin:0 .5rem .5rem 0;line-height:1.2rem;word-break:break-all;font-weight:400}html.writer-html5 .rst-content dl.citation>dt>span.brackets:before,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:before{content:"["}html.writer-html5 .rst-content dl.citation>dt>span.brackets:after,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:after{content:"]"}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a{word-break:keep-all}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a:not(:first-child):before,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.footnote>dd{margin:0 0 .5rem;line-height:1.2rem}html.writer-html5 .rst-content dl.citation>dd p,html.writer-html5 .rst-content dl.footnote>dd p{font-size:.9rem}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{padding-left:1rem;padding-right:1rem;font-size:.9rem;line-height:1.2rem}html.writer-html5 .rst-content aside.citation p,html.writer-html5 .rst-content aside.footnote p,html.writer-html5 .rst-content div.citation p{font-size:.9rem;line-height:1.2rem;margin-bottom:12px}html.writer-html5 .rst-content aside.citation span.backrefs,html.writer-html5 .rst-content aside.footnote span.backrefs,html.writer-html5 .rst-content div.citation span.backrefs{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content aside.citation span.backrefs>a,html.writer-html5 .rst-content aside.footnote span.backrefs>a,html.writer-html5 .rst-content div.citation span.backrefs>a{word-break:keep-all}html.writer-html5 .rst-content aside.citation span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content aside.footnote span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content div.citation span.backrefs>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content aside.citation span.label,html.writer-html5 .rst-content aside.footnote span.label,html.writer-html5 .rst-content div.citation span.label{line-height:1.2rem}html.writer-html5 .rst-content aside.citation-list,html.writer-html5 .rst-content aside.footnote-list,html.writer-html5 .rst-content div.citation-list{margin-bottom:24px}html.writer-html5 .rst-content dl.option-list kbd{font-size:.9rem}.rst-content table.docutils.footnote,html.writer-html4 .rst-content table.docutils.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content aside.footnote-list aside.footnote,html.writer-html5 .rst-content div.citation-list>div.citation,html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{color:grey}.rst-content table.docutils.footnote code,.rst-content table.docutils.footnote tt,html.writer-html4 .rst-content table.docutils.citation code,html.writer-html4 .rst-content table.docutils.citation tt,html.writer-html5 .rst-content aside.footnote-list aside.footnote code,html.writer-html5 .rst-content aside.footnote-list aside.footnote tt,html.writer-html5 .rst-content aside.footnote code,html.writer-html5 .rst-content aside.footnote tt,html.writer-html5 .rst-content div.citation-list>div.citation code,html.writer-html5 .rst-content div.citation-list>div.citation tt,html.writer-html5 .rst-content dl.citation code,html.writer-html5 .rst-content dl.citation tt,html.writer-html5 .rst-content dl.footnote code,html.writer-html5 .rst-content dl.footnote tt{color:#555}.rst-content .wy-table-responsive.citation,.rst-content .wy-table-responsive.footnote{margin-bottom:0}.rst-content .wy-table-responsive.citation+:not(.citation),.rst-content .wy-table-responsive.footnote+:not(.footnote){margin-top:24px}.rst-content .wy-table-responsive.citation:last-child,.rst-content .wy-table-responsive.footnote:last-child{margin-bottom:24px}.rst-content table.docutils th{border-color:#e1e4e5}html.writer-html5 .rst-content table.docutils th{border:1px solid #e1e4e5}html.writer-html5 .rst-content table.docutils td>p,html.writer-html5 .rst-content table.docutils th>p{line-height:1rem;margin-bottom:0;font-size:.9rem}.rst-content table.docutils td .last,.rst-content table.docutils td .last>:last-child{margin-bottom:0}.rst-content table.field-list,.rst-content table.field-list td{border:none}.rst-content table.field-list td p{line-height:inherit}.rst-content table.field-list td>strong{display:inline-block}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left}.rst-content code,.rst-content tt{color:#000;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;padding:2px 5px}.rst-content code big,.rst-content code em,.rst-content tt big,.rst-content tt em{font-size:100%!important;line-height:normal}.rst-content code.literal,.rst-content tt.literal{color:#e74c3c;white-space:normal}.rst-content code.xref,.rst-content tt.xref,a .rst-content code,a .rst-content tt{font-weight:700;color:#404040;overflow-wrap:normal}.rst-content kbd,.rst-content pre,.rst-content samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace}.rst-content a code,.rst-content a tt{color:#2980b9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:700;margin-bottom:12px}.rst-content dl ol,.rst-content dl p,.rst-content dl table,.rst-content dl ul{margin-bottom:12px}.rst-content dl dd{margin:0 0 12px 24px;line-height:24px}.rst-content dl dd>ol:last-child,.rst-content dl dd>p:last-child,.rst-content dl dd>table:last-child,.rst-content dl dd>ul:last-child{margin-bottom:0}html.writer-html4 .rst-content dl:not(.docutils),html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple){margin-bottom:24px}html.writer-html4 .rst-content dl:not(.docutils)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{display:table;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980b9;border-top:3px solid #6ab0de;padding:6px;position:relative}html.writer-html4 .rst-content dl:not(.docutils)>dt:before,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:before{color:#6ab0de}html.writer-html4 .rst-content dl:not(.docutils)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{margin-bottom:6px;border:none;border-left:3px solid #ccc;background:#f0f0f0;color:#555}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils)>dt:first-child,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:first-child{margin-top:0}html.writer-html4 .rst-content dl:not(.docutils) code.descclassname,html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descclassname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{background-color:transparent;border:none;padding:0;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .optional,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .property,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .property{display:inline-block;padding-right:8px;max-width:100%}html.writer-html4 .rst-content dl:not(.docutils) .k,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .k{font-style:italic}html.writer-html4 .rst-content dl:not(.docutils) .descclassname,html.writer-html4 .rst-content dl:not(.docutils) .descname,html.writer-html4 .rst-content dl:not(.docutils) .sig-name,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .sig-name{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#000}.rst-content .viewcode-back,.rst-content .viewcode-link{display:inline-block;color:#27ae60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:700}.rst-content code.download,.rst-content tt.download{background:inherit;padding:inherit;font-weight:400;font-family:inherit;font-size:inherit;color:inherit;border:inherit;white-space:inherit}.rst-content code.download span:first-child,.rst-content tt.download span:first-child{-webkit-font-smoothing:subpixel-antialiased}.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{margin-right:4px}.rst-content .guilabel,.rst-content .menuselection{font-size:80%;font-weight:700;border-radius:4px;padding:2.4px 6px;margin:auto 2px}.rst-content .guilabel,.rst-content .menuselection{border:1px solid #7fbbe3;background:#e7f2fa}.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>.kbd,.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>kbd{color:inherit;font-size:80%;background-color:#fff;border:1px solid #a6a6a6;border-radius:4px;box-shadow:0 2px grey;padding:2.4px 6px;margin:auto 0}.rst-content .versionmodified{font-style:italic}@media screen and (max-width:480px){.rst-content .sidebar{width:100%;float:none;margin-left:0}}span[id*=MathJax-Span]{color:#404040}.math{text-align:center}@font-face{font-family:Lato;src:url(fonts/lato-normal.woff2?bd03a2cc277bbbc338d464e679fe9942) format("woff2"),url(fonts/lato-normal.woff?27bd77b9162d388cb8d4c4217c7c5e2a) format("woff");font-weight:400;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold.woff2?cccb897485813c7c256901dbca54ecf2) format("woff2"),url(fonts/lato-bold.woff?d878b6c29b10beca227e9eef4246111b) format("woff");font-weight:700;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold-italic.woff2?0b6bb6725576b072c5d0b02ecdd1900d) format("woff2"),url(fonts/lato-bold-italic.woff?9c7e4e9eb485b4a121c760e61bc3707c) format("woff");font-weight:700;font-style:italic;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-normal-italic.woff2?4eb103b4d12be57cb1d040ed5e162e9d) format("woff2"),url(fonts/lato-normal-italic.woff?f28f2d6482446544ef1ea1ccc6dd5892) format("woff");font-weight:400;font-style:italic;font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:400;src:url(fonts/Roboto-Slab-Regular.woff2?7abf5b8d04d26a2cafea937019bca958) format("woff2"),url(fonts/Roboto-Slab-Regular.woff?c1be9284088d487c5e3ff0a10a92e58c) format("woff");font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:700;src:url(fonts/Roboto-Slab-Bold.woff2?9984f4a9bda09be08e83f2506954adbe) format("woff2"),url(fonts/Roboto-Slab-Bold.woff?bed5564a116b05148e3b3bea6fb1162a) format("woff");font-display:block} \ No newline at end of file From b82a816cabf1217690406609d9956d8f40bb0b64 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:39:51 -0400 Subject: [PATCH 028/120] Persist last used fingerprint --- src/password_manager/manager.py | 7 ++- src/tests/test_last_used_fingerprint.py | 60 +++++++++++++++++++++++++ src/utils/fingerprint_manager.py | 47 +++++++++++-------- 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 src/tests/test_last_used_fingerprint.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f44e94b..0b68745 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -265,8 +265,13 @@ 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 + if current and current in fingerprints: + self.select_fingerprint(current) + return + + print(colored("\nAvailable Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): print(colored(f"{idx}. {fp}", "cyan")) 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/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: From 811c4f883d728743f627ad0c4d4941fe7dd58fc2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:18:57 -0400 Subject: [PATCH 029/120] update --- .../02-examples/01-entry_management_demo.py | 31 ------------------- .../02-examples/02-password_manager_demo.py | 15 --------- 2 files changed, 46 deletions(-) delete mode 100644 docs/docs/content/02-examples/01-entry_management_demo.py delete mode 100644 docs/docs/content/02-examples/02-password_manager_demo.py diff --git a/docs/docs/content/02-examples/01-entry_management_demo.py b/docs/docs/content/02-examples/01-entry_management_demo.py deleted file mode 100644 index d5a29d8..0000000 --- a/docs/docs/content/02-examples/01-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/docs/docs/content/02-examples/02-password_manager_demo.py b/docs/docs/content/02-examples/02-password_manager_demo.py deleted file mode 100644 index 27da95f..0000000 --- a/docs/docs/content/02-examples/02-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() From d4284f1bab092d8e00cccc610aa41476c0352ba5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:43:22 -0400 Subject: [PATCH 030/120] Sync vault after entry commands --- src/seedpass/cli.py | 11 +++++++++++ src/tests/test_cli_entry_add_commands.py | 1 + src/tests/test_typer_cli.py | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 3dffb39..e67dd94 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -150,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") @@ -172,6 +173,7 @@ def entry_add_totp( digits=digits, ) typer.echo(uri) + pm.sync_vault() @entry_app.command("add-ssh") @@ -190,6 +192,7 @@ def entry_add_ssh( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-pgp") @@ -212,6 +215,7 @@ def entry_add_pgp( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-nostr") @@ -229,6 +233,7 @@ def entry_add_nostr( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-seed") @@ -249,6 +254,7 @@ def entry_add_seed( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("add-key-value") @@ -262,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") @@ -280,6 +287,7 @@ def entry_add_managed_account( notes=notes, ) typer.echo(str(idx)) + pm.sync_vault() @entry_app.command("modify") @@ -308,6 +316,7 @@ def entry_modify( digits=digits, value=value, ) + pm.sync_vault() @entry_app.command("archive") @@ -316,6 +325,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") @@ -324,6 +334,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") diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index b69f652..b663303 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -103,6 +103,7 @@ def test_entry_add_commands( entry_manager=SimpleNamespace(**{method: func}), parent_seed="seed", select_fingerprint=lambda fp: None, + sync_vault=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index ea4ad87..afa9ade 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -332,6 +332,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( @@ -362,6 +363,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"]) @@ -378,6 +380,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"]) @@ -395,6 +398,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"]) From 8414ee53c6851bf120a3ee136b497b06f53de30f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:06:05 -0400 Subject: [PATCH 031/120] Trigger vault sync after imports --- src/password_manager/manager.py | 1 + src/seedpass/api.py | 1 + src/seedpass/cli.py | 1 + src/tests/test_api_new_endpoints.py | 4 ++++ src/tests/test_typer_cli.py | 27 ++++++++++++++++++++++++++- 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0b68745..e7b5349 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -3257,6 +3257,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")) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index e87c45a..916a162 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -513,6 +513,7 @@ 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"} diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index e67dd94..e7694b4 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -372,6 +372,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)) diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index f8ac6f0..758714c 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -218,6 +218,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 +231,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 +242,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 +256,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): diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index afa9ade..90d4067 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 = {} From 238a07a8e68af35a496a64093288707860ea3b76 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:14:32 -0400 Subject: [PATCH 032/120] test: ensure sync_vault on entry add commands --- src/tests/test_cli_entry_add_commands.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index b663303..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,11 +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=lambda: None, + sync_vault=sync_vault, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) @@ -111,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 From be3bc28cbc0d3200d10bf59352d810ac3b54b941 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:45:16 -0400 Subject: [PATCH 033/120] docs: note vault import Nostr sync --- docs/docs/content/01-getting-started/01-advanced_cli.md | 4 ++-- docs/docs/content/index.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 70c2395..1e54484 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -70,7 +70,7 @@ 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` | @@ -158,7 +158,7 @@ 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. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index b071d38..4f8e77f 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -193,6 +193,7 @@ 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" @@ -398,7 +399,7 @@ Back in the Settings menu you can: * 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. +* 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. From 7ad60c71fe2195b96b6b4ead714fb05fb1340c19 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:01:11 -0400 Subject: [PATCH 034/120] Add unlock duration timer --- src/password_manager/manager.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e7b5349..9b0a948 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -133,6 +133,7 @@ class PasswordManager: self.secret_mode_enabled: bool = False self.clipboard_clear_delay: int = 45 self.profile_stack: list[tuple[str, Path, str]] = [] + self.last_unlock_duration: float | None = None # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -225,6 +226,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) @@ -233,6 +235,13 @@ class PasswordManager: 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", + ) + ) def initialize_fingerprint_manager(self): """ From 5af3228d4b9b6f9320678378b58f8194dfa4fb44 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:20:56 -0400 Subject: [PATCH 035/120] Prompt for fingerprint selection if multiple --- src/password_manager/manager.py | 9 ++- src/tests/test_multiple_fingerprint_prompt.py | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/tests/test_multiple_fingerprint_prompt.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 9b0a948..7c62dda 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -276,13 +276,16 @@ class PasswordManager: try: fingerprints = self.fingerprint_manager.list_fingerprints() current = self.fingerprint_manager.current_fingerprint - if current and current in fingerprints: - self.select_fingerprint(current) + + # 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")) 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 From dcff36050865f5df78d47decf192eb8df813c1b9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:42:12 -0400 Subject: [PATCH 036/120] Refactor sync trigger --- src/main.py | 4 ++++ src/password_manager/manager.py | 21 +++++++++++++++++++-- src/tests/test_auto_sync.py | 1 + src/tests/test_cli_invalid_input.py | 1 + src/tests/test_inactivity_lock.py | 2 ++ src/tests/test_menu_navigation.py | 1 + src/tests/test_menu_options.py | 1 + src/tests/test_menu_search.py | 1 + src/tests/test_unlock_sync.py | 2 ++ 9 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 8085c53..d22c8d6 100644 --- a/src/main.py +++ b/src/main.py @@ -741,6 +741,7 @@ def handle_settings(password_manager: PasswordManager) -> None: password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() + password_manager.start_background_sync() pause() elif choice == "13": handle_display_stats(password_manager) @@ -777,6 +778,7 @@ def display_menu( if callable(display_fn): display_fn() pause() + password_manager.start_background_sync() while True: fp, parent_fp, child_fp = getattr( password_manager, @@ -793,6 +795,7 @@ def display_menu( print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() password_manager.unlock_vault() + password_manager.start_background_sync() continue # Periodically push updates to Nostr if ( @@ -815,6 +818,7 @@ def display_menu( print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() password_manager.unlock_vault() + password_manager.start_background_sync() continue password_manager.update_activity() if not choice: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7c62dda..7f414ce 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -19,6 +19,7 @@ from typing import Optional import shutil import time import builtins +import threading from termcolor import colored from utils.color_scheme import color_text from utils.input_utils import timed_input @@ -234,7 +235,6 @@ 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( @@ -358,7 +358,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.", @@ -967,6 +966,24 @@ class PasswordManager: except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") + def start_background_sync(self) -> None: + """Launch a thread to synchronize the vault without blocking the UI.""" + if ( + hasattr(self, "_sync_thread") + and self._sync_thread + and self._sync_thread.is_alive() + ): + return + + def _worker() -> None: + try: + 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 sync_index_from_nostr_if_missing(self) -> None: """Retrieve the password database from Nostr if it doesn't exist locally.""" index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index 53cf949..c5dc13c 100644 --- a/src/tests/test_auto_sync.py +++ b/src/tests/test_auto_sync.py @@ -22,6 +22,7 @@ def test_auto_sync_triggers_post(monkeypatch): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, ) called = False diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 589f162..57686a9 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -46,6 +46,7 @@ def _make_pm(called, locked=None): update_activity=update, lock_vault=lock, unlock_vault=unlock, + start_background_sync=lambda: None, ) return pm, locked diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py index 0c68561..2e7c4ed 100644 --- a/src/tests/test_inactivity_lock.py +++ b/src/tests/test_inactivity_lock.py @@ -34,6 +34,7 @@ def test_inactivity_triggers_lock(monkeypatch): update_activity=update_activity, lock_vault=lock_vault, unlock_vault=unlock_vault, + start_background_sync=lambda: None, ) monkeypatch.setattr(main, "timed_input", lambda *_: "") @@ -70,6 +71,7 @@ def test_input_timeout_triggers_lock(monkeypatch): update_activity=update_activity, lock_vault=lock_vault, unlock_vault=unlock_vault, + start_background_sync=lambda: None, ) responses = iter([TimeoutError(), ""]) diff --git a/src/tests/test_menu_navigation.py b/src/tests/test_menu_navigation.py index 8ab7bae..9b426e7 100644 --- a/src/tests/test_menu_navigation.py +++ b/src/tests/test_menu_navigation.py @@ -30,6 +30,7 @@ def _make_pm(calls): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, ) diff --git a/src/tests/test_menu_options.py b/src/tests/test_menu_options.py index ff8e7cf..9638300 100644 --- a/src/tests/test_menu_options.py +++ b/src/tests/test_menu_options.py @@ -24,6 +24,7 @@ def _make_pm(calls): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, ) diff --git a/src/tests/test_menu_search.py b/src/tests/test_menu_search.py index 0e1d439..01c8747 100644 --- a/src/tests/test_menu_search.py +++ b/src/tests/test_menu_search.py @@ -23,6 +23,7 @@ def _make_pm(called): update_activity=lambda: None, lock_vault=lambda: None, unlock_vault=lambda: None, + start_background_sync=lambda: None, ) return pm diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index 7eef4c5..892ea9a 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -22,5 +22,7 @@ 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"] From 1e270c9ab129a5c9e5e2114e9c7f996ed4101b16 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:50:08 -0400 Subject: [PATCH 037/120] Defer nostr client connections --- src/nostr/client.py | 28 ++++++++++++++++++++++++++-- src/tests/test_nostr_client.py | 2 ++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index 320ba72..4ebe15f 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -123,12 +123,22 @@ class NostrClient: 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 not self._connected: + self.initialize_client_pool() def initialize_client_pool(self) -> None: """Add relays to the client and connect.""" asyncio.run(self._initialize_client_pool()) + async def _connect_async(self) -> None: + """Ensure the client is connected within an async context.""" + if not self._connected: + await self._initialize_client_pool() + async def _initialize_client_pool(self) -> None: if hasattr(self.client, "add_relays"): await self.client.add_relays(self.relays) @@ -136,6 +146,7 @@ class NostrClient: 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: @@ -190,6 +201,7 @@ class NostrClient: If provided, include an ``alt`` tag so uploads can be associated with a specific event like a password change. """ + self.connect() self.last_error = None try: content = base64.b64encode(encrypted_json).decode("utf-8") @@ -221,9 +233,11 @@ class NostrClient: def publish_event(self, event): """Publish a prepared event to the configured relays.""" + self.connect() return asyncio.run(self._publish_event(event)) async def _publish_event(self, event): + await self._connect_async() return await self.client.send_event(event) def update_relays(self, new_relays: List[str]) -> None: @@ -232,12 +246,13 @@ class NostrClient: self.relays = new_relays signer = NostrSigner.keys(self.keys) self.client = Client(signer) - self.initialize_client_pool() + self._connected = False def retrieve_json_from_nostr_sync( self, retries: int = 0, delay: float = 2.0 ) -> Optional[bytes]: """Retrieve the latest Kind 1 event from the author with optional retries.""" + self.connect() self.last_error = None attempt = 0 while True: @@ -255,6 +270,7 @@ class NostrClient: return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: + 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 +304,7 @@ class NostrClient: Maximum chunk size in bytes. Defaults to 50 kB. """ + 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") @@ -320,6 +337,8 @@ class NostrClient: async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: """Retrieve the latest manifest and all snapshot chunks.""" + await self._connect_async() + pubkey = self.keys.public_key() f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1) timeout = timedelta(seconds=10) @@ -358,6 +377,8 @@ class NostrClient: async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" + await self._connect_async() + content = base64.b64encode(delta_bytes).decode("utf-8") tag = Tag.event(EventId.parse(manifest_id)) builder = EventBuilder(Kind(KIND_DELTA), content).tags([tag]) @@ -372,6 +393,8 @@ class NostrClient: async def fetch_deltas_since(self, version: int) -> list[bytes]: """Retrieve delta events newer than the given version.""" + await self._connect_async() + pubkey = self.keys.public_key() f = ( Filter() @@ -409,6 +432,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/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 310ab4b..9c76a73 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -88,6 +88,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 +96,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 From 7745155ee8eefcdabcbd17a5c210f37c410ebdf3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:04:48 -0400 Subject: [PATCH 038/120] check relays asynchronously --- src/main.py | 4 +++ src/password_manager/manager.py | 40 +++++++++++++++++------- src/tests/test_auto_sync.py | 1 + src/tests/test_background_relay_check.py | 23 ++++++++++++++ src/tests/test_cli_invalid_input.py | 1 + src/tests/test_inactivity_lock.py | 2 ++ src/tests/test_menu_navigation.py | 1 + src/tests/test_menu_options.py | 1 + src/tests/test_menu_search.py | 1 + 9 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 src/tests/test_background_relay_check.py diff --git a/src/main.py b/src/main.py index d22c8d6..33c8661 100644 --- a/src/main.py +++ b/src/main.py @@ -742,6 +742,7 @@ def handle_settings(password_manager: PasswordManager) -> None: print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() password_manager.start_background_sync() + getattr(password_manager, "start_background_relay_check", lambda: None)() pause() elif choice == "13": handle_display_stats(password_manager) @@ -779,6 +780,7 @@ def display_menu( display_fn() pause() password_manager.start_background_sync() + getattr(password_manager, "start_background_relay_check", lambda: None)() while True: fp, parent_fp, child_fp = getattr( password_manager, @@ -796,6 +798,7 @@ def display_menu( 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 ( @@ -819,6 +822,7 @@ def display_menu( 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: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7f414ce..428d7ce 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -925,17 +925,6 @@ class PasswordManager: 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: @@ -984,6 +973,35 @@ class PasswordManager: 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: + print( + colored( + f"Only {healthy} relay(s) responded with your latest event." + " Consider adding more relays via Settings.", + "yellow", + ) + ) + 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 sync_index_from_nostr_if_missing(self) -> None: """Retrieve the password database from Nostr if it doesn't exist locally.""" index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index c5dc13c..26f0e22 100644 --- a/src/tests/test_auto_sync.py +++ b/src/tests/test_auto_sync.py @@ -23,6 +23,7 @@ def test_auto_sync_triggers_post(monkeypatch): 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..e1af48e --- /dev/null +++ b/src/tests/test_background_relay_check.py @@ -0,0 +1,23 @@ +import time +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 +from constants import MIN_HEALTHY_RELAYS + + +def test_background_relay_check_runs_async(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + 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 diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 57686a9..0882df4 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -47,6 +47,7 @@ def _make_pm(called, locked=None): 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_inactivity_lock.py b/src/tests/test_inactivity_lock.py index 2e7c4ed..32d81da 100644 --- a/src/tests/test_inactivity_lock.py +++ b/src/tests/test_inactivity_lock.py @@ -35,6 +35,7 @@ def test_inactivity_triggers_lock(monkeypatch): 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 *_: "") @@ -72,6 +73,7 @@ def test_input_timeout_triggers_lock(monkeypatch): 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_menu_navigation.py b/src/tests/test_menu_navigation.py index 9b426e7..f7ef100 100644 --- a/src/tests/test_menu_navigation.py +++ b/src/tests/test_menu_navigation.py @@ -31,6 +31,7 @@ def _make_pm(calls): 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_options.py b/src/tests/test_menu_options.py index 9638300..d70b609 100644 --- a/src/tests/test_menu_options.py +++ b/src/tests/test_menu_options.py @@ -25,6 +25,7 @@ def _make_pm(calls): 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 01c8747..e1f78c8 100644 --- a/src/tests/test_menu_search.py +++ b/src/tests/test_menu_search.py @@ -24,6 +24,7 @@ def _make_pm(called): lock_vault=lambda: None, unlock_vault=lambda: None, start_background_sync=lambda: None, + start_background_relay_check=lambda: None, ) return pm From 888d50a6a743a65886bd06fe1c021ba69fc883e2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:18:17 -0400 Subject: [PATCH 039/120] Add configurable KDF iterations --- src/password_manager/config_manager.py | 15 +++++++++++ src/password_manager/manager.py | 37 +++++++++++++++++++++----- src/tests/test_config_manager.py | 12 +++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index b1c8b8e..68a154e 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -44,6 +44,7 @@ class ConfigManager: "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, + "kdf_iterations": 100_000, "additional_backup_path": "", "secret_mode_enabled": False, "clipboard_clear_delay": 45, @@ -57,6 +58,7 @@ class ConfigManager: data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) + data.setdefault("kdf_iterations", 100_000) data.setdefault("additional_backup_path", "") data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) @@ -137,6 +139,19 @@ 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", 100_000)) + 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) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 428d7ce..87127a3 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -381,7 +381,12 @@ class PasswordManager: if password is None: password = prompt_existing_password("Enter your master password: ") - seed_key = derive_key_from_password(password) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 100_000 + ) + seed_key = derive_key_from_password(password, iterations=iterations) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) try: self.parent_seed = seed_mgr.decrypt_parent_seed() @@ -428,7 +433,12 @@ class PasswordManager: password = prompt_existing_password("Enter your master password: ") try: - seed_key = derive_key_from_password(password) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 100_000 + ) + 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() @@ -572,7 +582,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 100_000 + ) + key = derive_key_from_password(password, iterations=iterations) # Initialize FingerprintManager if not already initialized if not self.fingerprint_manager: @@ -692,7 +707,8 @@ class PasswordManager: # 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) + iterations = self.config_manager.get_kdf_iterations() + 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 +849,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 100_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) @@ -3357,7 +3378,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") @@ -3569,7 +3591,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) diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index b035a6b..385657d 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -23,6 +23,7 @@ def test_config_defaults_and_round_trip(): assert cfg["pin_hash"] == "" assert cfg["password_hash"] == "" assert cfg["additional_backup_path"] == "" + assert cfg["kdf_iterations"] == 100_000 cfg_mgr.set_pin("1234") cfg_mgr.set_relays(["wss://example.com"], require_pin=False) @@ -146,3 +147,14 @@ 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() == 100_000 + + cfg_mgr.set_kdf_iterations(200_000) + assert cfg_mgr.get_kdf_iterations() == 200_000 From 86d233f5a939d802a39fa6fb670448a39461eeaa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:23:44 -0400 Subject: [PATCH 040/120] Add progress prints for setup and initialization --- src/password_manager/manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 87127a3..dec5831 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -386,8 +386,10 @@ class PasswordManager: if getattr(self, "config_manager", None) else 100_000 ) + print("Deriving key...") 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: @@ -939,6 +941,7 @@ class PasswordManager: self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False)) self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45)) + print("Connecting to relays...") self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, From 496950c501210a264a0baf0154124436713f1e09 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:37:42 -0400 Subject: [PATCH 041/120] Improve password retry flow --- src/password_manager/manager.py | 92 ++++++++++++++++++--------------- src/utils/password_prompt.py | 63 ++++++++++++---------- 2 files changed, 88 insertions(+), 67 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index dec5831..18fb6e8 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -377,51 +377,61 @@ 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: ") - - iterations = ( - self.config_manager.get_kdf_iterations() - if getattr(self, "config_manager", None) - else 100_000 - ) - print("Deriving key...") - seed_key = derive_key_from_password(password, iterations=iterations) - seed_mgr = EncryptionManager(seed_key, fingerprint_dir) - print("Decrypting seed...") + 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: ") + + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 100_000 + ) + print("Deriving key...") + 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 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( From ca7f51d226588832faeee483a72b0d4d02d265f7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 10:03:30 -0400 Subject: [PATCH 042/120] Initialize managed accounts from nostr or create new --- src/password_manager/manager.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 18fb6e8..af9d3bd 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -61,6 +61,7 @@ from utils.terminal_utils import ( ) from utils.fingerprint import generate_fingerprint from constants import MIN_HEALTHY_RELAYS +from password_manager.migrations import LATEST_VERSION from constants import ( APP_DIR, @@ -1037,10 +1038,15 @@ class PasswordManager: self._relay_thread.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: @@ -1056,11 +1062,24 @@ class PasswordManager: encrypted = deltas[-1] except ValueError: pass - self.vault.decrypt_and_save_index_from_nostr(encrypted) - logger.info("Initialized local database from Nostr.") + 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 From b52d027ec73ac797e0727ef16671769ec42a44f0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:03:39 -0400 Subject: [PATCH 043/120] Validate entry type fields --- src/password_manager/entry_management.py | 88 ++++++++++++++++++++++++ src/seedpass/api.py | 23 ++++--- src/seedpass/cli.py | 24 ++++--- src/tests/test_api_new_endpoints.py | 13 ++++ src/tests/test_modify_totp_entry.py | 12 ++++ src/tests/test_typer_cli.py | 15 ++++ 6 files changed, 155 insertions(+), 20 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index b8c9bcb..87404a4 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -723,6 +723,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 +883,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.""" diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 916a162..85a23f1 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -207,16 +207,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"} diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index e7694b4..92f46a5 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -306,16 +306,20 @@ 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() diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 758714c..e939048 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -93,6 +93,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 = {} 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_typer_cli.py b/src/tests/test_typer_cli.py index 90d4067..b64a2d1 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -396,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 = {} From 196aaa4dcfe5c8c82ba77dcb0bfa673423f46463 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:23:06 -0400 Subject: [PATCH 044/120] feat(tui): hide unsupported actions --- src/password_manager/manager.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index af9d3bd..0ccef11 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1730,6 +1730,7 @@ class PasswordManager: """Provide actions for a retrieved entry.""" while True: 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")) @@ -1740,7 +1741,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() From 5f78d20685bfa2353ad75c273f7b41bb277b5a7a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:40:11 -0400 Subject: [PATCH 045/120] Add KDF iteration settings option --- src/main.py | 51 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index 33c8661..d5867c1 100644 --- a/src/main.py +++ b/src/main.py @@ -493,6 +493,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 @@ -699,10 +732,11 @@ 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")) choice = input("Select an option or press Enter to go back: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -735,19 +769,22 @@ 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() password_manager.start_background_sync() getattr(password_manager, "start_background_relay_check", lambda: None)() pause() - elif choice == "13": + elif choice == "14": handle_display_stats(password_manager) pause() - elif choice == "14": + elif choice == "15": handle_toggle_secret_mode(password_manager) pause() elif not choice: From f4fe208b7fd57a15723ca831fadbed7b85138f0f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:48:10 -0400 Subject: [PATCH 046/120] Add index caching to EntryManager --- src/password_manager/entry_management.py | 17 +++++++++++++++-- src/tests/test_entry_add.py | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 87404a4..23a015a 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -53,9 +53,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 +90,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 +99,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}") 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" From 1f460b3aaedaf14243b788fa503d10318256e208 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:53:20 -0400 Subject: [PATCH 047/120] Clear entry cache on vault lock --- src/password_manager/manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0ccef11..445b2e2 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -215,6 +215,8 @@ class PasswordManager: 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 From d27e3708c5f7c9bf912f937d46dbd33f5e41388f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:05:06 -0400 Subject: [PATCH 048/120] Switch encryption to AES-GCM --- src/password_manager/encryption.py | 53 +++++++++++++------------ src/password_manager/portable_backup.py | 2 +- src/tests/test_portable_backup.py | 6 ++- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 5f6fe1c..c309e52 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -4,7 +4,7 @@ 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 +of data and files using a provided AES-GCM encryption key. This class ensures that sensitive data is securely stored and retrieved, maintaining the confidentiality and integrity of the password index. @@ -22,7 +22,9 @@ import os from pathlib import Path from typing import Optional -from cryptography.fernet import Fernet, InvalidToken +import base64 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.exceptions import InvalidTag from termcolor import colored from utils.file_lock import ( exclusive_lock, @@ -36,7 +38,7 @@ class EncryptionManager: """ EncryptionManager Class - Manages the encryption and decryption of data and files using a Fernet encryption key. + Manages the encryption and decryption of data and files using an AES-GCM encryption key. """ def __init__(self, encryption_key: bytes, fingerprint_dir: Path): @@ -44,19 +46,23 @@ class EncryptionManager: Initializes the EncryptionManager with the provided encryption key and fingerprint directory. Parameters: - encryption_key (bytes): The Fernet encryption key. + encryption_key (bytes): A base64-encoded AES-GCM 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) - logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}") + if isinstance(encryption_key, str): + encryption_key = encryption_key.encode() + self.key = base64.urlsafe_b64decode(encryption_key) + self.cipher = AESGCM(self.key) + logger.debug( + f"EncryptionManager initialized for {self.fingerprint_dir} using AES-GCM" + ) except Exception as e: logger.error( - f"Failed to initialize Fernet with provided encryption key: {e}" + f"Failed to initialize AESGCM with provided encryption key: {e}" ) print( colored(f"Error: Failed to initialize encryption manager: {e}", "red") @@ -119,7 +125,7 @@ class EncryptionManager: f"Parent seed decrypted successfully from '{parent_seed_path}'." ) return parent_seed - except InvalidToken: + except InvalidTag: logger.error( "Invalid encryption key or corrupted data while decrypting parent seed." ) @@ -130,15 +136,13 @@ class EncryptionManager: raise def encrypt_data(self, data: bytes) -> bytes: - """ - Encrypts the given data using Fernet. + """Encrypt ``data`` with AES-GCM and prepend the nonce.""" - :param data: Data to encrypt. - :return: Encrypted data. - """ try: - encrypted_data = self.fernet.encrypt(data) - logger.debug("Data encrypted successfully.") + nonce = os.urandom(12) + ciphertext = self.cipher.encrypt(nonce, data, None) + encrypted_data = nonce + ciphertext + logger.debug("Data encrypted successfully with AES-GCM.") return encrypted_data except Exception as e: logger.error(f"Failed to encrypt data: {e}", exc_info=True) @@ -146,17 +150,14 @@ class EncryptionManager: raise def decrypt_data(self, encrypted_data: bytes) -> bytes: - """ - Decrypts the provided encrypted data using the derived key. + """Decrypt AES-GCM data that includes a prepended nonce.""" - :param encrypted_data: The encrypted data to decrypt. - :return: The decrypted data as bytes. - """ try: - decrypted_data = self.fernet.decrypt(encrypted_data) - logger.debug("Data decrypted successfully.") + nonce, ciphertext = encrypted_data[:12], encrypted_data[12:] + decrypted_data = self.cipher.decrypt(nonce, ciphertext, None) + logger.debug("Data decrypted successfully with AES-GCM.") return decrypted_data - except InvalidToken: + except InvalidTag: logger.error( "Invalid encryption key or corrupted data while decrypting data." ) @@ -228,7 +229,7 @@ class EncryptionManager: decrypted_data = self.decrypt_data(encrypted_data) logger.debug(f"Data decrypted successfully from '{file_path}'.") return decrypted_data - except InvalidToken: + except InvalidTag: logger.error( "Invalid encryption key or corrupted data while decrypting file." ) @@ -308,7 +309,7 @@ class EncryptionManager: f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True ) raise - except InvalidToken: + except InvalidTag: logger.error( "Invalid encryption key or corrupted data while decrypting JSON data." ) diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index 14c7dce..3e27671 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"), } diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 8c89fae..07e6fe0 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -43,13 +43,15 @@ 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) assert vault.load_index()["pw"] == data["pw"] -from cryptography.fernet import InvalidToken +from cryptography.exceptions import InvalidTag def test_corruption_detection(monkeypatch): @@ -66,7 +68,7 @@ def test_corruption_detection(monkeypatch): content["payload"] = base64.b64encode(payload).decode() path.write_text(json.dumps(content)) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTag): import_backup(vault, backup, path, parent_seed=SEED) From f7c2017f1c82218704c05bbdfd37e1d10fef1378 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:32:04 -0400 Subject: [PATCH 049/120] tests: use AES-GCM key generation --- src/tests/test_encryption_checksum.py | 5 +++-- src/tests/test_encryption_files.py | 7 ++++--- src/tests/test_fingerprint_encryption.py | 5 +++-- src/tests/test_nostr_client.py | 7 ++++--- src/tests/test_nostr_contract.py | 5 +++-- src/tests/test_nostr_index_size.py | 5 +++-- src/tests/test_nostr_real.py | 6 ++++-- src/tests/test_nostr_snapshot.py | 6 ++++-- src/tests/test_publish_json_result.py | 5 +++-- 9 files changed, 31 insertions(+), 20 deletions(-) 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_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_nostr_client.py b/src/tests/test_nostr_client.py index 9c76a73..2b25b24 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( 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_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_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( From 817c8d63301cf4951677c93e8a4a091316a1e854 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 21:06:59 -0400 Subject: [PATCH 050/120] cleanup seed profile on failure --- src/password_manager/manager.py | 68 ++++++++++++++++++------------- src/tests/test_profile_cleanup.py | 49 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 28 deletions(-) create mode 100644 src/tests/test_profile_cleanup.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 445b2e2..82550c9 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -719,39 +719,46 @@ 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) - iterations = self.config_manager.get_kdf_iterations() - seed_key = derive_key_from_password(password, iterations=iterations) + 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() + 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.sync_index_from_nostr() + 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")) @@ -800,7 +807,12 @@ 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) + except BaseException: + # Clean up partial profile on failure or interruption + self.fingerprint_manager.remove_fingerprint(fingerprint) + raise return fingerprint # Return the generated fingerprint else: 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") == [] From 10f447c9302d2254571623acc02bea5d6a0f7ba7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:09:03 -0400 Subject: [PATCH 051/120] Use cached index in EntryManager --- src/password_manager/entry_management.py | 39 ++++++++++++------------ src/tests/test_index_cache.py | 33 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 src/tests/test_index_cache.py diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 23a015a..aad4722 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -119,7 +119,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 @@ -156,7 +156,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)] = { @@ -190,7 +190,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)) @@ -217,7 +217,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: @@ -279,7 +279,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, @@ -325,7 +325,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, @@ -377,7 +377,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, @@ -407,7 +407,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, @@ -465,7 +465,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, @@ -537,7 +537,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, @@ -612,7 +612,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(): @@ -662,7 +662,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: @@ -719,7 +719,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: @@ -919,7 +919,7 @@ class EntryManager: ``True``. """ try: - data = self.vault.load_index() + data = self._load_index() entries_data = data.get("entries", {}) if not entries_data: @@ -1030,7 +1030,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: @@ -1119,11 +1119,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.") @@ -1154,7 +1154,7 @@ class EntryManager: Updates the checksum file for the password database to ensure data integrity. """ try: - data = self.vault.load_index() + data = self._load_index() json_content = json.dumps(data, indent=4) checksum = hashlib.sha256(json_content.encode("utf-8")).hexdigest() @@ -1200,6 +1200,7 @@ class EntryManager: ) ) + self.clear_cache() self.update_checksum() except Exception as e: @@ -1253,7 +1254,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/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 From bfdadebd5f764029a067c3bc0f72502513cc96c9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:16:04 -0400 Subject: [PATCH 052/120] Handle missing config manager for existing seed setup --- src/password_manager/manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 82550c9..e631bb4 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -723,7 +723,11 @@ class PasswordManager: # 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() + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 100_000 + ) seed_key = derive_key_from_password(password, iterations=iterations) self.encryption_manager = EncryptionManager( From c7cb9aa6ec6dcb14ed721f49434d5f815f33d473 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:24:15 -0400 Subject: [PATCH 053/120] Add legacy Fernet migration --- src/password_manager/encryption.py | 46 ++++++++++++++++++++++++------ src/password_manager/vault.py | 11 +++++++ src/tests/test_legacy_migration.py | 43 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 src/tests/test_legacy_migration.py diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index c309e52..9b02115 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -25,6 +25,8 @@ from typing import Optional import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.exceptions import InvalidTag +from cryptography.fernet import Fernet, InvalidToken +from cryptography.fernet import Fernet, InvalidToken from termcolor import colored from utils.file_lock import ( exclusive_lock, @@ -34,6 +36,16 @@ from utils.file_lock import ( logger = logging.getLogger(__name__) +def decrypt_legacy_fernet(encryption_key: bytes | str, payload: bytes) -> bytes: + """Decrypt *payload* using legacy Fernet.""" + if isinstance(encryption_key, str): + key = encryption_key.encode() + else: + key = encryption_key + f = Fernet(key) + return f.decrypt(payload) + + class EncryptionManager: """ EncryptionManager Class @@ -55,6 +67,7 @@ class EncryptionManager: try: if isinstance(encryption_key, str): encryption_key = encryption_key.encode() + self.key_b64 = encryption_key self.key = base64.urlsafe_b64decode(encryption_key) self.cipher = AESGCM(self.key) logger.debug( @@ -304,16 +317,31 @@ class EncryptionManager: 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 + except (InvalidTag, json.JSONDecodeError): + logger.info( + f"AES-GCM decryption failed for '{file_path}', attempting legacy format" ) - raise - except InvalidTag: - logger.error( - "Invalid encryption key or corrupted data while decrypting JSON data." - ) - raise + with exclusive_lock(file_path) as fh: + fh.seek(0) + legacy_bytes = fh.read() + try: + legacy_plain = decrypt_legacy_fernet(self.key_b64, legacy_bytes) + data = json.loads(legacy_plain.decode("utf-8").strip()) + except (InvalidToken, json.JSONDecodeError) as e: + logger.error( + f"Legacy decryption failed for '{file_path}': {e}", exc_info=True + ) + raise + + legacy_path = file_path.with_suffix(file_path.suffix + ".fernet") + os.rename(file_path, legacy_path) + chk = file_path.parent / f"{file_path.stem}_checksum.txt" + if chk.exists(): + chk.rename(chk.with_suffix(chk.suffix + ".fernet")) + + self.save_json_data(data, relative_path) + self.update_checksum(relative_path) + return data except Exception as e: logger.error( f"Failed to load JSON data from '{file_path}': {e}", 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/tests/test_legacy_migration.py b/src/tests/test_legacy_migration.py new file mode 100644 index 0000000..a179cd9 --- /dev/null +++ b/src/tests/test_legacy_migration.py @@ -0,0 +1,43 @@ +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() + assert (tmp_path / ("seedpass_entries_db.json.enc.fernet")).exists() From 865149826e3bf81840230ada8821db99637ecdd8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:31:30 -0400 Subject: [PATCH 054/120] docs: explain automatic legacy vault migration --- README.md | 7 +++++++ .../content/01-getting-started/04-migrations.md | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 28bed77..bd26a2e 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,13 @@ 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. + ## Usage After successfully installing the dependencies, install the package with: diff --git a/docs/docs/content/01-getting-started/04-migrations.md b/docs/docs/content/01-getting-started/04-migrations.md index 2c1cf46..3c9f18e 100644 --- a/docs/docs/content/01-getting-started/04-migrations.md +++ b/docs/docs/content/01-getting-started/04-migrations.md @@ -23,3 +23,16 @@ raise an error. 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. From e411d2f80a1ce0e091bb1905251e518dfe99116f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 08:53:03 -0400 Subject: [PATCH 055/120] Remove duplicate Fernet import --- src/password_manager/encryption.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 9b02115..9ee872b 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -26,7 +26,6 @@ import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.exceptions import InvalidTag from cryptography.fernet import Fernet, InvalidToken -from cryptography.fernet import Fernet, InvalidToken from termcolor import colored from utils.file_lock import ( exclusive_lock, From 4f7ff30657d6d7b2a2467e896dc56e3a4adca00d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 08:58:56 -0400 Subject: [PATCH 056/120] Add Fernet fallback cipher --- src/password_manager/encryption.py | 31 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 9ee872b..de697f0 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -3,10 +3,11 @@ """ Encryption Module -This module provides the EncryptionManager class, which handles encryption and decryption -of data and files using a provided AES-GCM encryption key. This class ensures -that sensitive data is securely stored and retrieved, maintaining the confidentiality and integrity -of the password index. +This module provides the ``EncryptionManager`` class which handles encryption and +decryption of data and files using a provided AES-GCM encryption key. Legacy +databases encrypted with Fernet are still supported for decryption. 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. @@ -46,19 +47,22 @@ def decrypt_legacy_fernet(encryption_key: bytes | str, payload: bytes) -> bytes: class EncryptionManager: - """ - EncryptionManager Class + """EncryptionManager Class - Manages the encryption and decryption of data and files using an AES-GCM encryption key. + Manages the encryption and decryption of data and files using an AES-GCM + key. A :class:`cryptography.fernet.Fernet` instance is also kept for + decrypting legacy files that were encrypted using Fernet. """ def __init__(self, encryption_key: bytes, fingerprint_dir: Path): - """ - Initializes the EncryptionManager with the provided encryption key and fingerprint directory. + """Initialize the manager with a base64 encoded key and directory. + + The provided key is used to create both an AES-GCM cipher for current + operations and a Fernet cipher for decrypting legacy files. Parameters: - encryption_key (bytes): A base64-encoded AES-GCM key. - fingerprint_dir (Path): The directory corresponding to the fingerprint. + encryption_key (bytes): Base64 encoded key material. + fingerprint_dir (Path): Directory corresponding to the fingerprint. """ self.fingerprint_dir = fingerprint_dir self.parent_seed_file = self.fingerprint_dir / "parent_seed.enc" @@ -68,9 +72,10 @@ class EncryptionManager: encryption_key = encryption_key.encode() self.key_b64 = encryption_key self.key = base64.urlsafe_b64decode(encryption_key) + self.fernet = Fernet(self.key_b64) self.cipher = AESGCM(self.key) logger.debug( - f"EncryptionManager initialized for {self.fingerprint_dir} using AES-GCM" + f"EncryptionManager initialized for {self.fingerprint_dir} using AES-GCM with Fernet fallback" ) except Exception as e: logger.error( @@ -318,7 +323,7 @@ class EncryptionManager: return data except (InvalidTag, json.JSONDecodeError): logger.info( - f"AES-GCM decryption failed for '{file_path}', attempting legacy format" + f"AES-GCM decryption failed for '{file_path}', attempting Fernet fallback" ) with exclusive_lock(file_path) as fh: fh.seek(0) From 0ba38c3dc2dbf78379e094ef3cacc3287aed5169 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 09:08:09 -0400 Subject: [PATCH 057/120] Add Fernet migration fallback for parent seed --- src/password_manager/encryption.py | 42 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index de697f0..de0c020 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -124,29 +124,45 @@ class EncryptionManager: raise def decrypt_parent_seed(self) -> str: - """ - Decrypts and returns the parent seed from 'parent_seed.enc' within the fingerprint directory. + """Decrypt and return the stored parent seed.""" - :return: The decrypted parent seed. - """ + parent_seed_path = self.fingerprint_dir / "parent_seed.enc" 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() + try: + decrypted = self.decrypt_data(encrypted_data) + parent_seed = decrypted.decode("utf-8").strip() + logger.debug( + f"Parent seed decrypted successfully from '{parent_seed_path}'." + ) + return parent_seed + except (InvalidTag, InvalidToken): + logger.info( + "AES-GCM decryption failed for parent seed, attempting Fernet fallback" + ) + try: + decrypted = self.fernet.decrypt(encrypted_data) + except InvalidToken as e: + logger.error( + f"Fernet decryption failed for '{parent_seed_path}': {e}", + exc_info=True, + ) + raise + parent_seed = decrypted.decode("utf-8").strip() + + legacy_path = parent_seed_path.with_suffix( + parent_seed_path.suffix + ".fernet" + ) + os.rename(parent_seed_path, legacy_path) + self.encrypt_parent_seed(parent_seed) logger.debug( - f"Parent seed decrypted successfully from '{parent_seed_path}'." + f"Parent seed decrypted with Fernet and re-encrypted using AES-GCM at '{parent_seed_path}'." ) return parent_seed - except InvalidTag: - 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")) From 065835d47022f791cebd81626b1b248e2c753c9d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 09:20:54 -0400 Subject: [PATCH 058/120] Add test for parent seed migration --- src/tests/test_seed_migration.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/tests/test_seed_migration.py diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py new file mode 100644 index 0000000..9fb150a --- /dev/null +++ b/src/tests/test_seed_migration.py @@ -0,0 +1,31 @@ +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" + legacy_backup = tmp_path / "parent_seed.enc.fernet" + + assert new_file.exists() + assert legacy_backup.exists() + assert new_file.read_bytes() != encrypted + assert legacy_backup.read_bytes() == encrypted From 0f6751849337293afbdbaa783ab8db6dd22a8e7b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 09:26:46 -0400 Subject: [PATCH 059/120] docs: describe automatic parent seed migration --- README.md | 3 +++ docs/docs/content/01-getting-started/04-migrations.md | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index bd26a2e..d4de0c3 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,9 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers > `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 diff --git a/docs/docs/content/01-getting-started/04-migrations.md b/docs/docs/content/01-getting-started/04-migrations.md index 3c9f18e..f6a1ce0 100644 --- a/docs/docs/content/01-getting-started/04-migrations.md +++ b/docs/docs/content/01-getting-started/04-migrations.md @@ -36,3 +36,12 @@ 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. From dacc591c25735ea74bddf9c0ea326820aa53bf25 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 09:48:36 -0400 Subject: [PATCH 060/120] feat: centralize decryption with version prefix --- src/password_manager/encryption.py | 140 +++++++++++++++-------------- 1 file changed, 71 insertions(+), 69 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index de0c020..c0f6954 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -132,35 +132,22 @@ class EncryptionManager: fh.seek(0) encrypted_data = fh.read() - try: - decrypted = self.decrypt_data(encrypted_data) - parent_seed = decrypted.decode("utf-8").strip() + is_legacy = not encrypted_data.startswith(b"V2:") + decrypted = self.decrypt_data(encrypted_data) + parent_seed = decrypted.decode("utf-8").strip() + + if is_legacy: + legacy_path = parent_seed_path.with_suffix( + parent_seed_path.suffix + ".fernet" + ) + os.rename(parent_seed_path, legacy_path) + self.encrypt_parent_seed(parent_seed) logger.debug( - f"Parent seed decrypted successfully from '{parent_seed_path}'." + f"Parent seed migrated from Fernet and re-encrypted at '{parent_seed_path}'." ) - return parent_seed - except (InvalidTag, InvalidToken): - logger.info( - "AES-GCM decryption failed for parent seed, attempting Fernet fallback" - ) - try: - decrypted = self.fernet.decrypt(encrypted_data) - except InvalidToken as e: - logger.error( - f"Fernet decryption failed for '{parent_seed_path}': {e}", - exc_info=True, - ) - raise - parent_seed = decrypted.decode("utf-8").strip() - - legacy_path = parent_seed_path.with_suffix( - parent_seed_path.suffix + ".fernet" - ) - os.rename(parent_seed_path, legacy_path) - self.encrypt_parent_seed(parent_seed) logger.debug( - f"Parent seed decrypted with Fernet and re-encrypted using AES-GCM at '{parent_seed_path}'." + f"Parent seed decrypted successfully from '{parent_seed_path}'." ) return parent_seed except Exception as e: @@ -169,12 +156,12 @@ class EncryptionManager: raise def encrypt_data(self, data: bytes) -> bytes: - """Encrypt ``data`` with AES-GCM and prepend the nonce.""" + """Encrypt ``data`` with AES-GCM and prepend a version header.""" try: nonce = os.urandom(12) ciphertext = self.cipher.encrypt(nonce, data, None) - encrypted_data = nonce + ciphertext + encrypted_data = b"V2:" + nonce + ciphertext logger.debug("Data encrypted successfully with AES-GCM.") return encrypted_data except Exception as e: @@ -183,22 +170,31 @@ class EncryptionManager: raise def decrypt_data(self, encrypted_data: bytes) -> bytes: - """Decrypt AES-GCM data that includes a prepended nonce.""" + """Decrypt data using AES-GCM or legacy Fernet.""" try: - nonce, ciphertext = encrypted_data[:12], encrypted_data[12:] + # Attempt AES-GCM decryption first + if encrypted_data.startswith(b"V2:"): + nonce = encrypted_data[3:15] + ciphertext = encrypted_data[15:] + else: + nonce = encrypted_data[:12] + ciphertext = encrypted_data[12:] + decrypted_data = self.cipher.decrypt(nonce, ciphertext, None) logger.debug("Data decrypted successfully with AES-GCM.") return decrypted_data except InvalidTag: - 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 + if encrypted_data.startswith(b"V2:"): + logger.error("AES-GCM decryption failed: invalid tag", exc_info=True) + raise + try: + decrypted_data = self.fernet.decrypt(encrypted_data) + logger.info("Legacy Fernet data decrypted successfully.") + return decrypted_data + except InvalidToken: + logger.error("Legacy Fernet decryption failed", exc_info=True) + raise InvalidTag("Data could not be decrypted") def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None: """ @@ -262,9 +258,10 @@ class EncryptionManager: decrypted_data = self.decrypt_data(encrypted_data) logger.debug(f"Data decrypted successfully from '{file_path}'.") return decrypted_data - except InvalidTag: + except (InvalidTag, InvalidToken) as e: logger.error( - "Invalid encryption key or corrupted data while decrypting file." + "Invalid encryption key or corrupted data while decrypting file.", + exc_info=True, ) raise except Exception as e: @@ -331,37 +328,33 @@ class EncryptionManager: ) return {"entries": {}} + with exclusive_lock(file_path) as fh: + fh.seek(0) + encrypted_bytes = fh.read() + + is_legacy = not encrypted_bytes.startswith(b"V2:") + 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}") + decrypted_data = self.decrypt_data(encrypted_bytes) + data = json.loads(decrypted_data.decode("utf-8")) + + if is_legacy: + legacy_path = file_path.with_suffix(file_path.suffix + ".fernet") + os.rename(file_path, legacy_path) + chk = file_path.parent / f"{file_path.stem}_checksum.txt" + if chk.exists(): + chk.rename(chk.with_suffix(chk.suffix + ".fernet")) + self.save_json_data(data, relative_path) + self.update_checksum(relative_path) + logger.info(f"Migrated legacy vault file: {file_path}") + + logger.debug(f"JSON data loaded and decrypted from '{file_path}'") return data - except (InvalidTag, json.JSONDecodeError): - logger.info( - f"AES-GCM decryption failed for '{file_path}', attempting Fernet fallback" + except (InvalidTag, InvalidToken, json.JSONDecodeError) as e: + logger.error( + f"Could not load or migrate data from {file_path}: {e}", exc_info=True ) - with exclusive_lock(file_path) as fh: - fh.seek(0) - legacy_bytes = fh.read() - try: - legacy_plain = decrypt_legacy_fernet(self.key_b64, legacy_bytes) - data = json.loads(legacy_plain.decode("utf-8").strip()) - except (InvalidToken, json.JSONDecodeError) as e: - logger.error( - f"Legacy decryption failed for '{file_path}': {e}", exc_info=True - ) - raise - - legacy_path = file_path.with_suffix(file_path.suffix + ".fernet") - os.rename(file_path, legacy_path) - chk = file_path.parent / f"{file_path.stem}_checksum.txt" - if chk.exists(): - chk.rename(chk.with_suffix(chk.suffix + ".fernet")) - - self.save_json_data(data, relative_path) - self.update_checksum(relative_path) - return data + raise except Exception as e: logger.error( f"Failed to load JSON data from '{file_path}': {e}", exc_info=True @@ -468,8 +461,18 @@ class EncryptionManager: 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.") + logger.info("Index file from Nostr was processed and saved successfully.") print(colored("Index file updated from Nostr successfully.", "green")) + except (InvalidToken, InvalidTag, json.JSONDecodeError) 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" + ) + ) + raise except Exception as e: logger.error( f"Failed to decrypt and save data from Nostr: {e}", exc_info=True @@ -479,7 +482,6 @@ class EncryptionManager: 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 def validate_seed(self, seed_phrase: str) -> bool: From 2f143b6710e6f112c068a6f0c56ec775f9b4fa2a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 10:22:43 -0400 Subject: [PATCH 061/120] Fix decryption migration logic --- src/password_manager/encryption.py | 529 ++++++++--------------------- src/tests/test_legacy_migration.py | 1 - src/tests/test_portable_backup.py | 4 +- src/tests/test_seed_migration.py | 4 +- 4 files changed, 142 insertions(+), 396 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index c0f6954..6bcdc07 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -1,68 +1,38 @@ -# password_manager/encryption.py - -""" -Encryption Module - -This module provides the ``EncryptionManager`` class which handles encryption and -decryption of data and files using a provided AES-GCM encryption key. Legacy -databases encrypted with Fernet are still supported for decryption. 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 import hashlib import os +import base64 from pathlib import Path from typing import Optional -import base64 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__) -def decrypt_legacy_fernet(encryption_key: bytes | str, payload: bytes) -> bytes: - """Decrypt *payload* using legacy Fernet.""" - if isinstance(encryption_key, str): - key = encryption_key.encode() - else: - key = encryption_key - f = Fernet(key) - return f.decrypt(payload) - - class EncryptionManager: - """EncryptionManager Class - - Manages the encryption and decryption of data and files using an AES-GCM - key. A :class:`cryptography.fernet.Fernet` instance is also kept for - decrypting legacy files that were encrypted using Fernet. + """ + Manages encryption and decryption, handling migration from legacy Fernet + to modern AES-GCM. """ def __init__(self, encryption_key: bytes, fingerprint_dir: Path): - """Initialize the manager with a base64 encoded key and directory. - - The provided key is used to create both an AES-GCM cipher for current - operations and a Fernet cipher for decrypting legacy files. + """ + Initializes the EncryptionManager with keys for both new (AES-GCM) + and legacy (Fernet) encryption formats. Parameters: - encryption_key (bytes): Base64 encoded key material. - fingerprint_dir (Path): Directory corresponding to the fingerprint. + 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" @@ -70,409 +40,175 @@ class EncryptionManager: try: 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.key = base64.urlsafe_b64decode(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} using AES-GCM with Fernet fallback" - ) + + logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}") except Exception as e: logger.error( - f"Failed to initialize AESGCM 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: - """Decrypt and return the stored parent seed.""" - - parent_seed_path = self.fingerprint_dir / "parent_seed.enc" - try: - with exclusive_lock(parent_seed_path) as fh: - fh.seek(0) - encrypted_data = fh.read() - - is_legacy = not encrypted_data.startswith(b"V2:") - decrypted = self.decrypt_data(encrypted_data) - parent_seed = decrypted.decode("utf-8").strip() - - if is_legacy: - legacy_path = parent_seed_path.with_suffix( - parent_seed_path.suffix + ".fernet" - ) - os.rename(parent_seed_path, legacy_path) - self.encrypt_parent_seed(parent_seed) - logger.debug( - f"Parent seed migrated from Fernet and re-encrypted at '{parent_seed_path}'." - ) - - logger.debug( - f"Parent seed decrypted successfully from '{parent_seed_path}'." - ) - return parent_seed - 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: - """Encrypt ``data`` with AES-GCM and prepend a version header.""" - + """ + (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: - nonce = os.urandom(12) + nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM ciphertext = self.cipher.encrypt(nonce, data, None) - encrypted_data = b"V2:" + nonce + ciphertext - logger.debug("Data encrypted successfully with AES-GCM.") - return encrypted_data + 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: - """Decrypt data using AES-GCM or legacy Fernet.""" - - try: - # Attempt AES-GCM decryption first - if encrypted_data.startswith(b"V2:"): + """ + (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 the new V2 format first + if encrypted_data.startswith(b"V2:"): + try: nonce = encrypted_data[3:15] ciphertext = encrypted_data[15:] - else: - nonce = encrypted_data[:12] - ciphertext = encrypted_data[12:] + return self.cipher.decrypt(nonce, ciphertext, None) + except InvalidTag as e: + logger.error("AES-GCM decryption failed: Invalid authentication tag.") + raise InvalidToken("AES-GCM decryption failed.") from e - decrypted_data = self.cipher.decrypt(nonce, ciphertext, None) - logger.debug("Data decrypted successfully with AES-GCM.") - return decrypted_data - except InvalidTag: - if encrypted_data.startswith(b"V2:"): - logger.error("AES-GCM decryption failed: invalid tag", exc_info=True) - raise + # If it's not V2, it must be the legacy Fernet format + else: + logger.warning("Data is in legacy Fernet format. Attempting migration.") try: - decrypted_data = self.fernet.decrypt(encrypted_data) - logger.info("Legacy Fernet data decrypted successfully.") - return decrypted_data - except InvalidToken: - logger.error("Legacy Fernet decryption failed", exc_info=True) - raise InvalidTag("Data could not be decrypted") + 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) + 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 (InvalidTag, InvalidToken) as e: - logger.error( - "Invalid encryption key or corrupted data while decrypting file.", - exc_info=True, - ) - 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 + 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}'.") 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." - ) - print( - colored( - f"Info: Index file '{file_path}' not found. Initializing new password database.", - "yellow", - ) - ) return {"entries": {}} with exclusive_lock(file_path) as fh: fh.seek(0) - encrypted_bytes = fh.read() + encrypted_data = fh.read() - is_legacy = not encrypted_bytes.startswith(b"V2:") + is_legacy = not encrypted_data.startswith(b"V2:") try: - decrypted_data = self.decrypt_data(encrypted_bytes) + decrypted_data = self.decrypt_data(encrypted_data) data = json.loads(decrypted_data.decode("utf-8")) + # If it was a legacy file, re-save it in the new format now if is_legacy: - legacy_path = file_path.with_suffix(file_path.suffix + ".fernet") - os.rename(file_path, legacy_path) - chk = file_path.parent / f"{file_path.stem}_checksum.txt" - if chk.exists(): - chk.rename(chk.with_suffix(chk.suffix + ".fernet")) + logger.info(f"Migrating and re-saving legacy vault file: {file_path}") self.save_json_data(data, relative_path) self.update_checksum(relative_path) - logger.info(f"Migrated legacy vault file: {file_path}") - logger.debug(f"JSON data loaded and decrypted from '{file_path}'") return data - except (InvalidTag, InvalidToken, json.JSONDecodeError) as e: + except (InvalidToken, InvalidTag, json.JSONDecodeError) as e: logger.error( - f"Could not load or migrate data from {file_path}: {e}", exc_info=True - ) - 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'. - """ - 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.") - - 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.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", - ) + f"FATAL: Could not decrypt or parse data from {file_path}: {e}", + exc_info=True, ) 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}", - exc_info=True, - ) - print( - colored( - f"Error: Failed to read encrypted index file '{relative_path}': {e}", - "red", - ) - ) + 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 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'. - """ + """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) + decrypted_data = self.decrypt_data( + encrypted_data + ) # This now handles both formats data = json.loads(decrypted_data.decode("utf-8")) - self.save_json_data(data, relative_path) + 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 (InvalidToken, InvalidTag, json.JSONDecodeError) 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" - ) - ) - raise except Exception as e: logger.error( f"Failed to decrypt and save data from Nostr: {e}", exc_info=True @@ -484,13 +220,34 @@ class EncryptionManager: ) raise - def validate_seed(self, seed_phrase: str) -> bool: - """ - Validates the seed phrase format using BIP-39 standards. + def update_checksum(self, relative_path: Optional[Path] = None) -> None: + """Updates the checksum file for the specified file.""" + if relative_path is None: + relative_path = Path("seedpass_entries_db.json.enc") - :param seed_phrase: The BIP39 seed phrase to validate. - :return: True if valid, False otherwise. - """ + 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() + checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt" + with exclusive_lock(checksum_file) as fh: + fh.seek(0) + fh.truncate() + fh.write(checksum.encode("utf-8")) + os.chmod(checksum_file, 0o600) + except Exception as e: + logger.error( + f"Failed to update checksum for '{relative_path}': {e}", exc_info=True + ) + raise + + # ... validate_seed and derive_seed_from_mnemonic can remain the same ... + def validate_seed(self, seed_phrase: str) -> bool: try: words = seed_phrase.split() if len(words) != 12: @@ -499,7 +256,6 @@ class EncryptionManager: 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: @@ -508,13 +264,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/tests/test_legacy_migration.py b/src/tests/test_legacy_migration.py index a179cd9..87cd3ed 100644 --- a/src/tests/test_legacy_migration.py +++ b/src/tests/test_legacy_migration.py @@ -40,4 +40,3 @@ def test_legacy_index_migrates(tmp_path: Path): assert new_file.exists() assert not legacy_file.exists() assert not (tmp_path / "seedpass_passwords_db_checksum.txt").exists() - assert (tmp_path / ("seedpass_entries_db.json.enc.fernet")).exists() diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 07e6fe0..dc8910b 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -51,7 +51,7 @@ def test_round_trip(monkeypatch): assert vault.load_index()["pw"] == data["pw"] -from cryptography.exceptions import InvalidTag +from cryptography.fernet import InvalidToken def test_corruption_detection(monkeypatch): @@ -68,7 +68,7 @@ def test_corruption_detection(monkeypatch): content["payload"] = base64.b64encode(payload).decode() path.write_text(json.dumps(content)) - with pytest.raises(InvalidTag): + with pytest.raises(InvalidToken): import_backup(vault, backup, path, parent_seed=SEED) diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py index 9fb150a..a273be3 100644 --- a/src/tests/test_seed_migration.py +++ b/src/tests/test_seed_migration.py @@ -23,9 +23,7 @@ def test_parent_seed_migrates_from_fernet(tmp_path: Path) -> None: assert decrypted == TEST_SEED new_file = tmp_path / "parent_seed.enc" - legacy_backup = tmp_path / "parent_seed.enc.fernet" assert new_file.exists() - assert legacy_backup.exists() assert new_file.read_bytes() != encrypted - assert legacy_backup.read_bytes() == encrypted + assert new_file.read_bytes().startswith(b"V2:") From 6bc8fe70f6f584aa36b53262ab037d95cfb585c1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 10:47:45 -0400 Subject: [PATCH 062/120] Add optional orjson support --- requirements.lock | 1 + src/password_manager/encryption.py | 29 ++++++++++++++++++++---- src/password_manager/entry_management.py | 16 ++++++++++--- src/requirements.txt | 1 + 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/requirements.lock b/requirements.lock index 0318410..f762a5c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -32,6 +32,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/src/password_manager/encryption.py b/src/password_manager/encryption.py index 6bcdc07..8c1f8de 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -2,7 +2,17 @@ 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 @@ -145,7 +155,10 @@ class EncryptionManager: def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None: if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") - json_data = json.dumps(data, indent=4).encode("utf-8") + 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}'.") @@ -169,7 +182,10 @@ class EncryptionManager: try: decrypted_data = self.decrypt_data(encrypted_data) - data = json.loads(decrypted_data.decode("utf-8")) + 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: @@ -178,7 +194,7 @@ class EncryptionManager: self.update_checksum(relative_path) return data - except (InvalidToken, InvalidTag, json.JSONDecodeError) as e: + except (InvalidToken, InvalidTag, JSONDecodeError) as e: logger.error( f"FATAL: Could not decrypt or parse data from {file_path}: {e}", exc_info=True, @@ -204,7 +220,10 @@ class EncryptionManager: decrypted_data = self.decrypt_data( encrypted_data ) # This now handles both formats - data = json.loads(decrypted_data.decode("utf-8")) + 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.") diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index aad4722..a9b90ae 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 @@ -1155,8 +1162,11 @@ class EntryManager: """ try: data = self._load_index() - json_content = json.dumps(data, indent=4) - checksum = hashlib.sha256(json_content.encode("utf-8")).hexdigest() + if USE_ORJSON: + json_bytes = json_lib.dumps(data) + else: + json_bytes = json_lib.dumps(data, separators=(",", ":")).encode("utf-8") + checksum = hashlib.sha256(json_bytes).hexdigest() # The checksum file path already includes the fingerprint directory checksum_path = self.checksum_file diff --git a/src/requirements.txt b/src/requirements.txt index 1bfbef1..64539a2 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -30,3 +30,4 @@ uvicorn>=0.35.0 httpx>=0.28.1 requests>=2.32 python-multipart +orjson From 526d31325a0a142e00dba45d7137001e332a89a7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 10:55:51 -0400 Subject: [PATCH 063/120] Use canonical serializer for entry checksum --- src/password_manager/entry_management.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index a9b90ae..0aaed6a 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -35,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 @@ -1162,11 +1163,8 @@ class EntryManager: """ try: data = self._load_index() - if USE_ORJSON: - json_bytes = json_lib.dumps(data) - else: - json_bytes = json_lib.dumps(data, separators=(",", ":")).encode("utf-8") - checksum = hashlib.sha256(json_bytes).hexdigest() + 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 From ba53cf23329fcfe360a434af4fe2837d18dd458c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 11:30:47 -0400 Subject: [PATCH 064/120] Add backup interval setting and throttled backups --- src/password_manager/backup.py | 10 +++++++- src/password_manager/config_manager.py | 16 ++++++++++++ src/seedpass/cli.py | 1 + src/tests/test_backup_interval.py | 34 ++++++++++++++++++++++++++ src/tests/test_cli_config_set_extra.py | 1 + src/tests/test_config_manager.py | 11 +++++++++ 6 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_backup_interval.py 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 68a154e..c2c23a1 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -46,6 +46,7 @@ class ConfigManager: "inactivity_timeout": INACTIVITY_TIMEOUT, "kdf_iterations": 100_000, "additional_backup_path": "", + "backup_interval": 0, "secret_mode_enabled": False, "clipboard_clear_delay": 45, } @@ -60,6 +61,7 @@ class ConfigManager: data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("kdf_iterations", 100_000) data.setdefault("additional_backup_path", "") + data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) @@ -85,6 +87,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}") @@ -187,3 +190,16 @@ 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)) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 92f46a5..5112f85 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -461,6 +461,7 @@ 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 ), + "backup_interval": lambda v: cfg.set_backup_interval(float(v)), } action = mapping.get(key) 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..999df2e 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -14,6 +14,7 @@ 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), ( "relays", "wss://a.com, wss://b.com", diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 385657d..799ea84 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -158,3 +158,14 @@ def test_kdf_iterations_round_trip(): 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 From d333564aa723a12feab71eaa7317596d3d654e7c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 11:37:51 -0400 Subject: [PATCH 065/120] Add kdf_iterations config set option and test --- src/seedpass/cli.py | 1 + src/tests/test_cli_config_set_extra.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 5112f85..66c1624 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -461,6 +461,7 @@ 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)), "backup_interval": lambda v: cfg.set_backup_interval(float(v)), } diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index 999df2e..97af474 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -15,6 +15,7 @@ runner = CliRunner() ("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), ( "relays", "wss://a.com, wss://b.com", From 1c4a8c0aa446d3ccb81e23d44f9759f5ccfda387 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 11:50:26 -0400 Subject: [PATCH 066/120] Document kdf and backup settings --- README.md | 20 ++++++++++++++----- .../01-getting-started/01-advanced_cli.md | 10 +++++----- .../01-getting-started/02-api_reference.md | 16 +++++++++++++++ src/tests/test_cli_doc_examples.py | 2 ++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d4de0c3..84d71d2 100644 --- a/README.md +++ b/README.md @@ -403,11 +403,20 @@ Back in the Settings menu you can: - 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 `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. +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 +``` + +Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. ## Running Tests @@ -475,6 +484,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **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. +- **KDF Iteration Caution:** Lowering `kdf_iterations` makes password cracking easier, while a high `backup_interval` leaves fewer recent backups. ## Contributing diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 1e54484..8ac0258 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -91,8 +91,8 @@ 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 secret_mode_enabled true` | +| Get a setting value | `config get` | `seedpass config get kdf_iterations` | +| Set a setting value | `config set` | `seedpass config set backup_interval 3600` | ### Fingerprint Commands @@ -171,8 +171,8 @@ Code: 123456 ### `config` Commands -- **`seedpass config get `** – Retrieve a configuration value such as `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, or `relays`. -- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set secret_mode_enabled true`. +- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, or `relays`. +- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. - **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay. ### `fingerprint` Commands @@ -208,5 +208,5 @@ 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_enabled` through the `config` commands. +- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, or `secret_mode_enabled` through the `config` commands. - `entry get` is script‑friendly and can be piped into other commands. diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md index 64e4c7a..9c0e30c 100644 --- a/docs/docs/content/01-getting-started/02-api_reference.md +++ b/docs/docs/content/01-getting-started/02-api_reference.md @@ -102,6 +102,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`: diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 84223ca..48c8692 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -57,6 +57,8 @@ class DummyPM: 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, From f86067c1d868be65752d7dc36609cbeffe2eb6cc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 12:24:10 -0400 Subject: [PATCH 067/120] Add Argon2 key derivation option --- requirements.lock | 1 + src/password_manager/config_manager.py | 15 ++++++ src/password_manager/manager.py | 21 +++++++- src/requirements.txt | 1 + src/seedpass/cli.py | 1 + src/tests/test_cli_config_set_extra.py | 1 + src/tests/test_kdf_modes.py | 75 ++++++++++++++++++++++++++ src/tests/test_key_derivation.py | 9 ++++ src/utils/key_derivation.py | 36 +++++++++++++ 9 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/tests/test_kdf_modes.py diff --git a/requirements.lock b/requirements.lock index f762a5c..f3e21b4 100644 --- a/requirements.lock +++ b/requirements.lock @@ -2,6 +2,7 @@ aiohappyeyeballs==2.6.1 aiohttp==3.12.13 aiosignal==1.3.2 attrs==25.3.0 +argon2-cffi==23.1.0 base58==2.1.1 bcrypt==4.3.0 bech32==1.2.0 diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index c2c23a1..18a46ab 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -45,6 +45,7 @@ class ConfigManager: "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, "kdf_iterations": 100_000, + "kdf_mode": "pbkdf2", "additional_backup_path": "", "backup_interval": 0, "secret_mode_enabled": False, @@ -60,6 +61,7 @@ class ConfigManager: data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("kdf_iterations", 100_000) + data.setdefault("kdf_mode", "pbkdf2") data.setdefault("additional_backup_path", "") data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) @@ -155,6 +157,19 @@ class ConfigManager: config = self.load_config(require_pin=False) return int(config.get("kdf_iterations", 100_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) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e631bb4..65a044f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -35,6 +35,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, ) @@ -387,13 +388,21 @@ class PasswordManager: 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 100_000 ) print("Deriving key...") - seed_key = derive_key_from_password(password, iterations=iterations) + 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: @@ -448,12 +457,20 @@ class PasswordManager: password = prompt_existing_password("Enter your master password: ") try: + 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 100_000 ) - seed_key = derive_key_from_password(password, iterations=iterations) + 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() diff --git a/src/requirements.txt b/src/requirements.txt index 64539a2..396ec2c 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -31,3 +31,4 @@ httpx>=0.28.1 requests>=2.32 python-multipart orjson +argon2-cffi diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 66c1624..27776e1 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -462,6 +462,7 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: [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)), } diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index 97af474..95abdbd 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -16,6 +16,7 @@ runner = CliRunner() ("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"), ( "relays", "wss://a.com, wss://b.com", 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/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. From b4dfd4b292c17f92c21347f457a065f2444b97bf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:00:02 -0400 Subject: [PATCH 068/120] Add configurable password policy --- .../01-getting-started/01-advanced_cli.md | 5 +- src/password_manager/config_manager.py | 41 +++++++++++ src/password_manager/manager.py | 1 + src/password_manager/password_generation.py | 28 ++++++-- src/seedpass/cli.py | 4 ++ src/tests/test_password_generation_policy.py | 68 +++++++++++++++++++ src/tests/test_password_helpers.py | 3 +- src/tests/test_password_length_constraints.py | 3 +- src/tests/test_password_properties.py | 3 +- 9 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 src/tests/test_password_generation_policy.py diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 8ac0258..7b71add 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -171,8 +171,8 @@ Code: 123456 ### `config` Commands -- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, or `relays`. -- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. +- **`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`, 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`, or `min_special` to adjust password complexity. - **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay. ### `fingerprint` Commands @@ -209,4 +209,5 @@ 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 `kdf_iterations`, `backup_interval`, `inactivity_timeout`, or `secret_mode_enabled` 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/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 18a46ab..7486012 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -50,6 +50,10 @@ class ConfigManager: "backup_interval": 0, "secret_mode_enabled": False, "clipboard_clear_delay": 45, + "min_uppercase": 2, + "min_lowercase": 2, + "min_digits": 2, + "min_special": 2, } try: data = self.vault.load_config() @@ -66,6 +70,10 @@ class ConfigManager: data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) + data.setdefault("min_uppercase", 2) + data.setdefault("min_lowercase", 2) + data.setdefault("min_digits", 2) + data.setdefault("min_special", 2) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -218,3 +226,36 @@ class ConfigManager: """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) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 65a044f..ceabd04 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -976,6 +976,7 @@ 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 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/seedpass/cli.py b/src/seedpass/cli.py index 27776e1..6c93192 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -464,6 +464,10 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "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)), + "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)), } action = mapping.get(key) 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 From cca860adf51efa8d34318207df7ba25e9b378c1f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:15:05 -0400 Subject: [PATCH 069/120] Add offline mode feature --- README.md | 1 + .../01-getting-started/01-advanced_cli.md | 2 + docs/docs/content/index.md | 1 + src/nostr/client.py | 38 ++++++++++++++++-- src/password_manager/config_manager.py | 13 ++++++ src/password_manager/manager.py | 11 ++++- src/seedpass/cli.py | 35 ++++++++++++++++ src/tests/test_cli_doc_examples.py | 2 + src/tests/test_cli_toggle_offline_mode.py | 40 +++++++++++++++++++ src/tests/test_offline_mode_behavior.py | 27 +++++++++++++ 10 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 src/tests/test_cli_toggle_offline_mode.py create mode 100644 src/tests/test_offline_mode_behavior.py diff --git a/README.md b/README.md index 84d71d2..535523f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **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. ## Prerequisites diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 7b71add..61375bf 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -93,6 +93,7 @@ Manage profile‑specific settings. | :--- | :--- | :--- | | 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 @@ -174,6 +175,7 @@ Code: 123456 - **`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`, 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`, or `min_special` to adjust password complexity. - **`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 diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 4f8e77f..1a748ff 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -60,6 +60,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **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 diff --git a/src/nostr/client.py b/src/nostr/client.py index 4ebe15f..1268f73 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -90,6 +90,7 @@ class NostrClient: fingerprint: str, relays: Optional[List[str]] = None, parent_seed: Optional[str] = None, + offline_mode: bool = False, ) -> None: self.encryption_manager = encryption_manager self.fingerprint = fingerprint @@ -110,7 +111,11 @@ 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 # store the last error encountered during network operations self.last_error: Optional[str] = None @@ -127,19 +132,27 @@ class NostrClient: 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: @@ -181,6 +194,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( @@ -201,6 +216,8 @@ 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: @@ -233,10 +250,14 @@ 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) @@ -252,6 +273,8 @@ class NostrClient: self, retries: int = 0, delay: float = 2.0 ) -> Optional[bytes]: """Retrieve the latest Kind 1 event from the author with optional retries.""" + if self.offline_mode or not self.relays: + return None self.connect() self.last_error = None attempt = 0 @@ -270,6 +293,8 @@ 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() @@ -304,6 +329,8 @@ class NostrClient: Maximum chunk size in bytes. Defaults to 50 kB. """ + 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): @@ -336,7 +363,8 @@ class NostrClient: 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() @@ -376,7 +404,8 @@ class NostrClient: 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") @@ -392,7 +421,8 @@ class NostrClient: 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() diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 7486012..da02273 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -41,6 +41,7 @@ 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, @@ -61,6 +62,7 @@ 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) @@ -196,11 +198,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: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index ceabd04..451d71d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -135,6 +135,7 @@ 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 @@ -982,17 +983,19 @@ class PasswordManager: # 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)) - - print("Connecting to relays...") + 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, parent_seed=getattr(self, "parent_seed", None), ) @@ -1028,6 +1031,8 @@ class PasswordManager: 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 @@ -3312,6 +3317,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 diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 6c93192..7de30b5 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -535,6 +535,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.""" diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 48c8692..e9012d4 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -63,8 +63,10 @@ class DummyPM: set_clipboard_clear_delay=lambda v: None, set_additional_backup_path=lambda v: None, set_relays=lambda v, require_pin=False: 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 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_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 From f1018a5c2b02d4b118ddb4879154f7215861f36c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:51:15 -0400 Subject: [PATCH 070/120] Add Hypothesis fuzz test for EncryptionManager --- docs/docs/content/index.md | 5 +++ src/tests/test_fuzz_key_derivation.py | 63 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/tests/test_fuzz_key_derivation.py diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 1a748ff..66c3069 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -423,6 +423,11 @@ 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. 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 From 96d5a1bb5773021f31d15e448a6a2c60dc5de1ed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 14:40:15 -0400 Subject: [PATCH 071/120] Lower KDF iteration default --- README.md | 3 ++- docs/docs/content/index.md | 1 + src/password_manager/config_manager.py | 6 +++--- src/password_manager/manager.py | 10 +++++----- src/tests/test_config_manager.py | 4 ++-- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 535523f..e3d1e70 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ seedpass config set kdf_iterations 200000 seedpass config set backup_interval 3600 ``` -Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. +The default configuration uses **50,000** PBKDF2 iterations. Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. ## Running Tests @@ -485,6 +485,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **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. ## Contributing diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 66c3069..ef46fa5 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -494,6 +494,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **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. ## Contributing diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index da02273..c3bba67 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -45,7 +45,7 @@ class ConfigManager: "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, - "kdf_iterations": 100_000, + "kdf_iterations": 50_000, "kdf_mode": "pbkdf2", "additional_backup_path": "", "backup_interval": 0, @@ -66,7 +66,7 @@ class ConfigManager: data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) - data.setdefault("kdf_iterations", 100_000) + data.setdefault("kdf_iterations", 50_000) data.setdefault("kdf_mode", "pbkdf2") data.setdefault("additional_backup_path", "") data.setdefault("backup_interval", 0) @@ -165,7 +165,7 @@ class ConfigManager: def get_kdf_iterations(self) -> int: """Retrieve the PBKDF2 iteration count.""" config = self.load_config(require_pin=False) - return int(config.get("kdf_iterations", 100_000)) + return int(config.get("kdf_iterations", 50_000)) def set_kdf_mode(self, mode: str) -> None: """Persist the key derivation function mode.""" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 451d71d..e74b1e5 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -397,7 +397,7 @@ class PasswordManager: iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) - else 100_000 + else 50_000 ) print("Deriving key...") if mode == "argon2": @@ -466,7 +466,7 @@ class PasswordManager: iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) - else 100_000 + else 50_000 ) if mode == "argon2": seed_key = derive_key_from_password_argon2(password) @@ -618,7 +618,7 @@ class PasswordManager: iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) - else 100_000 + else 50_000 ) key = derive_key_from_password(password, iterations=iterations) @@ -744,7 +744,7 @@ class PasswordManager: iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) - else 100_000 + else 50_000 ) seed_key = derive_key_from_password(password, iterations=iterations) @@ -901,7 +901,7 @@ class PasswordManager: iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) - else 100_000 + else 50_000 ) seed_key = derive_key_from_password(password, iterations=iterations) diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 799ea84..9402bb8 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -23,7 +23,7 @@ def test_config_defaults_and_round_trip(): assert cfg["pin_hash"] == "" assert cfg["password_hash"] == "" assert cfg["additional_backup_path"] == "" - assert cfg["kdf_iterations"] == 100_000 + assert cfg["kdf_iterations"] == 50_000 cfg_mgr.set_pin("1234") cfg_mgr.set_relays(["wss://example.com"], require_pin=False) @@ -154,7 +154,7 @@ def test_kdf_iterations_round_trip(): vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, Path(tmpdir)) - assert cfg_mgr.get_kdf_iterations() == 100_000 + assert cfg_mgr.get_kdf_iterations() == 50_000 cfg_mgr.set_kdf_iterations(200_000) assert cfg_mgr.get_kdf_iterations() == 200_000 From f5dcaf9af49bfca2b7fdfa72172de5f06af99ffb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 14:59:22 -0400 Subject: [PATCH 072/120] Add quick unlock config option --- README.md | 2 ++ .../01-getting-started/01-advanced_cli.md | 6 +++--- docs/docs/content/index.md | 1 + src/password_manager/config_manager.py | 13 +++++++++++++ src/seedpass/api.py | 1 + src/seedpass/cli.py | 3 +++ src/tests/test_api.py | 17 +++++++++++++++++ src/tests/test_cli_config_set_extra.py | 1 + src/tests/test_config_manager.py | 12 ++++++++++++ 9 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3d1e70..a9d620c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **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. - **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. @@ -415,6 +416,7 @@ 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 ``` The default configuration uses **50,000** PBKDF2 iterations. Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 61375bf..6644a02 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -172,8 +172,8 @@ Code: 123456 ### `config` Commands -- **`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`, 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`, or `min_special` to adjust password complexity. +- **`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`, 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`, 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. @@ -210,6 +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 `kdf_iterations`, `backup_interval`, `inactivity_timeout`, or `secret_mode_enabled` through the `config` commands. +- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, 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/docs/content/index.md b/docs/docs/content/index.md index ef46fa5..2f061ed 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -51,6 +51,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **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. - **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. diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index c3bba67..e3c9d10 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -51,6 +51,7 @@ class ConfigManager: "backup_interval": 0, "secret_mode_enabled": False, "clipboard_clear_delay": 45, + "quick_unlock": False, "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, @@ -72,6 +73,7 @@ class ConfigManager: data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) + data.setdefault("quick_unlock", False) data.setdefault("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) @@ -272,3 +274,14 @@ class ConfigManager: 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)) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 85a23f1..77ad26e 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -271,6 +271,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) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 7de30b5..6280749 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -468,6 +468,9 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "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") + ), } action = mapping.get(key) 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_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index 95abdbd..be4cae6 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -17,6 +17,7 @@ runner = CliRunner() ("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), ( "relays", "wss://a.com, wss://b.com", diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 9402bb8..61016a3 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -23,6 +23,7 @@ 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") @@ -169,3 +170,14 @@ def test_backup_interval_round_trip(): 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 From 8350504d006edabe71295b09fca1e2323fe22212 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 15:27:24 -0400 Subject: [PATCH 073/120] Use background sync when quick unlock --- src/password_manager/manager.py | 32 ++++++++++++++++++++++++++++---- src/tests/test_unlock_sync.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e74b1e5..548bb8e 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -530,7 +530,13 @@ class PasswordManager: # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr() + if ( + getattr(self, "config_manager", None) + and self.config_manager.get_quick_unlock() + ): + self.start_background_sync() + else: + self.sync_index_from_nostr() print(colored(f"Switched to seed profile {selected_fingerprint}.", "green")) # Re-initialize NostrClient with the new fingerprint @@ -603,7 +609,13 @@ class PasswordManager: self.initialize_managers() self.locked = False self.update_activity() - self.sync_index_from_nostr() + if ( + getattr(self, "config_manager", None) + and self.config_manager.get_quick_unlock() + ): + self.start_background_sync() + else: + self.sync_index_from_nostr() def handle_existing_seed(self) -> None: """ @@ -775,7 +787,13 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr() + if ( + getattr(self, "config_manager", None) + and self.config_manager.get_quick_unlock() + ): + self.start_background_sync() + else: + self.sync_index_from_nostr() return fingerprint # Return the generated or added fingerprint except BaseException: # Clean up partial profile on failure or interruption @@ -930,7 +948,13 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr() + if ( + getattr(self, "config_manager", None) + and self.config_manager.get_quick_unlock() + ): + self.start_background_sync() + else: + 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")) diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index 892ea9a..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): @@ -26,3 +27,30 @@ def test_unlock_triggers_sync(monkeypatch, tmp_path): 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"] From 78499b267eb67e97864dd793d24280f3ab2bde33 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 15:42:24 -0400 Subject: [PATCH 074/120] Add configurable Nostr retry settings --- README.md | 2 ++ .../01-getting-started/01-advanced_cli.md | 6 ++-- src/constants.py | 5 ++-- src/nostr/client.py | 16 +++++++++- src/password_manager/config_manager.py | 30 +++++++++++++++++++ src/seedpass/cli.py | 2 ++ src/tests/test_cli_config_set_extra.py | 2 ++ src/tests/test_cli_doc_examples.py | 2 ++ src/tests/test_config_manager.py | 15 ++++++++++ 9 files changed, 74 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a9d620c..5ecc86f 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,8 @@ You can adjust these settings directly from the command line: 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. Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 6644a02..7fa3768 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -172,8 +172,8 @@ Code: 123456 ### `config` Commands -- **`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`, 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`, or `quick_unlock` to adjust settings. +- **`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. @@ -210,6 +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 `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, or `quick_unlock` 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/src/constants.py b/src/constants.py index dfcd0d1..2ebdf36 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 # ----------------------------------- diff --git a/src/nostr/client.py b/src/nostr/client.py index 1268f73..915ac8f 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -27,6 +27,7 @@ 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 # Backwards compatibility for tests that patch these symbols @@ -270,11 +271,24 @@ class NostrClient: self._connected = False 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: + 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, + ) + 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 diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index e3c9d10..08c5988 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -52,6 +52,8 @@ class ConfigManager: "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, @@ -74,6 +76,8 @@ class ConfigManager: 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) @@ -285,3 +289,29 @@ class ConfigManager: """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)) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 6280749..1eb32d5 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -464,6 +464,8 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "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)), diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index be4cae6..6c06b0c 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -18,6 +18,8 @@ runner = CliRunner() ("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 index e9012d4..44bf430 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -63,6 +63,8 @@ class DummyPM: 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, diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 61016a3..d26e465 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -181,3 +181,18 @@ def test_quick_unlock_round_trip(): 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 From 80c67905ae9144ddf7d5156df7b41281b2315b07 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 15:57:31 -0400 Subject: [PATCH 075/120] Allow passing ConfigManager to NostrClient --- scripts/generate_test_profile.py | 9 ++++++--- src/nostr/client.py | 22 +++++++++++++++------- src/password_manager/manager.py | 3 +++ src/password_manager/portable_backup.py | 6 +++++- src/tests/test_generate_test_profile.py | 3 ++- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3b92f8b..2837227 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -46,7 +46,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" @@ -98,7 +100,7 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] cfg_mgr.set_password_hash(hashed) 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: @@ -159,7 +161,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 +175,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/src/nostr/client.py b/src/nostr/client.py index 915ac8f..b37d6de 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 @@ -30,6 +30,9 @@ 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 @@ -92,10 +95,12 @@ class NostrClient: 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 if parent_seed is None: parent_seed = self.encryption_manager.decrypt_parent_seed() @@ -278,13 +283,16 @@ class NostrClient: return None if retries is None or delay is None: - from password_manager.config_manager import ConfigManager - from password_manager.vault import Vault + 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, - ) + 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)) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 548bb8e..50c7e17 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -544,6 +544,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( @@ -1020,6 +1021,7 @@ class PasswordManager: fingerprint=self.current_fingerprint, relays=relay_list, offline_mode=self.offline_mode, + config_manager=self.config_manager, parent_seed=getattr(self, "parent_seed", None), ) @@ -3718,6 +3720,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), ) diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index 3e27671..8731818 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -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/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() From 4893daa1b4571c29efc0a2bb695260346ee96d67 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:09:58 -0400 Subject: [PATCH 076/120] Add verbose timing logs --- src/nostr/client.py | 11 +++++++++ src/password_manager/config_manager.py | 11 +++++++++ src/password_manager/manager.py | 9 ++++++++ src/seedpass/cli.py | 3 +++ src/tests/test_verbose_timing.py | 32 ++++++++++++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 src/tests/test_verbose_timing.py diff --git a/src/nostr/client.py b/src/nostr/client.py index b37d6de..1a6bcb8 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -101,6 +101,7 @@ class NostrClient: 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() @@ -123,6 +124,12 @@ class NostrClient: 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 @@ -351,6 +358,7 @@ 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() @@ -381,6 +389,9 @@ class NostrClient: manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) self.current_manifest = manifest 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: diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 08c5988..269a10a 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -58,6 +58,7 @@ class ConfigManager: "min_lowercase": 2, "min_digits": 2, "min_special": 2, + "verbose_timing": False, } try: data = self.vault.load_config() @@ -82,6 +83,7 @@ class ConfigManager: 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" @@ -315,3 +317,12 @@ class ConfigManager: """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/manager.py b/src/password_manager/manager.py index 50c7e17..0407852 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -138,6 +138,7 @@ class PasswordManager: 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() @@ -247,6 +248,8 @@ class PasswordManager: "yellow", ) ) + if getattr(self, "verbose_timing", False): + logger.info("Vault unlocked in %.2f seconds", self.last_unlock_duration) def initialize_fingerprint_manager(self): """ @@ -1014,6 +1017,7 @@ class PasswordManager: ) 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( @@ -1034,6 +1038,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: @@ -1054,6 +1059,10 @@ class PasswordManager: 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.""" diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 1eb32d5..d8df065 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -473,6 +473,9 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "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) 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 From aabc93509707a6f563e7490ec99680f7c7341a9a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:49:57 -0400 Subject: [PATCH 077/120] Expose quick unlock and offline mode --- README.md | 6 +++- docs/docs/content/index.md | 6 +++- src/main.py | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5ecc86f..81e9a9d 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,8 @@ Back in the Settings menu you can: - 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: @@ -421,7 +423,7 @@ seedpass config set nostr_max_retries 2 seedpass config set nostr_retry_delay 1 ``` -The default configuration uses **50,000** PBKDF2 iterations. Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. +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 @@ -491,6 +493,8 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **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 diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 2f061ed..a9231e3 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -412,7 +412,9 @@ Back in the Settings menu you can: 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 `15` to toggle Offline Mode and work locally without contacting Nostr. +* Choose `16` to toggle Quick Unlock so subsequent actions skip the password prompt. +* Select `17` to return to the main menu. ## Running Tests @@ -496,6 +498,8 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **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 don't prompt again during the session. Use with caution on shared systems. ## Contributing diff --git a/src/main.py b/src/main.py index d5867c1..733b12e 100644 --- a/src/main.py +++ b/src/main.py @@ -617,6 +617,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: @@ -737,6 +792,8 @@ def handle_settings(password_manager: PasswordManager) -> None: 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) @@ -787,6 +844,12 @@ def handle_settings(password_manager: PasswordManager) -> None: 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: From d559477342013f419b625b93d71449b473a8938c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:09:30 -0400 Subject: [PATCH 078/120] Use background sync unconditionally --- src/password_manager/manager.py | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0407852..931c5fa 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -533,13 +533,7 @@ class PasswordManager: # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() - if ( - getattr(self, "config_manager", None) - and self.config_manager.get_quick_unlock() - ): - self.start_background_sync() - else: - 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 @@ -613,13 +607,7 @@ class PasswordManager: self.initialize_managers() self.locked = False self.update_activity() - if ( - getattr(self, "config_manager", None) - and self.config_manager.get_quick_unlock() - ): - self.start_background_sync() - else: - self.sync_index_from_nostr() + self.start_background_sync() def handle_existing_seed(self) -> None: """ @@ -791,13 +779,7 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - if ( - getattr(self, "config_manager", None) - and self.config_manager.get_quick_unlock() - ): - self.start_background_sync() - else: - self.sync_index_from_nostr() + self.start_background_sync() return fingerprint # Return the generated or added fingerprint except BaseException: # Clean up partial profile on failure or interruption @@ -952,13 +934,7 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - if ( - getattr(self, "config_manager", None) - and self.config_manager.get_quick_unlock() - ): - self.start_background_sync() - else: - self.sync_index_from_nostr() + self.start_background_sync() 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")) From 863348f194a2adf7cd2d38d9f271601ba1dabeed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:17:34 -0400 Subject: [PATCH 079/120] Add tests for background sync triggers --- src/tests/test_background_sync_always.py | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/tests/test_background_sync_always.py 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 From c669bf0e9f52d41e087cfcf91ef3bad1ddc3028e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:26:52 -0400 Subject: [PATCH 080/120] docs: clarify sync behavior and quick unlock --- docs/docs/content/index.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index a9231e3..37453b7 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -51,7 +51,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **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. +- **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. @@ -413,7 +413,7 @@ Back in the Settings menu you can: 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. +* 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 @@ -454,7 +454,8 @@ 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. +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 @@ -499,7 +500,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **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 don't prompt again during the session. Use with caution on shared systems. + - **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 From b01b73c1d5672f0300d4a0fa143ccc60310ee066 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:53:07 -0400 Subject: [PATCH 081/120] Avoid blocking network calls at startup --- src/password_manager/manager.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 931c5fa..0802dd9 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -587,7 +587,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.""" @@ -1053,7 +1053,10 @@ class PasswordManager: def _worker() -> None: try: - self.sync_index_from_nostr() + 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}") @@ -3785,29 +3788,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 From 6fe4b86a19319562897798652f547aa33bb0f30c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 18:15:58 -0400 Subject: [PATCH 082/120] Prevent background sync thread from blocking cleanup --- src/password_manager/manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0802dd9..b90822a 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -934,7 +934,6 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - self.start_background_sync() 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")) From 0e7c3e8a84c503c1a33b8eae4927a4e26ed0c2d3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 18:29:47 -0400 Subject: [PATCH 083/120] flush writes for concurrency safety --- src/password_manager/encryption.py | 88 +++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 8c1f8de..4ba85d4 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -8,7 +8,9 @@ try: JSONDecodeError = orjson.JSONDecodeError USE_ORJSON = True -except Exception: # pragma: no cover - fallback for environments without orjson +except ( + Exception +): # pragma: no cover - fallback for environments without orjson import json as json_lib from json import JSONDecodeError @@ -58,7 +60,9 @@ class EncryptionManager: self.key = base64.urlsafe_b64decode(self.key_b64) self.cipher = AESGCM(self.key) - logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}") + logger.debug( + f"EncryptionManager initialized for {self.fingerprint_dir}" + ) except Exception as e: logger.error( f"Failed to initialize ciphers with provided encryption key: {e}", @@ -91,12 +95,16 @@ class EncryptionManager: ciphertext = encrypted_data[15:] return self.cipher.decrypt(nonce, ciphertext, None) except InvalidTag as e: - logger.error("AES-GCM decryption failed: Invalid authentication tag.") + logger.error( + "AES-GCM decryption failed: Invalid authentication tag." + ) 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.") + logger.warning( + "Data is in legacy Fernet format. Attempting migration." + ) try: return self.fernet.decrypt(encrypted_data) except InvalidToken as e: @@ -118,7 +126,9 @@ class EncryptionManager: 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}'.") + 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.""" @@ -130,7 +140,9 @@ class EncryptionManager: decrypted_data = self.decrypt_data(encrypted_data) if is_legacy: - logger.info("Parent seed was in legacy format. Re-encrypting to V2 format.") + 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() @@ -143,6 +155,8 @@ class EncryptionManager: 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: @@ -152,13 +166,17 @@ class EncryptionManager: encrypted_data = fh.read() return self.decrypt_data(encrypted_data) - def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None: + def save_json_data( + self, data: dict, relative_path: Optional[Path] = None + ) -> None: if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") if USE_ORJSON: json_data = json_lib.dumps(data) else: - json_data = json_lib.dumps(data, separators=(",", ":")).encode("utf-8") + 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}'.") @@ -189,7 +207,9 @@ class EncryptionManager: # 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}") + logger.info( + f"Migrating and re-saving legacy vault file: {file_path}" + ) self.save_json_data(data, relative_path) self.update_checksum(relative_path) @@ -224,17 +244,25 @@ class EncryptionManager: 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.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")) + 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 + 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" + f"Error: Failed to decrypt and save data from Nostr: {e}", + "red", ) ) raise @@ -258,10 +286,13 @@ class EncryptionManager: fh.seek(0) fh.truncate() fh.write(checksum.encode("utf-8")) + fh.flush() + os.fsync(fh.fileno()) os.chmod(checksum_file, 0o600) except Exception as e: logger.error( - f"Failed to update checksum for '{relative_path}': {e}", exc_info=True + f"Failed to update checksum for '{relative_path}': {e}", + exc_info=True, ) raise @@ -272,17 +303,24 @@ class EncryptionManager: 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 logger.debug("Seed phrase validated successfully.") return True except Exception as e: logging.error(f"Error validating seed phrase: {e}", exc_info=True) - print(colored(f"Error: Failed to validate seed phrase: {e}", "red")) + print( + colored(f"Error: Failed to validate seed phrase: {e}", "red") + ) return False - def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes: + def derive_seed_from_mnemonic( + self, mnemonic: str, passphrase: str = "" + ) -> bytes: try: if not isinstance(mnemonic, str): if isinstance(mnemonic, list): @@ -290,13 +328,21 @@ class EncryptionManager: else: mnemonic = str(mnemonic) if not isinstance(mnemonic, str): - raise TypeError("Mnemonic must be a string after conversion") + raise TypeError( + "Mnemonic must be a string after conversion" + ) from bip_utils import Bip39SeedGenerator seed = Bip39SeedGenerator(mnemonic).Generate(passphrase) logger.debug("Seed derived successfully from mnemonic.") return seed except Exception as e: - logger.error(f"Failed to derive seed from mnemonic: {e}", exc_info=True) - print(colored(f"Error: Failed to derive seed from mnemonic: {e}", "red")) + logger.error( + f"Failed to derive seed from mnemonic: {e}", exc_info=True + ) + print( + colored( + f"Error: Failed to derive seed from mnemonic: {e}", "red" + ) + ) raise From 79488b43739ae04849846eb0877ca9ce81d167bb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 18:58:59 -0400 Subject: [PATCH 084/120] Fix KDF iteration config in test profile script --- scripts/generate_test_profile.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 2837227..5f6d31d 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -98,6 +98,8 @@ def initialize_profile( # 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, cfg_mgr @@ -146,7 +148,10 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None: 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", From 0840ee63c016d2aff9a44a18561bb394c1d09639 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:41:16 -0400 Subject: [PATCH 085/120] Add integration test for initialize_profile with PasswordManager --- src/tests/test_profile_init_integration.py | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/tests/test_profile_init_integration.py 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"] From 26badd8cd78652b9022920b05821ce967e23ba7d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:46:59 -0400 Subject: [PATCH 086/120] Start sync after new seed --- src/password_manager/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index b90822a..b37bb8c 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -835,6 +835,7 @@ class PasswordManager: # Now, save and encrypt the seed with the 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) From a3fd02f0c9f87b1da67b0d29331dcd20f12057da Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:07:56 -0400 Subject: [PATCH 087/120] Add test for legacy Fernet data with V2 prefix --- src/password_manager/encryption.py | 79 +++++++++------------------- src/tests/test_v2_prefix_fallback.py | 21 ++++++++ 2 files changed, 46 insertions(+), 54 deletions(-) create mode 100644 src/tests/test_v2_prefix_fallback.py diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 4ba85d4..32731a6 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -8,9 +8,7 @@ try: JSONDecodeError = orjson.JSONDecodeError USE_ORJSON = True -except ( - Exception -): # pragma: no cover - fallback for environments without orjson +except Exception: # pragma: no cover - fallback for environments without orjson import json as json_lib from json import JSONDecodeError @@ -60,9 +58,7 @@ class EncryptionManager: self.key = base64.urlsafe_b64decode(self.key_b64) self.cipher = AESGCM(self.key) - logger.debug( - f"EncryptionManager initialized for {self.fingerprint_dir}" - ) + logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}") except Exception as e: logger.error( f"Failed to initialize ciphers with provided encryption key: {e}", @@ -95,16 +91,19 @@ class EncryptionManager: ciphertext = encrypted_data[15:] return self.cipher.decrypt(nonce, ciphertext, None) except InvalidTag as e: - logger.error( - "AES-GCM decryption failed: Invalid authentication tag." - ) - raise InvalidToken("AES-GCM decryption failed.") from 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." - ) + logger.warning("Data is in legacy Fernet format. Attempting migration.") try: return self.fernet.decrypt(encrypted_data) except InvalidToken as e: @@ -126,9 +125,7 @@ class EncryptionManager: 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}'." - ) + 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.""" @@ -140,9 +137,7 @@ class EncryptionManager: decrypted_data = self.decrypt_data(encrypted_data) if is_legacy: - logger.info( - "Parent seed was in legacy format. Re-encrypting to V2 format." - ) + 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() @@ -166,17 +161,13 @@ class EncryptionManager: encrypted_data = fh.read() return self.decrypt_data(encrypted_data) - def save_json_data( - self, data: dict, relative_path: Optional[Path] = None - ) -> None: + def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None: if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") if USE_ORJSON: json_data = json_lib.dumps(data) else: - json_data = json_lib.dumps(data, separators=(",", ":")).encode( - "utf-8" - ) + 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}'.") @@ -207,9 +198,7 @@ class EncryptionManager: # 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}" - ) + logger.info(f"Migrating and re-saving legacy vault file: {file_path}") self.save_json_data(data, relative_path) self.update_checksum(relative_path) @@ -244,16 +233,10 @@ class EncryptionManager: 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.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") - ) + 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}", @@ -313,14 +296,10 @@ class EncryptionManager: return True except Exception as e: logging.error(f"Error validating seed phrase: {e}", exc_info=True) - print( - colored(f"Error: Failed to validate seed phrase: {e}", "red") - ) + print(colored(f"Error: Failed to validate seed phrase: {e}", "red")) return False - def derive_seed_from_mnemonic( - self, mnemonic: str, passphrase: str = "" - ) -> bytes: + def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes: try: if not isinstance(mnemonic, str): if isinstance(mnemonic, list): @@ -328,21 +307,13 @@ class EncryptionManager: else: mnemonic = str(mnemonic) if not isinstance(mnemonic, str): - raise TypeError( - "Mnemonic must be a string after conversion" - ) + raise TypeError("Mnemonic must be a string after conversion") from bip_utils import Bip39SeedGenerator seed = Bip39SeedGenerator(mnemonic).Generate(passphrase) logger.debug("Seed derived successfully from mnemonic.") return seed except Exception as e: - logger.error( - f"Failed to derive seed from mnemonic: {e}", exc_info=True - ) - print( - colored( - f"Error: Failed to derive seed from mnemonic: {e}", "red" - ) - ) + logger.error(f"Failed to derive seed from mnemonic: {e}", exc_info=True) + print(colored(f"Error: Failed to derive seed from mnemonic: {e}", "red")) raise diff --git a/src/tests/test_v2_prefix_fallback.py b/src/tests/test_v2_prefix_fallback.py new file mode 100644 index 0000000..6082485 --- /dev/null +++ b/src/tests/test_v2_prefix_fallback.py @@ -0,0 +1,21 @@ +import logging +from pathlib import Path + +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 From 78104681e401d47ebd87b9fbe7cca05c83c303a2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:15:58 -0400 Subject: [PATCH 088/120] Validate AES-GCM payload length --- src/password_manager/encryption.py | 3 +++ src/tests/test_v2_prefix_fallback.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 32731a6..38da332 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -89,6 +89,9 @@ class EncryptionManager: 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.") diff --git a/src/tests/test_v2_prefix_fallback.py b/src/tests/test_v2_prefix_fallback.py index 6082485..0d23cbf 100644 --- a/src/tests/test_v2_prefix_fallback.py +++ b/src/tests/test_v2_prefix_fallback.py @@ -1,6 +1,10 @@ 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 @@ -19,3 +23,16 @@ def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: 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 From e396c1f2b7813af85491b9e099cac209c01ad272 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:40:54 -0400 Subject: [PATCH 089/120] Add sync test for generate_test_profile --- src/tests/test_generate_test_profile_sync.py | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/tests/test_generate_test_profile_sync.py 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..508b6f4 --- /dev/null +++ b/src/tests/test_generate_test_profile_sync.py @@ -0,0 +1,71 @@ +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 + retrieved = gzip.decompress(b"".join(chunks)) + assert retrieved == encrypted From 11d78a98c994f4c468c200522004178ed837efc1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:47:26 -0400 Subject: [PATCH 090/120] docs: clarify test profile location --- README.md | 9 ++++++++- docs/docs/content/index.md | 15 +++++++++------ scripts/generate_test_profile.py | 6 ++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 81e9a9d..e17fc72 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,14 @@ 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 diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 37453b7..ffc6439 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -450,12 +450,15 @@ 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. Synchronization also runs in the -background after unlocking or when switching profiles. +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 diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 5f6d31d..4576828 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -5,6 +5,12 @@ This script populates a profile directory with a variety of entry types. 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 From 57997e49580c72ae9ed693bab09fe063a387d601 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:32:11 -0400 Subject: [PATCH 091/120] Record manifest ID and timestamp --- README.md | 2 +- docs/docs/content/index.md | 2 +- src/main.py | 15 ++++++-------- src/nostr/backup_models.py | 2 +- src/nostr/client.py | 35 ++++++++++++++++++++++++++++++--- src/password_manager/manager.py | 28 +++++++++++--------------- 6 files changed, 52 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e17fc72..d209e77 100644 --- a/README.md +++ b/README.md @@ -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 records the UNIX timestamp of the most recent delta. - **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. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index ffc6439..1b5f6d5 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -40,7 +40,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 most recent delta. - **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. diff --git a/src/main.py b/src/main.py index 733b12e..dbd043f 100644 --- a/src/main.py +++ b/src/main.py @@ -318,15 +318,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 ) 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 1a6bcb8..fa24873 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -135,6 +135,7 @@ class NostrClient: 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 @@ -388,6 +389,8 @@ 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 + self.current_manifest.delta_since = int(time.time()) self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start @@ -406,13 +409,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] = [] @@ -433,6 +441,7 @@ class NostrClient: chunks.append(chunk_bytes) self.current_manifest = manifest + self.current_manifest_id = getattr(manifest_event, "id", None) return manifest, chunks async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: @@ -447,8 +456,28 @@ 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 diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index b37bb8c..c003d5d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1022,13 +1022,10 @@ 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) @@ -1108,15 +1105,10 @@ 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] try: self.vault.decrypt_and_save_index_from_nostr(encrypted) logger.info("Initialized local database from Nostr.") @@ -3841,4 +3833,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") + ) From cbad7ccf7534a59dffe614560e2fdf0a866fce18 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:38:52 -0400 Subject: [PATCH 092/120] Ensure client pool reinitializes on relay update --- src/nostr/client.py | 1 + src/tests/test_nostr_client.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/nostr/client.py b/src/nostr/client.py index fa24873..b2fabe1 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -282,6 +282,7 @@ class NostrClient: signer = NostrSigner.keys(self.keys) self.client = Client(signer) self._connected = False + self.initialize_client_pool() def retrieve_json_from_nostr_sync( self, retries: int | None = None, delay: float | None = None diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 2b25b24..c3a6e9a 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -131,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 From 2f89c02f9bcc231404e9c718c449f7f0456208a8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:04:50 -0400 Subject: [PATCH 093/120] Add full sync roundtrip test --- src/tests/test_full_sync_roundtrip.py | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/tests/test_full_sync_roundtrip.py diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py new file mode 100644 index 0000000..ba1aa22 --- /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, monkeypatch): + 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"" + # Use a constant timestamp so dummy relay returns the delta + monkeypatch.setattr("nostr.client.time.time", lambda: 1) + asyncio.run(client.publish_delta(delta_bytes, manifest_id)) + + # 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"] From 905b4ec8ba7f8500935a6994191dc5bc5d841e23 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:18:28 -0400 Subject: [PATCH 094/120] Record manifest details and timestamp --- README.md | 2 +- docs/docs/content/index.md | 2 +- src/nostr/client.py | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d209e77..ea09228 100644 --- a/README.md +++ b/README.md @@ -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. The manifest's `delta_since` field records the UNIX timestamp of the most recent delta. +- **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. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 1b5f6d5..09b38fd 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -40,7 +40,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. The manifest's `delta_since` field stores the UNIX timestamp of the most recent delta. +- **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. diff --git a/src/nostr/client.py b/src/nostr/client.py index b2fabe1..d863a74 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -391,6 +391,7 @@ class NostrClient: 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): @@ -442,7 +443,10 @@ class NostrClient: chunks.append(chunk_bytes) self.current_manifest = manifest - self.current_manifest_id = getattr(manifest_event, "id", None) + 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: From 1d92d5e1ca737e76a33244074a36050a17f0a57c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:29:18 -0400 Subject: [PATCH 095/120] Reconnect after relay update --- src/nostr/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nostr/client.py b/src/nostr/client.py index d863a74..d0f0af3 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -282,6 +282,7 @@ class NostrClient: 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( From e7837bcfbeb99c164c78cfb934b333437e1eda3b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:37:42 -0400 Subject: [PATCH 096/120] Add full sync roundtrip test --- src/tests/test_full_sync_roundtrip_new.py | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/tests/test_full_sync_roundtrip_new.py 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..2d0c36d --- /dev/null +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -0,0 +1,64 @@ +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, monkeypatch): + 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"" + monkeypatch.setattr("nostr.client.time.time", lambda: 1) + asyncio.run(client.publish_delta(delta_bytes, manifest_id)) + + # 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"] From 6b401d85c8ecf4a158dd9bd85fba9c0ce6687544 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:49:52 -0400 Subject: [PATCH 097/120] test: record delta timestamp and manifest delta_since --- src/tests/helpers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 914968a..c04cd1a 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])) @@ -183,11 +185,18 @@ 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"): + event.created_at = int(time.time()) self.deltas.append(event) return DummySendResult(eid) From 2f8659c49f72b1d6ec7e07449b750984f5f21cfa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 06:04:25 -0400 Subject: [PATCH 098/120] test: assert nostr client reload on relay update --- src/tests/test_api_new_endpoints.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index e939048..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): @@ -352,8 +354,9 @@ def test_backup_parent_seed_endpoint(client, tmp_path): assert called["path"] == path -def test_relay_management_endpoints(client): +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): @@ -366,11 +369,16 @@ def test_relay_management_endpoints(client): api._pm.config_manager.load_config = load_config api._pm.config_manager.set_relays = set_relays - api._pm.nostr_client = SimpleNamespace( - close_client_pool=lambda: called.setdefault("close", True), - initialize_client_pool=lambda: called.setdefault("init", True), - relays=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}"} @@ -391,3 +399,5 @@ def test_relay_management_endpoints(client): 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) From e45483c6ebc2e232d1fe26dd670f12b3b37bfb45 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:55:55 -0400 Subject: [PATCH 099/120] Add timestamp tracking to dummy relay and update tests --- src/tests/helpers.py | 4 +++- src/tests/test_full_sync_roundtrip.py | 6 +++--- src/tests/test_full_sync_roundtrip_new.py | 5 +++-- src/tests/test_generate_test_profile_sync.py | 1 + src/tests/test_nostr_dummy_client.py | 4 ++++ 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/tests/helpers.py b/src/tests/helpers.py index c04cd1a..ab6f0c4 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -163,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] = [] @@ -196,7 +197,8 @@ class DummyRelayClient: self.chunks[ident] = event elif event.kind == KIND_DELTA: if not hasattr(event, "created_at"): - event.created_at = int(time.time()) + self.ts_counter += 1 + event.created_at = self.ts_counter self.deltas.append(event) return DummySendResult(eid) diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index ba1aa22..2787261 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -29,7 +29,7 @@ def _init_pm(dir_path: Path, client) -> PasswordManager: return pm -def test_full_sync_roundtrip(dummy_nostr_client, monkeypatch): +def test_full_sync_roundtrip(dummy_nostr_client): client, relay = dummy_nostr_client with TemporaryDirectory() as tmpdir: base = Path(tmpdir) @@ -54,9 +54,9 @@ def test_full_sync_roundtrip(dummy_nostr_client, monkeypatch): # 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"" - # Use a constant timestamp so dummy relay returns the delta - monkeypatch.setattr("nostr.client.time.time", lambda: 1) 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() diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index 2d0c36d..2787261 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -29,7 +29,7 @@ def _init_pm(dir_path: Path, client) -> PasswordManager: return pm -def test_full_sync_roundtrip(dummy_nostr_client, monkeypatch): +def test_full_sync_roundtrip(dummy_nostr_client): client, relay = dummy_nostr_client with TemporaryDirectory() as tmpdir: base = Path(tmpdir) @@ -54,8 +54,9 @@ def test_full_sync_roundtrip(dummy_nostr_client, monkeypatch): # 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"" - monkeypatch.setattr("nostr.client.time.time", lambda: 1) 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() diff --git a/src/tests/test_generate_test_profile_sync.py b/src/tests/test_generate_test_profile_sync.py index 508b6f4..216bb3d 100644 --- a/src/tests/test_generate_test_profile_sync.py +++ b/src/tests/test_generate_test_profile_sync.py @@ -67,5 +67,6 @@ def test_generate_test_profile_sync(monkeypatch, dummy_nostr_client): 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_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] From 1c2bb84e75b4c59bb0af353b1ea96a38bfcaf6c8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:54:00 -0400 Subject: [PATCH 100/120] Update test profile generator with new entry types --- scripts/generate_test_profile.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 4576828..2632e9a 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -1,7 +1,8 @@ #!/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. @@ -121,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", @@ -143,13 +144,25 @@ 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: From 62c006c4cb24630a162c402e78683e139646d536 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:17:35 -0400 Subject: [PATCH 101/120] feat: live stats while syncing --- src/main.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/main.py b/src/main.py index dbd043f..5b2878a 100644 --- a/src/main.py +++ b/src/main.py @@ -232,12 +232,39 @@ 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() + return + + while True: + clear_screen() + display_fn() + 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")) @@ -837,7 +864,6 @@ def handle_settings(password_manager: PasswordManager) -> None: pause() elif choice == "14": handle_display_stats(password_manager) - pause() elif choice == "15": handle_toggle_secret_mode(password_manager) pause() @@ -872,12 +898,9 @@ 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, From ba40c5108d385d9d2ebb17696b66e4baed6b2cbf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:05:39 -0400 Subject: [PATCH 102/120] Add notification queue to PasswordManager --- src/password_manager/manager.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c003d5d..366245f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -20,6 +20,8 @@ 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 @@ -96,6 +98,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 @@ -126,6 +136,7 @@ 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() # Track changes to trigger periodic Nostr sync self.is_dirty: bool = False @@ -216,6 +227,10 @@ 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 for later retrieval.""" + self.notifications.put(Notification(message, level)) + def lock_vault(self) -> None: """Clear sensitive information from memory.""" if self.entry_manager is not None: @@ -1076,12 +1091,10 @@ class PasswordManager: ): 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", - ) + 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}") From 19d9c45d6feda80d488766f0022a4cbdfa827b95 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:20:05 -0400 Subject: [PATCH 103/120] Add notification draining with color categories --- src/main.py | 18 ++++++++++++++++++ src/utils/color_scheme.py | 3 +++ 2 files changed, 21 insertions(+) diff --git a/src/main.py b/src/main.py index 5b2878a..b4f796d 100644 --- a/src/main.py +++ b/src/main.py @@ -27,6 +27,7 @@ from utils import ( pause, clear_and_print_fingerprint, ) +import queue from local_bip85.bip85 import Bip85Error @@ -100,6 +101,22 @@ def confirm_action(prompt: str) -> bool: print(colored("Please enter 'Y' or 'N'.", "red")) +def drain_notifications(pm: PasswordManager) -> None: + """Display all queued notifications.""" + queue_obj = getattr(pm, "notifications", None) + if queue_obj is None: + return + while True: + try: + note = queue_obj.get_nowait() + except queue.Empty: + break + category = note.level.lower() + if category not in ("info", "warning", "error"): + category = "info" + print(color_text(note.message, category)) + + def handle_switch_fingerprint(password_manager: PasswordManager): """ Handles switching the active fingerprint. @@ -932,6 +949,7 @@ def display_menu( for handler in logging.getLogger().handlers: handler.flush() print(color_text(menu, "menu")) + drain_notifications(password_manager) try: choice = timed_input( "Enter your choice (1-8) or press Enter to exit: ", diff --git a/src/utils/color_scheme.py b/src/utils/color_scheme.py index db6798f..b0212c2 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": "cyan", + "warning": "yellow", + "error": "red", "default": "white", } From d87d9ed59f1ca9f7fb0d1d5d0c8695fb1b11953b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:43:09 -0400 Subject: [PATCH 104/120] Add notifications API endpoint --- .../01-getting-started/02-api_reference.md | 12 +++++++++++- src/seedpass/api.py | 16 ++++++++++++++++ src/tests/test_api_notifications.py | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_api_notifications.py diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md index 9c0e30c..07a23d9 100644 --- a/docs/docs/content/01-getting-started/02-api_reference.md +++ b/docs/docs/content/01-getting-started/02-api_reference.md @@ -31,6 +31,7 @@ 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. - `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. @@ -186,7 +187,16 @@ 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 + 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 ``` ### Changing the Master Password diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 77ad26e..fdc748c 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -6,6 +6,7 @@ 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, Response @@ -379,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 diff --git a/src/tests/test_api_notifications.py b/src/tests/test_api_notifications.py new file mode 100644 index 0000000..06cc9a1 --- /dev/null +++ b/src/tests/test_api_notifications.py @@ -0,0 +1,18 @@ +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() From 78fbe5e88f646ee585fb2c338a5e44c3c6c18395 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:51:42 -0400 Subject: [PATCH 105/120] Add tests for notifications and relay warnings --- src/tests/test_api_notifications.py | 12 +++++++ src/tests/test_background_relay_check.py | 14 ++++++++ src/tests/test_menu_notifications.py | 46 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 src/tests/test_menu_notifications.py diff --git a/src/tests/test_api_notifications.py b/src/tests/test_api_notifications.py index 06cc9a1..fe81046 100644 --- a/src/tests/test_api_notifications.py +++ b/src/tests/test_api_notifications.py @@ -16,3 +16,15 @@ def test_notifications_endpoint(client): {"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() == [] diff --git a/src/tests/test_background_relay_check.py b/src/tests/test_background_relay_check.py index e1af48e..f8a1347 100644 --- a/src/tests/test_background_relay_check.py +++ b/src/tests/test_background_relay_check.py @@ -1,5 +1,6 @@ import time from types import SimpleNamespace +import queue from pathlib import Path import sys @@ -21,3 +22,16 @@ def test_background_relay_check_runs_async(monkeypatch): time.sleep(0.05) assert called["args"] == MIN_HEALTHY_RELAYS + + +def test_background_relay_check_warns_when_unhealthy(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + 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_menu_notifications.py b/src/tests/test_menu_notifications.py new file mode 100644 index 0000000..aabf1e5 --- /dev/null +++ b/src/tests/test_menu_notifications.py @@ -0,0 +1,46 @@ +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() + q.put(SimpleNamespace(message=msg, level="INFO")) + return SimpleNamespace( + notifications=q, + 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_and_print_fingerprint", lambda *a, **k: None) + 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 "hello" in out From 741845799a8509bf9c984b4c35ac4063dd180602 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 12:28:44 -0400 Subject: [PATCH 106/120] Warn when few relays respond --- src/password_manager/manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 366245f..4a1a8a2 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1092,8 +1092,8 @@ class PasswordManager: 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.", + f"Only {healthy} relay(s) responded with your latest event. " + "Consider adding more relays via Settings.", level="WARNING", ) except Exception as exc: From 5bf2bc458ce9be19987c6166db88a4d795c7eab4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 12:53:53 -0400 Subject: [PATCH 107/120] Update menu notification handling --- src/main.py | 27 ++++++++++++++++++--------- src/tests/test_menu_notifications.py | 22 ++++++++++++++++++++-- src/utils/color_scheme.py | 5 +++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/main.py b/src/main.py index b4f796d..1c9d473 100644 --- a/src/main.py +++ b/src/main.py @@ -101,20 +101,23 @@ def confirm_action(prompt: str) -> bool: print(colored("Please enter 'Y' or 'N'.", "red")) -def drain_notifications(pm: PasswordManager) -> None: - """Display all queued notifications.""" +def drain_notifications(pm: PasswordManager) -> str | None: + """Return the most recent queued notification message, clearing the queue.""" queue_obj = getattr(pm, "notifications", None) if queue_obj is None: - return + return None + last_note = None while True: try: - note = queue_obj.get_nowait() + last_note = queue_obj.get_nowait() except queue.Empty: break - category = note.level.lower() - if category not in ("info", "warning", "error"): - category = "info" - print(color_text(note.message, category)) + if not last_note: + return None + category = getattr(last_note, "level", "info").lower() + if category not in ("info", "warning", "error"): + category = "info" + return color_text(getattr(last_note, "message", ""), category) def handle_switch_fingerprint(password_manager: PasswordManager): @@ -949,7 +952,13 @@ def display_menu( for handler in logging.getLogger().handlers: handler.flush() print(color_text(menu, "menu")) - drain_notifications(password_manager) + print() + last_note = drain_notifications(password_manager) + sys.stdout.write("\033[F\033[2K") + if last_note: + print(last_note) + else: + print() try: choice = timed_input( "Enter your choice (1-8) or press Enter to exit: ", diff --git a/src/tests/test_menu_notifications.py b/src/tests/test_menu_notifications.py index aabf1e5..123f4b4 100644 --- a/src/tests/test_menu_notifications.py +++ b/src/tests/test_menu_notifications.py @@ -12,7 +12,8 @@ import main def _make_pm(msg): q = queue.Queue() - q.put(SimpleNamespace(message=msg, level="INFO")) + if msg is not None: + q.put(SimpleNamespace(message=msg, level="INFO")) return SimpleNamespace( notifications=q, is_dirty=False, @@ -43,4 +44,21 @@ def test_display_menu_prints_notifications(monkeypatch, capsys): with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) out = capsys.readouterr().out - assert "hello" in out + assert "\x1b[F\x1b[2K" in out + assert out.count("hello") == 1 + + +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_and_print_fingerprint", lambda *a, **k: None) + inputs = iter(["9", ""]) + monkeypatch.setattr(main, "timed_input", lambda *a, **k: next(inputs)) + monkeypatch.setattr(main, "drain_notifications", lambda _pm: next(msgs, None)) + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + out = capsys.readouterr().out + assert out.count("first") == 1 + assert out.count("second") == 1 + assert out.count("Select an option:") == 2 diff --git a/src/utils/color_scheme.py b/src/utils/color_scheme.py index b0212c2..96dccfd 100644 --- a/src/utils/color_scheme.py +++ b/src/utils/color_scheme.py @@ -20,7 +20,7 @@ _COLOR_MAP = { "index": "yellow", "menu": "cyan", "stats": "green", - "info": "cyan", + "info": "white", "warning": "yellow", "error": "red", "default": "white", @@ -32,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) From ba066bf0d4cc133b98c5e9a2ef8e7295e2542857 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:16:59 -0400 Subject: [PATCH 108/120] Run sync in background for TUI actions --- src/password_manager/manager.py | 35 +++++++++++++++++++--------- src/tests/test_manager_add_totp.py | 4 +++- src/tests/test_manager_workflow.py | 6 +++++ src/tests/test_profile_management.py | 5 ++++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 4a1a8a2..9b60b4e 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1102,6 +1102,19 @@ class PasswordManager: 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. @@ -1225,7 +1238,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( @@ -1299,7 +1312,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}", @@ -1348,7 +1361,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}", @@ -1411,7 +1424,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}", @@ -1479,7 +1492,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}", @@ -1545,7 +1558,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}", @@ -1601,7 +1614,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}", @@ -1676,7 +1689,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}", @@ -1737,7 +1750,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}", @@ -2772,7 +2785,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." ) @@ -3045,7 +3058,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( 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_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_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() From 8413908c94baf3d0f319a1271958532957bc0ff9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:40:18 -0400 Subject: [PATCH 109/120] Add message and notifications to stats screen --- src/main.py | 13 ++++++++++++ src/tests/test_stats_screen.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/tests/test_stats_screen.py diff --git a/src/main.py b/src/main.py index 1c9d473..cc9cafe 100644 --- a/src/main.py +++ b/src/main.py @@ -264,11 +264,24 @@ def _display_live_stats( if not sys.stdin or not sys.stdin.isatty(): clear_screen() display_fn() + note = drain_notifications(password_manager) + if note: + print(note) + print(colored("Press Enter to continue.", "cyan")) + pause() return while True: clear_screen() display_fn() + print() + note = drain_notifications(password_manager) + sys.stdout.write("\033[F\033[2K") + if note: + print(note) + else: + print() + print(colored("Press Enter to continue.", "cyan")) sys.stdout.flush() try: user_input = timed_input("", interval) diff --git a/src/tests/test_stats_screen.py b/src/tests/test_stats_screen.py new file mode 100644 index 0000000..f7fc7c5 --- /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, "drain_notifications", lambda *_: None) + 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, "drain_notifications", 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 From ee240cbd1eec368481f9a0860e25543947c5f5d6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:58:58 -0400 Subject: [PATCH 110/120] Add notification tracking and tests --- src/constants.py | 3 ++ src/password_manager/manager.py | 25 ++++++++++- .../test_manager_current_notification.py | 45 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/tests/test_manager_current_notification.py diff --git a/src/constants.py b/src/constants.py index 2ebdf36..7d99552 100644 --- a/src/constants.py +++ b/src/constants.py @@ -51,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/password_manager/manager.py b/src/password_manager/manager.py index 9b60b4e..c6d10c3 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -75,6 +75,7 @@ from constants import ( DEFAULT_PASSWORD_LENGTH, INACTIVITY_TIMEOUT, DEFAULT_SEED_BACKUP_FILENAME, + NOTIFICATION_DURATION, initialize_app, ) @@ -137,6 +138,8 @@ class PasswordManager: 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 @@ -228,8 +231,26 @@ class PasswordManager: self.last_activity = time.time() def notify(self, message: str, level: str = "INFO") -> None: - """Enqueue a notification for later retrieval.""" - self.notifications.put(Notification(message, level)) + """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.""" diff --git a/src/tests/test_manager_current_notification.py b/src/tests/test_manager_current_notification.py new file mode 100644 index 0000000..2fceb74 --- /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 isinstance(note, Notification) + 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 From 9615dcdb3129579c909e741ddf371566bf535da1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:10:43 -0400 Subject: [PATCH 111/120] Fix notification test --- src/tests/test_manager_current_notification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_manager_current_notification.py b/src/tests/test_manager_current_notification.py index 2fceb74..ab94d9e 100644 --- a/src/tests/test_manager_current_notification.py +++ b/src/tests/test_manager_current_notification.py @@ -23,7 +23,7 @@ def test_notify_sets_current(monkeypatch): monkeypatch.setattr("password_manager.manager.time.time", lambda: current["val"]) pm.notify("hello") note = pm._current_notification - assert isinstance(note, Notification) + assert hasattr(note, "message") assert note.message == "hello" assert pm._notification_expiry == 100.0 + NOTIFICATION_DURATION assert pm.notifications.qsize() == 1 From b8a5ed9f6688d9f2ac8a60384127ac0d5d715244 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:23:37 -0400 Subject: [PATCH 112/120] Add header clearing with notifications --- src/main.py | 14 ++++---- src/password_manager/manager.py | 48 ++++++++++++++-------------- src/tests/test_menu_notifications.py | 4 +-- src/utils/__init__.py | 8 ++++- src/utils/terminal_utils.py | 40 +++++++++++++++++++++++ 5 files changed, 80 insertions(+), 34 deletions(-) diff --git a/src/main.py b/src/main.py index cc9cafe..a10b296 100644 --- a/src/main.py +++ b/src/main.py @@ -25,7 +25,7 @@ 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 @@ -737,7 +737,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, @@ -783,7 +783,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, @@ -827,7 +827,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, @@ -940,7 +940,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, @@ -1004,7 +1004,7 @@ def display_menu( None, ), ) - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Add Entry", parent_fingerprint=parent_fp, @@ -1059,7 +1059,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, diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c6d10c3..771ea12 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -59,8 +59,8 @@ 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 @@ -1177,7 +1177,7 @@ class PasswordManager: def handle_add_password(self) -> None: try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Add Entry > Password", parent_fingerprint=parent_fp, @@ -1277,7 +1277,7 @@ 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( + clear_header_with_notification( fp, "Main Menu > Add Entry > 2FA (TOTP)", parent_fingerprint=parent_fp, @@ -1405,7 +1405,7 @@ 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( fp, "Main Menu > Add Entry > SSH Key", parent_fingerprint=parent_fp, @@ -1461,7 +1461,7 @@ 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( fp, "Main Menu > Add Entry > Seed Phrase", parent_fingerprint=parent_fp, @@ -1529,7 +1529,7 @@ 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( fp, "Main Menu > Add Entry > PGP Key", parent_fingerprint=parent_fp, @@ -1595,7 +1595,7 @@ 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( fp, "Main Menu > Add Entry > Nostr Key Pair", parent_fingerprint=parent_fp, @@ -1651,7 +1651,7 @@ 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( fp, "Main Menu > Add Entry > Key/Value", parent_fingerprint=parent_fp, @@ -1726,7 +1726,7 @@ 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( fp, "Main Menu > Add Entry > Managed Account", parent_fingerprint=parent_fp, @@ -2015,7 +2015,7 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Retrieve Entry", parent_fingerprint=parent_fp, @@ -2468,7 +2468,7 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Modify Entry", parent_fingerprint=parent_fp, @@ -2829,7 +2829,7 @@ 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( fp, "Main Menu > Search Entries", parent_fingerprint=parent_fp, @@ -2849,7 +2849,7 @@ class PasswordManager: while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Search Entries", parent_fingerprint=parent_fp, @@ -2980,7 +2980,7 @@ class PasswordManager: try: while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > List Entries", parent_fingerprint=parent_fp, @@ -3028,7 +3028,7 @@ class PasswordManager: continue while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > List Entries", parent_fingerprint=parent_fp, @@ -3120,7 +3120,7 @@ class PasswordManager: return while True: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Archived Entries", parent_fingerprint=parent_fp, @@ -3177,7 +3177,7 @@ 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( fp, "Main Menu > 2FA Codes", parent_fingerprint=parent_fp, @@ -3203,7 +3203,7 @@ class PasswordManager: 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( fp, "Main Menu > 2FA Codes", parent_fingerprint=parent_fp, @@ -3264,7 +3264,7 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Settings > Verify Script Checksum", parent_fingerprint=parent_fp, @@ -3305,7 +3305,7 @@ class PasswordManager: return try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Settings > Generate Script Checksum", parent_fingerprint=parent_fp, @@ -3423,7 +3423,7 @@ 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( fp, "Main Menu > Settings > Export database", parent_fingerprint=parent_fp, @@ -3446,7 +3446,7 @@ 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( fp, "Main Menu > Settings > Import database", parent_fingerprint=parent_fp, @@ -3468,7 +3468,7 @@ 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( fp, "Main Menu > Settings > Export 2FA codes", parent_fingerprint=parent_fp, @@ -3541,7 +3541,7 @@ class PasswordManager: """ try: fp, parent_fp, child_fp = self.header_fingerprint_args - clear_and_print_fingerprint( + clear_header_with_notification( fp, "Main Menu > Settings > Backup Parent Seed", parent_fingerprint=parent_fp, diff --git a/src/tests/test_menu_notifications.py b/src/tests/test_menu_notifications.py index 123f4b4..786c6e3 100644 --- a/src/tests/test_menu_notifications.py +++ b/src/tests/test_menu_notifications.py @@ -39,7 +39,7 @@ def _make_pm(msg): def test_display_menu_prints_notifications(monkeypatch, capsys): pm = _make_pm("hello") monkeypatch.setattr(main, "_display_live_stats", lambda *_: None) - monkeypatch.setattr(main, "clear_and_print_fingerprint", lambda *a, **k: None) + monkeypatch.setattr(main, "clear_header_with_notification", lambda *a, **k: None) monkeypatch.setattr(main, "timed_input", lambda *a, **k: "") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -52,7 +52,7 @@ 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_and_print_fingerprint", lambda *a, **k: None) + monkeypatch.setattr(main, "clear_header_with_notification", lambda *a, **k: None) inputs = iter(["9", ""]) monkeypatch.setattr(main, "timed_input", lambda *a, **k: next(inputs)) monkeypatch.setattr(main, "drain_notifications", lambda _pm: next(msgs, None)) 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/terminal_utils.py b/src/utils/terminal_utils.py index 8c856ed..c0b370c 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,44 @@ 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 + if note: + category = getattr(note, "level", "info").lower() + if category not in ("info", "warning", "error"): + category = "info" + print(color_text(getattr(note, "message", ""), category)) + else: + print() + + 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(): From 91842225142c88e97ac7c63cd9f4dd0d4c68595e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:40:46 -0400 Subject: [PATCH 113/120] Refactor notification handling --- src/main.py | 53 +++++++++++++++------------- src/tests/test_menu_notifications.py | 17 ++++++--- src/tests/test_stats_screen.py | 4 +-- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/main.py b/src/main.py index a10b296..4cd9993 100644 --- a/src/main.py +++ b/src/main.py @@ -26,6 +26,7 @@ from utils import ( clear_screen, pause, clear_header_with_notification, + clear_and_print_fingerprint, ) import queue from local_bip85.bip85 import Bip85Error @@ -102,22 +103,34 @@ def confirm_action(prompt: str) -> bool: def drain_notifications(pm: PasswordManager) -> str | None: - """Return the most recent queued notification message, clearing the queue.""" + """Return the next queued notification message if available.""" queue_obj = getattr(pm, "notifications", None) if queue_obj is None: return None - last_note = None - while True: - try: - last_note = queue_obj.get_nowait() - except queue.Empty: - break - if not last_note: + try: + note = queue_obj.get_nowait() + except queue.Empty: return None - category = getattr(last_note, "level", "info").lower() + category = getattr(note, "level", "info").lower() if category not in ("info", "warning", "error"): category = "info" - return color_text(getattr(last_note, "message", ""), category) + 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): @@ -264,7 +277,7 @@ def _display_live_stats( if not sys.stdin or not sys.stdin.isatty(): clear_screen() display_fn() - note = drain_notifications(password_manager) + note = get_notification_text(password_manager) if note: print(note) print(colored("Press Enter to continue.", "cyan")) @@ -274,13 +287,9 @@ def _display_live_stats( while True: clear_screen() display_fn() - print() - note = drain_notifications(password_manager) - sys.stdout.write("\033[F\033[2K") + note = get_notification_text(password_manager) if note: print(note) - else: - print() print(colored("Press Enter to continue.", "cyan")) sys.stdout.flush() try: @@ -940,12 +949,15 @@ def display_menu( "header_fingerprint_args", (getattr(password_manager, "current_fingerprint", None), None, None), ) - clear_header_with_notification( + clear_and_print_fingerprint( fp, "Main Menu", parent_fingerprint=parent_fp, child_fingerprint=child_fp, ) + note_line = get_notification_text(password_manager) + if note_line: + print(note_line) if time.time() - password_manager.last_activity > inactivity_timeout: print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() @@ -965,13 +977,6 @@ def display_menu( for handler in logging.getLogger().handlers: handler.flush() print(color_text(menu, "menu")) - print() - last_note = drain_notifications(password_manager) - sys.stdout.write("\033[F\033[2K") - if last_note: - print(last_note) - else: - print() try: choice = timed_input( "Enter your choice (1-8) or press Enter to exit: ", diff --git a/src/tests/test_menu_notifications.py b/src/tests/test_menu_notifications.py index 786c6e3..63a43e0 100644 --- a/src/tests/test_menu_notifications.py +++ b/src/tests/test_menu_notifications.py @@ -39,26 +39,33 @@ def _make_pm(msg): 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 *a, **k: None) + monkeypatch.setattr( + main, "clear_and_print_fingerprint", lambda *a, **k: print("HEADER") + ) + monkeypatch.setattr(main, "get_notification_text", lambda *_: "hello") 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 "\x1b[F\x1b[2K" in out - assert out.count("hello") == 1 + 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 *a, **k: None) + monkeypatch.setattr( + main, "clear_and_print_fingerprint", lambda *a, **k: print("HEADER") + ) inputs = iter(["9", ""]) monkeypatch.setattr(main, "timed_input", lambda *a, **k: next(inputs)) - monkeypatch.setattr(main, "drain_notifications", lambda _pm: next(msgs, None)) + monkeypatch.setattr(main, "get_notification_text", lambda _pm: next(msgs, "")) 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_stats_screen.py b/src/tests/test_stats_screen.py index f7fc7c5..f65d72d 100644 --- a/src/tests/test_stats_screen.py +++ b/src/tests/test_stats_screen.py @@ -14,7 +14,7 @@ def _make_pm(): def test_live_stats_shows_message(monkeypatch, capsys): pm = _make_pm() - monkeypatch.setattr(main, "drain_notifications", lambda *_: None) + monkeypatch.setattr(main, "get_notification_text", lambda *_: "") monkeypatch.setattr( main, "timed_input", @@ -27,7 +27,7 @@ def test_live_stats_shows_message(monkeypatch, capsys): def test_live_stats_shows_notification(monkeypatch, capsys): pm = _make_pm() - monkeypatch.setattr(main, "drain_notifications", lambda *_: "note") + monkeypatch.setattr(main, "get_notification_text", lambda *_: "note") monkeypatch.setattr( main, "timed_input", From 83bdb9ae7aaa369a242132ee7a5ae6cf057cd27c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:47:52 -0400 Subject: [PATCH 114/120] docs: add notification box details --- README.md | 3 +++ docs/docs/content/01-getting-started/02-api_reference.md | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea09228..f503b9d 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **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. diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md index 07a23d9..5c23bf6 100644 --- a/docs/docs/content/01-getting-started/02-api_reference.md +++ b/docs/docs/content/01-getting-started/02-api_reference.md @@ -31,7 +31,7 @@ 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. +- `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. @@ -199,6 +199,10 @@ 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`: From 85881f3d6442c150758952aa720f60c24e0d362b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:55:19 -0400 Subject: [PATCH 115/120] Bump aiohttp dependency --- requirements.lock | 2 +- src/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.lock b/requirements.lock index f3e21b4..0aef5d6 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,5 +1,5 @@ aiohappyeyeballs==2.6.1 -aiohttp==3.12.13 +aiohttp==3.12.14 aiosignal==1.3.2 attrs==25.3.0 argon2-cffi==23.1.0 diff --git a/src/requirements.txt b/src/requirements.txt index 396ec2c..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 From 300cf4a0a12a24a7546c64d73e390a812bfe92c3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:02:44 -0400 Subject: [PATCH 116/120] Bump aiosignal for aiohttp --- requirements.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.lock b/requirements.lock index 0aef5d6..a0f0be4 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,6 +1,6 @@ aiohappyeyeballs==2.6.1 aiohttp==3.12.14 -aiosignal==1.3.2 +aiosignal==1.4.0 attrs==25.3.0 argon2-cffi==23.1.0 base58==2.1.1 From 55df7a3c56c17b762c83348bc4bcde918cf158ce Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:36:05 -0400 Subject: [PATCH 117/120] Use notify for warnings --- src/password_manager/manager.py | 105 ++++++++---------- src/tests/test_archive_from_retrieve.py | 3 + src/tests/test_archive_restore.py | 9 +- src/tests/test_manager_checksum_backup.py | 7 +- .../test_manager_warning_notifications.py | 45 ++++++++ src/tests/test_parent_seed_backup.py | 2 + 6 files changed, 107 insertions(+), 64 deletions(-) create mode 100644 src/tests/test_manager_warning_notifications.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 771ea12..846b83f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -723,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): " @@ -827,7 +827,7 @@ class PasswordManager: 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]: @@ -879,7 +879,7 @@ class PasswordManager: 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: @@ -1434,7 +1434,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")) @@ -1493,7 +1493,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( @@ -1568,7 +1568,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")) @@ -2003,7 +2003,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")) @@ -2103,7 +2103,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( @@ -2142,7 +2142,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) @@ -2193,7 +2193,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( @@ -2385,11 +2385,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) @@ -2522,8 +2520,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}): " @@ -2533,11 +2532,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( @@ -2553,11 +2550,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 @@ -2644,11 +2639,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 @@ -2749,11 +2742,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 @@ -2837,13 +2828,13 @@ 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 @@ -3068,7 +3059,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) @@ -3115,7 +3106,7 @@ 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: @@ -3196,7 +3187,7 @@ 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()) @@ -3274,11 +3265,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 @@ -3301,7 +3290,7 @@ 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 @@ -3500,7 +3489,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( @@ -3548,17 +3537,13 @@ class PasswordManager: 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 @@ -3573,7 +3558,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 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_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_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_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 From 74f5911bf78c2361aa7369d793313b225c9b07f7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:01:49 -0400 Subject: [PATCH 118/120] Use notification helper consistently --- src/main.py | 7 +--- src/password_manager/manager.py | 59 +++++++++++++++++++++++++--- src/tests/test_menu_notifications.py | 18 +++++++-- src/utils/terminal_utils.py | 8 ++-- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/main.py b/src/main.py index 4cd9993..1751c3f 100644 --- a/src/main.py +++ b/src/main.py @@ -26,7 +26,6 @@ from utils import ( clear_screen, pause, clear_header_with_notification, - clear_and_print_fingerprint, ) import queue from local_bip85.bip85 import Bip85Error @@ -949,15 +948,13 @@ def display_menu( "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, child_fingerprint=child_fp, ) - note_line = get_notification_text(password_manager) - if note_line: - print(note_line) if time.time() - password_manager.last_activity > inactivity_timeout: print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 846b83f..874c598 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1178,6 +1178,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Password", parent_fingerprint=parent_fp, @@ -1277,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_header_with_notification( - 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)") @@ -1406,6 +1408,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > SSH Key", parent_fingerprint=parent_fp, @@ -1462,6 +1465,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Seed Phrase", parent_fingerprint=parent_fp, @@ -1530,6 +1534,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > PGP Key", parent_fingerprint=parent_fp, @@ -1596,6 +1601,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Nostr Key Pair", parent_fingerprint=parent_fp, @@ -1652,6 +1658,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Key/Value", parent_fingerprint=parent_fp, @@ -1727,6 +1734,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Add Entry > Managed Account", parent_fingerprint=parent_fp, @@ -1823,6 +1831,14 @@ 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")) @@ -1908,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: @@ -1977,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")) @@ -2016,6 +2048,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Retrieve Entry", parent_fingerprint=parent_fp, @@ -2467,6 +2500,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Modify Entry", parent_fingerprint=parent_fp, @@ -2821,6 +2855,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Search Entries", parent_fingerprint=parent_fp, @@ -2841,6 +2876,7 @@ class PasswordManager: while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Search Entries", parent_fingerprint=parent_fp, @@ -2972,6 +3008,7 @@ class PasswordManager: while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > List Entries", parent_fingerprint=parent_fp, @@ -3020,6 +3057,7 @@ class PasswordManager: while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > List Entries", parent_fingerprint=parent_fp, @@ -3112,6 +3150,7 @@ class PasswordManager: while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Archived Entries", parent_fingerprint=parent_fp, @@ -3169,6 +3208,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > 2FA Codes", parent_fingerprint=parent_fp, @@ -3195,6 +3235,7 @@ class PasswordManager: while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > 2FA Codes", parent_fingerprint=parent_fp, @@ -3256,6 +3297,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Settings > Verify Script Checksum", parent_fingerprint=parent_fp, @@ -3295,6 +3337,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Settings > Generate Script Checksum", parent_fingerprint=parent_fp, @@ -3413,6 +3456,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Settings > Export database", parent_fingerprint=parent_fp, @@ -3436,6 +3480,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Settings > Import database", parent_fingerprint=parent_fp, @@ -3458,6 +3503,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Settings > Export 2FA codes", parent_fingerprint=parent_fp, @@ -3531,6 +3577,7 @@ class PasswordManager: try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( + self, fp, "Main Menu > Settings > Backup Parent Seed", parent_fingerprint=parent_fp, diff --git a/src/tests/test_menu_notifications.py b/src/tests/test_menu_notifications.py index 63a43e0..95aa9be 100644 --- a/src/tests/test_menu_notifications.py +++ b/src/tests/test_menu_notifications.py @@ -16,6 +16,7 @@ def _make_pm(msg): q.put(SimpleNamespace(message=msg, level="INFO")) return SimpleNamespace( notifications=q, + get_current_notification=lambda: q.queue[-1] if not q.empty() else None, is_dirty=False, last_update=time.time(), last_activity=time.time(), @@ -40,9 +41,17 @@ def test_display_menu_prints_notifications(monkeypatch, capsys): pm = _make_pm("hello") monkeypatch.setattr(main, "_display_live_stats", lambda *_: None) monkeypatch.setattr( - main, "clear_and_print_fingerprint", lambda *a, **k: print("HEADER") + main, + "clear_header_with_notification", + lambda pm, *a, **k: ( + print("HEADER"), + print( + pm.get_current_notification().message + if pm.get_current_notification() + else "" + ), + ), ) - monkeypatch.setattr(main, "get_notification_text", lambda *_: "hello") monkeypatch.setattr(main, "timed_input", lambda *a, **k: "") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -56,11 +65,12 @@ def test_display_menu_reuses_notification_line(monkeypatch, capsys): msgs = iter(["first", "second"]) monkeypatch.setattr(main, "_display_live_stats", lambda *_: None) monkeypatch.setattr( - main, "clear_and_print_fingerprint", lambda *a, **k: print("HEADER") + 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)) - monkeypatch.setattr(main, "get_notification_text", lambda _pm: next(msgs, "")) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) out = capsys.readouterr().out diff --git a/src/utils/terminal_utils.py b/src/utils/terminal_utils.py index c0b370c..f1e05e7 100644 --- a/src/utils/terminal_utils.py +++ b/src/utils/terminal_utils.py @@ -80,13 +80,15 @@ def clear_header_with_notification( 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" - print(color_text(getattr(note, "message", ""), category)) - else: - print() + line = color_text(getattr(note, "message", ""), category) + + print(line) def pause(message: str = "Press Enter to continue...") -> None: From 63d7d219914733bdb663c6816f2d23654054d3e8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:09:30 -0400 Subject: [PATCH 119/120] test: ensure notifications endpoint leaves current message --- src/tests/test_api_notifications.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tests/test_api_notifications.py b/src/tests/test_api_notifications.py index fe81046..e0805a9 100644 --- a/src/tests/test_api_notifications.py +++ b/src/tests/test_api_notifications.py @@ -28,3 +28,18 @@ def test_notifications_endpoint_clears_queue(client): 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 From 16982f489ddbac12617fa5839b6157fef446ae76 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:24:08 -0400 Subject: [PATCH 120/120] test: update helper PasswordManager mocks --- src/tests/test_background_relay_check.py | 4 ++++ src/tests/test_menu_notifications.py | 8 +++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/tests/test_background_relay_check.py b/src/tests/test_background_relay_check.py index f8a1347..d537c70 100644 --- a/src/tests/test_background_relay_check.py +++ b/src/tests/test_background_relay_check.py @@ -12,6 +12,8 @@ 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) @@ -26,6 +28,8 @@ def test_background_relay_check_runs_async(monkeypatch): 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) diff --git a/src/tests/test_menu_notifications.py b/src/tests/test_menu_notifications.py index 95aa9be..402d4da 100644 --- a/src/tests/test_menu_notifications.py +++ b/src/tests/test_menu_notifications.py @@ -17,6 +17,8 @@ def _make_pm(msg): 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(), @@ -45,11 +47,7 @@ def test_display_menu_prints_notifications(monkeypatch, capsys): "clear_header_with_notification", lambda pm, *a, **k: ( print("HEADER"), - print( - pm.get_current_notification().message - if pm.get_current_notification() - else "" - ), + print(main.get_notification_text(pm)), ), ) monkeypatch.setattr(main, "timed_input", lambda *a, **k: "")