From 384c5c88efef334feade43f2621d212c75b103c5 Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 23 Feb 2026 03:45:15 -0800 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20add=20uv-managed?= =?UTF-8?q?=20SSM=20CLI=20with=20docs=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a full Click + Rich CLI package (ssm_cli) with:\n- configure/login/auth set-token/logout/setup/run/whoami\n- secrets download and FIFO mount\n- profile management\n- deterministic resolution precedence\n- keyring-first token storage with file fallback\n- API client wrapper, retries, and cache/offline fallback\n\nAdd CLI-focused unit tests and wire the console script in pyproject/uv lock.\n\nUpdate documentation with full CLI and first-time setup guides and remove repository wiki page references. --- README.md | 39 ++- docs/CLI.md | 175 ++++++++++++ docs/DEVELOPMENT.md | 16 ++ docs/FIRST_TIME_SETUP.md | 70 +++++ pyproject.toml | 9 +- ssm_cli/__init__.py | 4 + ssm_cli/__main__.py | 5 + ssm_cli/api.py | 146 ++++++++++ ssm_cli/auth.py | 86 ++++++ ssm_cli/cache.py | 58 ++++ ssm_cli/config.py | 172 ++++++++++++ ssm_cli/exceptions.py | 8 + ssm_cli/main.py | 489 ++++++++++++++++++++++++++++++++++ ssm_cli/resolve.py | 111 ++++++++ ssm_cli/run_utils.py | 28 ++ tests/test_cli_api.py | 68 +++++ tests/test_cli_commands.py | 36 +++ tests/test_cli_fetch_cache.py | 53 ++++ tests/test_cli_resolve.py | 89 +++++++ tests/test_cli_run_utils.py | 26 ++ uv.lock | 386 ++++++++++++++++++++++++++- 21 files changed, 2070 insertions(+), 4 deletions(-) create mode 100644 docs/CLI.md create mode 100644 docs/FIRST_TIME_SETUP.md create mode 100644 ssm_cli/__init__.py create mode 100644 ssm_cli/__main__.py create mode 100644 ssm_cli/api.py create mode 100644 ssm_cli/auth.py create mode 100644 ssm_cli/cache.py create mode 100644 ssm_cli/config.py create mode 100644 ssm_cli/exceptions.py create mode 100644 ssm_cli/main.py create mode 100644 ssm_cli/resolve.py create mode 100644 ssm_cli/run_utils.py create mode 100644 tests/test_cli_api.py create mode 100644 tests/test_cli_commands.py create mode 100644 tests/test_cli_fetch_cache.py create mode 100644 tests/test_cli_resolve.py create mode 100644 tests/test_cli_run_utils.py diff --git a/README.md b/README.md index 5cac7cf..3098564 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Docker Image Architecture GitHub Repository License - Documentation + Documentation

Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead. @@ -38,7 +38,7 @@ Open: - Create the first admin username/password. - Then sign in with username/password and start creating projects/configs/secrets. -For full onboarding screens and flow, see the [First-Time Usage Guide](https://github.com/bearlike/simple-secrets-manager/wiki/First%E2%80%90Time-Usage). +For full onboarding and bootstrap flow, see [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md). ### Standard usage flow @@ -48,6 +48,39 @@ For full onboarding screens and flow, see the [First-Time Usage Guide](https://g 4. Export secrets in JSON or `.env` format when needed. 5. Create scoped tokens for services and CI/CD. +### CLI (Doppler-like workflow) + +After `uv sync`, the CLI entrypoint is available at `.venv/bin/ssm`. + +Configure base URL and set a token: + +```bash +.venv/bin/ssm configure --base-url http://localhost:8080/api --profile dev +.venv/bin/ssm auth set-token --token "" --profile dev +``` + +Set directory defaults: + +```bash +.venv/bin/ssm setup --project my-project --config dev --profile dev +``` + +Run commands with injected secrets: + +```bash +.venv/bin/ssm run --profile dev -- python app.py +``` + +Other useful commands: + +```bash +.venv/bin/ssm whoami --profile dev +.venv/bin/ssm secrets download --profile dev --format json +.venv/bin/ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json +``` + +Command reference and detailed CLI behavior are documented in [`docs/CLI.md`](docs/CLI.md). + ## Contributing ### Prerequisites @@ -110,6 +143,8 @@ npm run build ### Developer docs - Full development guide: [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) +- First-time setup guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) +- CLI reference: [`docs/CLI.md`](docs/CLI.md) - Frontend notes: [`frontend/README.md`](frontend/README.md) ## Supplementary Reference diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..e6160a3 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,175 @@ +# CLI Reference + +Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Rich. + +## Install and verify + +From repository root: + +```bash +uv sync +uv run ssm --help +``` + +If you prefer direct binary execution after sync: + +```bash +.venv/bin/ssm --help +``` + +## Resolution order (DRY/KISS) + +The CLI resolves values using one deterministic order. + +### Base URL + +1. `--base-url` +2. `SSM_BASE_URL` +3. active profile in global config +4. global `base_url` + +### Project and config + +1. `--project` / `--config` +2. `SSM_PROJECT` / `SSM_CONFIG` +3. local directory config (`.ssm/config.json`) +4. active profile defaults in global config + +### Profile + +1. `--profile` +2. `SSM_PROFILE` +3. local directory profile (`.ssm/config.json`) +4. global active profile +5. `default` + +### Token + +1. `SSM_TOKEN` +2. stored token for `@` in keyring +3. stored token in file fallback + +## Config and credential files + +- Global config: `~/.config/ssm/config.json` +- Local config: `/.ssm/config.json` +- Credential fallback: `~/.config/ssm/credentials.json` (mode `0600`) +- Cache: `~/.cache/ssm/secrets/.json` + +Test overrides are supported via env vars: + +- `SSM_GLOBAL_CONFIG_FILE` +- `SSM_LOCAL_CONFIG_FILE` +- `SSM_CREDENTIALS_FILE` +- `SSM_CACHE_DIR` + +## Authentication + +### Login (username/password) + +Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token. + +```bash +uv run ssm configure --base-url http://localhost:8080/api --profile dev +uv run ssm login --profile dev +``` + +### Service/personal token (recommended for CI or automation) + +```bash +uv run ssm auth set-token --profile dev --token "" +``` + +### Logout + +```bash +uv run ssm logout --profile dev +``` + +Clear all local token records: + +```bash +uv run ssm logout --all-profiles +``` + +## Project/config setup + +Set local defaults for current directory: + +```bash +uv run ssm setup --project my-project --config dev --profile dev +``` + +## Core commands + +### Run command with injected secrets + +```bash +uv run ssm run --profile dev -- python app.py +``` + +Useful flags: + +- `--offline`: cache-only +- `--cache-ttl `: cache freshness window +- `--print-env`: print resolved keys +- `--show-values`: print values (only with `--print-env`) + +### Download secrets + +```bash +uv run ssm secrets download --profile dev --format json +uv run ssm secrets download --profile dev --format env +``` + +Note: `.env` output fails when any value contains a newline. Use JSON in that case. + +### Mount secrets to FIFO + +```bash +uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json +``` + +- Creates FIFO with `0600` permissions. +- Writes one payload then removes FIFO unless `--keep` is used. + +### Session validation + +```bash +uv run ssm whoami --profile dev +``` + +`whoami` validates token by calling `GET /projects` and reports visible scope behavior. + +## Profiles + +List profiles: + +```bash +uv run ssm profile list +``` + +Activate profile: + +```bash +uv run ssm profile use dev +``` + +Set profile defaults: + +```bash +uv run ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate +``` + +## Exit behavior + +- `run` exits with the child process exit code. +- Configuration/auth errors generally use exit code `2`. +- Offline cache miss uses exit code `4`. + +## Local quality checks + +```bash +./scripts/quality.sh check +cd frontend && npm run lint && npm run build +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 74126be..46dab97 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -84,6 +84,15 @@ npm run lint npm run build ``` +CLI: + +```bash +uv sync +uv run ssm --help +``` + +Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md). + ## Integration smoke checks Backend health endpoint (Swagger index): @@ -98,6 +107,13 @@ Frontend HTTP check: curl -sS -I http://localhost:8080 ``` +CLI smoke check: + +```bash +uv run ssm configure --base-url http://localhost:8080/api --profile dev +uv run ssm whoami --profile dev +``` + ## CI publish flow Container publishing is handled by `.github/workflows/ci.yml`. diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md new file mode 100644 index 0000000..d05db1f --- /dev/null +++ b/docs/FIRST_TIME_SETUP.md @@ -0,0 +1,70 @@ +# First-Time Setup Guide + +This guide covers the first initialization of a fresh Simple Secrets Manager deployment. + +## Prerequisites + +- Backend reachable at `http://localhost:5000/api` or via proxy at `http://localhost:8080/api` +- MongoDB configured and reachable by the backend + +## Step 1: Check onboarding state + +```bash +curl -sS http://localhost:5000/api/onboarding/status +``` + +Expected on a fresh install: + +```json +{"isInitialized": false, "state": "not_initialized"} +``` + +## Step 2: Bootstrap first admin user + +```bash +curl -sS -X POST "http://localhost:5000/api/onboarding/bootstrap" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"Str0ng!Passw0rd","issueToken":true}' +``` + +This creates the first admin account and marks onboarding complete. + +## Step 3: Sign in from UI + +- Open `http://localhost:8080` +- Use the created username/password +- Create projects/configs/secrets in the admin console + +## Step 4: Acquire token for API/CLI use + +### Option A: Login from CLI with username/password + +```bash +uv run ssm configure --base-url http://localhost:8080/api --profile dev +uv run ssm login --profile dev +``` + +### Option B: Set existing token + +```bash +uv run ssm auth set-token --profile dev --token "" +``` + +## Step 5: Verify access + +```bash +uv run ssm whoami --profile dev +``` + +## Common issues + +- `System already initialized`: bootstrap was already completed. +- `Missing API token`: login or set token before calling protected endpoints. +- `Missing scope: `: token does not include required action scope. +- CLI `env` export fails for multiline values: use JSON format instead. + +## Security notes + +- Tokens should be scoped for least privilege. +- Prefer service tokens for CI/CD and machine workloads. +- Rotate/revoke tokens regularly via `/api/auth/tokens/v2/revoke`. diff --git a/pyproject.toml b/pyproject.toml index 2202121..87ee39e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,10 @@ dependencies = [ "flask-cors>=5.0.0,<6.0.0", "flask-restx>=0.5.1", "pymongo>4,<5", + "click>=8.1.8,<9.0.0", + "rich>=13.9.4,<14.0.0", + "requests>=2.32.3,<3.0.0", + "keyring>=25.6.0,<26.0.0", ] [project.urls] @@ -33,8 +37,11 @@ dev = [ requires = ["setuptools>=77.0.3"] build-backend = "setuptools.build_meta" +[project.scripts] +ssm = "ssm_cli.main:cli" + [tool.setuptools] -packages = ["Access", "Api", "Engines"] +packages = ["Access", "Api", "Engines", "ssm_cli"] py-modules = ["connection", "server"] [tool.ruff] diff --git a/ssm_cli/__init__.py b/ssm_cli/__init__.py new file mode 100644 index 0000000..a216f77 --- /dev/null +++ b/ssm_cli/__init__.py @@ -0,0 +1,4 @@ +"""Simple Secrets Manager CLI package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/ssm_cli/__main__.py b/ssm_cli/__main__.py new file mode 100644 index 0000000..5563dc3 --- /dev/null +++ b/ssm_cli/__main__.py @@ -0,0 +1,5 @@ +from ssm_cli.main import cli + + +if __name__ == "__main__": + cli() diff --git a/ssm_cli/api.py b/ssm_cli/api.py new file mode 100644 index 0000000..1f1bb39 --- /dev/null +++ b/ssm_cli/api.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin, urlparse + +import requests # type: ignore[import-untyped] + + +@dataclass +class ApiError(Exception): + message: str + status_code: int = 1 + body: Any = None + + +def normalize_base_url(value: str) -> str: + url = value.strip().rstrip("/") + if not url: + return url + if not url.startswith("http://") and not url.startswith("https://"): + url = f"http://{url}" + + parsed = urlparse(url) + path = parsed.path.rstrip("/") + if not path.endswith("/api"): + path = f"{path}/api" if path else "/api" + normalized = parsed._replace(path=path, query="", fragment="").geturl() + return normalized.rstrip("/") + + +class ApiClient: + def __init__(self, base_url: str, token: str | None = None, timeout: int = 10, retries: int = 2): + self.base_url = normalize_base_url(base_url) + self.token = token + self.timeout = timeout + self.retries = retries + self.session = requests.Session() + + def _build_url(self, path: str) -> str: + clean_path = path if path.startswith("/") else f"/{path}" + return urljoin(f"{self.base_url}/", clean_path.lstrip("/")) + + def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + token: str | None = None, + basic_auth: tuple[str, str] | None = None, + accept: str | None = None, + ) -> Any: + headers: dict[str, str] = {} + effective_token = token if token is not None else self.token + if effective_token: + headers["Authorization"] = f"Bearer {effective_token}" + if accept: + headers["Accept"] = accept + + url = self._build_url(path) + last_exception: Exception | None = None + + for attempt in range(self.retries + 1): + try: + response = self.session.request( + method=method.upper(), + url=url, + params=params, + json=json_body, + headers=headers, + auth=basic_auth, + timeout=self.timeout, + ) + return self._parse_response(response) + except requests.RequestException as exc: + last_exception = exc + if attempt >= self.retries: + break + time.sleep(0.2 * (attempt + 1)) + + raise ApiError(message=f"Network error: {last_exception}", status_code=1) + + def _parse_response(self, response: requests.Response) -> Any: + content_type = response.headers.get("content-type", "") + + if response.status_code >= 400: + body = self._safe_parse(response, content_type) + message = self._error_message(body, response.status_code) + raise ApiError(message=message, status_code=response.status_code, body=body) + + return self._safe_parse(response, content_type) + + @staticmethod + def _safe_parse(response: requests.Response, content_type: str) -> Any: + if response.status_code == 204: + return None + if "application/json" in content_type: + try: + return response.json() + except Exception: + return {} + return response.text + + @staticmethod + def _error_message(body: Any, status_code: int) -> str: + if isinstance(body, str) and body.strip(): + return body.strip() + if isinstance(body, dict): + for key in ("message", "error", "status"): + value = body.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return f"API request failed ({status_code})" + + def login_userpass(self, username: str, password: str) -> dict[str, Any]: + payload = self.request("GET", "/auth/tokens/", basic_auth=(username, password)) + if not isinstance(payload, dict) or not isinstance(payload.get("token"), str): + raise ApiError("Token response is invalid", status_code=1, body=payload) + return payload + + def export_secrets_json(self, project: str, config: str) -> dict[str, str]: + payload = self.request( + "GET", + f"/projects/{project}/configs/{config}/secrets", + params={"format": "json", "include_parent": "true", "include_meta": "false"}, + accept="application/json", + ) + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, dict): + raise ApiError("Secrets response is invalid", status_code=1, body=payload) + + parsed: dict[str, str] = {} + for key, value in data.items(): + if isinstance(key, str) and isinstance(value, str): + parsed[key] = value + return parsed + + def list_projects(self) -> list[dict[str, Any]]: + payload = self.request("GET", "/projects", accept="application/json") + projects = payload.get("projects") if isinstance(payload, dict) else None + if not isinstance(projects, list): + raise ApiError("Projects response is invalid", status_code=1, body=payload) + return [item for item in projects if isinstance(item, dict)] diff --git a/ssm_cli/auth.py b/ssm_cli/auth.py new file mode 100644 index 0000000..4b4b3ab --- /dev/null +++ b/ssm_cli/auth.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from ssm_cli.config import load_credentials, save_credentials + +keyring: Any +try: + import keyring as _keyring # type: ignore[import-untyped] + + keyring = _keyring +except Exception: # pragma: no cover - import failure depends on platform + keyring = None + +KEYRING_SERVICE = "simple-secrets-manager-cli" +KEYRING_SENTINEL = "__KEYRING__" + + +def _token_key(profile: str, base_url: str) -> str: + return f"{profile}@{base_url}" + + +def set_token(profile: str, base_url: str, token: str) -> str: + key = _token_key(profile, base_url) + creds = load_credentials() + + if keyring is not None: + try: + keyring.set_password(KEYRING_SERVICE, key, token) + creds[key] = KEYRING_SENTINEL + save_credentials(creds) + return "keyring" + except Exception: + pass + + creds[key] = token + save_credentials(creds) + return "file" + + +def get_token(profile: str, base_url: str) -> Tuple[Optional[str], Optional[str]]: + key = _token_key(profile, base_url) + + if keyring is not None: + try: + token = keyring.get_password(KEYRING_SERVICE, key) + if token: + return token, "keyring" + except Exception: + pass + + creds = load_credentials() + token = creds.get(key) + if token and token != KEYRING_SENTINEL: + return token, "file" + return None, None + + +def clear_token(profile: str, base_url: str) -> None: + key = _token_key(profile, base_url) + + if keyring is not None: + try: + keyring.delete_password(KEYRING_SERVICE, key) + except Exception: + pass + + creds = load_credentials() + if key in creds: + del creds[key] + save_credentials(creds) + + +def clear_all_tokens() -> None: + creds = load_credentials() + keys = list(creds.keys()) + + if keyring is not None: + for key in keys: + try: + keyring.delete_password(KEYRING_SERVICE, key) + except Exception: + pass + + if creds: + save_credentials({}) diff --git a/ssm_cli/cache.py b/ssm_cli/cache.py new file mode 100644 index 0000000..13e5721 --- /dev/null +++ b/ssm_cli/cache.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import hashlib +import json +import time +from pathlib import Path + +from ssm_cli.config import cache_root + + +def _cache_file(base_url: str, project: str, config: str) -> Path: + identity = f"{base_url}|{project}|{config}" + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest() + return cache_root() / "secrets" / f"{digest}.json" + + +def save_secret_cache(base_url: str, project: str, config: str, data: dict[str, str]) -> None: + path = _cache_file(base_url, project, config) + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"fetched_at": int(time.time()), "data": data} + temp = path.with_suffix(path.suffix + ".tmp") + with temp.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True) + handle.write("\n") + temp.replace(path) + + +def load_secret_cache( + base_url: str, + project: str, + config: str, + max_age_seconds: int | None = None, +) -> dict[str, str] | None: + path = _cache_file(base_url, project, config) + if not path.exists(): + return None + + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + + if not isinstance(payload, dict): + return None + + fetched_at = payload.get("fetched_at") + data = payload.get("data") + if not isinstance(fetched_at, int) or not isinstance(data, dict): + return None + + if max_age_seconds is not None: + age = int(time.time()) - fetched_at + if age > max_age_seconds: + return None + + parsed: dict[str, str] = {} + for key, value in data.items(): + if isinstance(key, str) and isinstance(value, str): + parsed[key] = value + return parsed diff --git a/ssm_cli/config.py b/ssm_cli/config.py new file mode 100644 index 0000000..5e77312 --- /dev/null +++ b/ssm_cli/config.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DEFAULT_PROFILE = "default" + + +def global_config_path() -> Path: + override = os.getenv("SSM_GLOBAL_CONFIG_FILE") + if override: + return Path(override).expanduser() + return Path.home() / ".config" / "ssm" / "config.json" + + +def local_config_path(cwd: Path | None = None) -> Path: + override = os.getenv("SSM_LOCAL_CONFIG_FILE") + if override: + return Path(override).expanduser() + base = cwd or Path.cwd() + return base / ".ssm" / "config.json" + + +def credentials_path() -> Path: + override = os.getenv("SSM_CREDENTIALS_FILE") + if override: + return Path(override).expanduser() + return Path.home() / ".config" / "ssm" / "credentials.json" + + +def cache_root() -> Path: + override = os.getenv("SSM_CACHE_DIR") + if override: + return Path(override).expanduser() + return Path.home() / ".cache" / "ssm" + + +@dataclass +class ProfileConfig: + base_url: str | None = None + project: str | None = None + config: str | None = None + + +@dataclass +class GlobalConfig: + base_url: str | None = None + active_profile: str = DEFAULT_PROFILE + profiles: dict[str, ProfileConfig] = field(default_factory=dict) + + +@dataclass +class LocalConfig: + profile: str | None = None + project: str | None = None + config: str | None = None + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + return {} + return data + + +def _atomic_write_json(path: Path, data: dict[str, Any], mode: int = 0o600) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix(path.suffix + ".tmp") + with temp.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + os.chmod(temp, mode) + temp.replace(path) + + +def load_global_config() -> GlobalConfig: + raw = _read_json(global_config_path()) + profiles_raw_obj = raw.get("profiles") + profiles_raw = profiles_raw_obj if isinstance(profiles_raw_obj, dict) else {} + profiles: dict[str, ProfileConfig] = {} + for name, value in profiles_raw.items(): + if not isinstance(name, str) or not isinstance(value, dict): + continue + profiles[name] = ProfileConfig( + base_url=_str_or_none(value.get("base_url")), + project=_str_or_none(value.get("project")), + config=_str_or_none(value.get("config")), + ) + + active_profile = _str_or_none(raw.get("active_profile")) or DEFAULT_PROFILE + if active_profile not in profiles: + profiles[active_profile] = ProfileConfig() + + return GlobalConfig( + base_url=_str_or_none(raw.get("base_url")), + active_profile=active_profile, + profiles=profiles, + ) + + +def save_global_config(cfg: GlobalConfig) -> None: + raw_profiles: dict[str, dict[str, str]] = {} + for name, profile in cfg.profiles.items(): + if not name: + continue + item: dict[str, str] = {} + if profile.base_url: + item["base_url"] = profile.base_url + if profile.project: + item["project"] = profile.project + if profile.config: + item["config"] = profile.config + raw_profiles[name] = item + + payload: dict[str, Any] = { + "active_profile": cfg.active_profile or DEFAULT_PROFILE, + "profiles": raw_profiles, + } + if cfg.base_url: + payload["base_url"] = cfg.base_url + + _atomic_write_json(global_config_path(), payload, mode=0o600) + + +def load_local_config(cwd: Path | None = None) -> LocalConfig: + raw = _read_json(local_config_path(cwd)) + return LocalConfig( + profile=_str_or_none(raw.get("profile")), + project=_str_or_none(raw.get("project")), + config=_str_or_none(raw.get("config")), + ) + + +def save_local_config(cfg: LocalConfig, cwd: Path | None = None) -> None: + payload: dict[str, str] = {} + if cfg.profile: + payload["profile"] = cfg.profile + if cfg.project: + payload["project"] = cfg.project + if cfg.config: + payload["config"] = cfg.config + _atomic_write_json(local_config_path(cwd), payload, mode=0o600) + + +def load_credentials() -> dict[str, str]: + raw = _read_json(credentials_path()) + tokens = raw.get("tokens") + if not isinstance(tokens, dict): + return {} + parsed: dict[str, str] = {} + for key, value in tokens.items(): + if isinstance(key, str) and isinstance(value, str): + parsed[key] = value + return parsed + + +def save_credentials(tokens: dict[str, str]) -> None: + _atomic_write_json(credentials_path(), {"tokens": tokens}, mode=0o600) + + +def _str_or_none(value: Any) -> str | None: + if isinstance(value, str): + stripped = value.strip() + if stripped: + return stripped + return None diff --git a/ssm_cli/exceptions.py b/ssm_cli/exceptions.py new file mode 100644 index 0000000..f56ad42 --- /dev/null +++ b/ssm_cli/exceptions.py @@ -0,0 +1,8 @@ +from __future__ import annotations + + +class CliError(Exception): + def __init__(self, message: str, exit_code: int = 1): + super().__init__(message) + self.message = message + self.exit_code = exit_code diff --git a/ssm_cli/main.py b/ssm_cli/main.py new file mode 100644 index 0000000..cc313cc --- /dev/null +++ b/ssm_cli/main.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +import json +import os +import stat +from functools import wraps +from pathlib import Path +from typing import Any, Callable + +import click +from rich.console import Console +from rich.table import Table + +from ssm_cli import auth +from ssm_cli.api import ApiClient, ApiError, normalize_base_url +from ssm_cli.cache import load_secret_cache, save_secret_cache +from ssm_cli.config import ( + DEFAULT_PROFILE, + GlobalConfig, + LocalConfig, + ProfileConfig, + load_global_config, + save_global_config, + save_local_config, +) +from ssm_cli.exceptions import CliError +from ssm_cli.resolve import Resolution, resolve_context +from ssm_cli.run_utils import render_env_lines, run_with_env + +console = Console() +err_console = Console(stderr=True) + + +def _fail(message: str, code: int = 1) -> None: + err_console.print(f"[red]Error:[/red] {message}") + raise click.exceptions.Exit(code) + + +def _handle_errors(fn: Callable[..., Any]) -> Callable[..., Any]: + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return fn(*args, **kwargs) + except CliError as exc: + _fail(exc.message, exc.exit_code) + except ApiError as exc: + _fail(f"{exc.message} (status={exc.status_code})", 1) + + return wrapper + + +def _profile_name(override: str | None, cfg: GlobalConfig) -> str: + env = os.getenv("SSM_PROFILE") + return (override or env or cfg.active_profile or DEFAULT_PROFILE).strip() or DEFAULT_PROFILE + + +def _ensure_profile(cfg: GlobalConfig, profile_name: str) -> ProfileConfig: + profile = cfg.profiles.get(profile_name) + if profile is None: + profile = ProfileConfig() + cfg.profiles[profile_name] = profile + return profile + + +def _fetch_secrets( + resolution: Resolution, + *, + offline: bool, + cache_ttl: int, +) -> tuple[dict[str, str], str]: + if not resolution.base_url or not resolution.project or not resolution.config: + raise CliError("Missing base_url/project/config for secret retrieval", exit_code=2) + + if offline: + cached = load_secret_cache( + resolution.base_url, + resolution.project, + resolution.config, + max_age_seconds=cache_ttl, + ) + if cached is None: + raise CliError("No cached secrets found for offline mode", exit_code=4) + return cached, "cache" + + client = ApiClient(resolution.base_url, token=resolution.token) + try: + data = client.export_secrets_json(resolution.project, resolution.config) + save_secret_cache(resolution.base_url, resolution.project, resolution.config, data) + return data, "remote" + except ApiError: + cached = load_secret_cache( + resolution.base_url, + resolution.project, + resolution.config, + max_age_seconds=cache_ttl, + ) + if cached is not None: + console.print("[yellow]Using cached secrets because live fetch failed.[/yellow]") + return cached, "cache-fallback" + raise + + +def _print_env_table(secrets: dict[str, str], show_values: bool) -> None: + table = Table(title="Resolved environment", show_lines=False) + table.add_column("Key", style="cyan") + table.add_column("Value", style="magenta") + for key in sorted(secrets.keys()): + value = secrets[key] if show_values else "[redacted]" + table.add_row(key, value) + console.print(table) + + +@click.group(help="Simple Secrets Manager CLI") +def cli() -> None: + pass + + +@cli.command(help="Configure API base URL for a profile") +@click.option("--base-url", required=True, help="Base URL (for example http://localhost:8080/api)") +@click.option("--profile", default=None, help="Profile name") +@click.option("--activate/--no-activate", default=True, help="Set profile as active") +@_handle_errors +def configure(base_url: str, profile: str | None, activate: bool) -> None: + cfg = load_global_config() + profile_name = _profile_name(profile, cfg) + normalized = normalize_base_url(base_url) + profile_cfg = _ensure_profile(cfg, profile_name) + profile_cfg.base_url = normalized + cfg.base_url = normalized + if activate: + cfg.active_profile = profile_name + save_global_config(cfg) + console.print(f"Configured [bold]{profile_name}[/bold] -> {normalized}") + + +@cli.group(name="auth", help="Authentication helpers") +def auth_cmd() -> None: + pass + + +@auth_cmd.command("set-token", help="Store a token for the current base URL/profile") +@click.option("--token", prompt=True, hide_input=True, help="Token value") +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--profile", default=None, help="Profile name") +@_handle_errors +def auth_set_token(token: str, base_url: str | None, profile: str | None) -> None: + resolution = resolve_context(base_url=base_url, profile=profile, require_base_url=True) + storage = auth.set_token(resolution.profile, resolution.base_url or "", token.strip()) + console.print(f"Token saved for profile [bold]{resolution.profile}[/bold] ({storage})") + + +@cli.command(help="Login with username/password and store returned token") +@click.option("--username", prompt=True, help="Username") +@click.option("--password", prompt=True, hide_input=True, help="Password") +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--profile", default=None, help="Profile name") +@_handle_errors +def login(username: str, password: str, base_url: str | None, profile: str | None) -> None: + resolution = resolve_context(base_url=base_url, profile=profile, require_base_url=True) + client = ApiClient(resolution.base_url or "") + token_payload = client.login_userpass(username.strip(), password) + token = token_payload.get("token") + if not isinstance(token, str): + raise CliError("Login response did not include a token") + + storage = auth.set_token(resolution.profile, resolution.base_url or "", token) + + cfg = load_global_config() + profile_cfg = _ensure_profile(cfg, resolution.profile) + if not profile_cfg.base_url: + profile_cfg.base_url = resolution.base_url + if cfg.active_profile != resolution.profile: + cfg.active_profile = resolution.profile + save_global_config(cfg) + + expires = token_payload.get("expires_at") + if isinstance(expires, str) and expires: + console.print(f"Logged in. Token stored in {storage}. Expires at {expires}") + else: + console.print(f"Logged in. Token stored in {storage}.") + + +@cli.command(help="Remove locally stored token") +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--profile", default=None, help="Profile name") +@click.option("--all-profiles", is_flag=True, default=False, help="Remove all file-backed tokens") +@_handle_errors +def logout(base_url: str | None, profile: str | None, all_profiles: bool) -> None: + if all_profiles: + auth.clear_all_tokens() + console.print("Cleared all locally stored tokens.") + return + + resolution = resolve_context(base_url=base_url, profile=profile, require_base_url=True) + auth.clear_token(resolution.profile, resolution.base_url or "") + console.print(f"Cleared token for profile [bold]{resolution.profile}[/bold].") + + +@cli.command(help="Set project/config defaults for current directory") +@click.option("--project", prompt=True, help="Project slug") +@click.option("--config", "config_name", prompt=True, help="Config slug") +@click.option("--profile", default=None, help="Profile name") +@click.option("--sync-profile/--local-only", default=True, help="Also save as profile defaults") +@_handle_errors +def setup(project: str, config_name: str, profile: str | None, sync_profile: bool) -> None: + resolution = resolve_context(profile=profile) + save_local_config(LocalConfig(profile=resolution.profile, project=project.strip(), config=config_name.strip())) + + if sync_profile: + cfg = load_global_config() + profile_cfg = _ensure_profile(cfg, resolution.profile) + profile_cfg.project = project.strip() + profile_cfg.config = config_name.strip() + if resolution.base_url and not profile_cfg.base_url: + profile_cfg.base_url = resolution.base_url + save_global_config(cfg) + + console.print("Directory defaults saved in .ssm/config.json") + + +@cli.command( + context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False}, + help="Run command with secrets injected into child process environment", +) +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--project", default=None, help="Project slug override") +@click.option("--config", "config_name", default=None, help="Config slug override") +@click.option("--profile", default=None, help="Profile name") +@click.option("--offline", is_flag=True, default=False, help="Use cached secrets only") +@click.option("--cache-ttl", default=3600, show_default=True, type=int, help="Cache max age in seconds") +@click.option("--print-env", is_flag=True, default=False, help="Print resolved keys before execution") +@click.option("--show-values", is_flag=True, default=False, help="Show values with --print-env") +@click.argument("command", nargs=-1, type=click.UNPROCESSED) +@_handle_errors +def run( + base_url: str | None, + project: str | None, + config_name: str | None, + profile: str | None, + offline: bool, + cache_ttl: int, + print_env: bool, + show_values: bool, + command: tuple[str, ...], +) -> None: + if not command: + raise CliError("Command is required after `ssm run --`", exit_code=2) + resolution = resolve_context( + base_url=base_url, + project=project, + config=config_name, + profile=profile, + require_base_url=True, + require_project_config=True, + require_token=True, + ) + secrets, source = _fetch_secrets(resolution, offline=offline, cache_ttl=cache_ttl) + + if print_env: + _print_env_table(secrets, show_values=show_values) + + if source != "remote": + console.print(f"Using secrets from [bold]{source}[/bold].") + + code = run_with_env(command, secrets) + raise click.exceptions.Exit(code) + + +@cli.group(help="Secret export and mount commands") +def secrets() -> None: + pass + + +@secrets.command("download", help="Download secrets to stdout") +@click.option("--format", "output_format", type=click.Choice(["json", "env"]), default="json", show_default=True) +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--project", default=None, help="Project slug override") +@click.option("--config", "config_name", default=None, help="Config slug override") +@click.option("--profile", default=None, help="Profile name") +@click.option("--offline", is_flag=True, default=False, help="Use cached secrets only") +@click.option("--cache-ttl", default=3600, show_default=True, type=int, help="Cache max age in seconds") +@_handle_errors +def secrets_download( + output_format: str, + base_url: str | None, + project: str | None, + config_name: str | None, + profile: str | None, + offline: bool, + cache_ttl: int, +) -> None: + resolution = resolve_context( + base_url=base_url, + project=project, + config=config_name, + profile=profile, + require_base_url=True, + require_project_config=True, + require_token=True, + ) + secrets_data, source = _fetch_secrets(resolution, offline=offline, cache_ttl=cache_ttl) + if source != "remote": + console.print(f"Using secrets from [bold]{source}[/bold].", style="yellow") + + if output_format == "json": + console.print_json(json.dumps(secrets_data, sort_keys=True)) + return + + console.print(render_env_lines(secrets_data), soft_wrap=True) + + +@secrets.command("mount", help="Write secrets to a named pipe (FIFO)") +@click.option("--path", "fifo_path", required=True, type=click.Path(path_type=Path), help="FIFO path") +@click.option("--format", "output_format", type=click.Choice(["json", "env"]), default="env", show_default=True) +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--project", default=None, help="Project slug override") +@click.option("--config", "config_name", default=None, help="Config slug override") +@click.option("--profile", default=None, help="Profile name") +@click.option("--offline", is_flag=True, default=False, help="Use cached secrets only") +@click.option("--cache-ttl", default=3600, show_default=True, type=int, help="Cache max age in seconds") +@click.option("--keep", is_flag=True, default=False, help="Keep FIFO after write") +@_handle_errors +def secrets_mount( + fifo_path: Path, + output_format: str, + base_url: str | None, + project: str | None, + config_name: str | None, + profile: str | None, + offline: bool, + cache_ttl: int, + keep: bool, +) -> None: + resolution = resolve_context( + base_url=base_url, + project=project, + config=config_name, + profile=profile, + require_base_url=True, + require_project_config=True, + require_token=True, + ) + secrets_data, source = _fetch_secrets(resolution, offline=offline, cache_ttl=cache_ttl) + + payload = json.dumps(secrets_data, sort_keys=True) if output_format == "json" else render_env_lines(secrets_data) + + if fifo_path.exists(): + file_mode = fifo_path.stat().st_mode + if not stat.S_ISFIFO(file_mode): + raise CliError(f"Path exists and is not a FIFO: {fifo_path}") + fifo_path.unlink() + + fifo_path.parent.mkdir(parents=True, exist_ok=True) + os.mkfifo(fifo_path, mode=0o600) + + console.print(f"FIFO created at {fifo_path}. Waiting for reader...") + if source != "remote": + console.print(f"Using secrets from [bold]{source}[/bold].", style="yellow") + + try: + with fifo_path.open("w", encoding="utf-8") as handle: + handle.write(payload) + handle.write("\n") + console.print("Payload written to FIFO.") + finally: + if not keep and fifo_path.exists(): + fifo_path.unlink() + + +@cli.command(help="Validate the active token and print context") +@click.option("--base-url", default=None, help="Base URL override") +@click.option("--profile", default=None, help="Profile name") +@_handle_errors +def whoami(base_url: str | None, profile: str | None) -> None: + resolution = resolve_context( + base_url=base_url, + profile=profile, + require_base_url=True, + require_token=True, + ) + + client = ApiClient(resolution.base_url or "", token=resolution.token) + scope_message = "" + try: + projects = client.list_projects() + scope_message = f"projects:read available ({len(projects)} visible)" + except ApiError as exc: + if exc.status_code == 403: + scope_message = "token valid, but missing projects:read" + else: + raise + + token_preview = "[hidden]" + if resolution.token and len(resolution.token) >= 8: + token_preview = f"{resolution.token[:4]}...{resolution.token[-4:]}" + + table = Table(title="Current session") + table.add_column("Field", style="cyan") + table.add_column("Value", style="green") + table.add_row("Profile", resolution.profile) + table.add_row("Base URL", resolution.base_url or "") + table.add_row("Token Source", resolution.token_source or "unknown") + table.add_row("Token", token_preview) + table.add_row("Scopes", scope_message) + console.print(table) + + +@cli.group(help="Manage CLI profiles") +def profile() -> None: + pass + + +@profile.command("list", help="List profiles") +@_handle_errors +def profile_list() -> None: + cfg = load_global_config() + table = Table(title="Profiles") + table.add_column("Profile", style="cyan") + table.add_column("Active") + table.add_column("Base URL") + table.add_column("Project") + table.add_column("Config") + + names = sorted(cfg.profiles.keys()) + if not names: + names = [cfg.active_profile] + + for name in names: + profile_cfg = cfg.profiles.get(name) or ProfileConfig() + table.add_row( + name, + "yes" if name == cfg.active_profile else "", + profile_cfg.base_url or "", + profile_cfg.project or "", + profile_cfg.config or "", + ) + console.print(table) + + +@profile.command("use", help="Set active profile") +@click.argument("name") +@_handle_errors +def profile_use(name: str) -> None: + profile_name = name.strip() + if not profile_name: + raise CliError("Profile name cannot be empty", exit_code=2) + + cfg = load_global_config() + _ensure_profile(cfg, profile_name) + cfg.active_profile = profile_name + save_global_config(cfg) + console.print(f"Active profile set to [bold]{profile_name}[/bold]") + + +@profile.command("set", help="Set profile fields") +@click.argument("name") +@click.option("--base-url", default=None, help="Base URL") +@click.option("--project", default=None, help="Default project") +@click.option("--config", "config_name", default=None, help="Default config") +@click.option("--activate", is_flag=True, default=False, help="Set as active profile") +@_handle_errors +def profile_set(name: str, base_url: str | None, project: str | None, config_name: str | None, activate: bool) -> None: + if not any([base_url, project, config_name, activate]): + raise CliError("No changes requested. Provide at least one option.", exit_code=2) + + profile_name = name.strip() + if not profile_name: + raise CliError("Profile name cannot be empty", exit_code=2) + + cfg = load_global_config() + profile_cfg = _ensure_profile(cfg, profile_name) + + if base_url: + normalized = normalize_base_url(base_url) + profile_cfg.base_url = normalized + cfg.base_url = normalized + if project: + profile_cfg.project = project.strip() + if config_name: + profile_cfg.config = config_name.strip() + if activate: + cfg.active_profile = profile_name + + save_global_config(cfg) + console.print(f"Profile [bold]{profile_name}[/bold] updated.") + + +if __name__ == "__main__": + cli() diff --git a/ssm_cli/resolve.py b/ssm_cli/resolve.py new file mode 100644 index 0000000..5baa19b --- /dev/null +++ b/ssm_cli/resolve.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from ssm_cli import auth +from ssm_cli.api import normalize_base_url +from ssm_cli.config import DEFAULT_PROFILE, load_global_config, load_local_config +from ssm_cli.exceptions import CliError + + +@dataclass +class Resolution: + profile: str + base_url: str | None + project: str | None + config: str | None + token: str | None + token_source: str | None + + +def _pick(*values: str | None) -> str | None: + for value in values: + if value is not None: + stripped = value.strip() + if stripped: + return stripped + return None + + +def resolve_context( + *, + base_url: str | None = None, + project: str | None = None, + config: str | None = None, + profile: str | None = None, + cwd: Path | None = None, + require_base_url: bool = False, + require_project_config: bool = False, + require_token: bool = False, +) -> Resolution: + global_cfg = load_global_config() + local_cfg = load_local_config(cwd) + + resolved_profile = _pick( + profile, + os.getenv("SSM_PROFILE"), + local_cfg.profile, + global_cfg.active_profile, + DEFAULT_PROFILE, + ) + if resolved_profile is None: + resolved_profile = DEFAULT_PROFILE + + profile_cfg = global_cfg.profiles.get(resolved_profile) + + resolved_base_url = _pick( + base_url, + os.getenv("SSM_BASE_URL"), + profile_cfg.base_url if profile_cfg else None, + global_cfg.base_url, + ) + if resolved_base_url: + resolved_base_url = normalize_base_url(resolved_base_url) + + resolved_project = _pick( + project, + os.getenv("SSM_PROJECT"), + local_cfg.project, + profile_cfg.project if profile_cfg else None, + ) + resolved_config = _pick( + config, + os.getenv("SSM_CONFIG"), + local_cfg.config, + profile_cfg.config if profile_cfg else None, + ) + + env_token = _pick(os.getenv("SSM_TOKEN")) + if env_token is not None: + token = env_token + token_source = "env" + else: + token = None + token_source = None + if resolved_base_url: + token, token_source = auth.get_token(resolved_profile, resolved_base_url) + + result = Resolution( + profile=resolved_profile, + base_url=resolved_base_url, + project=resolved_project, + config=resolved_config, + token=token, + token_source=token_source, + ) + + if require_base_url and not result.base_url: + raise CliError("Base URL is not configured. Run `ssm configure` or pass --base-url.", exit_code=2) + + if require_project_config and (not result.project or not result.config): + raise CliError( + "Project/config is not configured. Run `ssm setup` or pass --project and --config.", + exit_code=2, + ) + + if require_token and not result.token: + raise CliError("Token is not configured. Run `ssm login` or `ssm auth set-token`.", exit_code=2) + + return result diff --git a/ssm_cli/run_utils.py b/ssm_cli/run_utils.py new file mode 100644 index 0000000..9390085 --- /dev/null +++ b/ssm_cli/run_utils.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Iterable + +from ssm_cli.exceptions import CliError + + +def merge_env(secrets: dict[str, str], base_env: dict[str, str] | None = None) -> dict[str, str]: + env = dict(base_env if base_env is not None else os.environ) + env.update(secrets) + return env + + +def render_env_lines(secrets: dict[str, str]) -> str: + lines = [] + for key, value in secrets.items(): + if "\n" in value: + raise CliError(f"Value for {key} contains newline; env format does not support it", exit_code=3) + lines.append(f"{key}={value}") + return "\n".join(lines) + + +def run_with_env(command: Iterable[str], secrets: dict[str, str]) -> int: + merged_env = merge_env(secrets) + completed = subprocess.run(list(command), env=merged_env, check=False) + return completed.returncode diff --git a/tests/test_cli_api.py b/tests/test_cli_api.py new file mode 100644 index 0000000..0adbca9 --- /dev/null +++ b/tests/test_cli_api.py @@ -0,0 +1,68 @@ +import json + +import requests # type: ignore[import-untyped] + +from ssm_cli.api import ApiClient, ApiError, normalize_base_url + + +def _response(status_code: int, payload, content_type: str = "application/json") -> requests.Response: + response = requests.Response() + response.status_code = status_code + response.headers["content-type"] = content_type + if isinstance(payload, (dict, list)): + response._content = json.dumps(payload).encode("utf-8") + elif isinstance(payload, str): + response._content = payload.encode("utf-8") + else: + response._content = b"" + response.url = "http://localhost/api" + return response + + +def test_normalize_base_url_appends_api_path(): + assert normalize_base_url("localhost:8080") == "http://localhost:8080/api" + assert normalize_base_url("http://localhost:8080/") == "http://localhost:8080/api" + assert normalize_base_url("http://localhost:8080/api") == "http://localhost:8080/api" + + +def test_login_userpass_parses_token(monkeypatch): + client = ApiClient("http://localhost:8080") + + def fake_request(**kwargs): + assert kwargs["auth"] == ("admin", "password") + return _response(200, {"token": "abc123", "status": "OK"}) + + monkeypatch.setattr(client.session, "request", fake_request) + + payload = client.login_userpass("admin", "password") + assert payload["token"] == "abc123" + + +def test_export_secrets_json_parses_values(monkeypatch): + client = ApiClient("http://localhost:8080", token="t") + + def fake_request(**kwargs): + assert kwargs["headers"]["Authorization"] == "Bearer t" + assert kwargs["params"]["format"] == "json" + return _response(200, {"data": {"API_KEY": "value", "PORT": "8080"}, "status": "OK"}) + + monkeypatch.setattr(client.session, "request", fake_request) + + data = client.export_secrets_json("proj", "cfg") + assert data == {"API_KEY": "value", "PORT": "8080"} + + +def test_request_raises_api_error_on_http_failure(monkeypatch): + client = ApiClient("http://localhost:8080", token="t") + + def fake_request(**kwargs): + return _response(401, {"message": "Not Authorized"}) + + monkeypatch.setattr(client.session, "request", fake_request) + + try: + client.request("GET", "/projects") + raise AssertionError("ApiError expected") + except ApiError as exc: + assert exc.status_code == 401 + assert "Not Authorized" in exc.message diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 0000000..897f475 --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,36 @@ +import json + +from click.testing import CliRunner + +from ssm_cli.main import cli + + +def _read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def test_configure_and_setup_write_configs(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SSM_GLOBAL_CONFIG_FILE", str(tmp_path / "global.json")) + monkeypatch.setenv("SSM_LOCAL_CONFIG_FILE", str(tmp_path / ".ssm" / "config.json")) + monkeypatch.setenv("SSM_CREDENTIALS_FILE", str(tmp_path / "credentials.json")) + + runner = CliRunner() + + configured = runner.invoke(cli, ["configure", "--base-url", "http://localhost:8080", "--profile", "dev"]) + assert configured.exit_code == 0, configured.output + + setup = runner.invoke( + cli, + ["setup", "--project", "payments", "--config", "dev", "--profile", "dev", "--local-only"], + ) + assert setup.exit_code == 0, setup.output + + global_data = _read_json(tmp_path / "global.json") + assert global_data["active_profile"] == "dev" + assert global_data["profiles"]["dev"]["base_url"] == "http://localhost:8080/api" + + local_data = _read_json(tmp_path / ".ssm" / "config.json") + assert local_data["project"] == "payments" + assert local_data["config"] == "dev" + assert local_data["profile"] == "dev" diff --git a/tests/test_cli_fetch_cache.py b/tests/test_cli_fetch_cache.py new file mode 100644 index 0000000..96458a3 --- /dev/null +++ b/tests/test_cli_fetch_cache.py @@ -0,0 +1,53 @@ +from ssm_cli.api import ApiError +from ssm_cli.main import _fetch_secrets +from ssm_cli.resolve import Resolution + + +def _resolution() -> Resolution: + return Resolution( + profile="default", + base_url="http://localhost:8080/api", + project="project-a", + config="dev", + token="token", + token_source="file", + ) + + +def test_fetch_secrets_uses_cache_when_offline(monkeypatch, tmp_path): + monkeypatch.setenv("SSM_CACHE_DIR", str(tmp_path)) + # Seed with the exact hashed filename by doing one successful save flow. + from ssm_cli.cache import save_secret_cache + + save_secret_cache("http://localhost:8080/api", "project-a", "dev", {"A": "1"}) + + data, source = _fetch_secrets(_resolution(), offline=True, cache_ttl=3600) + assert data == {"A": "1"} + assert source == "cache" + + +def test_fetch_secrets_falls_back_to_cache_on_api_error(monkeypatch, tmp_path): + monkeypatch.setenv("SSM_CACHE_DIR", str(tmp_path)) + + from ssm_cli.cache import save_secret_cache + + save_secret_cache("http://localhost:8080/api", "project-a", "dev", {"CACHED": "yes"}) + + def fail_export(self, project, config): + raise ApiError("boom", status_code=503) + + monkeypatch.setattr("ssm_cli.main.ApiClient.export_secrets_json", fail_export) + + data, source = _fetch_secrets(_resolution(), offline=False, cache_ttl=3600) + assert data == {"CACHED": "yes"} + assert source == "cache-fallback" + + +def test_fetch_secrets_raises_when_offline_cache_missing(monkeypatch, tmp_path): + monkeypatch.setenv("SSM_CACHE_DIR", str(tmp_path)) + + try: + _fetch_secrets(_resolution(), offline=True, cache_ttl=60) + raise AssertionError("Expected failure") + except Exception as exc: + assert "No cached secrets found" in str(exc) diff --git a/tests/test_cli_resolve.py b/tests/test_cli_resolve.py new file mode 100644 index 0000000..206ee13 --- /dev/null +++ b/tests/test_cli_resolve.py @@ -0,0 +1,89 @@ +import json +from pathlib import Path + +from ssm_cli.resolve import resolve_context + + +def _write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_resolve_precedence_flags_over_env_and_files(monkeypatch, tmp_path): + global_file = tmp_path / "global.json" + local_file = tmp_path / "project" / ".ssm" / "config.json" + + _write_json( + global_file, + { + "active_profile": "globalprof", + "base_url": "http://global:8080/api", + "profiles": { + "globalprof": { + "base_url": "http://profile:8080/api", + "project": "profile-project", + "config": "profile-config", + }, + "flagprof": { + "base_url": "http://flag-profile:8080/api", + "project": "flag-profile-project", + "config": "flag-profile-config", + }, + }, + }, + ) + _write_json(local_file, {"profile": "localprof", "project": "local-project", "config": "local-config"}) + + monkeypatch.setenv("SSM_GLOBAL_CONFIG_FILE", str(global_file)) + monkeypatch.setenv("SSM_LOCAL_CONFIG_FILE", str(local_file)) + monkeypatch.setenv("SSM_BASE_URL", "http://env:8080/api") + monkeypatch.setenv("SSM_PROJECT", "env-project") + monkeypatch.setenv("SSM_CONFIG", "env-config") + monkeypatch.setenv("SSM_TOKEN", "env-token") + + result = resolve_context( + base_url="http://flag:8080/api", + project="flag-project", + config="flag-config", + profile="flagprof", + ) + + assert result.profile == "flagprof" + assert result.base_url == "http://flag:8080/api" + assert result.project == "flag-project" + assert result.config == "flag-config" + assert result.token == "env-token" + assert result.token_source == "env" + + +def test_resolve_uses_env_then_local_then_global(monkeypatch, tmp_path): + global_file = tmp_path / "global.json" + local_file = tmp_path / "project" / ".ssm" / "config.json" + + _write_json( + global_file, + { + "active_profile": "dev", + "profiles": { + "dev": { + "base_url": "http://profile-host:8080/api", + "project": "global-project", + "config": "global-config", + } + }, + }, + ) + _write_json(local_file, {"project": "local-project", "config": "local-config"}) + + monkeypatch.setenv("SSM_GLOBAL_CONFIG_FILE", str(global_file)) + monkeypatch.setenv("SSM_LOCAL_CONFIG_FILE", str(local_file)) + monkeypatch.delenv("SSM_PROJECT", raising=False) + monkeypatch.delenv("SSM_CONFIG", raising=False) + monkeypatch.delenv("SSM_TOKEN", raising=False) + + result = resolve_context() + + assert result.profile == "dev" + assert result.base_url == "http://profile-host:8080/api" + assert result.project == "local-project" + assert result.config == "local-config" diff --git a/tests/test_cli_run_utils.py b/tests/test_cli_run_utils.py new file mode 100644 index 0000000..f5481f0 --- /dev/null +++ b/tests/test_cli_run_utils.py @@ -0,0 +1,26 @@ +import os +import sys + +from ssm_cli.run_utils import merge_env, run_with_env + + +def test_merge_env_overrides_without_mutating_parent(monkeypatch): + monkeypatch.setenv("BASE_ONLY", "1") + parent = dict(os.environ) + + merged = merge_env({"BASE_ONLY": "2", "NEW_KEY": "x"}, base_env=parent) + + assert parent["BASE_ONLY"] == "1" + assert "NEW_KEY" not in parent + assert merged["BASE_ONLY"] == "2" + assert merged["NEW_KEY"] == "x" + + +def test_run_with_env_injects_into_child_process(): + command = [ + sys.executable, + "-c", + "import os,sys; sys.exit(0 if os.getenv('SSM_TEST_KEY') == 'injected' else 7)", + ] + code = run_with_env(command, {"SSM_TEST_KEY": "injected"}) + assert code == 0 diff --git a/uv.lock b/uv.lock index 2dfd77f..6653a08 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -52,6 +61,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -147,6 +165,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" }, + { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" }, + { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" }, + { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" }, + { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "click" version = "8.1.8" @@ -196,6 +319,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, +] + [[package]] name = "debugpy" version = "1.8.20" @@ -347,12 +519,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/3f/b82cd8e733a355db1abb8297afbf59ec972c00ef90bf8d4eed287958b204/flask_restx-1.3.2-py2.py3-none-any.whl", hash = "sha256:6e035496e8223668044fc45bf769e526352fd648d9e159bd631d94fd645a687b", size = 2799859, upload-time = "2025-09-23T20:34:23.055Z" }, ] +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -520,6 +701,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -532,6 +749,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -668,6 +894,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, + { name = "secretstorage", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "librt" version = "0.8.1" @@ -778,6 +1023,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -886,6 +1162,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -1268,6 +1562,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyzmq" version = "27.1.0" @@ -1391,6 +1694,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + [[package]] name = "rpds-py" version = "0.27.1" @@ -1708,17 +2041,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, ] +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version < '3.10'" }, + { name = "jeepney", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version >= '3.10'" }, + { name = "jeepney", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "simple-secrets-manager" version = "1.3.0" source = { editable = "." } dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "flask-cors" }, { name = "flask-httpauth" }, { name = "flask-restx" }, + { name = "keyring" }, { name = "loguru" }, { name = "pymongo" }, { name = "python-dotenv" }, + { name = "requests" }, + { name = "rich" }, ] [package.dev-dependencies] @@ -1731,12 +2102,16 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.1.8,<9.0.0" }, { name = "flask-cors", specifier = ">=5.0.0,<6.0.0" }, { name = "flask-httpauth", specifier = ">4,<5" }, { name = "flask-restx", specifier = ">=0.5.1" }, + { name = "keyring", specifier = ">=25.6.0,<26.0.0" }, { name = "loguru", specifier = ">=0.7.3,<0.8.0" }, { name = "pymongo", specifier = ">4,<5" }, { name = "python-dotenv", specifier = ">=1.1.0,<2.0.0" }, + { name = "requests", specifier = ">=2.32.3,<3.0.0" }, + { name = "rich", specifier = ">=13.9.4,<14.0.0" }, ] [package.metadata.requires-dev] @@ -1861,6 +2236,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" From bbe8b507d772a7ca865ca7525c940324170ff0fc Mon Sep 17 00:00:00 2001 From: Krishnakanth Alagiri Date: Mon, 23 Feb 2026 14:58:55 -0800 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=93=9D=20Clarify=20API-only=20deplo?= =?UTF-8?q?yment=20instructions=20in=20setup=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated the first-time setup guide to clarify API-only deployment instructions and added a note about using the API for sign-in. --- docs/FIRST_TIME_SETUP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md index d05db1f..8e8c6eb 100644 --- a/docs/FIRST_TIME_SETUP.md +++ b/docs/FIRST_TIME_SETUP.md @@ -1,6 +1,6 @@ # First-Time Setup Guide -This guide covers the first initialization of a fresh Simple Secrets Manager deployment. +This guide walks you through initializing a fresh Simple Secrets Manager deployment using only the API. You’ll skip the front-end completely. We usually recommend the first-time user wizard in the UI, so stick to these instructions only if you're deploying in a restrictive environment where browser access isn't an option. ## Prerequisites @@ -29,7 +29,7 @@ curl -sS -X POST "http://localhost:5000/api/onboarding/bootstrap" \ This creates the first admin account and marks onboarding complete. -## Step 3: Sign in from UI +## Step 3: Sign in from UI (or use the API) - Open `http://localhost:8080` - Use the created username/password From bbbc1f3bf83aa83945cfd0671d5deb991e6a41ad Mon Sep 17 00:00:00 2001 From: Krishnakanth Alagiri Date: Mon, 23 Feb 2026 15:00:56 -0800 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=93=9D=20Update=20README=20to=20rep?= =?UTF-8?q?lace=20'.venv/bin/ssm'=20with=20'uv=20run=20ssm'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3098564..4efc850 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,7 @@ Simple Secrets Manager is a lightweight, self-hosted secret manager for teams th image -## Product - -### What it is for +## What it is for? - Store secrets by `project` and `config` (`dev`, `staging`, `prod`, etc.). - Inherit values across configs and override only where needed. @@ -55,32 +53,34 @@ After `uv sync`, the CLI entrypoint is available at `.venv/bin/ssm`. Configure base URL and set a token: ```bash -.venv/bin/ssm configure --base-url http://localhost:8080/api --profile dev -.venv/bin/ssm auth set-token --token "" --profile dev +uv run ssm configure --base-url http://localhost:8080/api --profile dev +uv run ssm auth set-token --token "" --profile dev ``` Set directory defaults: ```bash -.venv/bin/ssm setup --project my-project --config dev --profile dev +uv run ssm setup --project my-project --config dev --profile dev ``` Run commands with injected secrets: ```bash -.venv/bin/ssm run --profile dev -- python app.py +uv run ssm run --profile dev -- python app.py ``` Other useful commands: ```bash -.venv/bin/ssm whoami --profile dev -.venv/bin/ssm secrets download --profile dev --format json -.venv/bin/ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json +uv run ssm whoami --profile dev +uv run ssm secrets download --profile dev --format json +uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json ``` Command reference and detailed CLI behavior are documented in [`docs/CLI.md`](docs/CLI.md). +--- + ## Contributing ### Prerequisites From c60a257ca74d594d4d0cb402ec62720baba518eb Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 23 Feb 2026 15:07:38 -0800 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=93=9D=20docs(cli):=20split=20produ?= =?UTF-8?q?ct=20docs=20and=20adopt=20uvx-first=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor root README into product-facing documentation only and move developer workflows into dedicated developer docs.\n\nAdd docs/DEVELOPER_GUIDE.md, update development references, and switch CLI documentation to UVX-first commands for running outside repository directories.\n\nAlso harden mypy configuration to ignore build artifacts generated during UVX package builds. --- README.md | 119 ++++++++------------------------------- docs/CLI.md | 55 ++++++++++-------- docs/DEVELOPER_GUIDE.md | 101 +++++++++++++++++++++++++++++++++ docs/DEVELOPMENT.md | 12 +++- docs/FIRST_TIME_SETUP.md | 8 +-- pyproject.toml | 6 +- 6 files changed, 177 insertions(+), 124 deletions(-) create mode 100644 docs/DEVELOPER_GUIDE.md diff --git a/README.md b/README.md index 4efc850..de2bac1 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,21 @@ Docker Image Architecture GitHub Repository License - Documentation + Documentation

-Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead. +Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need project/config-based secret organization without enterprise overhead. image -## What it is for? +## Product Overview - Store secrets by `project` and `config` (`dev`, `staging`, `prod`, etc.). - Inherit values across configs and override only where needed. - Manage values from UI or API. - Use username/password for humans and scoped tokens for automation. -### Quick start (Docker) +## Quick Start (Docker) ```bash docker compose up -d --build @@ -30,15 +30,15 @@ Open: - Backend API (proxy): `http://localhost:8080/api` - Backend API (direct): `http://localhost:5000/api` -### First-time setup +## First-Time Setup - On a fresh install, login shows initial setup. - Create the first admin username/password. -- Then sign in with username/password and start creating projects/configs/secrets. +- Then sign in and create projects/configs/secrets. -For full onboarding and bootstrap flow, see [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md). +Step-by-step guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) -### Standard usage flow +## Standard Usage Flow 1. Create a project. 2. Create one or more configs. @@ -46,106 +46,28 @@ For full onboarding and bootstrap flow, see [`docs/FIRST_TIME_SETUP.md`](docs/FI 4. Export secrets in JSON or `.env` format when needed. 5. Create scoped tokens for services and CI/CD. -### CLI (Doppler-like workflow) +## CLI (UVX-first) -After `uv sync`, the CLI entrypoint is available at `.venv/bin/ssm`. - -Configure base URL and set a token: - -```bash -uv run ssm configure --base-url http://localhost:8080/api --profile dev -uv run ssm auth set-token --token "" --profile dev -``` - -Set directory defaults: - -```bash -uv run ssm setup --project my-project --config dev --profile dev -``` - -Run commands with injected secrets: - -```bash -uv run ssm run --profile dev -- python app.py -``` - -Other useful commands: - -```bash -uv run ssm whoami --profile dev -uv run ssm secrets download --profile dev --format json -uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json -``` - -Command reference and detailed CLI behavior are documented in [`docs/CLI.md`](docs/CLI.md). - ---- - -## Contributing - -### Prerequisites - -- Docker + Docker Compose -- Python + `uv` -- Node.js + npm - -### Local backend setup - -```bash -git clone --depth 1 https://github.com/bearlike/simple-secrets-manager -cd simple-secrets-manager -``` - -Create `.env` at repository root: - -```bash -CONNECTION_STRING=mongodb://username:password@mongo.hostname:27017 -TOKEN_SALT=change-me -CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080,http://127.0.0.1:8080 -BIND_HOST=0.0.0.0 -PORT=5000 -``` - -Run backend: +Run the CLI from anywhere without cloning this repository: ```bash -uv sync -uv run python3 server.py +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help ``` -### Local frontend setup +Configure and authenticate: ```bash -cd frontend -npm install -echo "VITE_API_BASE_URL=/api" > .env.local -npm run dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --token "" --profile dev ``` -Open `http://localhost:5173`. - -### Quality checks - -Backend: - -```bash -./scripts/quality.sh check -``` - -Frontend: +Run any process with injected secrets: ```bash -cd frontend -npm run lint -npm run build +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py ``` -### Developer docs - -- Full development guide: [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) -- First-time setup guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) -- CLI reference: [`docs/CLI.md`](docs/CLI.md) -- Frontend notes: [`frontend/README.md`](frontend/README.md) +Detailed CLI reference: [`docs/CLI.md`](docs/CLI.md) ## Supplementary Reference @@ -199,4 +121,9 @@ curl -sS -X POST "$BASE_URL/auth/tokens/v2/revoke" \ -d '{"token_id":""}' ``` -Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md) +## Documentation Index + +- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) +- CLI reference: [`docs/CLI.md`](docs/CLI.md) +- Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md) +- Developer docs: [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md) diff --git a/docs/CLI.md b/docs/CLI.md index e6160a3..ffb4389 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2,19 +2,30 @@ Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Rich. -## Install and verify +## Install and run with UVX (recommended) -From repository root: +Run from anywhere without cloning this repository: ```bash -uv sync -uv run ssm --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help +``` + +Pin to a release tag: + +```bash +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm --help ``` -If you prefer direct binary execution after sync: +Pin to a commit SHA: ```bash -.venv/bin/ssm --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm --help +``` + +Local development fallback (inside this repo): + +```bash +uv run ssm --help ``` ## Resolution order (DRY/KISS) @@ -56,7 +67,7 @@ The CLI resolves values using one deterministic order. - Credential fallback: `~/.config/ssm/credentials.json` (mode `0600`) - Cache: `~/.cache/ssm/secrets/.json` -Test overrides are supported via env vars: +Test overrides via env vars: - `SSM_GLOBAL_CONFIG_FILE` - `SSM_LOCAL_CONFIG_FILE` @@ -70,26 +81,26 @@ Test overrides are supported via env vars: Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token. ```bash -uv run ssm configure --base-url http://localhost:8080/api --profile dev -uv run ssm login --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev ``` ### Service/personal token (recommended for CI or automation) ```bash -uv run ssm auth set-token --profile dev --token "" +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token "" ``` ### Logout ```bash -uv run ssm logout --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --profile dev ``` Clear all local token records: ```bash -uv run ssm logout --all-profiles +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --all-profiles ``` ## Project/config setup @@ -97,7 +108,7 @@ uv run ssm logout --all-profiles Set local defaults for current directory: ```bash -uv run ssm setup --project my-project --config dev --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm setup --project my-project --config dev --profile dev ``` ## Core commands @@ -105,7 +116,7 @@ uv run ssm setup --project my-project --config dev --profile dev ### Run command with injected secrets ```bash -uv run ssm run --profile dev -- python app.py +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py ``` Useful flags: @@ -118,8 +129,8 @@ Useful flags: ### Download secrets ```bash -uv run ssm secrets download --profile dev --format json -uv run ssm secrets download --profile dev --format env +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format json +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format env ``` Note: `.env` output fails when any value contains a newline. Use JSON in that case. @@ -127,7 +138,7 @@ Note: `.env` output fails when any value contains a newline. Use JSON in that ca ### Mount secrets to FIFO ```bash -uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json ``` - Creates FIFO with `0600` permissions. @@ -136,7 +147,7 @@ uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format jso ### Session validation ```bash -uv run ssm whoami --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev ``` `whoami` validates token by calling `GET /projects` and reports visible scope behavior. @@ -146,19 +157,19 @@ uv run ssm whoami --profile dev List profiles: ```bash -uv run ssm profile list +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile list ``` Activate profile: ```bash -uv run ssm profile use dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile use dev ``` Set profile defaults: ```bash -uv run ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate ``` ## Exit behavior @@ -167,7 +178,7 @@ uv run ssm profile set dev --base-url http://localhost:8080/api --project my-pro - Configuration/auth errors generally use exit code `2`. - Offline cache miss uses exit code `4`. -## Local quality checks +## Local quality checks (for maintainers) ```bash ./scripts/quality.sh check diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..5755b3e --- /dev/null +++ b/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,101 @@ +# Developer Guide + +This document contains developer-facing setup and maintenance workflows. + +## Scope + +- Backend API lives at repository root. +- Frontend admin console lives in `frontend/`. +- CLI package lives in `ssm_cli/`. + +## Prerequisites + +- Docker + Docker Compose +- Python + `uv` +- Node.js + npm + +## Clone and bootstrap + +```bash +git clone --depth 1 https://github.com/bearlike/simple-secrets-manager +cd simple-secrets-manager +uv sync +``` + +## Local backend setup + +Create `.env` at repository root: + +```bash +CONNECTION_STRING=mongodb://username:password@mongo.hostname:27017 +TOKEN_SALT=change-me +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080,http://127.0.0.1:8080 +BIND_HOST=0.0.0.0 +PORT=5000 +``` + +Start backend: + +```bash +uv run python3 server.py +``` + +## Local frontend setup + +```bash +cd frontend +npm install +echo "VITE_API_BASE_URL=/api" > .env.local +npm run dev +``` + +## Local CLI setup + +Run CLI from source during development: + +```bash +uv run ssm --help +``` + +## Full stack via Docker Compose + +```bash +docker compose up -d --build +``` + +Endpoints: + +- Frontend: `http://localhost:8080` +- Backend API proxy: `http://localhost:8080/api` +- Backend API direct: `http://localhost:5000/api` + +## Quality gates + +Backend: + +```bash +./scripts/quality.sh check +``` + +Frontend: + +```bash +cd frontend +npm run lint +npm run build +``` + +## Smoke checks + +CLI smoke check against Docker stack: + +```bash +uv run ssm configure --base-url http://localhost:8080/api --profile dev +uv run ssm whoami --profile dev +``` + +## Additional references + +- Development details: [`docs/DEVELOPMENT.md`](DEVELOPMENT.md) +- CLI reference (user-facing): [`docs/CLI.md`](CLI.md) +- Frontend-specific notes: [`frontend/README.md`](../frontend/README.md) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 46dab97..4d18a2c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,4 +1,8 @@ -# Development Guide +# Development Details + +This document is a deeper engineering reference. + +For day-to-day developer onboarding, use [`docs/DEVELOPER_GUIDE.md`](DEVELOPER_GUIDE.md) first. ## Monorepo overview @@ -93,6 +97,12 @@ uv run ssm --help Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md). +UVX distribution smoke check (outside repository path): + +```bash +uvx --from /absolute/path/to/Simple-Secrets-Manager ssm --help +``` + ## Integration smoke checks Backend health endpoint (Swagger index): diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md index 8e8c6eb..6099ae3 100644 --- a/docs/FIRST_TIME_SETUP.md +++ b/docs/FIRST_TIME_SETUP.md @@ -40,20 +40,20 @@ This creates the first admin account and marks onboarding complete. ### Option A: Login from CLI with username/password ```bash -uv run ssm configure --base-url http://localhost:8080/api --profile dev -uv run ssm login --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev ``` ### Option B: Set existing token ```bash -uv run ssm auth set-token --profile dev --token "" +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token "" ``` ## Step 5: Verify access ```bash -uv run ssm whoami --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev ``` ## Common issues diff --git a/pyproject.toml b/pyproject.toml index 87ee39e..e782897 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,4 +55,8 @@ select = ["E", "F"] [tool.mypy] python_version = "3.9" ignore_missing_imports = true -exclude = ["^\\.venv/"] +exclude = [ + "^\\.venv/", + "^build/", + "^simple_secrets_manager\\.egg-info/", +] From 6ea3283b32578b5db4b63ca4b6b39fd843a7e478 Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 23 Feb 2026 15:16:37 -0800 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20add=20ssm-cli=20?= =?UTF-8?q?uvx=20entrypoint=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose as a first-class console script while keeping as a backward-compatible alias.\n\nUpdate product, CLI, onboarding, and developer documentation to use UVX with so users can run the CLI outside repository directories.\n\nKeep mypy excludes for generated build artifacts to avoid duplicate-module failures during local packaging workflows. --- README.md | 8 ++++---- docs/CLI.md | 38 ++++++++++++++++++++------------------ docs/DEVELOPER_GUIDE.md | 6 +++--- docs/DEVELOPMENT.md | 8 ++++---- docs/FIRST_TIME_SETUP.md | 8 ++++---- pyproject.toml | 1 + 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index de2bac1..6ccd455 100644 --- a/README.md +++ b/README.md @@ -51,20 +51,20 @@ Step-by-step guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) Run the CLI from anywhere without cloning this repository: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help ``` Configure and authenticate: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --token "" --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --token "" --profile dev ``` Run any process with injected secrets: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py ``` Detailed CLI reference: [`docs/CLI.md`](docs/CLI.md) diff --git a/docs/CLI.md b/docs/CLI.md index ffb4389..80f1737 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -7,27 +7,29 @@ Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Ri Run from anywhere without cloning this repository: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help ``` Pin to a release tag: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm-cli --help ``` Pin to a commit SHA: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm-cli --help ``` Local development fallback (inside this repo): ```bash -uv run ssm --help +uv run ssm-cli --help ``` +`ssm` remains available as a backward-compatible alias. + ## Resolution order (DRY/KISS) The CLI resolves values using one deterministic order. @@ -81,26 +83,26 @@ Test overrides via env vars: Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token. ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev ``` ### Service/personal token (recommended for CI or automation) ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token "" +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token "" ``` ### Logout ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --profile dev ``` Clear all local token records: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --all-profiles +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --all-profiles ``` ## Project/config setup @@ -108,7 +110,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout Set local defaults for current directory: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm setup --project my-project --config dev --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli setup --project my-project --config dev --profile dev ``` ## Core commands @@ -116,7 +118,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm setup ### Run command with injected secrets ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py ``` Useful flags: @@ -129,8 +131,8 @@ Useful flags: ### Download secrets ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format json -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format env +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format json +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format env ``` Note: `.env` output fails when any value contains a newline. Use JSON in that case. @@ -138,7 +140,7 @@ Note: `.env` output fails when any value contains a newline. Use JSON in that ca ### Mount secrets to FIFO ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json ``` - Creates FIFO with `0600` permissions. @@ -147,7 +149,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secret ### Session validation ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev ``` `whoami` validates token by calling `GET /projects` and reports visible scope behavior. @@ -157,19 +159,19 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami List profiles: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile list +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile list ``` Activate profile: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile use dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile use dev ``` Set profile defaults: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate ``` ## Exit behavior diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 5755b3e..fbae522 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -54,7 +54,7 @@ npm run dev Run CLI from source during development: ```bash -uv run ssm --help +uv run ssm-cli --help ``` ## Full stack via Docker Compose @@ -90,8 +90,8 @@ npm run build CLI smoke check against Docker stack: ```bash -uv run ssm configure --base-url http://localhost:8080/api --profile dev -uv run ssm whoami --profile dev +uv run ssm-cli configure --base-url http://localhost:8080/api --profile dev +uv run ssm-cli whoami --profile dev ``` ## Additional references diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4d18a2c..fbdfb55 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -92,7 +92,7 @@ CLI: ```bash uv sync -uv run ssm --help +uv run ssm-cli --help ``` Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md). @@ -100,7 +100,7 @@ Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md). UVX distribution smoke check (outside repository path): ```bash -uvx --from /absolute/path/to/Simple-Secrets-Manager ssm --help +uvx --from /absolute/path/to/Simple-Secrets-Manager ssm-cli --help ``` ## Integration smoke checks @@ -120,8 +120,8 @@ curl -sS -I http://localhost:8080 CLI smoke check: ```bash -uv run ssm configure --base-url http://localhost:8080/api --profile dev -uv run ssm whoami --profile dev +uv run ssm-cli configure --base-url http://localhost:8080/api --profile dev +uv run ssm-cli whoami --profile dev ``` ## CI publish flow diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md index 6099ae3..6aea618 100644 --- a/docs/FIRST_TIME_SETUP.md +++ b/docs/FIRST_TIME_SETUP.md @@ -40,20 +40,20 @@ This creates the first admin account and marks onboarding complete. ### Option A: Login from CLI with username/password ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev ``` ### Option B: Set existing token ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token "" +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token "" ``` ## Step 5: Verify access ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev ``` ## Common issues diff --git a/pyproject.toml b/pyproject.toml index e782897..f5a6d57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ build-backend = "setuptools.build_meta" [project.scripts] ssm = "ssm_cli.main:cli" +ssm-cli = "ssm_cli.main:cli" [tool.setuptools] packages = ["Access", "Api", "Engines", "ssm_cli"] From 557dcf872b4bdc6d809ee055d70084fdd0cc060a Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 23 Feb 2026 15:37:13 -0800 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20simplify?= =?UTF-8?q?=20product=20docs=20and=20remove=20branch=20assumptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure product and supporting docs around install-once CLI usage with uv tools, plus clear auth/use flows for backend access.\n\nMake documentation branch-agnostic by removing feature-branch references and defaulting to mainline-safe install instructions.\n\nUpdate root README, CLI reference, first-time setup, developer guides, Docker runtime docs, and frontend README for consistency. --- README.md | 110 ++++++++-------------- docs/CLI.md | 193 +++++++++++++++++---------------------- docs/DEVELOPER_GUIDE.md | 14 +++ docs/DEVELOPMENT.md | 13 ++- docs/FIRST_TIME_SETUP.md | 61 ++++++++----- docs/README_dockerhub.md | 8 ++ frontend/README.md | 2 +- 7 files changed, 191 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index 6ccd455..eef991b 100644 --- a/README.md +++ b/README.md @@ -2,128 +2,94 @@

Docker Image Tag Docker Image Architecture - GitHub Repository + CI License - Documentation

-Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need project/config-based secret organization without enterprise overhead. +Simple Secrets Manager is a lightweight, self-hosted secrets platform with: -image +- Backend API +- Frontend Admin Console +- `ssm-cli` command-line client -## Product Overview +## Install the Product (Backend + Frontend Bundle) -- Store secrets by `project` and `config` (`dev`, `staging`, `prod`, etc.). -- Inherit values across configs and override only where needed. -- Manage values from UI or API. -- Use username/password for humans and scoped tokens for automation. - -## Quick Start (Docker) +Start the full stack with Docker Compose: ```bash docker compose up -d --build ``` -Open: +Endpoints: - Frontend: `http://localhost:8080` -- Backend API (proxy): `http://localhost:8080/api` -- Backend API (direct): `http://localhost:5000/api` +- Backend API via proxy: `http://localhost:8080/api` +- Backend API direct: `http://localhost:5000/api` ## First-Time Setup -- On a fresh install, login shows initial setup. -- Create the first admin username/password. -- Then sign in and create projects/configs/secrets. - -Step-by-step guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) +On a fresh install: -## Standard Usage Flow +1. Open `http://localhost:8080` +2. Complete initial setup (create first admin user) +3. Sign in and create projects/configs/secrets -1. Create a project. -2. Create one or more configs. -3. Add secrets manually or import a `.env` file from the config page. -4. Export secrets in JSON or `.env` format when needed. -5. Create scoped tokens for services and CI/CD. +API-only bootstrap steps are in [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md). -## CLI (UVX-first) +## Install CLI Once, Run Anywhere -Run the CLI from anywhere without cloning this repository: +Install `ssm-cli` globally via uv: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help +uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git +uv tool update-shell +ssm-cli --help ``` -Configure and authenticate: +If `ssm-cli` is not found, ensure uv's tool bin is on `PATH`: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --token "" --profile dev +export PATH="$(uv tool dir --bin):$PATH" ``` -Run any process with injected secrets: - -```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py -``` - -Detailed CLI reference: [`docs/CLI.md`](docs/CLI.md) - -## Supplementary Reference +## Authenticate CLI to Your Backend -### Environment variables - -| Variable | Description | -|----------|-------------| -| `CONNECTION_STRING` | MongoDB connection string. | -| `TOKEN_SALT` | Salt used before hashing API tokens. | -| `CORS_ORIGINS` | Comma-separated allowed origins for direct backend access on port `5000`. | -| `BIND_HOST` | Flask bind host (default `0.0.0.0`). | -| `PORT` | Flask port (default `5000`). | -| `VITE_API_BASE_URL` | Frontend API base URL override (`frontend/.env.local`), defaults to `/api`. | - -### API examples - -Set variables: +Set backend URL and token: ```bash -export BASE_URL="http://localhost:5000/api" -export TOKEN="" +ssm-cli configure --base-url http://localhost:8080/api --profile dev +ssm-cli auth set-token --token "" --profile dev ``` -List projects: +Or login with username/password: ```bash -curl -sS "$BASE_URL/projects" \ - -H "Authorization: Bearer $TOKEN" +ssm-cli login --profile dev ``` -List configs in a project: +## Use the Application from CLI + +Inject secrets into a process: ```bash -curl -sS "$BASE_URL/projects/my-project/configs" \ - -H "Authorization: Bearer $TOKEN" +ssm-cli run --profile dev -- python app.py ``` -Export secrets (with inherited values and metadata): +Download secrets: ```bash -curl -sS "$BASE_URL/projects/my-project/configs/dev/secrets?format=json&include_parent=true&include_meta=true" \ - -H "Authorization: Bearer $TOKEN" +ssm-cli secrets download --profile dev --format json ``` -Revoke a token by `token_id`: +Check active CLI session: ```bash -curl -sS -X POST "$BASE_URL/auth/tokens/v2/revoke" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"token_id":""}' +ssm-cli whoami --profile dev ``` -## Documentation Index +## Documentation -- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) - CLI reference: [`docs/CLI.md`](docs/CLI.md) +- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) - Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md) - Developer docs: [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md) diff --git a/docs/CLI.md b/docs/CLI.md index 80f1737..20f8121 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,186 +1,157 @@ # CLI Reference -Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Rich. +`ssm-cli` is the command-line client for Simple Secrets Manager. -## Install and run with UVX (recommended) +## 1) Install Once (Recommended) -Run from anywhere without cloning this repository: +Install globally with uv tools: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help +uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git +uv tool update-shell +ssm-cli --help ``` -Pin to a release tag: +If command is not found in a new shell: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm-cli --help +export PATH="$(uv tool dir --bin):$PATH" ``` -Pin to a commit SHA: +Upgrade later: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm-cli --help +uv tool upgrade simple-secrets-manager ``` -Local development fallback (inside this repo): +Uninstall: ```bash -uv run ssm-cli --help +uv tool uninstall simple-secrets-manager ``` -`ssm` remains available as a backward-compatible alias. - -## Resolution order (DRY/KISS) - -The CLI resolves values using one deterministic order. - -### Base URL - -1. `--base-url` -2. `SSM_BASE_URL` -3. active profile in global config -4. global `base_url` +## 2) Ephemeral Run (No Install) -### Project and config +Use UVX directly when you do not want a persistent install: -1. `--project` / `--config` -2. `SSM_PROJECT` / `SSM_CONFIG` -3. local directory config (`.ssm/config.json`) -4. active profile defaults in global config - -### Profile - -1. `--profile` -2. `SSM_PROFILE` -3. local directory profile (`.ssm/config.json`) -4. global active profile -5. `default` - -### Token - -1. `SSM_TOKEN` -2. stored token for `@` in keyring -3. stored token in file fallback - -## Config and credential files - -- Global config: `~/.config/ssm/config.json` -- Local config: `/.ssm/config.json` -- Credential fallback: `~/.config/ssm/credentials.json` (mode `0600`) -- Cache: `~/.cache/ssm/secrets/.json` +```bash +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help +``` -Test overrides via env vars: +Pin to tag: -- `SSM_GLOBAL_CONFIG_FILE` -- `SSM_LOCAL_CONFIG_FILE` -- `SSM_CREDENTIALS_FILE` -- `SSM_CACHE_DIR` - -## Authentication +```bash +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm-cli --help +``` -### Login (username/password) +## 3) Quick Start -Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token. +Configure backend URL: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev +ssm-cli configure --base-url http://localhost:8080/api --profile dev ``` -### Service/personal token (recommended for CI or automation) +Authenticate: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token "" +ssm-cli login --profile dev +# or +ssm-cli auth set-token --profile dev --token "" ``` -### Logout +Set default project/config for current directory: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --profile dev +ssm-cli setup --project my-project --config dev --profile dev ``` -Clear all local token records: +Run your app with injected secrets: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --all-profiles +ssm-cli run --profile dev -- python app.py ``` -## Project/config setup +## Core Commands -Set local defaults for current directory: +Download secrets: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli setup --project my-project --config dev --profile dev +ssm-cli secrets download --profile dev --format json +ssm-cli secrets download --profile dev --format env ``` -## Core commands - -### Run command with injected secrets +Mount secrets to FIFO: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py +ssm-cli secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json ``` -Useful flags: - -- `--offline`: cache-only -- `--cache-ttl `: cache freshness window -- `--print-env`: print resolved keys -- `--show-values`: print values (only with `--print-env`) - -### Download secrets +Validate current session: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format json -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format env +ssm-cli whoami --profile dev ``` -Note: `.env` output fails when any value contains a newline. Use JSON in that case. - -### Mount secrets to FIFO +Profile management: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json +ssm-cli profile list +ssm-cli profile use dev +ssm-cli profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate ``` -- Creates FIFO with `0600` permissions. -- Writes one payload then removes FIFO unless `--keep` is used. +## Resolution Order -### Session validation +### Base URL -```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev -``` +1. `--base-url` +2. `SSM_BASE_URL` +3. active profile in global config +4. global `base_url` -`whoami` validates token by calling `GET /projects` and reports visible scope behavior. +### Project and config -## Profiles +1. `--project` / `--config` +2. `SSM_PROJECT` / `SSM_CONFIG` +3. local directory config (`.ssm/config.json`) +4. active profile defaults in global config -List profiles: +### Profile -```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile list -``` +1. `--profile` +2. `SSM_PROFILE` +3. local directory profile (`.ssm/config.json`) +4. global active profile +5. `default` -Activate profile: +### Token -```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile use dev -``` +1. `SSM_TOKEN` +2. stored token for `@` in keyring +3. stored token in file fallback -Set profile defaults: +## File Locations -```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate -``` +- Global config: `~/.config/ssm/config.json` +- Local config: `/.ssm/config.json` +- Credential fallback: `~/.config/ssm/credentials.json` (`0600`) +- Cache: `~/.cache/ssm/secrets/.json` + +Test overrides via env vars: + +- `SSM_GLOBAL_CONFIG_FILE` +- `SSM_LOCAL_CONFIG_FILE` +- `SSM_CREDENTIALS_FILE` +- `SSM_CACHE_DIR` -## Exit behavior +## Exit Behavior -- `run` exits with the child process exit code. -- Configuration/auth errors generally use exit code `2`. -- Offline cache miss uses exit code `4`. +- `run` exits with child process exit code. +- Configuration/auth errors typically exit `2`. +- Offline cache miss exits `4`. -## Local quality checks (for maintainers) +## Maintainer Checks ```bash ./scripts/quality.sh check diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index fbae522..06be599 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -57,6 +57,20 @@ Run CLI from source during development: uv run ssm-cli --help ``` +Install CLI globally for manual QA outside repo: + +```bash +uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git +uv tool update-shell +ssm-cli --help +``` + +If needed: + +```bash +export PATH="$(uv tool dir --bin):$PATH" +``` + ## Full stack via Docker Compose ```bash diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fbdfb55..2cf43be 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -97,7 +97,15 @@ uv run ssm-cli --help Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md). -UVX distribution smoke check (outside repository path): +Global CLI install smoke check: + +```bash +uv tool install /absolute/path/to/Simple-Secrets-Manager +uv tool update-shell +ssm-cli --help +``` + +UVX distribution smoke check (ephemeral, no install): ```bash uvx --from /absolute/path/to/Simple-Secrets-Manager ssm-cli --help @@ -128,6 +136,7 @@ uv run ssm-cli whoami --profile dev Container publishing is handled by `.github/workflows/ci.yml`. -- Push to `main` or `feat/v1.3.0` with container/app changes triggers build+push to GHCR. +- Push to `main` with container/app changes triggers build+push to GHCR. +- Other branch pushes produce branch-ref tags and short SHA tags. - Tag push `vX.Y.Z` additionally publishes semantic tags. - Manual dispatch can publish an extra custom tag. diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md index 6aea618..21664d4 100644 --- a/docs/FIRST_TIME_SETUP.md +++ b/docs/FIRST_TIME_SETUP.md @@ -1,11 +1,12 @@ # First-Time Setup Guide -This guide walks you through initializing a fresh Simple Secrets Manager deployment using only the API. You’ll skip the front-end completely. We usually recommend the first-time user wizard in the UI, so stick to these instructions only if you're deploying in a restrictive environment where browser access isn't an option. +This guide initializes a fresh Simple Secrets Manager deployment. ## Prerequisites -- Backend reachable at `http://localhost:5000/api` or via proxy at `http://localhost:8080/api` -- MongoDB configured and reachable by the backend +- Backend reachable at `http://localhost:5000/api` or `http://localhost:8080/api` +- MongoDB configured and reachable by backend +- `uv` installed if you want CLI access ## Step 1: Check onboarding state @@ -13,7 +14,7 @@ This guide walks you through initializing a fresh Simple Secrets Manager deploym curl -sS http://localhost:5000/api/onboarding/status ``` -Expected on a fresh install: +Expected on fresh install: ```json {"isInitialized": false, "state": "not_initialized"} @@ -27,44 +28,56 @@ curl -sS -X POST "http://localhost:5000/api/onboarding/bootstrap" \ -d '{"username":"admin","password":"Str0ng!Passw0rd","issueToken":true}' ``` -This creates the first admin account and marks onboarding complete. - -## Step 3: Sign in from UI (or use the API) +## Step 3: Sign in from UI - Open `http://localhost:8080` -- Use the created username/password -- Create projects/configs/secrets in the admin console +- Sign in with created username/password +- Create projects/configs/secrets + +## Step 4: Install CLI (once) + +```bash +uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git +uv tool update-shell +ssm-cli --help +``` + +If needed: + +```bash +export PATH="$(uv tool dir --bin):$PATH" +``` -## Step 4: Acquire token for API/CLI use +## Step 5: Authenticate CLI -### Option A: Login from CLI with username/password +Option A: Login with username/password ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev +ssm-cli configure --base-url http://localhost:8080/api --profile dev +ssm-cli login --profile dev ``` -### Option B: Set existing token +Option B: Use an existing token ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token "" +ssm-cli auth set-token --profile dev --token "" ``` -## Step 5: Verify access +## Step 6: Verify access ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev +ssm-cli whoami --profile dev ``` ## Common issues -- `System already initialized`: bootstrap was already completed. -- `Missing API token`: login or set token before calling protected endpoints. -- `Missing scope: `: token does not include required action scope. -- CLI `env` export fails for multiline values: use JSON format instead. +- `System already initialized`: bootstrap already completed. +- `Missing API token`: login or set token first. +- `Missing scope: `: token lacks required scope. +- `.env` export fails for multiline values: use JSON format. ## Security notes -- Tokens should be scoped for least privilege. -- Prefer service tokens for CI/CD and machine workloads. -- Rotate/revoke tokens regularly via `/api/auth/tokens/v2/revoke`. +- Scope tokens with least privilege. +- Prefer service tokens for CI/CD. +- Rotate/revoke tokens via `/api/auth/tokens/v2/revoke`. diff --git a/docs/README_dockerhub.md b/docs/README_dockerhub.md index fffe212..311b853 100644 --- a/docs/README_dockerhub.md +++ b/docs/README_dockerhub.md @@ -40,6 +40,14 @@ Open: - backend via proxy: `http://localhost:8080/api` - backend direct: `http://localhost:5000/api` +CLI from anywhere: + +```bash +uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git +uv tool update-shell +ssm-cli --help +``` + ## Minimal compose example ```yaml diff --git a/frontend/README.md b/frontend/README.md index 22ea406..7c9cd7d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -50,7 +50,7 @@ All API calls are made relative to that base and include `Authorization: Bearer ## Local Backend + Frontend Workflow -1. Start `Simple-Secrets-Manager` backend (branch `feat/v1.3.0`) on `localhost:5000`. +1. Start `Simple-Secrets-Manager` backend on `localhost:5000`. 2. Confirm backend routes are available under `/api`. 3. Start this frontend with `npm run dev`. 4. In browser, log in with a valid token and verify: From 5011d72d171adbffcf7a46216bad9201aa847a61 Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 23 Feb 2026 15:41:16 -0800 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20docs(product):=20add=20CLI?= =?UTF-8?q?=20and=20Docker=20update=20instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document how to update an existing ssm-cli installation () and how to force reinstall from Git when needed.\n\nAdd explicit Docker Compose update instructions for both prebuilt-image deployments ( + ) and source rebuild deployments (Already up to date. + ). --- README.md | 28 ++++++++++++++++++++++++++++ docs/CLI.md | 6 ++++++ docs/FIRST_TIME_SETUP.md | 6 ++++++ docs/README_dockerhub.md | 16 ++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/README.md b/README.md index eef991b..70382ec 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,18 @@ If `ssm-cli` is not found, ensure uv's tool bin is on `PATH`: export PATH="$(uv tool dir --bin):$PATH" ``` +Already installed? Update to latest: + +```bash +uv tool upgrade simple-secrets-manager +``` + +If you installed from Git and want a fresh reinstall: + +```bash +uv tool install --force git+https://github.com/bearlike/Simple-Secrets-Manager.git +``` + ## Authenticate CLI to Your Backend Set backend URL and token: @@ -93,3 +105,19 @@ ssm-cli whoami --profile dev - First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md) - Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md) - Developer docs: [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md) + +## Update Existing Deployment + +If you run from this repository source: + +```bash +git pull +docker compose up -d --build +``` + +If you run prebuilt images only: + +```bash +docker compose pull +docker compose up -d +``` diff --git a/docs/CLI.md b/docs/CLI.md index 20f8121..93467c2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -24,6 +24,12 @@ Upgrade later: uv tool upgrade simple-secrets-manager ``` +If installed from Git and you want a fresh reinstall: + +```bash +uv tool install --force git+https://github.com/bearlike/Simple-Secrets-Manager.git +``` + Uninstall: ```bash diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md index 21664d4..0b28929 100644 --- a/docs/FIRST_TIME_SETUP.md +++ b/docs/FIRST_TIME_SETUP.md @@ -42,6 +42,12 @@ uv tool update-shell ssm-cli --help ``` +If already installed, update: + +```bash +uv tool upgrade simple-secrets-manager +``` + If needed: ```bash diff --git a/docs/README_dockerhub.md b/docs/README_dockerhub.md index 311b853..bbb3a23 100644 --- a/docs/README_dockerhub.md +++ b/docs/README_dockerhub.md @@ -40,6 +40,22 @@ Open: - backend via proxy: `http://localhost:8080/api` - backend direct: `http://localhost:5000/api` +## Update Existing Docker Deployment + +If using prebuilt images: + +```bash +docker compose pull +docker compose up -d +``` + +If running from source and rebuilding locally: + +```bash +git pull +docker compose up -d --build +``` + CLI from anywhere: ```bash From 4dbb24b08acb9e1dbd036ebb7dbb606a2e73cfe6 Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 23 Feb 2026 15:42:40 -0800 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=93=9D=20docs(agents):=20add=20work?= =?UTF-8?q?flow=20and=20commit=20guidelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add repository knowledge notes plus conventional commit workflow guidance to AGENTS.md. --- AGENTS.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 339e6d2..d94ed3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,3 +26,106 @@ This is a monorepo with: - Frontend: `http://localhost:8080` - Backend API via proxy: `http://localhost:8080/api` - Backend API direct: `http://localhost:5000/api` + +## Knowledge + +- When you have access to MCP tools like DeepWiki, use them to investigate the `bearlike/Simple-Secrets-Manager` repository directly. + - Ask targeted questions about the codebase to understand how it works. You can use DeepWiki for any open source repository. + - Focus specifically on the open-source, publicly available GitHub repository. This lets you dig into the implementation details without guessing. +- Always test and lint the codebase after changes. +- Playwright MCP tool, when accessible, can be used for testing front-end components and changes. +- Always scan related components to ensure consistency. Keep things stupid simple (KISS) and don't repeat yourself (DRY). This prevents code bloat. We need to avoid overengineering. + + +## Conventional Commit Guidelines + +Only use when asked to commit. + +Commit Message Format +``` + (): +empty line as separator + +empty line as separator + +``` + +Type Reference +-------------- +| Type | Title | Emoji | Description | Example Scopes (non-exaustive) | +| --- | --- | --- | --- | --- | +| build | Builds | 🏗️ | Changes that affect the build system or external dependencies | gulp, broccoli, npm | +| chore | Chores | 🔧 | Other changes that don't modify src or test files | scripts, config | +| ci | Continuous Integrations | 👷 | Changes to our CI configuration files and scripts | Travis, Circle, BrowserStack, SauceLabs,github actions, husky | +| docs | Documentation | 📝 | Documentation only changes | README, API | +| feat | Features | ✨ | A new feature | user, payment, gallery | +| fix | Bug Fixes | 🐛 | A bug fix | auth, data | +| perf | Performance Improvements | ⚡️ | A code change that improves performance | query, cache | +| refactor | Code Refactoring | ♻️ | A code change that neither fixes a bug nor adds a feature | utils, helpers | +| revert | Reverts | ⏪️ | Reverts a previous commit | query, utils, | +| style | Styles | 💄 | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) | formatting | +| test | Tests | ✅ | Adding missing tests or correcting existing tests | unit, e2e | +| i18n | | 🌐 | Internationalization | locale, translation | + +### Workflow + +**Follow these steps:** + +1. Run `git status` to review changed files. +2. Run `git diff` or `git diff --cached` to inspect changes. +3. Stage your changes with `git add `. +4. Construct your commit message using the following XML structure. +5. After generating your commit message, Copilot will automatically run the following command in your integrated terminal (no confirmation needed): + +```bash +git commit -m "type(scope): description" +``` + +6. Just execute this prompt and Copilot will handle the commit for you in the terminal. + +### Commit Message Structure + +```xml + + feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert + (api|frontend|cli|...) What core project/lib is being worked on + A short, imperative summary of the change. + (optional: more detailed explanation) +
(optional: e.g. BREAKING CHANGE: details, or issue references)
+
+``` + +### Examples + +```xml + + ✨ feat(api): add ability to parse arrays + 🐛 fix(frontend): correct button alignment + 📝 chore(docs): update README with usage instructions + ♻️ refactor(database): improve performance of data processing + 🔧 chore(api): update dependencies + +``` + +### Validation + +```xml + + ✨| 🐛 | ⚡️|🚨 |etc + Must be one of the allowed types. See https://www.conventionalcommits.org/en/v1.0.0/#specification + Optional, but recommended for clarity. + Required. Use the imperative mood (e.g., "add", not "added"). + Optional. Use for additional context. +
Use for breaking changes or issue references.
+
+``` + +### Final Step + +```xml + + git commit -m "type(scope): description" + Replace with your constructed message. Include body and footer if needed. + +``` + From e8e54057b22a92cc11ca776d95e4adc12e3ad700 Mon Sep 17 00:00:00 2001 From: Krishnakanth Alagiri Date: Mon, 23 Feb 2026 15:55:29 -0800 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D=20Revise=20README=20for=20be?= =?UTF-8?q?tter=20clarity=20and=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated README to improve clarity and organization of content, including sections on getting started, CLI installation, and contributing. --- README.md | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 70382ec..a6b75a2 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ License

-Simple Secrets Manager is a lightweight, self-hosted secrets platform with: +Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead. Comes with a `ssm-cli` command-line client. -- Backend API -- Frontend Admin Console -- `ssm-cli` command-line client +image -## Install the Product (Backend + Frontend Bundle) +## Getting Started + +### 1️⃣ Deploying the SSM Server Start the full stack with Docker Compose: @@ -26,7 +26,7 @@ Endpoints: - Backend API via proxy: `http://localhost:8080/api` - Backend API direct: `http://localhost:5000/api` -## First-Time Setup +#### First-Time Setup On a fresh install: @@ -36,7 +36,12 @@ On a fresh install: API-only bootstrap steps are in [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md). -## Install CLI Once, Run Anywhere +--- + +### 2️⃣ Installing `ssm-cli` locally + +`ssm-cli` is a lightweight command-line client that securely authenticates to Simple Secrets Manager and injects your project/config secrets into any command or runtime on demand. + Install `ssm-cli` globally via uv: @@ -64,7 +69,7 @@ If you installed from Git and want a fresh reinstall: uv tool install --force git+https://github.com/bearlike/Simple-Secrets-Manager.git ``` -## Authenticate CLI to Your Backend +#### Authenticate CLI to Your Backend Set backend URL and token: @@ -79,7 +84,7 @@ Or login with username/password: ssm-cli login --profile dev ``` -## Use the Application from CLI +#### Use the Application from CLI Inject secrets into a process: @@ -99,6 +104,8 @@ Check active CLI session: ssm-cli whoami --profile dev ``` +--- + ## Documentation - CLI reference: [`docs/CLI.md`](docs/CLI.md) @@ -121,3 +128,25 @@ If you run prebuilt images only: docker compose pull docker compose up -d ``` + +--- + +## Contributing 👏 + +We welcome contributions from the community to improve Meeseeks. Use the steps below. + +1. Fork the repository and clone it to your local machine. +2. Use the pre-commit hook to automate linting and testing, catching errors early. +3. Create a new branch for your contribution. +4. Make your changes, commit them, and push to your fork. +5. Open a pull request describing the change and the problem it solves. + +## Bug Reports and Feature Requests 🐞 + +If you encounter bugs or have ideas for features, open an issue on the [issue tracker](https://github.com/bearlike/Simple-Secrets-Manager/issues). Include reproduction steps and error messages when possible. + +Thank you for contributing. + +--- + +Licensed under [CC0 1.0 Universal](./LICENSE). From 1bffd8a25732c5c7cd6a23f9b959d1c279a5b72a Mon Sep 17 00:00:00 2001 From: Krishnakanth Alagiri Date: Mon, 23 Feb 2026 15:57:33 -0800 Subject: [PATCH 10/10] Update license badge in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a6b75a2..d57792a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Docker Image Tag Docker Image Architecture CI - License + License

Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead. Comes with a `ssm-cli` command-line client.