diff --git a/.env.example b/.env.example index fa9af61..8e44937 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,6 @@ WORKING_DIR=/workspace GITHUB_USER= GITHUB_TOKEN= TWINE_USERNAME=__token__ -TWINE_PASSWORD= \ No newline at end of file +TWINE_PASSWORD= +SPLENT_API_URL=http://host.docker.internal:5000 +SPLENT_API_TOKEN= diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..f966502 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,29 @@ +#!/bin/sh +# +# Hook commit-msg para validar el formato de los mensajes de commit +# Se ejecuta automáticamente durante un `git commit` + +set -eu + +# Obtener el mensaje del commit +commit_subject=$(sed -n '1p' "$1") + +# Ignorar ciertos tipos de commits automáticos +case "$commit_subject" in + Merge\ *|Revert\ *|Pull\ *|fixup\!*|squash\!*) + exit 0 + ;; +esac + +# Expresión regular para el formato esperado en los mensajes +commit_regex='^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([a-z0-9._/-]+\))?: .{5,}$' + +# Comprobar si el mensaje de commit cumple con el formato +if echo "$commit_subject" | grep -Eq "$commit_regex"; then + echo "✅ ¡El mensaje de commit es válido!" +else + echo "❌ El mensaje de commit no es válido." + echo "Formato esperado: tipo(alcance): descripcion" + echo "Ejemplo: feat(cli): add product env merge command" + exit 1 +fi \ No newline at end of file diff --git a/.github/workflows/ci-commits.yml b/.github/workflows/ci-commits.yml new file mode 100644 index 0000000..1cbfffe --- /dev/null +++ b/.github/workflows/ci-commits.yml @@ -0,0 +1,22 @@ +name: Commits Syntax Checker + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + +jobs: + check: + name: Conventional Commits + runs-on: ubuntu-24.04 + + steps: + - name: Validate commit messages + uses: webiny/action-conventional-commits@v1.3.0 diff --git a/.gitignore b/.gitignore index ca3fecf..c28c347 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build __pycache__/ *.pyc -*.pyo \ No newline at end of file +*.pyo +.env diff --git a/README.md b/README.md index eeb8780..e3dc706 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,102 @@ make setup # Prepare .env, start Docker, enter CLI container splent --help # See all available commands ``` +## Local marketplace flow + +When using the CLI from inside the SPLENT Docker container, `127.0.0.1` +points to the container itself. If your marketplace/API is running on your +host machine, use `host.docker.internal`. + +The marketplace/API must expose: + +```text +GET /api/auth/check +POST /api/packages +GET /api/packages +``` + +For local development with token authentication, configure the API with: + +```env +SPLENT_API_TOKEN=mi-token-secreto-local +``` + +Then, from the app workspace/container, log in: + +```bash +splent marketplace:login --url http://host.docker.internal:5000 --token mi-token-secreto-local +``` + +`marketplace:login` requires a token. You can also put the token in `.env` +first and then run login without flags: + +```env +SPLENT_API_URL=http://host.docker.internal:5000 +SPLENT_API_TOKEN=mi-token-secreto-local +``` + +```bash +splent marketplace:login +``` + +This validates the token and saves the marketplace configuration in the +workspace `.env`: + +```env +SPLENT_API_URL=http://host.docker.internal:5000 +SPLENT_API_TOKEN=mi-token-secreto-local +SPLENT_MARKETPLACE_AUTH=true +``` + +`SPLENT_MARKETPLACE_AUTH=true` means the token has been validated with +`GET /api/auth/check`. Marketplace feature commands that read or write package +metadata require this validated login state and a configured `SPLENT_API_TOKEN`: + +```text +feature:search +feature:info +feature:install +feature:clone +feature:versions +feature:publish +``` + +To log out: + +```bash +splent marketplace:logout +``` + +Logout keeps `SPLENT_API_URL` and `SPLENT_API_TOKEN`, but marks the marketplace +session as inactive: + +```env +SPLENT_API_URL=http://host.docker.internal:5000 +SPLENT_API_TOKEN=mi-token-secreto-local +SPLENT_MARKETPLACE_AUTH=false +``` + +Run `splent marketplace:login` again to re-validate the saved token. + +Create a feature and publish it: + +```bash +splent feature:create splent-io/splent_feature_demo_marketplace --type light +splent feature:publish splent-io/splent_feature_demo_marketplace +``` + +Verify that it appears in the marketplace: + +```bash +splent feature:search demo +``` + +You can also publish with an explicit version: + +```bash +splent feature:publish splent-io/splent_feature_demo_marketplace@0.1.0 +``` + ## Requirements - Docker + Docker Compose diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4fd9a34..ec78cc1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../../ dockerfile: splent_cli/docker/images/Dockerfile.cli + env_file: + - ../.env environment: HOST_UID: ${HOST_UID:-1000} HOST_GID: ${HOST_GID:-1000} @@ -21,4 +23,4 @@ services: networks: splent_network: - name: splent_network \ No newline at end of file + name: splent_network diff --git a/makefiles/Makefile.setup b/makefiles/Makefile.setup index 9834714..62257d8 100644 --- a/makefiles/Makefile.setup +++ b/makefiles/Makefile.setup @@ -1,4 +1,8 @@ +ifeq ($(shell docker compose version >/dev/null 2>&1; echo $$?),0) SPLENT_DOCKER_COMPOSE = docker compose -f docker/docker-compose.yml +else +SPLENT_DOCKER_COMPOSE = docker-compose -f docker/docker-compose.yml +endif HOST_PROJECT_DIR := $(shell cd .. && pwd) .PHONY: setup setup-rebuild env-prepare docker-up cli-enter diff --git a/scripts/setup_prompt.sh b/scripts/setup_prompt.sh index e8dc57d..9d58618 100644 --- a/scripts/setup_prompt.sh +++ b/scripts/setup_prompt.sh @@ -53,6 +53,12 @@ splent() { elif [ "$1" = "product:deselect" ]; then shift eval "$(command splent product:deselect --shell "$@")" + elif [ "$1" = "marketplace:login" ]; then + shift + eval "$(command splent marketplace:login --shell "$@")" + elif [ "$1" = "marketplace:logout" ]; then + shift + eval "$(command splent marketplace:logout --shell "$@")" else command splent "$@" fi @@ -80,4 +86,4 @@ _splent_completion() { complete -o nosort -F _splent_completion splent # --- END SPLENT CLI --- -EOF \ No newline at end of file +EOF diff --git a/src/splent_cli/cli.py b/src/splent_cli/cli.py index 1c93b29..5455b6e 100644 --- a/src/splent_cli/cli.py +++ b/src/splent_cli/cli.py @@ -1,13 +1,13 @@ import os import click -from dotenv import load_dotenv +from splent_cli.services.env import load_cli_env from splent_cli.utils.dynamic_imports import get_app from splent_cli.utils.command_loader import load_commands from splent_cli.utils.db_utils import check_db_connection -load_dotenv() +load_cli_env() class SPLENTCLI(click.Group): @@ -48,9 +48,11 @@ def _load_feature_commands(self) -> dict[str, click.BaseCommand]: group = click.Group( name=f"feature:{feature_short}", help=f"Commands contributed by splent_feature_{feature_short}.", - short_help=f"Subcommands: {cmd_names}" - if cmd_names - else f"Commands from splent_feature_{feature_short}.", + short_help=( + f"Subcommands: {cmd_names}" + if cmd_names + else f"Commands from splent_feature_{feature_short}." + ), ) group.requires_app = True # type: ignore[attr-defined] for cmd in commands: @@ -59,7 +61,9 @@ def _load_feature_commands(self) -> dict[str, click.BaseCommand]: except Exception as e: if os.getenv("SPLENT_DEBUG"): click.secho( - f" ⚠ Feature commands not loaded: {e}", fg="yellow", err=True + f" ⚠ Feature commands not loaded: {e}", + fg="yellow", + err=True, ) return self._feature_cmds_cache @@ -98,10 +102,17 @@ def format_commands(self, ctx, formatter): all_cmds = self.list_commands(ctx) feat_cmds = self._load_feature_commands() groups = { + "🛒 Marketplace": [ + cmd + for cmd in all_cmds + if cmd.startswith("marketplace:") or cmd == "feature:search" + ], "🌿 Feature Management": [ cmd for cmd in all_cmds - if cmd.startswith("feature:") and cmd not in feat_cmds + if cmd.startswith("feature:") + and cmd not in feat_cmds + and cmd != "feature:search" ], "🏗️ Product Management": [ cmd for cmd in all_cmds if cmd.startswith("product:") @@ -112,8 +123,12 @@ def format_commands(self, ctx, formatter): "🧱 Database": [cmd for cmd in all_cmds if cmd.startswith("db:")], "💾 Cache": [cmd for cmd in all_cmds if cmd.startswith("cache:")], "🔍 Checks": [cmd for cmd in all_cmds if cmd.startswith("check:")], - "📦 Export": [cmd for cmd in all_cmds if cmd.startswith("export:")], - "🚀 Release": [cmd for cmd in all_cmds if cmd.startswith("release:")], + "📦 Export": [ + cmd for cmd in all_cmds if cmd.startswith("export:") + ], + "🚀 Release": [ + cmd for cmd in all_cmds if cmd.startswith("release:") + ], "🧰 Utilities": [ cmd for cmd in all_cmds @@ -123,9 +138,12 @@ def format_commands(self, ctx, formatter): "🐍 Development & QA": [ cmd for cmd in all_cmds - if cmd in ("lint", "coverage", "selenium", "locust", "locust:stop") + if cmd + in ("lint", "coverage", "selenium", "locust", "locust:stop") + ], + "🔌 Feature Commands": [ + cmd for cmd in all_cmds if cmd in feat_cmds ], - "🔌 Feature Commands": [cmd for cmd in all_cmds if cmd in feat_cmds], } total = 0 for title, cmds in groups.items(): diff --git a/src/splent_cli/commands/feature/feature_clone.py b/src/splent_cli/commands/feature/feature_clone.py index 7e0ee60..4ebb68d 100644 --- a/src/splent_cli/commands/feature/feature_clone.py +++ b/src/splent_cli/commands/feature/feature_clone.py @@ -3,7 +3,12 @@ import subprocess import requests import click -from splent_cli.services import context +from splent_cli.services import context, marketplace +from splent_cli.services.api_client import ( + SplentAPIAuthError, + SplentAPIError, + get_package_by_name, +) from splent_cli.utils.cache_utils import make_feature_readonly from splent_cli.utils.feature_utils import normalize_namespace @@ -69,6 +74,46 @@ def _parse_full_name(full_name: str): return namespace, repo, version +def _feature_api_name(repo: str) -> str: + if repo.startswith("splent_feature_"): + return repo + return f"splent_feature_{repo}" + + +def _resolve_full_name_from_api(full_name: str) -> str: + raw = full_name + version = None + if "@" in raw: + raw, version = raw.split("@", 1) + + + repo = raw.split("/")[-1] + api_name = _feature_api_name(repo) + + try: + marketplace.require_marketplace_login() + package = get_package_by_name(api_name) + except SplentAPIAuthError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) from exc + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) from exc + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + resolved = package.get("full_name") + if not isinstance(resolved, str) or "/" not in resolved: + resolved = f"splent-io/{package.get('name') or api_name}" + + if version: + return f"{resolved}@{version}" + return resolved + + def _validate_identifier_part(value: str, label: str): if not re.fullmatch(r"[a-zA-Z0-9_\-\.]+", value): raise SystemExit( @@ -84,17 +129,18 @@ def _validate_identifier_part(value: str, label: str): @click.command( "feature:clone", short_help="Clone a feature into the local cache.", - help="Clone a feature into the local cache namespace.", + help="Clone a marketplace feature into the local cache.", ) @click.argument("full_name", required=True) def feature_clone(full_name): """ - Clone / into: + Clone , or / into: .splent_cache/features//@ - If version is omitted, fetches the latest tag or 'main'. """ + full_name = _resolve_full_name_from_api(full_name) namespace, repo, version = _parse_full_name(full_name) _validate_identifier_part(namespace, "namespace") diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py new file mode 100644 index 0000000..43b764b --- /dev/null +++ b/src/splent_cli/commands/feature/feature_info.py @@ -0,0 +1,175 @@ +import shutil +import textwrap + +import click + +from splent_cli.services import marketplace +from splent_cli.services.api_client import SplentAPIAuthError +from splent_cli.services.api_client import SplentAPIError, get_package_by_name + + +def _contract_description(package: dict) -> str: + contract = package.get("contract") or {} + return contract.get("description") or "" + + +def _contract_items(package: dict, key: str) -> list[str]: + contract = package.get("contract") or {} + values = contract.get(key) or {} + + if isinstance(values, dict): + items = [] + for name, value in sorted(values.items()): + if isinstance(value, list): + if value: + items.append(f"{name}: {', '.join(str(item) for item in value)}") + elif value: + items.append(f"{name}: {value}") + return items + if isinstance(values, list): + return sorted(str(name) for name in values) + if isinstance(values, str): + return [values] + return [] + + +def _terminal_width() -> int: + return min(shutil.get_terminal_size((100, 20)).columns, 120) + + +def _echo_wrapped(label: str, value: str, width: int) -> None: + prefix = f" {label:<12}" + wrapped = textwrap.wrap( + value or "-", + width=max(width - len(prefix), 30), + subsequent_indent=" " * len(prefix), + ) + if not wrapped: + click.echo(prefix + "-") + return + + click.echo(prefix + wrapped[0]) + for line in wrapped[1:]: + click.echo(line) + + +def _echo_contract_section(label: str, items: list[str], width: int) -> None: + click.echo(f" {label}") + if not items: + click.echo(" -") + return + + for item in items: + wrapped = textwrap.wrap( + item, + width=max(width - 6, 30), + initial_indent=" - ", + subsequent_indent=" ", + ) + for line in wrapped: + click.echo(line) + + +def _repo_url(package: dict) -> str | None: + return package.get("repo_url") or None + + +def _updated_at(package: dict) -> str: + metadata = package.get("metadata") or {} + value = metadata.get("updated_at") or package.get("updated_at") or "" + if "T" in value: + return value.split("T", 1)[0] + return value or "-" + + +def _feature_api_name(feature_name: str) -> str: + if "/" in feature_name: + owner, name = feature_name.split("/", 1) + if name.startswith("splent_feature_"): + return f"{owner}/{name}" + return f"{owner}/splent_feature_{name}" + + if feature_name.startswith("splent_feature_"): + return feature_name + return f"splent_feature_{feature_name}" + + +def _feature_api_candidates(feature_name: str) -> list[str]: + if "/" in feature_name: + candidates = [feature_name] + normalized = _feature_api_name(feature_name) + if normalized not in candidates: + candidates.append(normalized) + return candidates + + candidates = [_feature_api_name(feature_name)] + if feature_name not in candidates: + candidates.append(feature_name) + return candidates + + +@click.command("feature:info", short_help="Show marketplace feature details.") +@click.argument("feature_name", required=True) +def feature_info(feature_name): + """ + Show marketplace information for one feature. + + \b + Examples: + splent feature:info auth + splent feature:info splent_feature_auth + """ + candidates = _feature_api_candidates(feature_name) + api_name = candidates[0] + click.echo(click.style(f"\n Loading feature {api_name}...\n", fg="cyan")) + + package = None + try: + marketplace.require_marketplace_login() + for candidate in candidates: + try: + package = get_package_by_name(candidate) + except SplentAPIError as exc: + if ( + ("HTTP 404" in str(exc) or "HTTP 500" in str(exc)) + and candidate != candidates[-1] + ): + continue + raise + if isinstance(package, dict): + api_name = candidate + break + except SplentAPIAuthError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + width = _terminal_width() + name = package.get("name") or api_name + desc = _contract_description(package) + updated = _updated_at(package) + provides = _contract_items(package, "provides") + requires = _contract_items(package, "requires") + + click.secho(f" {name}", bold=True) + _echo_wrapped("full name", package.get("full_name") or "-", width) + _echo_wrapped("updated", updated, width) + _echo_wrapped("summary", desc, width) + _echo_contract_section("provides", provides, width) + _echo_contract_section("requires", requires, width) + + url = _repo_url(package) + if url: + _echo_wrapped("repo", url, width) + + click.echo() + + +cli_command = feature_info diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index c7e1343..9602851 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -4,7 +4,13 @@ import tomllib import click import requests -from splent_cli.services import context, compose +from splent_cli.services import context, compose, marketplace +from splent_cli.services.api_client import ( + SplentAPIAuthError, + SplentAPIError, + get_packages, + get_package_by_name, +) from splent_cli.utils.feature_utils import read_features_from_data @@ -42,6 +48,141 @@ def _get_product_feature_shorts( return shorts +def _feature_short_name(feature_ref: str) -> str: + name = str(feature_ref).strip() + if not name: + return "" + name = name.split("/")[-1] + name = name.split("@", 1)[0] + return name.replace("splent_feature_", "") + + +def _feature_api_name(feature_name: str) -> str: + if "/" in feature_name: + owner, name = feature_name.split("/", 1) + if name.startswith("splent_feature_"): + return f"{owner}/{name}" + return f"{owner}/splent_feature_{name}" + + if feature_name.startswith("splent_feature_"): + return feature_name + return f"splent_feature_{feature_name}" + + +def _feature_api_candidates(feature_name: str) -> list[str]: + if "/" in feature_name: + candidates = [feature_name] + normalized = _feature_api_name(feature_name) + if normalized not in candidates: + candidates.append(normalized) + short = normalized.split("/", 1)[1] + if short not in candidates: + candidates.append(short) + return candidates + + candidates = [_feature_api_name(feature_name)] + if feature_name not in candidates: + candidates.append(feature_name) + return candidates + + +def _package_matches_candidate(package: dict, candidate: str) -> bool: + values = { + str(package.get("name") or ""), + str(package.get("full_name") or ""), + str(package.get("repository") or ""), + } + return candidate in values + + +def _get_marketplace_package(feature_identifier: str) -> dict: + candidates = _feature_api_candidates(feature_identifier) + last_lookup_error = None + for candidate in candidates: + try: + package = get_package_by_name(candidate) + except SplentAPIError as exc: + last_lookup_error = exc + if "HTTP 404" in str(exc) or "HTTP 500" in str(exc): + continue + raise + if isinstance(package, dict): + return package + + try: + packages = get_packages() + except SplentAPIError: + if last_lookup_error: + raise last_lookup_error + raise + + if isinstance(packages, list): + for package in packages: + if isinstance(package, dict) and any( + _package_matches_candidate(package, candidate) + for candidate in candidates + ): + return package + + raise SplentAPIError( + f"Feature '{feature_identifier}' is not published in the Marketplace." + ) + + +def _get_marketplace_required_features(package: dict) -> list[str]: + contract = package.get("contract") or {} + requires = contract.get("requires") or {} + raw_features = requires.get("features") or [] + + if isinstance(raw_features, str): + raw_features = [raw_features] + if not isinstance(raw_features, list): + return [] + + return [ + short + for short in (_feature_short_name(feature) for feature in raw_features) + if short + ] + + +def _check_marketplace_dependencies( + workspace: str, + product: str, + env_name: str, + package: dict, +) -> list[str]: + required = _get_marketplace_required_features(package) + if not required: + return [] + + installed = _get_product_feature_shorts(workspace, product, env_name) + return [feature for feature in required if feature not in installed] + + +def _abort_missing_marketplace_dependencies( + short: str, + product: str, + namespace_github: str, + missing: list[str], +) -> None: + if not missing: + return + + click.echo() + click.secho( + f" Cannot install {short}: missing required feature(s) in {product}.", + fg="red", + ) + for feature in missing: + click.echo(f" - {feature}") + click.echo() + click.echo(" Install them first:") + for feature in missing: + click.echo(f" splent feature:install {namespace_github}/splent_feature_{feature}") + raise SystemExit(1) + + def _find_feature_pyproject( workspace: str, feature_name: str, namespace_fs: str, version: str | None ) -> str | None: @@ -169,7 +310,45 @@ def feature_install(feature_identifier, env_scope, mode, version): namespace, namespace_github, namespace_fs, feature_name = ( compose.parse_feature_identifier(feature_identifier) ) + + try: + marketplace.require_marketplace_login() + package = _get_marketplace_package(feature_identifier) + except SplentAPIAuthError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + package_name = package.get("name") or _feature_api_name(feature_identifier) + full_name = package.get("full_name") + if isinstance(full_name, str) and "/" in full_name: + feature_identifier, _, package_version = full_name.partition("@") + if package_version and not version: + version = package_version + else: + repository = package.get("repository") + if isinstance(repository, str) and "/" in repository: + feature_identifier = repository + else: + owner = package.get("owner") or namespace_github + feature_identifier = f"{owner}/{package_name}" + namespace, namespace_github, namespace_fs, feature_name = ( + compose.parse_feature_identifier(feature_identifier) + ) short = feature_name.replace("splent_feature_", "") + missing_marketplace_deps = _check_marketplace_dependencies( + workspace, product, env_name, package + ) + _abort_missing_marketplace_dependencies( + short, product, namespace_github, missing_marketplace_deps + ) # ── Ask mode if not specified ───────────────────────────────────── if not mode: diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py new file mode 100644 index 0000000..c02d70e --- /dev/null +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -0,0 +1,290 @@ +import os +import re +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import click +import tomllib + +from splent_cli.commands.feature.feature_contract import _resolve_feature, infer_contract +from splent_cli.services import context +from splent_cli.services import marketplace +from splent_cli.services.api_client import SplentAPIError, post +from splent_cli.utils.feature_utils import normalize_namespace + +DEFAULT_OWNER = "splent-io" + + +def _read_pyproject(feature_path: Path) -> dict: + pyproject_path = feature_path / "pyproject.toml" + if not pyproject_path.exists(): + return {} + + with open(pyproject_path, "rb") as f: + return tomllib.load(f) + + +def _git_remote_url(feature_path: Path) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(feature_path), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + url = result.stdout.strip() + return url or None + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + +def _parse_full_name(full_name: str) -> tuple[str, str, str | None]: + # Separa owner, nombre y versión; sin owner usa splent-io. + if "/" in full_name: + owner, rest = full_name.split("/", 1) + else: + owner = DEFAULT_OWNER + rest = full_name + + name, _, version = rest.partition("@") + return owner.replace("_", "-"), name, version or None + + +def _browser_repo_url(remote_url: str | None) -> str | None: + if not remote_url: + return None + + repo_owner, repo_name = _repo_from_remote(remote_url) + if repo_owner and repo_name: + return f"https://github.com/{repo_owner}/{repo_name.removesuffix('.git')}" + + parsed = urlparse(remote_url) + if parsed.scheme in {"http", "https"} and parsed.hostname: + netloc = parsed.hostname + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + path = parsed.path.removesuffix(".git") + return parsed._replace( + netloc=netloc, + path=path, + params="", + query="", + fragment="", + ).geturl() + + return None + + +def _repo_from_remote(remote_url: str | None) -> tuple[str | None, str | None]: + if not remote_url: + return None, None + + # Extrae owner/repo desde remotes SSH o HTTPS de GitHub. + patterns = [ + r"^git@github\.com:(?P[^/]+)/(?P.+?)(?:\.git)?$", + r"^https://(?:[^/@]+@)?github\.com/(?P[^/]+)/(?P.+?)(?:\.git)?$", + ] + for pattern in patterns: + match = re.match(pattern, remote_url) + if match: + return match.group("owner"), match.group("repo") + + parsed = urlparse(remote_url) + if parsed.netloc.endswith("github.com"): + parts = parsed.path.strip("/").split("/", 1) + if len(parts) == 2: + return parts[0], parts[1].removesuffix(".git") + + return None, None + + +def _canonical_full_name(owner: str, name: str, version: str | None) -> str: + ref = f"{owner}/{name}" + if version: + return f"{ref}@{version}" + return ref + + +def _list_value(value) -> list: + if isinstance(value, list): + return value + if isinstance(value, str) and value: + return [value] + return [] + + +def _contract_for_marketplace(contract: dict, pyproject: dict, name: str) -> dict: + # Convierte el contrato inferido al formato que consume el marketplace/API. + current_contract = ( + pyproject.get("tool", {}).get("splent", {}).get("contract", {}) + if isinstance(pyproject, dict) + else {} + ) + current_requires = current_contract.get("requires", {}) + if not isinstance(current_requires, dict): + current_requires = {} + project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} + + provides = { + "routes": contract.get("routes", []), + "blueprints": contract.get("blueprints", []), + "models": contract.get("models", []), + "commands": contract.get("commands", []), + "hooks": contract.get("hooks", []), + "services": contract.get("services", []), + "signals": contract.get("signals", []), + "translations": contract.get("translations", []), + "docker": contract.get("docker", []), + } + requires = { + "features": ( + _list_value(contract.get("requires_features")) + or _list_value(current_requires.get("features")) + ), + "env_vars": ( + _list_value(contract.get("env_vars")) + or _list_value(current_requires.get("env_vars")) + ), + "signals": ( + _list_value(contract.get("requires_signals")) + or _list_value(current_requires.get("signals")) + ), + } + + return { + "description": ( + current_contract.get("description") + or project.get("description") + or f"{name} feature" + ), + "provides": provides, + "requires": requires, + "extensible": { + "services": contract.get("extensible_services", []), + "templates": contract.get("extensible_templates", []), + "models": contract.get("extensible_models", []), + "hooks": contract.get("extensible_hooks", []), + "routes": contract.get("extensible_routes", False), + }, + "docker": contract.get("docker_contract", {}), + } + + +def _build_payload( + feature_path: Path, + namespace: str, + name: str, + full_name: str, + owner: str | None = None, +) -> dict: + ref_owner, _, ref_version = _parse_full_name(full_name) + github_owner = (owner or ref_owner).replace("_", "-") + + # Construye un payload compatible con la API actual y con metadatos extendidos. + inferred_contract = infer_contract(str(feature_path), namespace, name) + pyproject = _read_pyproject(feature_path) + marketplace_contract = _contract_for_marketplace(inferred_contract, pyproject, name) + + project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} + remote_url = _git_remote_url(feature_path) + repo_owner, repo_name = _repo_from_remote(remote_url) + repo_owner = repo_owner or github_owner + repo_name = repo_name or name + repo_url = ( + _browser_repo_url(remote_url) + or f"https://github.com/{repo_owner}/{repo_name.removesuffix('.git')}" + ) + canonical_name = _canonical_full_name(github_owner, name, ref_version) + + return { + "full_name": canonical_name, + "name": name, + "description": marketplace_contract["description"], + "provides": marketplace_contract["provides"], + "requires": marketplace_contract["requires"], + "namespace": namespace, + "owner": github_owner, + "repo_url": repo_url, + "repository": f"{repo_owner}/{repo_name}", + "github": { + "owner": github_owner, + "repo": repo_name, + "repository": f"{repo_owner}/{repo_name}", + "url": repo_url, + }, + "contract": marketplace_contract, + "metadata": { + "project_name": project.get("name"), + "version": project.get("version"), + "feature_version": ref_version, + "description": project.get("description"), + "workspace_path": str(feature_path), + "pyproject_present": bool(pyproject), + "source": "splent-cli", + }, + } + + +def _login_to_marketplace(token: str | None) -> None: + if token: + os.environ["SPLENT_API_TOKEN"] = token + return + + try: + marketplace.require_marketplace_login() + return + except SplentAPIError: + pass + + raise SplentAPIError( + "Marketplace login is required. " + "Run 'splent marketplace:login' or pass --token." + ) + + +@click.command("feature:publish", short_help="Publish feature metadata to the marketplace.") +@click.argument("full_name", required=True) +@click.option( + "--token", + default=None, + help="Marketplace API token. If omitted, SPLENT_API_TOKEN is used.", +) +@click.option( + "--owner", + default=None, + help="GitHub user or organization that owns the feature repository.", +) +def feature_publish(full_name, token, owner): + workspace = str(context.workspace()) + + try: + _login_to_marketplace(token) + feature_path, namespace, name = _resolve_feature(full_name, workspace) + namespace = normalize_namespace(namespace) + payload = _build_payload(feature_path, namespace, name, full_name, owner) + + click.echo() + click.secho(f"Publishing {payload['full_name']}...", bold=True) + click.echo(f" repository: {payload['repository']}") + click.echo(f" owner: {payload['owner']}") + + response = post("/api/packages", json=payload) + + click.secho("Feature published successfully.", fg="green") + if isinstance(response, dict) and response: + click.echo(click.style("Response:", bold=True)) + click.echo(str(response)) + + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + except SystemExit: + raise + except Exception as exc: + click.secho(f"❌ Unexpected error: {exc}", fg="red") + raise SystemExit(1) + + +cli_command = feature_publish diff --git a/src/splent_cli/commands/feature/feature_remove.py b/src/splent_cli/commands/feature/feature_remove.py index 57c7643..bd29c88 100644 --- a/src/splent_cli/commands/feature/feature_remove.py +++ b/src/splent_cli/commands/feature/feature_remove.py @@ -17,6 +17,30 @@ ) +def _feature_short_name(feature_ref: str) -> str: + name = str(feature_ref).strip() + if "/" in name: + name = name.split("/", 1)[1] + name = name.split("@", 1)[0] + return name.replace("splent_feature_", "") + + +def _feature_entry_matches(entry: str, requested_name: str, requested_org: str) -> bool: + if _feature_short_name(entry) != _feature_short_name(requested_name): + return False + + if "/" not in requested_name: + return True + + entry_base = entry.split("@", 1)[0] + if "/" not in entry_base: + return False + + entry_org, _ = entry_base.split("/", 1) + requested_org = requested_name.split("/", 1)[0] or requested_org + return normalize_namespace(entry_org) == normalize_namespace(requested_org) + + @click.command( "feature:remove", short_help="Unregister an editable feature from the active product (keeps files).", @@ -85,20 +109,12 @@ def feature_remove(feature_name, namespace, force): features = read_features_from_data(data) - # Try multiple formats to match pyproject entry - candidates = [ - feature_name, - f"{org}/{feature_name}", - f"{org_safe}/{feature_name}", + entries_to_remove = [ + entry for entry in features if _feature_entry_matches(entry, feature_name, org) ] - entry_name = feature_name - for candidate in candidates: - if candidate in features: - entry_name = candidate - break - - if entry_name in features: - features.remove(entry_name) + + if entries_to_remove: + features = [entry for entry in features if entry not in entries_to_remove] write_features_to_data(data, features) with open(pyproject_path, "wb") as f: tomli_w.dump(data, f) @@ -107,9 +123,13 @@ def feature_remove(feature_name, namespace, force): click.echo(click.style(f" {short} not found in pyproject.toml", dim=True)) # ── Remove symlink ──────────────────────────────────────────────── - link_path = os.path.join(product_path, "features", org_safe, feature_name) - if os.path.islink(link_path) or os.path.exists(link_path): - os.unlink(link_path) + link_names = {feature_name} + if not feature_name.startswith("splent_feature_"): + link_names.add(f"splent_feature_{feature_name}") + for link_name in link_names: + link_path = os.path.join(product_path, "features", org_safe, link_name) + if os.path.islink(link_path) or os.path.exists(link_path): + os.unlink(link_path) # ── Update manifest ─────────────────────────────────────────────── key = feature_key(org_safe, feature_name) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 2a35182..ca119db 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,126 +1,97 @@ -import urllib.request -import urllib.error -import json -import os import click +from splent_cli.services import marketplace +from splent_cli.services.api_client import ( + SplentAPIAuthError, + SplentAPIError, + get_packages, +) -def _github_request(url: str, token: str | None) -> dict | list | None: - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "splent-cli", - "X-GitHub-Api-Version": "2022-11-28", - } - if token: - headers["Authorization"] = f"token {token}" - req = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - if e.code == 404: - return None - raise - except urllib.error.URLError as e: - click.secho(f"❌ Network error: {e.reason}", fg="red") - raise SystemExit(1) +def _contract_description(package: dict) -> str: + contract = package.get("contract") or {} + return contract.get("description") or "" -def _latest_tag(org: str, repo: str, token: str | None) -> str | None: - data = _github_request( - f"https://api.github.com/repos/{org}/{repo}/releases/latest", token - ) - if data and data.get("tag_name"): - return data["tag_name"] - # fall back to tags - tags = _github_request(f"https://api.github.com/repos/{org}/{repo}/tags", token) - if tags and isinstance(tags, list) and tags: - return tags[0].get("name") - return None - - -@click.command("feature:search", short_help="Search for available features on GitHub.") -@click.argument("query", required=False) -@click.option( - "--org", - default="splent-io", - show_default=True, - help="GitHub organisation to search in.", -) -@click.option( - "--all", - "show_all", - is_flag=True, - help="Show all repos, not just splent_feature_* ones.", -) -def feature_search(query, org, show_all): - """ - List available features from a GitHub organisation. - \b - By default searches the splent-io org and filters by repos that match - the splent_feature_* naming convention. - Optionally filter by QUERY (partial name match). +def _updated_at(package: dict) -> str: + metadata = package.get("metadata") or {} + value = metadata.get("updated_at") or package.get("updated_at") or "" + if "T" in value: + return value.split("T", 1)[0] + return value or "-" - Examples: - splent feature:search - splent feature:search auth - splent feature:search --org my-org - """ - token = os.getenv("GITHUB_TOKEN") - - click.echo(click.style(f"\n🔍 Searching features in {org}...\n", fg="cyan")) - - # Paginate through all repos - repos = [] - page = 1 - while True: - url = f"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}&type=public" - batch = _github_request(url, token) - if not batch: - break - repos.extend(batch) - if len(batch) < 100: - break - page += 1 - - if repos is None: - click.secho(f"❌ Organisation '{org}' not found or not accessible.", fg="red") + +def _load_packages() -> list[dict]: + data = get_packages() + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + raise SplentAPIError("Unexpected package response.") + + +def _run_search(query, show_all): + click.echo(click.style("\n Searching features...\n", fg="cyan")) + + try: + marketplace.require_marketplace_login() + packages = _load_packages() + except SplentAPIAuthError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") raise SystemExit(1) - # Filter if not show_all: - repos = [r for r in repos if "feature" in r.get("name", "").lower()] - if query: - repos = [r for r in repos if query.lower() in r.get("name", "").lower()] + packages = [ + p + for p in packages + if (p.get("name") or "").startswith("splent_feature_") + ] - if not repos: - msg = f"No features found in {org}" + if query: + packages = [ + p + for p in packages + if query.lower() in (p.get("name") or "").lower() + ] + + if not packages: + msg = "No packages found" if query: msg += f" matching '{query}'" click.secho(f"ℹ️ {msg}.", fg="yellow") return - click.secho(f"Found {len(repos)} feature(s) in {org}:\n", fg="cyan") + click.secho(f" Found {len(packages)} package(s):\n", fg="cyan") - col = max(len(r["name"]) for r in repos) + 2 - for repo in sorted(repos, key=lambda r: r["name"]): - name = repo["name"] - desc = repo.get("description") or "" - latest = _latest_tag(org, name, token) - version_label = ( - click.style(latest, fg="green") - if latest - else click.style("no releases", fg="yellow") - ) - click.echo(f" {name:<{col}} {version_label:<20} {desc}") + name_col = max(len(p.get("name") or "-") for p in packages) + 2 + date_col = 12 + + for package in sorted(packages, key=lambda p: p.get("name") or ""): + name = package.get("name") or "-" + desc = _contract_description(package) + updated = _updated_at(package) + click.echo(f" {name:<{name_col}} {updated:<{date_col}} {desc}") click.echo() - if not token: - click.secho( - "💡 Set GITHUB_TOKEN to avoid rate limits and access private repos.", - fg="yellow", - ) -cli_command = feature_search +def _search_options(func): + func = click.argument("query", required=False)(func) + func = click.option( + "--all", + "show_all", + is_flag=True, + help="Show all packages, not just splent_feature_* ones.", + )(func) + return func + + +@click.command("feature:search", short_help="Search for available features.") +@_search_options +def feature_search(query, show_all): + """ + List available marketplace features. + """ + _run_search(query, show_all) diff --git a/src/splent_cli/commands/feature/feature_versions.py b/src/splent_cli/commands/feature/feature_versions.py index 3d936b5..1467713 100644 --- a/src/splent_cli/commands/feature/feature_versions.py +++ b/src/splent_cli/commands/feature/feature_versions.py @@ -5,7 +5,12 @@ import click -from splent_cli.services import compose +from splent_cli.services import compose, marketplace +from splent_cli.services.api_client import ( + SplentAPIAuthError, + SplentAPIError, + get_package_by_name, +) from splent_cli.utils.feature_utils import load_product_features @@ -81,6 +86,40 @@ def _latest_upload(v): return [] +def _feature_api_name(feature_name: str) -> str: + if feature_name.startswith("splent_feature_"): + return feature_name + return f"splent_feature_{feature_name}" + + +def _resolve_feature_from_api(namespace_github: str, feature_name: str) -> tuple[str, str]: + api_name = _feature_api_name(feature_name) + + try: + marketplace.require_marketplace_login() + package = get_package_by_name(api_name) + except SplentAPIAuthError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) from exc + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + full_name = package.get("full_name") + if isinstance(full_name, str) and "/" in full_name: + _, namespace_github, _, feature_name = compose.parse_feature_identifier( + full_name + ) + return namespace_github, feature_name + + return namespace_github, package.get("name") or api_name + + # ── Version helpers ─────────────────────────────────────────────────────────── @@ -330,6 +369,9 @@ def feature_versions( feature_identifier ) feature_name = feature_name.split("@")[0] + namespace_github, feature_name = _resolve_feature_from_api( + namespace_github, feature_name + ) show_github = not only_pypi show_pypi = not only_github diff --git a/src/splent_cli/commands/marketplace_login.py b/src/splent_cli/commands/marketplace_login.py new file mode 100644 index 0000000..7108de5 --- /dev/null +++ b/src/splent_cli/commands/marketplace_login.py @@ -0,0 +1,62 @@ +import os + +import click + +from splent_cli.services import marketplace + + +@click.command( + "marketplace:login", + short_help="Store Marketplace credentials in the workspace .env.", +) +@click.option( + "--token", + default=None, + help="Marketplace/API access token. Defaults to SPLENT_API_TOKEN.", +) +@click.option( + "--url", + default=None, + help="Optional Marketplace API URL. Defaults to SPLENT_API_URL.", +) +@click.option("--shell", is_flag=True, help="Output shell commands for eval.") +def marketplace_login(token, url, shell): + token = ( + token if token is not None else os.getenv(marketplace.MARKETPLACE_TOKEN_VAR) + ) + token = token.strip() if token else "" + api_url = ( + url + or os.getenv(marketplace.MARKETPLACE_API_URL_VAR) + or marketplace.DEFAULT_API_URL + ).rstrip("/") + + if not token: + click.secho("❌ Marketplace/API token is required.", fg="red") + raise SystemExit(1) + + try: + valid_token = marketplace.validate_api_token(api_url, token) + except marketplace.MarketplaceLoginError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + + if not valid_token: + click.secho("❌ Invalid Marketplace/API token.", fg="red") + raise SystemExit(1) + + marketplace.set_env_var(marketplace.MARKETPLACE_API_URL_VAR, api_url) + marketplace.set_env_var(marketplace.MARKETPLACE_TOKEN_VAR, token) + marketplace.set_env_var(marketplace.MARKETPLACE_AUTH_VAR, "true") + + if shell: + print(f"export {marketplace.MARKETPLACE_API_URL_VAR}={api_url}") + if token: + print(f"export {marketplace.MARKETPLACE_TOKEN_VAR}={token}") + print(f"export {marketplace.MARKETPLACE_AUTH_VAR}=true") + else: + click.secho(" Marketplace login saved.", fg="green") + click.echo(" Run: splent feature:search ") + + +cli_command = marketplace_login diff --git a/src/splent_cli/commands/marketplace_logout.py b/src/splent_cli/commands/marketplace_logout.py new file mode 100644 index 0000000..5fe1862 --- /dev/null +++ b/src/splent_cli/commands/marketplace_logout.py @@ -0,0 +1,20 @@ +import click + +from splent_cli.services import marketplace + + +@click.command( + "marketplace:logout", + short_help="Remove Marketplace credentials from the workspace .env.", +) +@click.option("--shell", is_flag=True, help="Output shell commands for eval.") +def marketplace_logout(shell): + marketplace.set_env_var(marketplace.MARKETPLACE_AUTH_VAR, "false") + + if shell: + print(f"export {marketplace.MARKETPLACE_AUTH_VAR}=false") + else: + click.secho(" Marketplace logout done.", fg="green") + + +cli_command = marketplace_logout diff --git a/src/splent_cli/services/api_client.py b/src/splent_cli/services/api_client.py new file mode 100644 index 0000000..21c00e2 --- /dev/null +++ b/src/splent_cli/services/api_client.py @@ -0,0 +1,74 @@ +import os +from urllib.parse import quote + +import requests + + +class SplentAPIError(RuntimeError): + pass + + +class SplentAPIAuthError(SplentAPIError): + pass + + +def _base_url() -> str: + return os.getenv("SPLENT_API_URL", "http://127.0.0.1:5000").rstrip("/") + + +def _headers() -> dict[str, str]: + if os.getenv("SPLENT_MARKETPLACE_AUTH") != "true": + return {} + + token = (os.getenv("SPLENT_API_TOKEN") or "").strip().strip("\"'") + if not token: + return {} + return {"Authorization": f"Bearer {token}"} + + +def _request(method: str, path: str, json_body: dict | None = None): + if not path.startswith("/"): + raise ValueError("API path must start with '/'") + + try: + response = requests.request( + method, + f"{_base_url()}{path}", + timeout=10, + headers=_headers(), + json=json_body, + ) + response.raise_for_status() + + if not response.content: + return {} + + return response.json() + + except requests.exceptions.JSONDecodeError as exc: + raise SplentAPIError("The SPLENT API returned an invalid JSON response.") from exc + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else "unknown" + if status in {401, 403}: + raise SplentAPIAuthError( + "Marketplace login required. Run: splent marketplace:login" + ) from exc + raise SplentAPIError(f"SPLENT API returned HTTP {status}.") from exc + except requests.exceptions.RequestException as exc: + raise SplentAPIError(f"Could not connect to the SPLENT API: {exc}") from exc + + +def get(path: str): + return _request("GET", path) + + +def post(path: str, json: dict | None = None): + return _request("POST", path, json_body=json) + + +def get_packages(): + return get("/api/packages") + + +def get_package_by_name(name: str): + return get(f"/api/packages/{quote(name, safe='/')}") diff --git a/src/splent_cli/services/env.py b/src/splent_cli/services/env.py new file mode 100644 index 0000000..3bef8dc --- /dev/null +++ b/src/splent_cli/services/env.py @@ -0,0 +1,43 @@ +import os +from pathlib import Path + +from dotenv import dotenv_values, load_dotenv + + +CLI_ENV_FILE_VAR = "SPLENT_CLI_ENV_FILE" +SYNCED_ENV_VARS = ( + "SPLENT_API_URL", + "SPLENT_API_TOKEN", + "SPLENT_MARKETPLACE_AUTH", +) + + +def cli_env_path() -> Path: + explicit_path = os.getenv(CLI_ENV_FILE_VAR) + if explicit_path: + return Path(explicit_path).expanduser() + + workspace = Path(os.getenv("WORKING_DIR", "/workspace")) + workspace_cli_env = workspace / "splent_cli" / ".env" + if workspace_cli_env.exists(): + return workspace_cli_env + + if os.getenv("WORKING_DIR"): + return workspace / ".env" + + source_root = Path(__file__).resolve().parents[3] + source_env = source_root / ".env" + if source_env.exists(): + return source_env + + return workspace / ".env" + + +def load_cli_env() -> None: + env_path = cli_env_path() + if env_path.exists(): + values = dotenv_values(env_path) + load_dotenv(env_path, override=True) + for key in SYNCED_ENV_VARS: + if key not in values: + os.environ.pop(key, None) diff --git a/src/splent_cli/services/marketplace.py b/src/splent_cli/services/marketplace.py new file mode 100644 index 0000000..26fc03a --- /dev/null +++ b/src/splent_cli/services/marketplace.py @@ -0,0 +1,120 @@ +import os +from pathlib import Path + +import requests + +from splent_cli.services import context +from splent_cli.services.api_client import SplentAPIAuthError +from splent_cli.services.env import cli_env_path +from splent_cli.services.env import load_cli_env + + +MARKETPLACE_TOKEN_VAR = "SPLENT_API_TOKEN" +MARKETPLACE_API_URL_VAR = "SPLENT_API_URL" +MARKETPLACE_AUTH_VAR = "SPLENT_MARKETPLACE_AUTH" +DEFAULT_API_URL = "https://api.splent.io" + + +class MarketplaceLoginError(RuntimeError): + pass + + +def _workspace_env_path() -> Path: + env_path = cli_env_path() + if env_path.exists(): + return env_path + + try: + return context.workspace() / ".env" + except SystemExit: + return Path.cwd() / ".env" + + +def _read_env_lines() -> list[str]: + env_path = _workspace_env_path() + if not env_path.exists(): + return [] + return env_path.read_text(encoding="utf-8").splitlines(keepends=True) + + +def _write_env_lines(lines: list[str]) -> None: + env_path = _workspace_env_path() + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.write_text("".join(lines), encoding="utf-8") + + +def set_env_var(key: str, value: str) -> None: + lines = _read_env_lines() + prefix = f"{key}=" + found = False + updated = [] + + for line in lines: + if line.startswith(prefix): + updated.append(f"{key}={value}\n") + found = True + else: + updated.append(line) + + if not found: + updated.append(f"{key}={value}\n") + + _write_env_lines(updated) + os.environ[key] = value + + +def unset_env_var(key: str) -> None: + lines = _read_env_lines() + prefix = f"{key}=" + updated = [line for line in lines if not line.startswith(prefix)] + _write_env_lines(updated) + os.environ.pop(key, None) + + +def clear_env_var(key: str) -> None: + set_env_var(key, '""') + os.environ.pop(key, None) + + +def token_value() -> str: + return (os.getenv(MARKETPLACE_TOKEN_VAR) or "").strip().strip("\"'") + + +def is_logged_in() -> bool: + return os.getenv(MARKETPLACE_AUTH_VAR) == "true" and bool(token_value()) + + +def require_marketplace_login() -> None: + load_cli_env() + if not is_logged_in(): + raise SplentAPIAuthError( + "Marketplace login required. Run: splent marketplace:login" + ) + + +def validate_api_token(api_url: str, token: str | None = None) -> bool: + url = api_url.rstrip("/") + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + try: + response = requests.get( + f"{url}/api/auth/check", + headers=headers, + timeout=10, + ) + except requests.exceptions.RequestException as exc: + raise MarketplaceLoginError( + f"Could not connect to the SPLENT API: {exc}" + ) from exc + + if response.status_code == 200: + return True + + if response.status_code in {401, 403}: + return False + + raise MarketplaceLoginError( + f"SPLENT API returned HTTP {response.status_code}." + ) diff --git a/tests/unit/commands/feature/test_feature_clone.py b/tests/unit/commands/feature/test_feature_clone.py index 6969569..56be4e2 100644 --- a/tests/unit/commands/feature/test_feature_clone.py +++ b/tests/unit/commands/feature/test_feature_clone.py @@ -1,4 +1,5 @@ """Tests for feature:clone — path traversal validation and error handling.""" + import subprocess from unittest.mock import patch, MagicMock from click.testing import CliRunner @@ -8,19 +9,25 @@ _build_repo_url, ) +_RESOLVE = ( + "splent_cli.commands.feature.feature_clone._resolve_full_name_from_api" +) + class TestPathTraversalValidation: def test_rejects_path_traversal_in_namespace(self, tmp_path, monkeypatch): monkeypatch.setenv("WORKING_DIR", str(tmp_path)) runner = CliRunner(mix_stderr=False) - result = runner.invoke(feature_clone, ["../../evil/repo@v1.0.0"]) + with patch(_RESOLVE, return_value="../../evil/repo@v1.0.0"): + result = runner.invoke(feature_clone, ["../../evil/repo@v1.0.0"]) assert result.exit_code == 1 assert "Invalid" in result.output def test_rejects_slash_in_repo_name(self, tmp_path, monkeypatch): monkeypatch.setenv("WORKING_DIR", str(tmp_path)) runner = CliRunner(mix_stderr=False) - result = runner.invoke(feature_clone, ["org/re/po@v1.0.0"]) + with patch(_RESOLVE, return_value="org/re/po@v1.0.0"): + result = runner.invoke(feature_clone, ["org/re/po@v1.0.0"]) # re/po as repo name after split — "re" is valid, "po@v1.0.0" is # valid, depends on split. Actually the split is on "/" so # "org/re/po@v1.0.0" → namespace="org", rest="re/po@v1.0.0" @@ -35,11 +42,13 @@ def test_accepts_valid_identifier(self): def test_rejects_special_chars(self): import pytest + with pytest.raises(SystemExit): _validate_identifier_part("../../evil", "namespace") def test_rejects_empty_string(self): import pytest + with pytest.raises(SystemExit): _validate_identifier_part("", "namespace") @@ -47,19 +56,25 @@ def test_rejects_empty_string(self): class TestTokenNotExposedInURL: def test_display_url_has_no_token_https(self, monkeypatch): monkeypatch.setenv("GITHUB_TOKEN", "secret_token_abc") - with patch("splent_cli.utils.git_url._ssh_available", return_value=False): + with patch( + "splent_cli.utils.git_url._ssh_available", return_value=False + ): _, display_url = _build_repo_url("myorg", "myrepo") assert "secret_token_abc" not in display_url assert "github.com/myorg/myrepo" in display_url def test_real_url_contains_token_https(self, monkeypatch): monkeypatch.setenv("GITHUB_TOKEN", "secret_token_abc") - with patch("splent_cli.utils.git_url._ssh_available", return_value=False): + with patch( + "splent_cli.utils.git_url._ssh_available", return_value=False + ): real_url, _ = _build_repo_url("myorg", "myrepo") assert "secret_token_abc" in real_url def test_ssh_both_urls_equal(self, monkeypatch): - with patch("splent_cli.utils.git_url._ssh_available", return_value=True): + with patch( + "splent_cli.utils.git_url._ssh_available", return_value=True + ): real_url, display_url = _build_repo_url("myorg", "myrepo") assert real_url == display_url assert "@github.com" in real_url @@ -73,13 +88,17 @@ def test_shows_clean_error_when_repo_not_found( monkeypatch.delenv("GITHUB_TOKEN", raising=False) runner = CliRunner(mix_stderr=False) - with patch("splent_cli.utils.git_url._ssh_available", return_value=False), \ - patch( + with ( + patch( + "splent_cli.utils.git_url._ssh_available", return_value=False + ), + patch( "splent_cli.commands.feature.feature_clone.subprocess.run" - ) as mock_run, \ - patch( + ) as mock_run, + patch( "splent_cli.commands.feature.feature_clone.requests.get" - ) as mock_get: + ) as mock_get, + ): mock_run.side_effect = subprocess.CalledProcessError( 128, "git clone" ) @@ -93,10 +112,7 @@ def test_shows_clean_error_when_repo_not_found( ) assert result.exit_code == 1 - assert ( - "not found" in result.output.lower() - or "❌" in result.output - ) + assert "not found" in result.output.lower() or "❌" in result.output # Crucially: no traceback assert "Traceback" not in result.output assert "CalledProcessError" not in result.output diff --git a/tests/unit/commands/feature/test_feature_clone_cleanup.py b/tests/unit/commands/feature/test_feature_clone_cleanup.py index f3b0784..3774f6e 100644 --- a/tests/unit/commands/feature/test_feature_clone_cleanup.py +++ b/tests/unit/commands/feature/test_feature_clone_cleanup.py @@ -1,4 +1,5 @@ """Tests for feature:clone — partial clone dir is cleaned up on failure.""" + import subprocess from unittest.mock import patch from click.testing import CliRunner @@ -6,6 +7,9 @@ _RUN = "splent_cli.commands.feature.feature_clone.subprocess.run" _SSH = "splent_cli.utils.git_url._ssh_available" +_RESOLVE = ( + "splent_cli.commands.feature.feature_clone._resolve_full_name_from_api" +) def _always_fail(cmd, **kwargs): @@ -16,7 +20,11 @@ class TestFeatureCloneCleanup: def test_no_partial_dir_after_both_failures(self, workspace): """If both clone attempts fail, no partial directory remains.""" runner = CliRunner(mix_stderr=False) - with patch(_SSH, return_value=False), patch(_RUN, side_effect=_always_fail): + with ( + patch(_RESOLVE, return_value="testns/myrepo@v1.0.0"), + patch(_SSH, return_value=False), + patch(_RUN, side_effect=_always_fail), + ): result = runner.invoke(feature_clone, ["testns/myrepo@v1.0.0"]) assert result.exit_code == 1 @@ -28,7 +36,11 @@ def test_no_partial_dir_after_both_failures(self, workspace): def test_error_message_on_clone_failure(self, workspace): """User gets a clear error message when clone fails.""" runner = CliRunner(mix_stderr=False) - with patch(_SSH, return_value=False), patch(_RUN, side_effect=_always_fail): + with ( + patch(_RESOLVE, return_value="testns/myrepo@v1.0.0"), + patch(_SSH, return_value=False), + patch(_RUN, side_effect=_always_fail), + ): result = runner.invoke(feature_clone, ["testns/myrepo@v1.0.0"]) assert result.exit_code == 1 diff --git a/tests/unit/commands/feature/test_feature_info.py b/tests/unit/commands/feature/test_feature_info.py new file mode 100644 index 0000000..72641b8 --- /dev/null +++ b/tests/unit/commands/feature/test_feature_info.py @@ -0,0 +1,122 @@ +""" +Tests for feature:info. +""" + +from unittest.mock import patch + +from click.testing import CliRunner +import pytest + +from splent_cli.commands.feature.feature_info import ( + _feature_api_candidates, + _feature_api_name, + feature_info, +) +from splent_cli.services.api_client import SplentAPIError + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def logged_in(monkeypatch): + monkeypatch.setattr( + "splent_cli.commands.feature.feature_info.marketplace.require_marketplace_login", + lambda: None, + ) + + +# --------------------------------------------------------------------------- +# _feature_api_name +# --------------------------------------------------------------------------- + + +class TestFeatureApiName: + def test_bare_name_gets_feature_prefix(self): + assert _feature_api_name("auth") == "splent_feature_auth" + + def test_prefixed_bare_name_is_preserved(self): + assert ( + _feature_api_name("splent_feature_auth") == "splent_feature_auth" + ) + + def test_namespaced_name_gets_feature_prefix_on_name_only(self): + assert ( + _feature_api_name("splent-io/auth") + == "splent-io/splent_feature_auth" + ) + + def test_namespaced_prefixed_name_is_preserved(self): + assert ( + _feature_api_name("splent-io/splent_feature_auth") + == "splent-io/splent_feature_auth" + ) + + def test_candidates_include_original_and_normalized_namespaced_name(self): + assert _feature_api_candidates("splent-io/demosito") == [ + "splent-io/demosito", + "splent-io/splent_feature_demosito", + ] + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +class TestFeatureInfoCommand: + def test_uses_original_namespaced_api_name_first(self, runner): + package = { + "name": "splent_feature_auth", + "full_name": "splent-io/splent_feature_auth", + "contract": {"description": "Auth feature"}, + } + + with patch( + "splent_cli.commands.feature.feature_info.get_package_by_name", + return_value=package, + ) as get_package: + result = runner.invoke(feature_info, ["splent-io/auth"]) + + assert result.exit_code == 0 + get_package.assert_called_once_with("splent-io/auth") + assert "splent-io/splent_feature_auth" in result.output + + def test_uses_original_namespaced_name_when_found(self, runner): + package = { + "name": "demosito", + "full_name": "splent-io/demosito", + "contract": {"description": "Demosito feature"}, + } + + with patch( + "splent_cli.commands.feature.feature_info.get_package_by_name", + return_value=package, + ) as get_package: + result = runner.invoke(feature_info, ["splent-io/demosito"]) + + assert result.exit_code == 0 + get_package.assert_called_once_with("splent-io/demosito") + assert "splent-io/demosito" in result.output + + def test_falls_back_to_normalized_namespaced_name_after_500(self, runner): + package = { + "name": "splent_feature_auth", + "full_name": "splent-io/splent_feature_auth", + "contract": {"description": "Auth feature"}, + } + + with patch( + "splent_cli.commands.feature.feature_info.get_package_by_name", + side_effect=[SplentAPIError("SPLENT API returned HTTP 500."), package], + ) as get_package: + result = runner.invoke(feature_info, ["splent-io/auth"]) + + assert result.exit_code == 0 + assert get_package.call_args_list[0].args == ("splent-io/auth",) + assert get_package.call_args_list[1].args == ( + "splent-io/splent_feature_auth", + ) + assert "splent-io/splent_feature_auth" in result.output diff --git a/tests/unit/commands/feature/test_feature_install.py b/tests/unit/commands/feature/test_feature_install.py new file mode 100644 index 0000000..5b61300 --- /dev/null +++ b/tests/unit/commands/feature/test_feature_install.py @@ -0,0 +1,341 @@ +""" +Tests for feature:install. +""" + +import importlib +import sys +import types +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner +import pytest + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def feature_install(monkeypatch): + # Needed because compose imports splent_framework in this test env. + sys.modules.pop("splent_cli.commands.feature.feature_install", None) + sys.modules.pop("splent_cli.services.compose", None) + + monkeypatch.setitem( + sys.modules, + "splent_cli.utils.feature_utils", + types.SimpleNamespace( + normalize_namespace=lambda value: value.replace("-", "_"), + read_features_from_data=lambda data, env: data.get("project", {}) + .get("optional-dependencies", {}) + .get("features", []), + ), + ) + + return importlib.import_module( + "splent_cli.commands.feature.feature_install" + ) + + +def _feature_pyproject(path, requires=None): + requires = requires or [] + path.write_text( + "[project]\n" + 'name = "splent_feature_auth"\n' + "\n" + "[tool.splent.contract.requires]\n" + f"features = {requires!r}\n" + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class TestHelpers: + def test_feature_short_name_normalizes_supported_refs(self, feature_install): + assert feature_install._feature_short_name("auth") == "auth" + assert feature_install._feature_short_name("splent_feature_auth") == "auth" + assert ( + feature_install._feature_short_name("splent-io/splent_feature_auth@v1") + == "auth" + ) + + def test_reads_required_features_from_marketplace_contract(self, feature_install): + package = { + "contract": { + "requires": { + "features": [ + "auth", + "splent_feature_profile", + "splent-io/splent_feature_mail@v1", + ] + } + } + } + + assert feature_install._get_marketplace_required_features(package) == [ + "auth", + "profile", + "mail", + ] + + def test_feature_api_candidates_try_namespaced_value_first(self, feature_install): + assert feature_install._feature_api_candidates("splent-io/profile") == [ + "splent-io/profile", + "splent-io/splent_feature_profile", + "splent_feature_profile", + ] + + def test_reads_required_features(self, tmp_path, feature_install): + pyproject = tmp_path / "pyproject.toml" + _feature_pyproject(pyproject, ["auth", "billing"]) + + result = feature_install._get_required_features(str(pyproject)) + + assert result == ["auth", "billing"] + + def test_versions_are_sorted(self, feature_install): + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = [ + {"name": "v1.0.0"}, + {"name": "v2.0.0"}, + {"name": "abc"}, + ] + + with patch( + "splent_cli.commands.feature.feature_install.requests.get", + return_value=response, + ): + versions = feature_install._get_available_versions( + "splent-io", "splent_feature_auth" + ) + + assert versions == ["v2.0.0", "v1.0.0"] + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +class TestFeatureInstallCommand: + def test_missing_marketplace_dependency_aborts_before_install_steps( + self, runner, product_workspace, feature_install + ): + with ( + patch( + "splent_cli.commands.feature.feature_install.marketplace.require_marketplace_login", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.get_package_by_name", + return_value={ + "name": "splent_feature_profile", + "full_name": "splent-io/splent_feature_profile@v1.0.0", + "contract": {"requires": {"features": ["auth"]}}, + }, + ), + patch( + "splent_cli.commands.feature.feature_install.subprocess.run", + return_value=MagicMock(returncode=0, stderr=""), + ) as run, + ): + result = runner.invoke( + feature_install.feature_install, + ["splent-io/profile", "--pinned", "--version", "v1.0.0"], + ) + + assert result.exit_code == 1 + assert "Cannot install profile" in result.output + assert "auth" in result.output + assert "splent feature:install splent-io/splent_feature_auth" in result.output + run.assert_not_called() + + def test_namespaced_install_falls_back_to_normalized_api_name( + self, runner, product_workspace, feature_install + ): + cache_dir = ( + product_workspace + / ".splent_cache" + / "features" + / "splent_io" + / "splent_feature_profile@v1.0.0" + ) + cache_dir.mkdir(parents=True) + _feature_pyproject(cache_dir / "pyproject.toml") + + def fake_get_package(name): + if name == "splent-io/profile": + raise feature_install.SplentAPIError("SPLENT API returned HTTP 404.") + return { + "name": "splent_feature_profile", + "full_name": "splent-io/splent_feature_profile@v1.0.0", + "contract": {"requires": {"features": []}}, + } + + with ( + patch( + "splent_cli.commands.feature.feature_install.marketplace.require_marketplace_login", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.get_package_by_name", + side_effect=fake_get_package, + ) as get_package, + patch( + "splent_cli.commands.feature.feature_install.compose.resolve_file", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.subprocess.run", + return_value=MagicMock(returncode=0, stderr=""), + ), + ): + result = runner.invoke( + feature_install.feature_install, + ["splent-io/profile", "--pinned", "--version", "v1.0.0"], + ) + + assert result.exit_code == 0 + assert [call.args[0] for call in get_package.call_args_list] == [ + "splent-io/profile", + "splent-io/splent_feature_profile", + ] + + def test_missing_marketplace_package_reports_not_published( + self, runner, product_workspace, feature_install + ): + with ( + patch( + "splent_cli.commands.feature.feature_install.marketplace.require_marketplace_login", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.get_package_by_name", + side_effect=feature_install.SplentAPIError( + "SPLENT API returned HTTP 500." + ), + ), + patch( + "splent_cli.commands.feature.feature_install.get_packages", + return_value=[], + ), + ): + result = runner.invoke( + feature_install.feature_install, + ["splent-io/splent_feature_auth"], + ) + + assert result.exit_code == 1 + assert "not published in the Marketplace" in result.output + + def test_marketplace_dependency_already_declared_allows_install( + self, runner, product_workspace, feature_install + ): + pyproject = product_workspace / "test_app" / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "test_app"\nversion = "1.0.0"\n' + '[project.optional-dependencies]\n' + 'features = ["splent-io/splent_feature_auth"]\n' + ) + + cache_dir = ( + product_workspace + / ".splent_cache" + / "features" + / "splent_io" + / "splent_feature_profile@v1.0.0" + ) + cache_dir.mkdir(parents=True) + _feature_pyproject(cache_dir / "pyproject.toml") + + with ( + patch( + "splent_cli.commands.feature.feature_install.marketplace.require_marketplace_login", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.get_package_by_name", + return_value={ + "name": "splent_feature_profile", + "full_name": "splent-io/splent_feature_profile@v1.0.0", + "contract": {"requires": {"features": ["auth"]}}, + }, + ), + patch( + "splent_cli.commands.feature.feature_install.compose.resolve_file", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.subprocess.run", + return_value=MagicMock(returncode=0, stderr=""), + ) as run, + ): + result = runner.invoke( + feature_install.feature_install, + ["splent-io/profile", "--pinned", "--version", "v1.0.0"], + ) + + calls = [call.args[0] for call in run.call_args_list] + + assert result.exit_code == 0 + assert [ + "splent", + "feature:attach", + "splent-io/splent_feature_profile", + "v1.0.0", + ] in calls + + def test_pinned_cached_feature_attaches_version( + self, runner, product_workspace, feature_install + ): + cache_dir = ( + product_workspace + / ".splent_cache" + / "features" + / "splent_io" + / "splent_feature_auth@v1.0.0" + ) + cache_dir.mkdir(parents=True) + _feature_pyproject(cache_dir / "pyproject.toml") + + with ( + patch( + "splent_cli.commands.feature.feature_install.marketplace.require_marketplace_login", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.get_package_by_name", + return_value={ + "name": "splent_feature_auth", + "full_name": "splent-io/splent_feature_auth@v1.0.0", + }, + ), + patch( + "splent_cli.commands.feature.feature_install.compose.resolve_file", + return_value=None, + ), + patch( + "splent_cli.commands.feature.feature_install.subprocess.run", + return_value=MagicMock(returncode=0, stderr=""), + ) as run, + ): + result = runner.invoke( + feature_install.feature_install, + ["splent-io/auth", "--pinned", "--version", "v1.0.0"], + ) + + calls = [call.args[0] for call in run.call_args_list] + + assert result.exit_code == 0 + assert [ + "splent", + "feature:attach", + "splent-io/splent_feature_auth", + "v1.0.0", + ] in calls + assert "auth installed" in result.output diff --git a/tests/unit/commands/feature/test_feature_publish.py b/tests/unit/commands/feature/test_feature_publish.py new file mode 100644 index 0000000..ad6e8a3 --- /dev/null +++ b/tests/unit/commands/feature/test_feature_publish.py @@ -0,0 +1,345 @@ +""" +Tests for feature:publish. +""" + +import importlib +import sys +import types +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def feature_publish_module(monkeypatch): + # Avoid importing heavy feature_contract dependencies in these unit tests. + module_name = "splent_cli.commands.feature.feature_publish" + previous_module = sys.modules.pop(module_name, None) + + monkeypatch.setitem( + sys.modules, + "tomli_w", + types.SimpleNamespace(dumps=lambda data: ""), + ) + monkeypatch.setitem( + sys.modules, + "splent_cli.commands.feature.feature_contract", + types.SimpleNamespace( + _resolve_feature=lambda full_name, workspace: None, + infer_contract=lambda feature_path, namespace, name: {}, + ), + ) + monkeypatch.setitem( + sys.modules, + "splent_cli.utils.feature_utils", + types.SimpleNamespace(normalize_namespace=lambda namespace: namespace), + ) + + module = importlib.import_module(module_name) + try: + yield module + finally: + sys.modules.pop(module_name, None) + if previous_module is not None: + sys.modules[module_name] = previous_module + + +# --------------------------------------------------------------------------- +# Name parsing +# --------------------------------------------------------------------------- + + +class TestParseFullName: + def test_default_owner_is_splent_io(self, feature_publish_module): + assert feature_publish_module._parse_full_name( + "splent_feature_auth" + ) == ( + "splent-io", + "splent_feature_auth", + None, + ) + + def test_owner_underscores_are_normalized_to_dashes( + self, feature_publish_module + ): + assert feature_publish_module._parse_full_name( + "splent_io/splent_feature_auth@v1" + ) == ( + "splent-io", + "splent_feature_auth", + "v1", + ) + + +# --------------------------------------------------------------------------- +# Git remotes +# --------------------------------------------------------------------------- + + +class TestRepoFromRemote: + def test_extracts_github_ssh_remote_without_git_suffix( + self, feature_publish_module + ): + assert feature_publish_module._repo_from_remote( + "git@github.com:splent-io/splent_feature_auth.git" + ) == ("splent-io", "splent_feature_auth") + + def test_extracts_github_https_remote_with_token( + self, feature_publish_module + ): + assert feature_publish_module._repo_from_remote( + "https://ghp_secret@github.com/splent-io/auth.git" + ) == ("splent-io", "auth") + + +class TestBrowserUrl: + def test_converts_github_ssh_remote_to_browser_url( + self, feature_publish_module + ): + assert ( + feature_publish_module._browser_repo_url( + "git@github.com:splent-io/auth.git" + ) + == "https://github.com/splent-io/auth" + ) + + def test_removes_token_and_git_suffix_from_github_https_remote( + self, feature_publish_module + ): + assert ( + feature_publish_module._browser_repo_url( + "https://token@github.com/splent-io/auth.git" + ) + == "https://github.com/splent-io/auth" + ) + + def test_removes_query_and_git_suffix_from_generic_https_remote( + self, feature_publish_module + ): + assert ( + feature_publish_module._browser_repo_url( + "https://git.example.com/org/auth.git?token=secret" + ) + == "https://git.example.com/org/auth" + ) + + +# --------------------------------------------------------------------------- +# Marketplace authentication +# --------------------------------------------------------------------------- + + +class TestMarketplaceLogin: + def test_token_argument_is_saved_for_current_publish( + self, monkeypatch, feature_publish_module + ): + monkeypatch.delenv("SPLENT_API_TOKEN", raising=False) + + feature_publish_module._login_to_marketplace("abc123") + + assert feature_publish_module.os.getenv("SPLENT_API_TOKEN") == "abc123" + + def test_logged_in_token_is_reused( + self, monkeypatch, feature_publish_module + ): + monkeypatch.setenv("SPLENT_API_TOKEN", "existing") + monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "true") + monkeypatch.setattr( + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: None, + ) + + feature_publish_module._login_to_marketplace(None) + + assert feature_publish_module.os.getenv("SPLENT_API_TOKEN") == "existing" + + def test_existing_token_without_login_is_rejected( + self, monkeypatch, feature_publish_module + ): + monkeypatch.setenv("SPLENT_API_TOKEN", "existing") + monkeypatch.delenv("SPLENT_MARKETPLACE_AUTH", raising=False) + monkeypatch.setattr( + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: (_ for _ in ()).throw(feature_publish_module.SplentAPIError()), + ) + + with pytest.raises( + feature_publish_module.SplentAPIError, + match="login is required", + ): + feature_publish_module._login_to_marketplace(None) + + def test_token_can_be_loaded_from_cli_env_file( + self, tmp_path, monkeypatch, feature_publish_module + ): + env_path = tmp_path / ".env" + env_path.write_text( + "SPLENT_API_TOKEN=from-file\n" + "SPLENT_MARKETPLACE_AUTH=true\n" + ) + monkeypatch.setenv("SPLENT_CLI_ENV_FILE", str(env_path)) + monkeypatch.delenv("SPLENT_API_TOKEN", raising=False) + monkeypatch.delenv("SPLENT_MARKETPLACE_AUTH", raising=False) + + feature_publish_module._login_to_marketplace(None) + + assert feature_publish_module.os.getenv("SPLENT_API_TOKEN") == "from-file" + + def test_missing_token_is_rejected( + self, monkeypatch, feature_publish_module + ): + monkeypatch.delenv("SPLENT_API_TOKEN", raising=False) + monkeypatch.setattr( + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: (_ for _ in ()).throw(feature_publish_module.SplentAPIError()), + ) + + with pytest.raises( + feature_publish_module.SplentAPIError, + match="login is required", + ): + feature_publish_module._login_to_marketplace(None) + + def test_empty_quoted_token_is_rejected( + self, monkeypatch, feature_publish_module + ): + monkeypatch.setenv("SPLENT_API_TOKEN", '""') + monkeypatch.setattr( + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: (_ for _ in ()).throw(feature_publish_module.SplentAPIError()), + ) + + with pytest.raises( + feature_publish_module.SplentAPIError, + match="login is required", + ): + feature_publish_module._login_to_marketplace(None) + + +# --------------------------------------------------------------------------- +# Payload +# --------------------------------------------------------------------------- + + +class TestBuildPayload: + def test_payload_uses_browser_repo_url_without_token_or_git_suffix( + self, tmp_path, feature_publish_module + ): + feature_path = tmp_path / "splent_feature_auth" + feature_path.mkdir() + + with ( + patch( + "splent_cli.commands.feature.feature_publish.infer_contract", + return_value={}, + ), + patch( + "splent_cli.commands.feature.feature_publish._git_remote_url", + return_value="https://token@github.com/splent-io/auth.git", + ), + ): + payload = feature_publish_module._build_payload( + feature_path, + "splent_io", + "splent_feature_auth", + "splent_io/splent_feature_auth@v1", + ) + + assert payload["full_name"] == "splent-io/splent_feature_auth@v1" + assert payload["repo_url"] == "https://github.com/splent-io/auth" + assert payload["github"]["url"] == "https://github.com/splent-io/auth" + assert "token" not in payload["repo_url"] + + def test_payload_falls_back_to_browser_github_url( + self, tmp_path, feature_publish_module + ): + feature_path = tmp_path / "splent_feature_auth" + feature_path.mkdir() + + with ( + patch( + "splent_cli.commands.feature.feature_publish.infer_contract", + return_value={}, + ), + patch( + "splent_cli.commands.feature.feature_publish._git_remote_url", + return_value=None, + ), + ): + payload = feature_publish_module._build_payload( + feature_path, + "splent_io", + "splent_feature_auth", + "splent_io/splent_feature_auth", + ) + + assert ( + payload["repo_url"] + == "https://github.com/splent-io/splent_feature_auth" + ) + + def test_canonical_full_name_preserves_version_when_present( + self, feature_publish_module + ): + assert ( + feature_publish_module._canonical_full_name( + "splent-io", "splent_feature_auth", "v1" + ) + == "splent-io/splent_feature_auth@v1" + ) + + def test_contract_requires_features_fall_back_to_pyproject( + self, feature_publish_module + ): + pyproject = { + "tool": { + "splent": { + "contract": { + "requires": { + "features": ["auth"], + "env_vars": ["SMTP_HOST"], + "signals": ["user-registered"], + } + } + } + } + } + + contract = feature_publish_module._contract_for_marketplace( + {}, + pyproject, + "splent_feature_profile", + ) + + assert contract["requires"] == { + "features": ["auth"], + "env_vars": ["SMTP_HOST"], + "signals": ["user-registered"], + } + + def test_inferred_contract_requires_take_precedence( + self, feature_publish_module + ): + pyproject = { + "tool": { + "splent": { + "contract": { + "requires": { + "features": ["auth"], + } + } + } + } + } + + contract = feature_publish_module._contract_for_marketplace( + {"requires_features": ["mail"]}, + pyproject, + "splent_feature_profile", + ) + + assert contract["requires"]["features"] == ["mail"] diff --git a/tests/unit/commands/feature/test_feature_remove.py b/tests/unit/commands/feature/test_feature_remove.py index bffaed3..49fbdc1 100644 --- a/tests/unit/commands/feature/test_feature_remove.py +++ b/tests/unit/commands/feature/test_feature_remove.py @@ -83,6 +83,42 @@ def test_namespaced_feature_removed(self, runner, product_workspace, monkeypatch assert result.exit_code == 0 assert "removed" in result.output.lower() + def test_removes_versioned_namespaced_splent_feature_by_short_name( + self, runner, product_workspace + ): + pyproject = product_workspace / "test_app" / "pyproject.toml" + self._write_pyproject( + pyproject, + [ + "splent-io/splent_feature_auth@v1.5.8", + "splent-io/splent_feature_profile@v1.5.7", + ], + ) + + result = runner.invoke(feature_remove, ["auth"]) + assert result.exit_code == 0 + assert "removed" in result.output.lower() + + with open(pyproject, "rb") as f: + data = tomllib.load(f) + features = data["tool"]["splent"]["features"] + assert "splent-io/splent_feature_auth@v1.5.8" not in features + assert "splent-io/splent_feature_profile@v1.5.7" in features + + def test_removes_unversioned_splent_feature_by_short_name( + self, runner, product_workspace + ): + pyproject = product_workspace / "test_app" / "pyproject.toml" + self._write_pyproject(pyproject, ["splent-io/splent_feature_profile"]) + + result = runner.invoke(feature_remove, ["profile"]) + assert result.exit_code == 0 + assert "removed" in result.output.lower() + + with open(pyproject, "rb") as f: + data = tomllib.load(f) + assert data["tool"]["splent"]["features"] == [] + # --------------------------------------------------------------------------- # Symlink removal diff --git a/tests/unit/commands/feature/test_feature_search.py b/tests/unit/commands/feature/test_feature_search.py new file mode 100644 index 0000000..b033a7c --- /dev/null +++ b/tests/unit/commands/feature/test_feature_search.py @@ -0,0 +1,202 @@ +""" +Tests for feature:search. +""" + +from unittest.mock import patch + +from click.testing import CliRunner +import pytest + +from splent_cli.commands.feature.feature_search import ( + _contract_description, + _load_packages, + _updated_at, + feature_search, +) +from splent_cli.services.api_client import SplentAPIAuthError, SplentAPIError + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def logged_in(monkeypatch): + monkeypatch.setattr( + "splent_cli.commands.feature.feature_search.marketplace.require_marketplace_login", + lambda: True, + ) + + +def _package(name, description="", updated_at="2026-05-04T10:00:00Z"): + return { + "name": name, + "contract": {"description": description}, + "metadata": {"updated_at": updated_at}, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class TestHelpers: + def test_contract_description_uses_contract_description(self): + package = {"contract": {"description": "Auth feature"}} + + assert _contract_description(package) == "Auth feature" + + def test_contract_description_returns_empty_when_missing(self): + assert _contract_description({}) == "" + + def test_updated_at_prefers_metadata_and_strips_time(self): + package = { + "updated_at": "2026-04-01T12:00:00Z", + "metadata": {"updated_at": "2026-05-04T10:00:00Z"}, + } + + assert _updated_at(package) == "2026-05-04" + + def test_updated_at_falls_back_to_top_level_value(self): + assert _updated_at({"updated_at": "2026-04-01"}) == "2026-04-01" + + def test_updated_at_returns_dash_when_missing(self): + assert _updated_at({}) == "-" + + +# --------------------------------------------------------------------------- +# _load_packages +# --------------------------------------------------------------------------- + + +class TestLoadPackages: + def test_returns_only_dict_items_from_api_list(self): + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + return_value=[_package("splent_feature_auth"), "bad", None], + ): + packages = _load_packages() + + assert packages == [_package("splent_feature_auth")] + + def test_raises_for_unexpected_api_response(self): + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + return_value={"items": []}, + ): + with pytest.raises(SplentAPIError, match="Unexpected"): + _load_packages() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +class TestFeatureSearchCommand: + def test_filters_to_splent_feature_packages_by_default(self, runner): + packages = [ + _package("splent_feature_auth", "Auth feature"), + _package("regular_package", "Regular package"), + ] + + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + return_value=packages, + ): + result = runner.invoke(feature_search) + + assert result.exit_code == 0 + assert "splent_feature_auth" in result.output + assert "Auth feature" in result.output + assert "regular_package" not in result.output + + def test_all_includes_non_feature_packages(self, runner): + packages = [ + _package("splent_feature_auth", "Auth feature"), + _package("regular_package", "Regular package"), + ] + + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + return_value=packages, + ): + result = runner.invoke(feature_search, ["--all"]) + + assert result.exit_code == 0 + assert "splent_feature_auth" in result.output + assert "regular_package" in result.output + + def test_query_filters_case_insensitively(self, runner): + packages = [ + _package("splent_feature_auth", "Auth feature"), + _package("splent_feature_billing", "Billing feature"), + ] + + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + return_value=packages, + ): + result = runner.invoke(feature_search, ["AUTH"]) + + assert result.exit_code == 0 + assert "splent_feature_auth" in result.output + assert "splent_feature_billing" not in result.output + + def test_outputs_empty_message_when_no_packages_match(self, runner): + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + return_value=[_package("splent_feature_auth")], + ): + result = runner.invoke(feature_search, ["billing"]) + + assert result.exit_code == 0 + assert "No packages found matching 'billing'" in result.output + + def test_exits_cleanly_when_api_client_fails(self, runner): + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + side_effect=SplentAPIError("Could not connect"), + ): + result = runner.invoke(feature_search) + + assert result.exit_code == 1 + assert "Could not connect" in result.output + assert "Check SPLENT_API_URL" in result.output + + def test_exits_with_login_message_when_auth_fails(self, runner): + with patch( + "splent_cli.commands.feature.feature_search.get_packages", + side_effect=SplentAPIAuthError( + "Marketplace login required. Run: splent marketplace:login" + ), + ): + result = runner.invoke(feature_search, ["--all"]) + + assert result.exit_code == 1 + assert "Marketplace login required" in result.output + assert "splent marketplace:login" in result.output + assert "Check SPLENT_API_URL" not in result.output + + def test_requires_marketplace_login_before_loading_packages( + self, runner, monkeypatch + ): + monkeypatch.setattr( + "splent_cli.commands.feature.feature_search.marketplace.require_marketplace_login", + lambda: (_ for _ in ()).throw( + SplentAPIAuthError( + "Marketplace login required. Run: splent marketplace:login" + ) + ), + ) + + with patch( + "splent_cli.commands.feature.feature_search.get_packages" + ) as get_packages: + result = runner.invoke(feature_search, ["--all"]) + + assert result.exit_code == 1 + assert "Marketplace login required" in result.output + get_packages.assert_not_called() diff --git a/tests/unit/commands/test_marketplace.py b/tests/unit/commands/test_marketplace.py new file mode 100644 index 0000000..5322995 --- /dev/null +++ b/tests/unit/commands/test_marketplace.py @@ -0,0 +1,189 @@ +""" +Tests for marketplace login/logout commands. +""" + +import os +from click.testing import CliRunner +import pytest +from unittest.mock import patch + +from splent_cli.commands.marketplace_login import marketplace_login +from splent_cli.commands.marketplace_logout import marketplace_logout +from splent_cli.services import marketplace + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def clear_token(monkeypatch): + monkeypatch.delenv("SPLENT_API_TOKEN", raising=False) + monkeypatch.delenv("SPLENT_API_URL", raising=False) + monkeypatch.delenv("SPLENT_MARKETPLACE_AUTH", raising=False) + + +class TestMarketplaceLogin: + def test_login_saves_token_in_env_file(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ): + result = runner.invoke(marketplace_login, ["--token", "abc123"]) + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_TOKEN=abc123" in content + assert "SPLENT_API_URL=https://api.splent.io" in content + assert "SPLENT_MARKETPLACE_AUTH=true" in content + assert "login" in result.output.lower() + + def test_login_without_token_is_rejected(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ) as validate: + result = runner.invoke( + marketplace_login, ["--url", "http://localhost:5000"] + ) + + assert result.exit_code == 1 + assert "token is required" in result.output + assert not (workspace / ".env").exists() + validate.assert_not_called() + + def test_login_uses_env_api_url_and_token(self, runner, workspace, monkeypatch): + monkeypatch.setenv("SPLENT_API_URL", "http://env-api.local/") + monkeypatch.setenv("SPLENT_API_TOKEN", "env-token") + + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ) as validate: + result = runner.invoke(marketplace_login) + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_URL=http://env-api.local" in content + assert "SPLENT_API_TOKEN=env-token" in content + assert "SPLENT_MARKETPLACE_AUTH=true" in content + validate.assert_called_once_with("http://env-api.local", "env-token") + + def test_login_flags_override_env_values(self, runner, workspace, monkeypatch): + monkeypatch.setenv("SPLENT_API_URL", "http://env-api.local") + monkeypatch.setenv("SPLENT_API_TOKEN", "env-token") + + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ) as validate: + result = runner.invoke( + marketplace_login, + ["--url", "http://flag-api.local", "--token", "flag-token"], + ) + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_URL=http://flag-api.local" in content + assert "SPLENT_API_TOKEN=flag-token" in content + assert "SPLENT_MARKETPLACE_AUTH=true" in content + validate.assert_called_once_with("http://flag-api.local", "flag-token") + + def test_login_can_output_shell_export(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ): + result = runner.invoke( + marketplace_login, ["--token", "abc123", "--shell"] + ) + + assert result.exit_code == 0 + assert "export SPLENT_API_URL=https://api.splent.io" in result.output + assert "export SPLENT_API_TOKEN=abc123" in result.output + assert "export SPLENT_MARKETPLACE_AUTH=true" in result.output + + def test_login_without_token_shell_is_rejected(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ) as validate: + result = runner.invoke(marketplace_login, ["--shell"]) + + assert result.exit_code == 1 + assert "token is required" in result.output + validate.assert_not_called() + + def test_login_with_url_saves_api_url(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ) as validate: + result = runner.invoke( + marketplace_login, + ["--token", "abc123", "--url", "http://localhost:5000"], + ) + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_URL=http://localhost:5000" in content + assert "SPLENT_MARKETPLACE_AUTH=true" in content + validate.assert_called_once_with("http://localhost:5000", "abc123") + + def test_invalid_login_does_not_save_token(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=False, + ): + result = runner.invoke(marketplace_login, ["--token", "bad"]) + + assert result.exit_code == 1 + assert "invalid" in result.output.lower() + assert not (workspace / ".env").exists() + + def test_connection_error_shows_clear_message(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + side_effect=marketplace.MarketplaceLoginError("Could not connect"), + ): + result = runner.invoke(marketplace_login, ["--token", "abc123"]) + + assert result.exit_code == 1 + assert "could not connect" in result.output.lower() + assert not (workspace / ".env").exists() + + +class TestMarketplaceLogout: + def test_logout_marks_marketplace_as_logged_out( + self, runner, workspace, monkeypatch + ): + (workspace / ".env").write_text( + "SPLENT_APP=test_app\n" + "SPLENT_API_URL=http://localhost:5000\n" + "SPLENT_API_TOKEN=abc123\n" + "SPLENT_MARKETPLACE_AUTH=true\n" + ) + monkeypatch.setenv("SPLENT_API_URL", "http://localhost:5000") + monkeypatch.setenv("SPLENT_API_TOKEN", "abc123") + monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "true") + + result = runner.invoke(marketplace_logout) + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_TOKEN=abc123" in content + assert "SPLENT_API_URL=http://localhost:5000" in content + assert "SPLENT_MARKETPLACE_AUTH=false" in content + assert os.getenv(marketplace.MARKETPLACE_API_URL_VAR) == "http://localhost:5000" + assert os.getenv(marketplace.MARKETPLACE_TOKEN_VAR) == "abc123" + assert os.getenv(marketplace.MARKETPLACE_AUTH_VAR) == "false" + assert "logout" in result.output.lower() + + def test_logout_can_output_shell_unset(self, runner, workspace): + result = runner.invoke(marketplace_logout, ["--shell"]) + + assert result.exit_code == 0 + assert f'export {marketplace.MARKETPLACE_TOKEN_VAR}=""' not in result.output + assert f"export {marketplace.MARKETPLACE_AUTH_VAR}=false" in result.output + assert f"unset {marketplace.MARKETPLACE_API_URL_VAR}" not in result.output diff --git a/tests/unit/services/test_api_client.py b/tests/unit/services/test_api_client.py new file mode 100644 index 0000000..869ddb6 --- /dev/null +++ b/tests/unit/services/test_api_client.py @@ -0,0 +1,69 @@ +""" +Tests for the API client helpers. +""" + +from unittest.mock import patch + +import pytest +import requests + +from splent_cli.services import api_client + + +class TestGetPackageByName: + def test_get_package_by_name_preserves_namespace_slash(self): + with patch("splent_cli.services.api_client.get") as get: + api_client.get_package_by_name("splent-io/splent_feature_auth") + + get.assert_called_once_with( + "/api/packages/splent-io/splent_feature_auth" + ) + + def test_get_package_by_name_quotes_spaces_but_not_slashes(self): + with patch("splent_cli.services.api_client.get") as get: + api_client.get_package_by_name("splent-io/feature with space") + + get.assert_called_once_with( + "/api/packages/splent-io/feature%20with%20space" + ) + + +class TestHeaders: + def test_headers_include_bearer_token_when_configured(self, monkeypatch): + monkeypatch.setenv("SPLENT_API_TOKEN", "abc123") + monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "true") + + assert api_client._headers() == {"Authorization": "Bearer abc123"} + + def test_headers_omit_empty_quoted_token(self, monkeypatch): + monkeypatch.setenv("SPLENT_API_TOKEN", '""') + monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "true") + + assert api_client._headers() == {} + + def test_headers_omit_token_when_logged_out(self, monkeypatch): + monkeypatch.setenv("SPLENT_API_TOKEN", "abc123") + monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "false") + + assert api_client._headers() == {} + + +class TestBaseUrl: + def test_base_url_strips_trailing_slash(self, monkeypatch): + monkeypatch.setenv("SPLENT_API_URL", "http://api.local/") + + assert api_client._base_url() == "http://api.local" + + +class TestRequestErrors: + def test_request_raises_auth_error_for_401(self): + response = requests.Response() + response.status_code = 401 + error = requests.exceptions.HTTPError(response=response) + + with ( + patch("splent_cli.services.api_client.requests.request") as request, + pytest.raises(api_client.SplentAPIAuthError, match="login required"), + ): + request.return_value.raise_for_status.side_effect = error + api_client.get("/api/packages") diff --git a/tests/unit/services/test_env.py b/tests/unit/services/test_env.py new file mode 100644 index 0000000..5608bd2 --- /dev/null +++ b/tests/unit/services/test_env.py @@ -0,0 +1,58 @@ +import os + +from splent_cli.services.env import CLI_ENV_FILE_VAR, cli_env_path, load_cli_env + + +def test_cli_env_path_prefers_explicit_path(tmp_path, monkeypatch): + env_path = tmp_path / "custom.env" + monkeypatch.setenv(CLI_ENV_FILE_VAR, str(env_path)) + monkeypatch.setenv("WORKING_DIR", str(tmp_path / "workspace")) + + assert cli_env_path() == env_path + + +def test_cli_env_path_prefers_workspace_cli_env(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + cli_env = workspace / "splent_cli" / ".env" + cli_env.parent.mkdir(parents=True) + cli_env.write_text("SPLENT_API_TOKEN=repo-token\n") + (workspace / ".env").write_text("SPLENT_API_TOKEN=workspace-token\n") + monkeypatch.setenv("WORKING_DIR", str(workspace)) + monkeypatch.delenv(CLI_ENV_FILE_VAR, raising=False) + + assert cli_env_path() == cli_env + + +def test_cli_env_path_falls_back_to_workspace_env(tmp_path, monkeypatch): + monkeypatch.setenv("WORKING_DIR", str(tmp_path)) + monkeypatch.delenv(CLI_ENV_FILE_VAR, raising=False) + + assert cli_env_path() == tmp_path / ".env" + + +def test_load_cli_env_overrides_existing_process_env(tmp_path, monkeypatch): + env_path = tmp_path / ".env" + env_path.write_text("SPLENT_API_TOKEN=file-token\n") + monkeypatch.setenv(CLI_ENV_FILE_VAR, str(env_path)) + monkeypatch.setenv("SPLENT_API_TOKEN", "process-token") + + load_cli_env() + + assert os.getenv("SPLENT_API_TOKEN") == "file-token" + + +def test_load_cli_env_unsets_marketplace_values_missing_from_file( + tmp_path, monkeypatch +): + env_path = tmp_path / ".env" + env_path.write_text("WORKING_DIR=/workspace\n") + monkeypatch.setenv(CLI_ENV_FILE_VAR, str(env_path)) + monkeypatch.setenv("SPLENT_API_URL", "http://env-api.local") + monkeypatch.setenv("SPLENT_API_TOKEN", "process-token") + monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "true") + + load_cli_env() + + assert os.getenv("SPLENT_API_URL") is None + assert os.getenv("SPLENT_API_TOKEN") is None + assert os.getenv("SPLENT_MARKETPLACE_AUTH") is None