From fe0c548da2ce2898ef1bf66558b79c51f73d4eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 17 Mar 2026 16:49:46 +0100 Subject: [PATCH 01/30] feat: --- makefiles/Makefile.setup | 4 ++++ 1 file changed, 4 insertions(+) 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 From 53ccac570a7a4b0f44fd79f66b0971e768a1996d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 23 Mar 2026 16:47:06 +0100 Subject: [PATCH 02/30] chore(hooks): add commit-msg hook to main --- .githooks/commit-msg | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100755 .githooks/commit-msg 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 From 27eccb6448df0458290ccde3cb65386f5be4bc6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 23 Mar 2026 16:56:23 +0100 Subject: [PATCH 03/30] ci: restore commits workflow --- .github/workflows/ci-commits.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/ci-commits.yml 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 From 13d6f1f6b84e53bb8d29fc012d691b67b03e3f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sat, 25 Apr 2026 20:05:00 +0200 Subject: [PATCH 04/30] feat: Add SPLENT API client --- .env.example | 3 ++- src/splent_cli/services/api_client.py | 38 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/splent_cli/services/api_client.py diff --git a/.env.example b/.env.example index fa9af61..a06e984 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ WORKING_DIR=/workspace GITHUB_USER= GITHUB_TOKEN= TWINE_USERNAME=__token__ -TWINE_PASSWORD= \ No newline at end of file +TWINE_PASSWORD= +SPLENT_API_URL=http://127.0.0.1:5000 diff --git a/src/splent_cli/services/api_client.py b/src/splent_cli/services/api_client.py new file mode 100644 index 0000000..030c384 --- /dev/null +++ b/src/splent_cli/services/api_client.py @@ -0,0 +1,38 @@ +import os +from urllib.parse import quote + +import requests + + +class SplentAPIError(RuntimeError): + pass + + +def _base_url() -> str: + return os.getenv("SPLENT_API_URL", "http://127.0.0.1:5000").rstrip("/") + + +def get(path: str): + if not path.startswith("/"): + raise ValueError("API path must start with '/'") + + try: + response = requests.get(f"{_base_url()}{path}", timeout=10) + response.raise_for_status() + 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" + 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_packages(): + return get("/api/packages") + + +def get_package_by_name(name: str): + return get(f"/api/packages/{quote(name, safe='')}") From 1e864bebb2a5773eb5a12d0cbca79844a3caac50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sat, 25 Apr 2026 21:14:28 +0200 Subject: [PATCH 05/30] feat: Use SPLENT API for feature search --- .../commands/feature/feature_search.py | 190 ++++++++++-------- 1 file changed, 106 insertions(+), 84 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 2a35182..104eee0 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,126 +1,148 @@ -import urllib.request -import urllib.error -import json -import os import click +from splent_cli.services.api_client import 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 _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 _package_matches(package: dict, query: str) -> bool: + haystack = [ + package.get("name") or "", + package.get("full_name") or "", + _contract_description(package), + " ".join(_contract_items(package, "provides")), + " ".join(_contract_items(package, "requires")), + ] + return query.lower() in " ".join(haystack).lower() -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 +def _format_items(items: list[str]) -> str: + if not items: + return "-" + return ", ".join(items) -@click.command("feature:search", short_help="Search for available features on GitHub.") +def _repo_url(package: dict) -> str | None: + return package.get("html_url") or None + + +def _updated_at(package: dict) -> str: + value = package.get("updated_at") or "" + if "T" in value: + return value.split("T", 1)[0] + return value or "-" + + +def _load_packages() -> list[dict]: + data = get_packages() + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + raise SplentAPIError("The SPLENT API returned an unexpected packages payload.") + + +@click.command("feature:search", short_help="Search for available SPLENT packages.") @click.argument("query", required=False) @click.option( "--org", default="splent-io", show_default=True, - help="GitHub organisation to search in.", + help="Deprecated. The API decides which GitHub organisation to read.", ) @click.option( "--all", "show_all", is_flag=True, - help="Show all repos, not just splent_feature_* ones.", + help="Show all API packages, not just splent_feature_* packages.", ) def feature_search(query, org, show_all): """ - List available features from a GitHub organisation. + List available packages from the SPLENT API. \b - By default searches the splent-io org and filters by repos that match - the splent_feature_* naming convention. + By default reads SPLENT_API_URL/api/packages and filters by packages that + match the splent_feature_* naming convention. Optionally filter by QUERY (partial name match). Examples: splent feature:search splent feature:search auth - splent feature:search --org my-org + SPLENT_API_URL=http://127.0.0.1:5000 splent feature:search """ - 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") + if org != "splent-io": + click.secho( + "⚠️ --org is ignored when searching via the SPLENT API.", + fg="yellow", + ) + + click.echo(click.style("\n🔍 Searching packages in SPLENT API...\n", fg="cyan")) + + try: + packages = _load_packages() + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Start splent-api or set SPLENT_API_URL to the API base URL.") raise SystemExit(1) - # Filter if not show_all: - repos = [r for r in repos if "feature" in r.get("name", "").lower()] + packages = [ + p for p in packages if (p.get("name") or "").startswith("splent_feature_") + ] + if query: - repos = [r for r in repos if query.lower() in r.get("name", "").lower()] + packages = [p for p in packages if _package_matches(p, query)] - if not repos: - msg = f"No features found in {org}" + 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") - - 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}") + click.secho(f"Found {len(packages)} package(s):\n", fg="cyan") - click.echo() - if not token: - click.secho( - "💡 Set GITHUB_TOKEN to avoid rate limits and access private repos.", - fg="yellow", - ) + col = max(len(p.get("name") or "") for p in packages) + 2 + + 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) + provides = _format_items(_contract_items(package, "provides")) + requires = _format_items(_contract_items(package, "requires")) + + click.echo(f" {name:<{col}} updated {updated} {desc}") + click.echo(f" {'':<{col}} provides: {provides}") + click.echo(f" {'':<{col}} requires: {requires}") + + url = _repo_url(package) + if url: + click.echo(f" {'':<{col}} {url}") + + click.echo() + + click.echo("Use SPLENT_API_URL to point this command at another splent-api server.") cli_command = feature_search From 716f70e3fa9ec35b90d28634223396c8e53f608b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 03:24:35 +0200 Subject: [PATCH 06/30] feat: Improve feature search with package API --- .../commands/feature/feature_search.py | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 104eee0..1df1c44 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,3 +1,6 @@ +import shutil +import textwrap + import click from splent_cli.services.api_client import SplentAPIError, get_packages @@ -39,10 +42,41 @@ def _package_matches(package: dict, query: str) -> bool: return query.lower() in " ".join(haystack).lower() -def _format_items(items: list[str]) -> str: +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: - return "-" - return ", ".join(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: @@ -60,50 +94,50 @@ def _load_packages() -> list[dict]: data = get_packages() if isinstance(data, list): return [item for item in data if isinstance(item, dict)] - raise SplentAPIError("The SPLENT API returned an unexpected packages payload.") + raise SplentAPIError("Unexpected package response.") -@click.command("feature:search", short_help="Search for available SPLENT packages.") +@click.command("feature:search", short_help="Search for available features.") @click.argument("query", required=False) @click.option( "--org", default="splent-io", show_default=True, - help="Deprecated. The API decides which GitHub organisation to read.", + help="GitHub organisation to search in.", ) @click.option( "--all", "show_all", is_flag=True, - help="Show all API packages, not just splent_feature_* packages.", + help="Show all packages, not just splent_feature_* ones.", ) def feature_search(query, org, show_all): """ - List available packages from the SPLENT API. + List available features. \b - By default reads SPLENT_API_URL/api/packages and filters by packages that - match the splent_feature_* naming convention. + By default filters packages that match the splent_feature_* naming + convention. Optionally filter by QUERY (partial name match). Examples: splent feature:search splent feature:search auth - SPLENT_API_URL=http://127.0.0.1:5000 splent feature:search + splent feature:search --all """ if org != "splent-io": click.secho( - "⚠️ --org is ignored when searching via the SPLENT API.", + " --org is ignored by the configured package index.", fg="yellow", ) - click.echo(click.style("\n🔍 Searching packages in SPLENT API...\n", fg="cyan")) + click.echo(click.style("\n Searching features...\n", fg="cyan")) try: packages = _load_packages() except SplentAPIError as exc: click.secho(f"❌ {exc}", fg="red") - click.echo(" Start splent-api or set SPLENT_API_URL to the API base URL.") + click.echo(" Check SPLENT_API_URL or start the package index.") raise SystemExit(1) if not show_all: @@ -121,28 +155,28 @@ def feature_search(query, org, show_all): click.secho(f"ℹ️ {msg}.", fg="yellow") return - click.secho(f"Found {len(packages)} package(s):\n", fg="cyan") + click.secho(f" Found {len(packages)} package(s):\n", fg="cyan") - col = max(len(p.get("name") or "") for p in packages) + 2 + width = _terminal_width() 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) - provides = _format_items(_contract_items(package, "provides")) - requires = _format_items(_contract_items(package, "requires")) + provides = _contract_items(package, "provides") + requires = _contract_items(package, "requires") - click.echo(f" {name:<{col}} updated {updated} {desc}") - click.echo(f" {'':<{col}} provides: {provides}") - click.echo(f" {'':<{col}} requires: {requires}") + click.secho(f" {name}", bold=True) + _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: - click.echo(f" {'':<{col}} {url}") + _echo_wrapped("repo", url, width) click.echo() - click.echo("Use SPLENT_API_URL to point this command at another splent-api server.") - cli_command = feature_search From 4d4d16ca688cf60d0010d858099317d88941e875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 04:04:20 +0200 Subject: [PATCH 07/30] refactor: refactoring feature search --- .../commands/feature/feature_search.py | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 1df1c44..c362d3f 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -31,17 +31,6 @@ def _contract_items(package: dict, key: str) -> list[str]: return [] -def _package_matches(package: dict, query: str) -> bool: - haystack = [ - package.get("name") or "", - package.get("full_name") or "", - _contract_description(package), - " ".join(_contract_items(package, "provides")), - " ".join(_contract_items(package, "requires")), - ] - return query.lower() in " ".join(haystack).lower() - - def _terminal_width() -> int: return min(shutil.get_terminal_size((100, 20)).columns, 120) @@ -99,19 +88,13 @@ def _load_packages() -> list[dict]: @click.command("feature:search", short_help="Search for available features.") @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 packages, not just splent_feature_* ones.", ) -def feature_search(query, org, show_all): +def feature_search(query, show_all): """ List available features. @@ -125,12 +108,6 @@ def feature_search(query, org, show_all): splent feature:search auth splent feature:search --all """ - if org != "splent-io": - click.secho( - " --org is ignored by the configured package index.", - fg="yellow", - ) - click.echo(click.style("\n Searching features...\n", fg="cyan")) try: @@ -146,7 +123,9 @@ def feature_search(query, org, show_all): ] if query: - packages = [p for p in packages if _package_matches(p, query)] + packages = [ + p for p in packages if query.lower() in (p.get("name") or "").lower() + ] if not packages: msg = "No packages found" From a2b6be1e83cd37f47782627d9c6d428e2855b0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 05:03:12 +0200 Subject: [PATCH 08/30] feat: Use splent API in feature install --- .env.example | 2 +- .../commands/feature/feature_install.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index a06e984..915f6fe 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,4 @@ GITHUB_USER= GITHUB_TOKEN= TWINE_USERNAME=__token__ TWINE_PASSWORD= -SPLENT_API_URL=http://127.0.0.1:5000 +SPLENT_API_URL=http://host.docker.internal:5000 diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index c7e1343..f57589f 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -5,6 +5,7 @@ import click import requests from splent_cli.services import context, compose +from splent_cli.services.api_client import SplentAPIError, get_package_by_name from splent_cli.utils.feature_utils import read_features_from_data @@ -169,6 +170,32 @@ def feature_install(feature_identifier, env_scope, mode, version): namespace, namespace_github, namespace_fs, feature_name = ( compose.parse_feature_identifier(feature_identifier) ) + + api_feature_name = ( + feature_name + if feature_name.startswith("splent_feature_") + else f"splent_feature_{feature_name}" + ) + try: + package = get_package_by_name(api_feature_name) + 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 api_feature_name + full_name = package.get("full_name") + if isinstance(full_name, str) and "/" in full_name: + feature_identifier = full_name + else: + feature_identifier = f"{namespace_github}/{package_name}" + namespace, namespace_github, namespace_fs, feature_name = ( + compose.parse_feature_identifier(feature_identifier) + ) short = feature_name.replace("splent_feature_", "") # ── Ask mode if not specified ───────────────────────────────────── From 1c81b93d20e0f87bfcdea1206681de83c8d202b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 18:32:56 +0200 Subject: [PATCH 09/30] feat: Resolve feature versions through package API --- .../commands/feature/feature_versions.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/splent_cli/commands/feature/feature_versions.py b/src/splent_cli/commands/feature/feature_versions.py index 3d936b5..abd62af 100644 --- a/src/splent_cli/commands/feature/feature_versions.py +++ b/src/splent_cli/commands/feature/feature_versions.py @@ -6,6 +6,7 @@ import click from splent_cli.services import compose +from splent_cli.services.api_client import SplentAPIError, get_package_by_name from splent_cli.utils.feature_utils import load_product_features @@ -81,6 +82,36 @@ 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: + package = get_package_by_name(api_name) + 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 +361,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 From d0237cbac7d4d8983e7ae362d2f122991a8e5a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 23:02:31 +0200 Subject: [PATCH 10/30] feat: Use marketplace API for feature clone --- .../commands/feature/feature_clone.py | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_clone.py b/src/splent_cli/commands/feature/feature_clone.py index 7e0ee60..b08fa5a 100644 --- a/src/splent_cli/commands/feature/feature_clone.py +++ b/src/splent_cli/commands/feature/feature_clone.py @@ -4,6 +4,7 @@ import requests import click from splent_cli.services import context +from splent_cli.services.api_client import 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 +70,41 @@ 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: + package = get_package_by_name(api_name) + 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 +120,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") From 6f1bfbf68bfa1ac86ea11ac194a21cc8993bf6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 23:38:50 +0200 Subject: [PATCH 11/30] feat: Add feature:info --- .../commands/feature/feature_clone.py | 1 + .../commands/feature/feature_info.py | 134 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/splent_cli/commands/feature/feature_info.py diff --git a/src/splent_cli/commands/feature/feature_clone.py b/src/splent_cli/commands/feature/feature_clone.py index b08fa5a..2c84261 100644 --- a/src/splent_cli/commands/feature/feature_clone.py +++ b/src/splent_cli/commands/feature/feature_clone.py @@ -81,6 +81,7 @@ def _resolve_full_name_from_api(full_name: str) -> str: version = None if "@" in raw: raw, version = raw.split("@", 1) + repo = raw.split("/")[-1] api_name = _feature_api_name(repo) 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..7a2bdfd --- /dev/null +++ b/src/splent_cli/commands/feature/feature_info.py @@ -0,0 +1,134 @@ +import shutil +import textwrap + +import click + +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("html_url") or None + + +def _updated_at(package: dict) -> str: + value = 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 feature_name.startswith("splent_feature_"): + return feature_name + return f"splent_feature_{feature_name}" + + +@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 + """ + api_name = _feature_api_name(feature_name) + click.echo(click.style(f"\n Loading feature {api_name}...\n", fg="cyan")) + + try: + package = get_package_by_name(api_name) + 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 From 17c7580ae89ef7176ead3577e15b2d8d301cb06e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 23:55:09 +0200 Subject: [PATCH 12/30] refactor: refactor feature search --- .../commands/feature/feature_search.py | 82 +------------------ 1 file changed, 4 insertions(+), 78 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index c362d3f..9710878 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,6 +1,3 @@ -import shutil -import textwrap - import click from splent_cli.services.api_client import SplentAPIError, get_packages @@ -11,67 +8,6 @@ def _contract_description(package: dict) -> str: 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("html_url") or None - - def _updated_at(package: dict) -> str: value = package.get("updated_at") or "" if "T" in value: @@ -136,26 +72,16 @@ def feature_search(query, show_all): click.secho(f" Found {len(packages)} package(s):\n", fg="cyan") - width = _terminal_width() + 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) - provides = _contract_items(package, "provides") - requires = _contract_items(package, "requires") - - click.secho(f" {name}", bold=True) - _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(f" {name:<{name_col}} {updated:<{date_col}} {desc}") - click.echo() + click.echo() cli_command = feature_search From 2c874c0bdc765725ccb079ab5dea1a474a813a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 28 Apr 2026 20:50:44 +0200 Subject: [PATCH 13/30] feat: Implement `feature:publish` command to register features in the marketplace Co-authored-by: Copilot --- .../commands/feature/feature_publish.py | 254 ++++++++++++++++++ src/splent_cli/services/api_client.py | 29 +- 2 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/splent_cli/commands/feature/feature_publish.py 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..92bb9c2 --- /dev/null +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -0,0 +1,254 @@ +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.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_feature_ref(feature_ref: str) -> tuple[str, str, str | None]: + # Separa owner, nombre y versión; sin owner usa splent-io. + if "/" in feature_ref: + owner, rest = feature_ref.split("/", 1) + else: + owner = DEFAULT_OWNER + rest = feature_ref + + name, _, version = rest.partition("@") + return owner.replace("_", "-"), name, version or None + + +def _safe_remote_url(remote_url: str | None) -> str | None: + if not remote_url: + return None + + # Evita publicar tokens si el remote HTTPS los incluye. + return re.sub(r"https://[^/@]+@", "https://", remote_url) + + +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_feature_ref(owner: str, name: str, version: str | None) -> str: + ref = f"{owner}/{name}" + if version: + return f"{ref}@{version}" + return ref + + +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 {} + ) + 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": contract.get("requires_features", []), + "env_vars": contract.get("env_vars", []), + "signals": contract.get("requires_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, + feature_ref: str, + owner: str | None = None, +) -> dict: + ref_owner, _, ref_version = _parse_feature_ref(feature_ref) + 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 = _safe_remote_url(remote_url) or f"https://github.com/{repo_owner}/{repo_name}.git" + canonical_ref = _canonical_feature_ref(github_owner, name, ref_version) + + return { + "feature_ref": canonical_ref, + "full_name": canonical_ref, + "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: + # Login simple por consola: guarda el token solo para esta ejecución. + if token: + os.environ["SPLENT_API_TOKEN"] = token + return + + if os.getenv("SPLENT_API_TOKEN"): + return + + click.echo("Marketplace login") + token = click.prompt( + " API token (leave empty for local/dev API)", + default="", + hide_input=True, + show_default=False, + ).strip() + if token: + os.environ["SPLENT_API_TOKEN"] = token + + +@click.command("feature:publish", short_help="Publish feature metadata to the marketplace.") +@click.argument("feature_ref", 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.", +) +@context.requires_product +def feature_publish(feature_ref, token, owner): + workspace = str(context.workspace()) + + try: + _login_to_marketplace(token) + feature_path, namespace, name = _resolve_feature(feature_ref, workspace) + namespace = normalize_namespace(namespace) + payload = _build_payload(feature_path, namespace, name, feature_ref, owner) + + click.echo() + click.secho(f"Publishing {payload['feature_ref']}...", 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/services/api_client.py b/src/splent_cli/services/api_client.py index 030c384..50e36b8 100644 --- a/src/splent_cli/services/api_client.py +++ b/src/splent_cli/services/api_client.py @@ -12,13 +12,30 @@ def _base_url() -> str: return os.getenv("SPLENT_API_URL", "http://127.0.0.1:5000").rstrip("/") -def get(path: str): +def _headers() -> dict[str, str]: + token = os.getenv("SPLENT_API_TOKEN") + 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.get(f"{_base_url()}{path}", timeout=10) + 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: @@ -30,6 +47,14 @@ def get(path: str): 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") From 25adcd31044d4728fcb8dab9c469a094c85c5015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Fri, 1 May 2026 11:07:45 +0200 Subject: [PATCH 14/30] refactor: Allow publishing features without selected product --- src/splent_cli/commands/feature/feature_publish.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index 92bb9c2..c81ebc4 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -219,7 +219,6 @@ def _login_to_marketplace(token: str | None) -> None: default=None, help="GitHub user or organization that owns the feature repository.", ) -@context.requires_product def feature_publish(feature_ref, token, owner): workspace = str(context.workspace()) From a93b389d8b61f65fa147d7aebcce807a4c03bb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Fri, 1 May 2026 17:30:34 +0200 Subject: [PATCH 15/30] refactor: change feature_ref by full_name --- .../commands/feature/feature_info.py | 5 ++-- .../commands/feature/feature_install.py | 11 +++++-- .../commands/feature/feature_publish.py | 29 +++++++++---------- .../commands/feature/feature_search.py | 3 +- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py index 7a2bdfd..05b2a09 100644 --- a/src/splent_cli/commands/feature/feature_info.py +++ b/src/splent_cli/commands/feature/feature_info.py @@ -69,11 +69,12 @@ def _echo_contract_section(label: str, items: list[str], width: int) -> None: def _repo_url(package: dict) -> str | None: - return package.get("html_url") or None + return package.get("repo_url") or None def _updated_at(package: dict) -> str: - value = package.get("updated_at") or "" + 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 "-" diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index f57589f..a0d824b 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -190,9 +190,16 @@ def feature_install(feature_identifier, env_scope, mode, version): package_name = package.get("name") or api_feature_name full_name = package.get("full_name") if isinstance(full_name, str) and "/" in full_name: - feature_identifier = full_name + feature_identifier, _, package_version = full_name.partition("@") + if package_version and not version: + version = package_version else: - feature_identifier = f"{namespace_github}/{package_name}" + 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) ) diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index c81ebc4..122ec96 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -39,13 +39,13 @@ def _git_remote_url(feature_path: Path) -> str | None: return None -def _parse_feature_ref(feature_ref: str) -> tuple[str, str, str | 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 feature_ref: - owner, rest = feature_ref.split("/", 1) + if "/" in full_name: + owner, rest = full_name.split("/", 1) else: owner = DEFAULT_OWNER - rest = feature_ref + rest = full_name name, _, version = rest.partition("@") return owner.replace("_", "-"), name, version or None @@ -82,7 +82,7 @@ def _repo_from_remote(remote_url: str | None) -> tuple[str | None, str | None]: return None, None -def _canonical_feature_ref(owner: str, name: str, version: str | None) -> str: +def _canonical_full_name(owner: str, name: str, version: str | None) -> str: ref = f"{owner}/{name}" if version: return f"{ref}@{version}" @@ -138,10 +138,10 @@ def _build_payload( feature_path: Path, namespace: str, name: str, - feature_ref: str, + full_name: str, owner: str | None = None, ) -> dict: - ref_owner, _, ref_version = _parse_feature_ref(feature_ref) + 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. @@ -155,11 +155,10 @@ def _build_payload( repo_owner = repo_owner or github_owner repo_name = repo_name or name repo_url = _safe_remote_url(remote_url) or f"https://github.com/{repo_owner}/{repo_name}.git" - canonical_ref = _canonical_feature_ref(github_owner, name, ref_version) + canonical_name = _canonical_full_name(github_owner, name, ref_version) return { - "feature_ref": canonical_ref, - "full_name": canonical_ref, + "full_name": canonical_name, "name": name, "description": marketplace_contract["description"], "provides": marketplace_contract["provides"], @@ -208,7 +207,7 @@ def _login_to_marketplace(token: str | None) -> None: @click.command("feature:publish", short_help="Publish feature metadata to the marketplace.") -@click.argument("feature_ref", required=True) +@click.argument("full_name", required=True) @click.option( "--token", default=None, @@ -219,17 +218,17 @@ def _login_to_marketplace(token: str | None) -> None: default=None, help="GitHub user or organization that owns the feature repository.", ) -def feature_publish(feature_ref, token, owner): +def feature_publish(full_name, token, owner): workspace = str(context.workspace()) try: _login_to_marketplace(token) - feature_path, namespace, name = _resolve_feature(feature_ref, workspace) + feature_path, namespace, name = _resolve_feature(full_name, workspace) namespace = normalize_namespace(namespace) - payload = _build_payload(feature_path, namespace, name, feature_ref, owner) + payload = _build_payload(feature_path, namespace, name, full_name, owner) click.echo() - click.secho(f"Publishing {payload['feature_ref']}...", bold=True) + click.secho(f"Publishing {payload['full_name']}...", bold=True) click.echo(f" repository: {payload['repository']}") click.echo(f" owner: {payload['owner']}") diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 9710878..5419e2d 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -9,7 +9,8 @@ def _contract_description(package: dict) -> str: def _updated_at(package: dict) -> str: - value = package.get("updated_at") or "" + 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 "-" From d6e6bdf7c171cad9b5e4de3a956801dcbb30aca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sat, 2 May 2026 18:26:58 +0200 Subject: [PATCH 16/30] feat: gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 1f8f98c200f5741f37b3e8a1791b8c2ef6ad26ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sat, 2 May 2026 18:37:10 +0200 Subject: [PATCH 17/30] feat: added splent_api_token to env.example and docker compose --- .env.example | 1 + docker/docker-compose.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 915f6fe..8e44937 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ GITHUB_TOKEN= TWINE_USERNAME=__token__ TWINE_PASSWORD= SPLENT_API_URL=http://host.docker.internal:5000 +SPLENT_API_TOKEN= diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4fd9a34..612e173 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,8 @@ services: HOST_UID: ${HOST_UID:-1000} HOST_GID: ${HOST_GID:-1000} SPLENT_CONTAINER: cli + SPLENT_API_URL: ${SPLENT_API_URL:-http://host.docker.internal:5000} + SPLENT_API_TOKEN: ${SPLENT_API_TOKEN:-} volumes: - ../../:/workspace - /var/run/docker.sock:/var/run/docker.sock @@ -21,4 +23,4 @@ services: networks: splent_network: - name: splent_network \ No newline at end of file + name: splent_network From 45d72fe60d882db99edceb4ed7c4c605b29a05bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 4 May 2026 19:21:45 +0200 Subject: [PATCH 18/30] fix: normalize marketplace package and repo URLs --- docker/docker-compose.yml | 4 +-- .../commands/feature/feature_info.py | 6 ++++ .../commands/feature/feature_publish.py | 28 ++++++++++++++++--- src/splent_cli/services/api_client.py | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 612e173..ec78cc1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,12 +5,12 @@ services: build: context: ../../ dockerfile: splent_cli/docker/images/Dockerfile.cli + env_file: + - ../.env environment: HOST_UID: ${HOST_UID:-1000} HOST_GID: ${HOST_GID:-1000} SPLENT_CONTAINER: cli - SPLENT_API_URL: ${SPLENT_API_URL:-http://host.docker.internal:5000} - SPLENT_API_TOKEN: ${SPLENT_API_TOKEN:-} volumes: - ../../:/workspace - /var/run/docker.sock:/var/run/docker.sock diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py index 05b2a09..8d36b83 100644 --- a/src/splent_cli/commands/feature/feature_info.py +++ b/src/splent_cli/commands/feature/feature_info.py @@ -81,6 +81,12 @@ def _updated_at(package: dict) -> str: 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}" diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index 122ec96..1ebf1ae 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -51,12 +51,29 @@ def _parse_full_name(full_name: str) -> tuple[str, str, str | None]: return owner.replace("_", "-"), name, version or None -def _safe_remote_url(remote_url: str | None) -> str | None: +def _browser_repo_url(remote_url: str | None) -> str | None: if not remote_url: return None - # Evita publicar tokens si el remote HTTPS los incluye. - return re.sub(r"https://[^/@]+@", "https://", remote_url) + 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]: @@ -154,7 +171,10 @@ def _build_payload( repo_owner, repo_name = _repo_from_remote(remote_url) repo_owner = repo_owner or github_owner repo_name = repo_name or name - repo_url = _safe_remote_url(remote_url) or f"https://github.com/{repo_owner}/{repo_name}.git" + 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 { diff --git a/src/splent_cli/services/api_client.py b/src/splent_cli/services/api_client.py index 50e36b8..4871d59 100644 --- a/src/splent_cli/services/api_client.py +++ b/src/splent_cli/services/api_client.py @@ -60,4 +60,4 @@ def get_packages(): def get_package_by_name(name: str): - return get(f"/api/packages/{quote(name, safe='')}") + return get(f"/api/packages/{quote(name, safe='/')}") From e7b8a455f3f07a13adc7fa6271ca722ec897c2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 5 May 2026 20:42:34 +0200 Subject: [PATCH 19/30] test: added tests (api client + feature search) --- .../commands/feature/test_feature_search.py | 159 ++++++++++++++++++ tests/unit/services/test_api_client.py | 39 +++++ 2 files changed, 198 insertions(+) create mode 100644 tests/unit/commands/feature/test_feature_search.py create mode 100644 tests/unit/services/test_api_client.py 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..763a91a --- /dev/null +++ b/tests/unit/commands/feature/test_feature_search.py @@ -0,0 +1,159 @@ +""" +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 SplentAPIError + + +@pytest.fixture +def runner(): + return CliRunner() + + +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 diff --git a/tests/unit/services/test_api_client.py b/tests/unit/services/test_api_client.py new file mode 100644 index 0000000..0113b2d --- /dev/null +++ b/tests/unit/services/test_api_client.py @@ -0,0 +1,39 @@ +""" +Tests for the API client helpers. +""" + +from unittest.mock import patch + +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") + + assert api_client._headers() == {"Authorization": "Bearer abc123"} + + +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" From 69a28e0ea10990cd503a26c7ce4ac94b94b9a474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 5 May 2026 23:15:33 +0200 Subject: [PATCH 20/30] test: adding info, install and publish tests --- .../commands/feature/test_feature_info.py | 69 ++++++ .../commands/feature/test_feature_install.py | 136 ++++++++++++ .../commands/feature/test_feature_publish.py | 197 ++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 tests/unit/commands/feature/test_feature_info.py create mode 100644 tests/unit/commands/feature/test_feature_install.py create mode 100644 tests/unit/commands/feature/test_feature_publish.py 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..6b3a286 --- /dev/null +++ b/tests/unit/commands/feature/test_feature_info.py @@ -0,0 +1,69 @@ +""" +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_name, + feature_info, +) + + +@pytest.fixture +def runner(): + return CliRunner() + + +# --------------------------------------------------------------------------- +# _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" + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +class TestFeatureInfoCommand: + def test_uses_normalized_namespaced_api_name(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/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..03657f0 --- /dev/null +++ b/tests/unit/commands/feature/test_feature_install.py @@ -0,0 +1,136 @@ +""" +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_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_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.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..6a8f491 --- /dev/null +++ b/tests/unit/commands/feature/test_feature_publish.py @@ -0,0 +1,197 @@ +""" +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" + ) + + +# --------------------------------------------------------------------------- +# 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" + ) From beb12704f00b822a7b1b1b80d69fe22e068de8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 5 May 2026 23:26:56 +0200 Subject: [PATCH 21/30] fix: Tests feature clone and clone cleanup fixed due to API relation now --- .../commands/feature/test_feature_clone.py | 44 +++++++++++++------ .../feature/test_feature_clone_cleanup.py | 16 ++++++- 2 files changed, 44 insertions(+), 16 deletions(-) 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 From 3dd4f72716f01f01fc45403c8d224bff2320883e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Wed, 6 May 2026 18:22:11 +0200 Subject: [PATCH 22/30] feat: added marketplace login and logout --- scripts/setup_prompt.sh | 8 +++- src/splent_cli/commands/marketplace_login.py | 40 +++++++++++++++++++ src/splent_cli/commands/marketplace_logout.py | 22 ++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/splent_cli/commands/marketplace_login.py create mode 100644 src/splent_cli/commands/marketplace_logout.py 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/commands/marketplace_login.py b/src/splent_cli/commands/marketplace_login.py new file mode 100644 index 0000000..9292091 --- /dev/null +++ b/src/splent_cli/commands/marketplace_login.py @@ -0,0 +1,40 @@ +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 access token. If omitted, you will be prompted.", +) +@click.option( + "--url", + default=None, + help="Optional Marketplace API URL.", +) +@click.option("--shell", is_flag=True, help="Output shell commands for eval.") +def marketplace_login(token, url, shell): + token = token or click.prompt("Marketplace token", hide_input=True) + token = token.strip() + if not token: + raise click.ClickException("Marketplace token is required.") + + marketplace.set_env_var(marketplace.MARKETPLACE_TOKEN_VAR, token) + if url: + marketplace.set_env_var(marketplace.MARKETPLACE_API_URL_VAR, url) + + if shell: + print(f"export {marketplace.MARKETPLACE_TOKEN_VAR}={token}") + if url: + print(f"export {marketplace.MARKETPLACE_API_URL_VAR}={url}") + 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..6bef222 --- /dev/null +++ b/src/splent_cli/commands/marketplace_logout.py @@ -0,0 +1,22 @@ +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.unset_env_var(marketplace.MARKETPLACE_TOKEN_VAR) + marketplace.unset_env_var(marketplace.MARKETPLACE_API_URL_VAR) + + if shell: + print(f"unset {marketplace.MARKETPLACE_TOKEN_VAR}") + print(f"unset {marketplace.MARKETPLACE_API_URL_VAR}") + else: + click.secho(" Marketplace logout done.", fg="green") + + +cli_command = marketplace_logout From c37d355831be6369339c6be7a3e768b3a98f3884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Wed, 6 May 2026 18:47:29 +0200 Subject: [PATCH 23/30] feat: added marketplace + edited search --- src/splent_cli/cli.py | 36 +++++++++--- .../commands/feature/feature_search.py | 50 ++++++++-------- src/splent_cli/services/marketplace.py | 57 +++++++++++++++++++ 3 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 src/splent_cli/services/marketplace.py diff --git a/src/splent_cli/cli.py b/src/splent_cli/cli.py index 1c93b29..eaa52d9 100644 --- a/src/splent_cli/cli.py +++ b/src/splent_cli/cli.py @@ -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_search.py b/src/splent_cli/commands/feature/feature_search.py index 5419e2d..d3aed4a 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -23,28 +23,7 @@ def _load_packages() -> list[dict]: raise SplentAPIError("Unexpected package response.") -@click.command("feature:search", short_help="Search for available features.") -@click.argument("query", required=False) -@click.option( - "--all", - "show_all", - is_flag=True, - help="Show all packages, not just splent_feature_* ones.", -) -def feature_search(query, show_all): - """ - List available features. - - \b - By default filters packages that match the splent_feature_* naming - convention. - Optionally filter by QUERY (partial name match). - - Examples: - splent feature:search - splent feature:search auth - splent feature:search --all - """ +def _run_search(query, show_all): click.echo(click.style("\n Searching features...\n", fg="cyan")) try: @@ -56,12 +35,16 @@ def feature_search(query, show_all): if not show_all: packages = [ - p for p in packages if (p.get("name") or "").startswith("splent_feature_") + p + for p in packages + if (p.get("name") or "").startswith("splent_feature_") ] if query: packages = [ - p for p in packages if query.lower() in (p.get("name") or "").lower() + p + for p in packages + if query.lower() in (p.get("name") or "").lower() ] if not packages: @@ -85,4 +68,21 @@ def feature_search(query, show_all): click.echo() -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/services/marketplace.py b/src/splent_cli/services/marketplace.py new file mode 100644 index 0000000..4bc9fa0 --- /dev/null +++ b/src/splent_cli/services/marketplace.py @@ -0,0 +1,57 @@ + +import os +from pathlib import Path + +from splent_cli.services import context + + +MARKETPLACE_TOKEN_VAR = "SPLENT_API_TOKEN" +MARKETPLACE_API_URL_VAR = "SPLENT_API_URL" + + +def _workspace_env_path() -> 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) From 7e4c3e40008af1a53d6ddf459ae58af8680128e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Wed, 6 May 2026 20:08:30 +0200 Subject: [PATCH 24/30] feat: added validation Ahora splent marketplace:login ya no guarda el token directamente: primero valida contra la API --- src/splent_cli/commands/marketplace_login.py | 18 ++- src/splent_cli/services/marketplace.py | 33 ++++- tests/unit/commands/test_marketplace.py | 119 +++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 tests/unit/commands/test_marketplace.py diff --git a/src/splent_cli/commands/marketplace_login.py b/src/splent_cli/commands/marketplace_login.py index 9292091..44677d0 100644 --- a/src/splent_cli/commands/marketplace_login.py +++ b/src/splent_cli/commands/marketplace_login.py @@ -24,14 +24,24 @@ def marketplace_login(token, url, shell): if not token: raise click.ClickException("Marketplace token is required.") + api_url = (url or marketplace.DEFAULT_API_URL).rstrip("/") + + 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 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) - if url: - marketplace.set_env_var(marketplace.MARKETPLACE_API_URL_VAR, url) if shell: + print(f"export {marketplace.MARKETPLACE_API_URL_VAR}={api_url}") print(f"export {marketplace.MARKETPLACE_TOKEN_VAR}={token}") - if url: - print(f"export {marketplace.MARKETPLACE_API_URL_VAR}={url}") else: click.secho(" Marketplace login saved.", fg="green") click.echo(" Run: splent feature:search ") diff --git a/src/splent_cli/services/marketplace.py b/src/splent_cli/services/marketplace.py index 4bc9fa0..2c22ff4 100644 --- a/src/splent_cli/services/marketplace.py +++ b/src/splent_cli/services/marketplace.py @@ -1,12 +1,18 @@ - import os from pathlib import Path +import requests + from splent_cli.services import context MARKETPLACE_TOKEN_VAR = "SPLENT_API_TOKEN" MARKETPLACE_API_URL_VAR = "SPLENT_API_URL" +DEFAULT_API_URL = "https://api.splent.io" + + +class MarketplaceLoginError(RuntimeError): + pass def _workspace_env_path() -> Path: @@ -55,3 +61,28 @@ def unset_env_var(key: str) -> None: updated = [line for line in lines if not line.startswith(prefix)] _write_env_lines(updated) os.environ.pop(key, None) + + +def validate_api_token(api_url: str, token: str) -> bool: + url = api_url.rstrip("/") + + try: + response = requests.get( + f"{url}/api/auth/check", + headers={"Authorization": f"Bearer {token}"}, + 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/test_marketplace.py b/tests/unit/commands/test_marketplace.py new file mode 100644 index 0000000..ec6aa24 --- /dev/null +++ b/tests/unit/commands/test_marketplace.py @@ -0,0 +1,119 @@ +""" +Tests for marketplace login/logout commands. +""" + +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) + + +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 "login" in result.output.lower() + + def test_login_prompts_for_token(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ): + result = runner.invoke(marketplace_login, input="secret\n") + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_TOKEN=secret" in content + + 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 + + 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 + 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_removes_token(self, runner, workspace, monkeypatch): + (workspace / ".env").write_text( + "SPLENT_APP=test_app\nSPLENT_API_TOKEN=abc123\n" + ) + monkeypatch.setenv("SPLENT_API_TOKEN", "abc123") + + result = runner.invoke(marketplace_logout) + + content = (workspace / ".env").read_text() + assert result.exit_code == 0 + assert "SPLENT_API_TOKEN" not in content + 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"unset {marketplace.MARKETPLACE_TOKEN_VAR}" in result.output From 268160e3aed3474891d66a2699144d98f81f4d7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Thu, 7 May 2026 13:16:02 +0200 Subject: [PATCH 25/30] docs: updating readme --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index eeb8780..afb045c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,58 @@ 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, configure the marketplace server with a token such as: + +```env +MARKETPLACE_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 +``` + +This saves the marketplace configuration in the workspace `.env`: + +```env +SPLENT_API_URL=http://host.docker.internal:5000 +SPLENT_API_TOKEN=mi-token-secreto-local +``` + +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 From 5b18043767eb1677da1373ee69114c935fa8628c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 10 May 2026 20:10:46 +0200 Subject: [PATCH 26/30] feat: updating validation, login logout --- README.md | 32 ++++++- src/splent_cli/cli.py | 4 +- .../commands/feature/feature_publish.py | 19 ++-- .../commands/feature/feature_search.py | 16 +++- src/splent_cli/commands/marketplace_login.py | 37 ++++--- src/splent_cli/commands/marketplace_logout.py | 6 +- src/splent_cli/services/api_client.py | 13 ++- src/splent_cli/services/env.py | 43 +++++++++ src/splent_cli/services/marketplace.py | 26 ++++- .../commands/feature/test_feature_publish.py | 96 +++++++++++++++++++ .../commands/feature/test_feature_search.py | 45 ++++++++- tests/unit/commands/test_marketplace.py | 89 +++++++++++++++-- tests/unit/services/test_api_client.py | 30 ++++++ tests/unit/services/test_env.py | 58 +++++++++++ 14 files changed, 470 insertions(+), 44 deletions(-) create mode 100644 src/splent_cli/services/env.py create mode 100644 tests/unit/services/test_env.py diff --git a/README.md b/README.md index afb045c..0c21852 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ POST /api/packages GET /api/packages ``` -For local development, configure the marketplace server with a token such as: +For local development with token authentication, configure the API with: ```env -MARKETPLACE_API_TOKEN=mi-token-secreto-local +SPLENT_API_TOKEN=mi-token-secreto-local ``` Then, from the app workspace/container, log in: @@ -35,13 +35,41 @@ Then, from the app workspace/container, log in: splent marketplace:login --url http://host.docker.internal:5000 --token mi-token-secreto-local ``` +For a local API without token authentication, omit `--token`: + +```bash +splent marketplace:login --url http://host.docker.internal:5000 +``` + This 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`. `feature:search` and `feature:publish` require this +validated login state. + +To log out: + +```bash +splent marketplace:logout +``` + +Logout keeps `SPLENT_API_URL` and `SPLENT_API_TOKEN` in `.env`, 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 diff --git a/src/splent_cli/cli.py b/src/splent_cli/cli.py index eaa52d9..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): diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index 1ebf1ae..0a439f8 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -9,7 +9,9 @@ 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.services.env import load_cli_env from splent_cli.utils.feature_utils import normalize_namespace DEFAULT_OWNER = "splent-io" @@ -207,23 +209,18 @@ def _build_payload( def _login_to_marketplace(token: str | None) -> None: - # Login simple por consola: guarda el token solo para esta ejecución. if token: os.environ["SPLENT_API_TOKEN"] = token return - if os.getenv("SPLENT_API_TOKEN"): + load_cli_env() + if marketplace.is_logged_in(): return - click.echo("Marketplace login") - token = click.prompt( - " API token (leave empty for local/dev API)", - default="", - hide_input=True, - show_default=False, - ).strip() - if token: - os.environ["SPLENT_API_TOKEN"] = token + 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.") diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index d3aed4a..bf36e97 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,6 +1,12 @@ import click -from splent_cli.services.api_client import SplentAPIError, get_packages +from splent_cli.services import marketplace +from splent_cli.services.api_client import ( + SplentAPIAuthError, + SplentAPIError, + get_packages, +) +from splent_cli.services.env import load_cli_env def _contract_description(package: dict) -> str: @@ -27,7 +33,15 @@ def _run_search(query, show_all): click.echo(click.style("\n Searching features...\n", fg="cyan")) try: + load_cli_env() + if not marketplace.is_logged_in(): + raise SplentAPIAuthError( + "Marketplace login required. Run: splent 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.") diff --git a/src/splent_cli/commands/marketplace_login.py b/src/splent_cli/commands/marketplace_login.py index 44677d0..9ec3b3c 100644 --- a/src/splent_cli/commands/marketplace_login.py +++ b/src/splent_cli/commands/marketplace_login.py @@ -1,3 +1,5 @@ +import os + import click from splent_cli.services import marketplace @@ -10,38 +12,49 @@ @click.option( "--token", default=None, - help="Marketplace access token. If omitted, you will be prompted.", + help="Marketplace/API access token. Defaults to SPLENT_API_TOKEN.", ) @click.option( "--url", default=None, - help="Optional Marketplace API URL.", + 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 or click.prompt("Marketplace token", hide_input=True) - token = token.strip() - if not token: - raise click.ClickException("Marketplace token is required.") - - api_url = (url or marketplace.DEFAULT_API_URL).rstrip("/") + 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("/") try: - valid_token = marketplace.validate_api_token(api_url, token) + valid_token = marketplace.validate_api_token(api_url, token or None) except marketplace.MarketplaceLoginError as exc: click.secho(f"❌ {exc}", fg="red") raise SystemExit(1) if not valid_token: - click.secho("❌ Invalid Marketplace token.", fg="red") + 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) + if token: + marketplace.set_env_var(marketplace.MARKETPLACE_TOKEN_VAR, token) + else: + marketplace.unset_env_var(marketplace.MARKETPLACE_TOKEN_VAR) + marketplace.set_env_var(marketplace.MARKETPLACE_AUTH_VAR, "true") if shell: print(f"export {marketplace.MARKETPLACE_API_URL_VAR}={api_url}") - print(f"export {marketplace.MARKETPLACE_TOKEN_VAR}={token}") + if token: + print(f"export {marketplace.MARKETPLACE_TOKEN_VAR}={token}") + else: + print(f"unset {marketplace.MARKETPLACE_TOKEN_VAR}") + print(f"export {marketplace.MARKETPLACE_AUTH_VAR}=true") else: click.secho(" Marketplace login saved.", fg="green") click.echo(" Run: splent feature:search ") diff --git a/src/splent_cli/commands/marketplace_logout.py b/src/splent_cli/commands/marketplace_logout.py index 6bef222..5fe1862 100644 --- a/src/splent_cli/commands/marketplace_logout.py +++ b/src/splent_cli/commands/marketplace_logout.py @@ -9,12 +9,10 @@ ) @click.option("--shell", is_flag=True, help="Output shell commands for eval.") def marketplace_logout(shell): - marketplace.unset_env_var(marketplace.MARKETPLACE_TOKEN_VAR) - marketplace.unset_env_var(marketplace.MARKETPLACE_API_URL_VAR) + marketplace.set_env_var(marketplace.MARKETPLACE_AUTH_VAR, "false") if shell: - print(f"unset {marketplace.MARKETPLACE_TOKEN_VAR}") - print(f"unset {marketplace.MARKETPLACE_API_URL_VAR}") + print(f"export {marketplace.MARKETPLACE_AUTH_VAR}=false") else: click.secho(" Marketplace logout done.", fg="green") diff --git a/src/splent_cli/services/api_client.py b/src/splent_cli/services/api_client.py index 4871d59..21c00e2 100644 --- a/src/splent_cli/services/api_client.py +++ b/src/splent_cli/services/api_client.py @@ -8,12 +8,19 @@ 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]: - token = os.getenv("SPLENT_API_TOKEN") + 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}"} @@ -42,6 +49,10 @@ def _request(method: str, path: str, json_body: dict | None = None): 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 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 index 2c22ff4..3288272 100644 --- a/src/splent_cli/services/marketplace.py +++ b/src/splent_cli/services/marketplace.py @@ -4,10 +4,12 @@ import requests from splent_cli.services import context +from splent_cli.services.env import cli_env_path 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" @@ -16,6 +18,10 @@ class MarketplaceLoginError(RuntimeError): def _workspace_env_path() -> Path: + env_path = cli_env_path() + if env_path.exists(): + return env_path + try: return context.workspace() / ".env" except SystemExit: @@ -63,13 +69,29 @@ def unset_env_var(key: str) -> None: os.environ.pop(key, None) -def validate_api_token(api_url: str, token: str) -> bool: +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 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={"Authorization": f"Bearer {token}"}, + headers=headers, timeout=10, ) except requests.exceptions.RequestException as exc: diff --git a/tests/unit/commands/feature/test_feature_publish.py b/tests/unit/commands/feature/test_feature_publish.py index 6a8f491..eb22144 100644 --- a/tests/unit/commands/feature/test_feature_publish.py +++ b/tests/unit/commands/feature/test_feature_publish.py @@ -124,6 +124,102 @@ def test_removes_query_and_git_suffix_from_generic_https_remote( ) +# --------------------------------------------------------------------------- +# 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, + "load_cli_env", + 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, + "load_cli_env", + lambda: None, + ) + + 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, + "load_cli_env", + lambda: None, + ) + + 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, + "load_cli_env", + lambda: None, + ) + + with pytest.raises( + feature_publish_module.SplentAPIError, + match="login is required", + ): + feature_publish_module._login_to_marketplace(None) + + # --------------------------------------------------------------------------- # Payload # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/feature/test_feature_search.py b/tests/unit/commands/feature/test_feature_search.py index 763a91a..99832ee 100644 --- a/tests/unit/commands/feature/test_feature_search.py +++ b/tests/unit/commands/feature/test_feature_search.py @@ -13,7 +13,7 @@ _updated_at, feature_search, ) -from splent_cli.services.api_client import SplentAPIError +from splent_cli.services.api_client import SplentAPIAuthError, SplentAPIError @pytest.fixture @@ -21,6 +21,18 @@ def runner(): return CliRunner() +@pytest.fixture(autouse=True) +def logged_in(monkeypatch): + monkeypatch.setattr( + "splent_cli.commands.feature.feature_search.load_cli_env", + lambda: None, + ) + monkeypatch.setattr( + "splent_cli.commands.feature.feature_search.marketplace.is_logged_in", + lambda: True, + ) + + def _package(name, description="", updated_at="2026-05-04T10:00:00Z"): return { "name": name, @@ -157,3 +169,34 @@ def test_exits_cleanly_when_api_client_fails(self, runner): 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.is_logged_in", + lambda: False, + ) + + 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 index ec6aa24..6bf6f49 100644 --- a/tests/unit/commands/test_marketplace.py +++ b/tests/unit/commands/test_marketplace.py @@ -2,6 +2,7 @@ Tests for marketplace login/logout commands. """ +import os from click.testing import CliRunner import pytest from unittest.mock import patch @@ -20,6 +21,7 @@ def runner(): 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: @@ -34,18 +36,61 @@ def test_login_saves_token_in_env_file(self, runner, workspace): 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_prompts_for_token(self, runner, workspace): + def test_login_without_token_saves_only_api_url(self, runner, workspace): with patch( "splent_cli.commands.marketplace_login.marketplace.validate_api_token", return_value=True, - ): - result = runner.invoke(marketplace_login, input="secret\n") + ) as validate: + result = runner.invoke( + marketplace_login, ["--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_API_TOKEN" not in content + assert "SPLENT_MARKETPLACE_AUTH=true" in content + validate.assert_called_once_with("http://localhost:5000", None) + + 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_TOKEN=secret" in content + 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( @@ -59,6 +104,19 @@ def test_login_can_output_shell_export(self, runner, workspace): 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_can_output_shell_unset(self, runner, workspace): + with patch( + "splent_cli.commands.marketplace_login.marketplace.validate_api_token", + return_value=True, + ): + result = runner.invoke(marketplace_login, ["--shell"]) + + assert result.exit_code == 0 + assert "export SPLENT_API_URL=https://api.splent.io" in result.output + assert "unset SPLENT_API_TOKEN" in result.output + assert "export SPLENT_MARKETPLACE_AUTH=true" in result.output def test_login_with_url_saves_api_url(self, runner, workspace): with patch( @@ -73,6 +131,7 @@ def test_login_with_url_saves_api_url(self, runner, workspace): 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): @@ -99,21 +158,35 @@ def test_connection_error_shows_clear_message(self, runner, workspace): class TestMarketplaceLogout: - def test_logout_removes_token(self, runner, workspace, monkeypatch): + def test_logout_marks_marketplace_as_logged_out( + self, runner, workspace, monkeypatch + ): (workspace / ".env").write_text( - "SPLENT_APP=test_app\nSPLENT_API_TOKEN=abc123\n" + "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" not in content + 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"unset {marketplace.MARKETPLACE_TOKEN_VAR}" in result.output + 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 index 0113b2d..869ddb6 100644 --- a/tests/unit/services/test_api_client.py +++ b/tests/unit/services/test_api_client.py @@ -4,6 +4,9 @@ from unittest.mock import patch +import pytest +import requests + from splent_cli.services import api_client @@ -28,12 +31,39 @@ def test_get_package_by_name_quotes_spaces_but_not_slashes(self): 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 From 803db1556475deff7b8560737ea5d5da53c39f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 11 May 2026 17:25:43 +0200 Subject: [PATCH 27/30] feat: feature search and publish with login --- README.md | 19 +++++++++++----- src/splent_cli/commands/marketplace_login.py | 13 +++++------ tests/unit/commands/test_marketplace.py | 23 +++++++++----------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 0c21852..4ef02ee 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,20 @@ Then, from the app workspace/container, log in: splent marketplace:login --url http://host.docker.internal:5000 --token mi-token-secreto-local ``` -For a local API without token authentication, omit `--token`: +`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 --url http://host.docker.internal:5000 +splent marketplace:login ``` -This saves the marketplace configuration in the workspace `.env`: +This validates the token and saves the marketplace configuration in the +workspace `.env`: ```env SPLENT_API_URL=http://host.docker.internal:5000 @@ -51,7 +58,7 @@ SPLENT_MARKETPLACE_AUTH=true `SPLENT_MARKETPLACE_AUTH=true` means the token has been validated with `GET /api/auth/check`. `feature:search` and `feature:publish` require this -validated login state. +validated login state and a configured `SPLENT_API_TOKEN`. To log out: @@ -59,8 +66,8 @@ To log out: splent marketplace:logout ``` -Logout keeps `SPLENT_API_URL` and `SPLENT_API_TOKEN` in `.env`, but marks the -marketplace session as inactive: +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 diff --git a/src/splent_cli/commands/marketplace_login.py b/src/splent_cli/commands/marketplace_login.py index 9ec3b3c..7108de5 100644 --- a/src/splent_cli/commands/marketplace_login.py +++ b/src/splent_cli/commands/marketplace_login.py @@ -31,8 +31,12 @@ def marketplace_login(token, url, shell): 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 or None) + valid_token = marketplace.validate_api_token(api_url, token) except marketplace.MarketplaceLoginError as exc: click.secho(f"❌ {exc}", fg="red") raise SystemExit(1) @@ -42,18 +46,13 @@ def marketplace_login(token, url, shell): raise SystemExit(1) marketplace.set_env_var(marketplace.MARKETPLACE_API_URL_VAR, api_url) - if token: - marketplace.set_env_var(marketplace.MARKETPLACE_TOKEN_VAR, token) - else: - marketplace.unset_env_var(marketplace.MARKETPLACE_TOKEN_VAR) + 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}") - else: - print(f"unset {marketplace.MARKETPLACE_TOKEN_VAR}") print(f"export {marketplace.MARKETPLACE_AUTH_VAR}=true") else: click.secho(" Marketplace login saved.", fg="green") diff --git a/tests/unit/commands/test_marketplace.py b/tests/unit/commands/test_marketplace.py index 6bf6f49..5322995 100644 --- a/tests/unit/commands/test_marketplace.py +++ b/tests/unit/commands/test_marketplace.py @@ -39,7 +39,7 @@ def test_login_saves_token_in_env_file(self, runner, workspace): assert "SPLENT_MARKETPLACE_AUTH=true" in content assert "login" in result.output.lower() - def test_login_without_token_saves_only_api_url(self, runner, workspace): + def test_login_without_token_is_rejected(self, runner, workspace): with patch( "splent_cli.commands.marketplace_login.marketplace.validate_api_token", return_value=True, @@ -48,12 +48,10 @@ def test_login_without_token_saves_only_api_url(self, runner, workspace): marketplace_login, ["--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_API_TOKEN" not in content - assert "SPLENT_MARKETPLACE_AUTH=true" in content - validate.assert_called_once_with("http://localhost:5000", None) + 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/") @@ -106,17 +104,16 @@ def test_login_can_output_shell_export(self, runner, workspace): assert "export SPLENT_API_TOKEN=abc123" in result.output assert "export SPLENT_MARKETPLACE_AUTH=true" in result.output - def test_login_without_token_can_output_shell_unset(self, runner, workspace): + 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 == 0 - assert "export SPLENT_API_URL=https://api.splent.io" in result.output - assert "unset SPLENT_API_TOKEN" in result.output - assert "export SPLENT_MARKETPLACE_AUTH=true" in result.output + 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( From 1148e6e581fdfaf53453bed4604d4c2997622018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 11 May 2026 17:44:25 +0200 Subject: [PATCH 28/30] feat: finishing the login and logout validation --- README.md | 13 ++++- .../commands/feature/feature_info.py | 32 +++++++++++- .../commands/feature/test_feature_info.py | 49 ++++++++++++++++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4ef02ee..e3dc706 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,17 @@ SPLENT_MARKETPLACE_AUTH=true ``` `SPLENT_MARKETPLACE_AUTH=true` means the token has been validated with -`GET /api/auth/check`. `feature:search` and `feature:publish` require this -validated login state and a configured `SPLENT_API_TOKEN`. +`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: diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py index 8d36b83..5f4e5e2 100644 --- a/src/splent_cli/commands/feature/feature_info.py +++ b/src/splent_cli/commands/feature/feature_info.py @@ -92,6 +92,20 @@ def _feature_api_name(feature_name: str) -> str: 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): @@ -103,11 +117,25 @@ def feature_info(feature_name): splent feature:info auth splent feature:info splent_feature_auth """ - api_name = _feature_api_name(feature_name) + 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: - package = get_package_by_name(api_name) + 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 SplentAPIError as exc: click.secho(f"❌ {exc}", fg="red") click.echo(" Check SPLENT_API_URL or start the package index.") diff --git a/tests/unit/commands/feature/test_feature_info.py b/tests/unit/commands/feature/test_feature_info.py index 6b3a286..dad5d97 100644 --- a/tests/unit/commands/feature/test_feature_info.py +++ b/tests/unit/commands/feature/test_feature_info.py @@ -8,9 +8,11 @@ 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 @@ -44,6 +46,12 @@ def test_namespaced_prefixed_name_is_preserved(self): == "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 @@ -51,7 +59,7 @@ def test_namespaced_prefixed_name_is_preserved(self): class TestFeatureInfoCommand: - def test_uses_normalized_namespaced_api_name(self, runner): + def test_uses_original_namespaced_api_name_first(self, runner): package = { "name": "splent_feature_auth", "full_name": "splent-io/splent_feature_auth", @@ -65,5 +73,42 @@ def test_uses_normalized_namespaced_api_name(self, runner): result = runner.invoke(feature_info, ["splent-io/auth"]) assert result.exit_code == 0 - get_package.assert_called_once_with("splent-io/splent_feature_auth") + 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 From 22c9c0f61ca486b9cc9cd5674b927689d9ac4028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 11 May 2026 17:49:28 +0200 Subject: [PATCH 29/30] feat: Align marketplace login/logout flow with feature commands --- .../commands/feature/feature_clone.py | 12 ++++++++-- .../commands/feature/feature_info.py | 6 +++++ .../commands/feature/feature_install.py | 12 ++++++++-- .../commands/feature/feature_publish.py | 7 +++--- .../commands/feature/feature_search.py | 7 +----- .../commands/feature/feature_versions.py | 12 ++++++++-- src/splent_cli/services/marketplace.py | 10 +++++++++ .../commands/feature/test_feature_info.py | 8 +++++++ .../commands/feature/test_feature_install.py | 4 ++++ .../commands/feature/test_feature_publish.py | 22 +++++++++---------- .../commands/feature/test_feature_search.py | 14 ++++++------ 11 files changed, 81 insertions(+), 33 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_clone.py b/src/splent_cli/commands/feature/feature_clone.py index 2c84261..4ebb68d 100644 --- a/src/splent_cli/commands/feature/feature_clone.py +++ b/src/splent_cli/commands/feature/feature_clone.py @@ -3,8 +3,12 @@ import subprocess import requests import click -from splent_cli.services import context -from splent_cli.services.api_client import SplentAPIError, get_package_by_name +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 @@ -87,7 +91,11 @@ def _resolve_full_name_from_api(full_name: str) -> str: 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.") diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py index 5f4e5e2..43b764b 100644 --- a/src/splent_cli/commands/feature/feature_info.py +++ b/src/splent_cli/commands/feature/feature_info.py @@ -3,6 +3,8 @@ 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 @@ -123,6 +125,7 @@ def feature_info(feature_name): package = None try: + marketplace.require_marketplace_login() for candidate in candidates: try: package = get_package_by_name(candidate) @@ -136,6 +139,9 @@ def feature_info(feature_name): 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.") diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index a0d824b..e62ff33 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -4,8 +4,12 @@ import tomllib import click import requests -from splent_cli.services import context, compose -from splent_cli.services.api_client import SplentAPIError, get_package_by_name +from splent_cli.services import context, compose, marketplace +from splent_cli.services.api_client import ( + SplentAPIAuthError, + SplentAPIError, + get_package_by_name, +) from splent_cli.utils.feature_utils import read_features_from_data @@ -177,7 +181,11 @@ def feature_install(feature_identifier, env_scope, mode, version): else f"splent_feature_{feature_name}" ) try: + marketplace.require_marketplace_login() package = get_package_by_name(api_feature_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.") diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index 0a439f8..70b91d6 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -11,7 +11,6 @@ from splent_cli.services import context from splent_cli.services import marketplace from splent_cli.services.api_client import SplentAPIError, post -from splent_cli.services.env import load_cli_env from splent_cli.utils.feature_utils import normalize_namespace DEFAULT_OWNER = "splent-io" @@ -213,9 +212,11 @@ def _login_to_marketplace(token: str | None) -> None: os.environ["SPLENT_API_TOKEN"] = token return - load_cli_env() - if marketplace.is_logged_in(): + try: + marketplace.require_marketplace_login() return + except SplentAPIError: + pass raise SplentAPIError( "Marketplace login is required. " diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index bf36e97..ca119db 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -6,7 +6,6 @@ SplentAPIError, get_packages, ) -from splent_cli.services.env import load_cli_env def _contract_description(package: dict) -> str: @@ -33,11 +32,7 @@ def _run_search(query, show_all): click.echo(click.style("\n Searching features...\n", fg="cyan")) try: - load_cli_env() - if not marketplace.is_logged_in(): - raise SplentAPIAuthError( - "Marketplace login required. Run: splent marketplace:login" - ) + marketplace.require_marketplace_login() packages = _load_packages() except SplentAPIAuthError as exc: click.secho(f"❌ {exc}", fg="red") diff --git a/src/splent_cli/commands/feature/feature_versions.py b/src/splent_cli/commands/feature/feature_versions.py index abd62af..1467713 100644 --- a/src/splent_cli/commands/feature/feature_versions.py +++ b/src/splent_cli/commands/feature/feature_versions.py @@ -5,8 +5,12 @@ import click -from splent_cli.services import compose -from splent_cli.services.api_client import SplentAPIError, get_package_by_name +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 @@ -92,7 +96,11 @@ def _resolve_feature_from_api(namespace_github: str, feature_name: str) -> tuple 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.") diff --git a/src/splent_cli/services/marketplace.py b/src/splent_cli/services/marketplace.py index 3288272..26fc03a 100644 --- a/src/splent_cli/services/marketplace.py +++ b/src/splent_cli/services/marketplace.py @@ -4,7 +4,9 @@ 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" @@ -82,6 +84,14 @@ 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 = {} diff --git a/tests/unit/commands/feature/test_feature_info.py b/tests/unit/commands/feature/test_feature_info.py index dad5d97..72641b8 100644 --- a/tests/unit/commands/feature/test_feature_info.py +++ b/tests/unit/commands/feature/test_feature_info.py @@ -20,6 +20,14 @@ 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 # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/feature/test_feature_install.py b/tests/unit/commands/feature/test_feature_install.py index 03657f0..4f76cf8 100644 --- a/tests/unit/commands/feature/test_feature_install.py +++ b/tests/unit/commands/feature/test_feature_install.py @@ -103,6 +103,10 @@ def test_pinned_cached_feature_attaches_version( _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={ diff --git a/tests/unit/commands/feature/test_feature_publish.py b/tests/unit/commands/feature/test_feature_publish.py index eb22144..d81cb77 100644 --- a/tests/unit/commands/feature/test_feature_publish.py +++ b/tests/unit/commands/feature/test_feature_publish.py @@ -145,8 +145,8 @@ def test_logged_in_token_is_reused( monkeypatch.setenv("SPLENT_API_TOKEN", "existing") monkeypatch.setenv("SPLENT_MARKETPLACE_AUTH", "true") monkeypatch.setattr( - feature_publish_module, - "load_cli_env", + feature_publish_module.marketplace, + "require_marketplace_login", lambda: None, ) @@ -160,9 +160,9 @@ def test_existing_token_without_login_is_rejected( monkeypatch.setenv("SPLENT_API_TOKEN", "existing") monkeypatch.delenv("SPLENT_MARKETPLACE_AUTH", raising=False) monkeypatch.setattr( - feature_publish_module, - "load_cli_env", - lambda: None, + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: (_ for _ in ()).throw(feature_publish_module.SplentAPIError()), ) with pytest.raises( @@ -192,9 +192,9 @@ def test_missing_token_is_rejected( ): monkeypatch.delenv("SPLENT_API_TOKEN", raising=False) monkeypatch.setattr( - feature_publish_module, - "load_cli_env", - lambda: None, + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: (_ for _ in ()).throw(feature_publish_module.SplentAPIError()), ) with pytest.raises( @@ -208,9 +208,9 @@ def test_empty_quoted_token_is_rejected( ): monkeypatch.setenv("SPLENT_API_TOKEN", '""') monkeypatch.setattr( - feature_publish_module, - "load_cli_env", - lambda: None, + feature_publish_module.marketplace, + "require_marketplace_login", + lambda: (_ for _ in ()).throw(feature_publish_module.SplentAPIError()), ) with pytest.raises( diff --git a/tests/unit/commands/feature/test_feature_search.py b/tests/unit/commands/feature/test_feature_search.py index 99832ee..b033a7c 100644 --- a/tests/unit/commands/feature/test_feature_search.py +++ b/tests/unit/commands/feature/test_feature_search.py @@ -24,11 +24,7 @@ def runner(): @pytest.fixture(autouse=True) def logged_in(monkeypatch): monkeypatch.setattr( - "splent_cli.commands.feature.feature_search.load_cli_env", - lambda: None, - ) - monkeypatch.setattr( - "splent_cli.commands.feature.feature_search.marketplace.is_logged_in", + "splent_cli.commands.feature.feature_search.marketplace.require_marketplace_login", lambda: True, ) @@ -188,8 +184,12 @@ def test_requires_marketplace_login_before_loading_packages( self, runner, monkeypatch ): monkeypatch.setattr( - "splent_cli.commands.feature.feature_search.marketplace.is_logged_in", - lambda: False, + "splent_cli.commands.feature.feature_search.marketplace.require_marketplace_login", + lambda: (_ for _ in ()).throw( + SplentAPIAuthError( + "Marketplace login required. Run: splent marketplace:login" + ) + ), ) with patch( From 16d6d33f355599079bd7690fb1a9f2e58be926fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Mon, 11 May 2026 23:41:47 +0200 Subject: [PATCH 30/30] feat: feature:install with dependencys, updated feature:remove also --- .../commands/feature/feature_install.py | 151 ++++++++++++- .../commands/feature/feature_publish.py | 26 ++- .../commands/feature/feature_remove.py | 52 +++-- .../commands/feature/test_feature_install.py | 201 ++++++++++++++++++ .../commands/feature/test_feature_publish.py | 52 +++++ .../commands/feature/test_feature_remove.py | 36 ++++ 6 files changed, 492 insertions(+), 26 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index e62ff33..9602851 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -8,6 +8,7 @@ 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 @@ -47,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: @@ -175,14 +311,9 @@ def feature_install(feature_identifier, env_scope, mode, version): compose.parse_feature_identifier(feature_identifier) ) - api_feature_name = ( - feature_name - if feature_name.startswith("splent_feature_") - else f"splent_feature_{feature_name}" - ) try: marketplace.require_marketplace_login() - package = get_package_by_name(api_feature_name) + package = _get_marketplace_package(feature_identifier) except SplentAPIAuthError as exc: click.secho(f"❌ {exc}", fg="red") raise SystemExit(1) @@ -195,7 +326,7 @@ def feature_install(feature_identifier, env_scope, mode, version): click.secho("❌ Invalid package response from API.", fg="red") raise SystemExit(1) - package_name = package.get("name") or api_feature_name + 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("@") @@ -212,6 +343,12 @@ def feature_install(feature_identifier, env_scope, mode, version): 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 index 70b91d6..c02d70e 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -107,6 +107,14 @@ def _canonical_full_name(owner: str, name: str, version: str | None) -> str: 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 = ( @@ -114,6 +122,9 @@ def _contract_for_marketplace(contract: dict, pyproject: dict, name: str) -> dic 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 = { @@ -128,9 +139,18 @@ def _contract_for_marketplace(contract: dict, pyproject: dict, name: str) -> dic "docker": contract.get("docker", []), } requires = { - "features": contract.get("requires_features", []), - "env_vars": contract.get("env_vars", []), - "signals": contract.get("requires_signals", []), + "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 { 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/tests/unit/commands/feature/test_feature_install.py b/tests/unit/commands/feature/test_feature_install.py index 4f76cf8..5b61300 100644 --- a/tests/unit/commands/feature/test_feature_install.py +++ b/tests/unit/commands/feature/test_feature_install.py @@ -55,6 +55,40 @@ def _feature_pyproject(path, requires=None): 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"]) @@ -89,6 +123,173 @@ def test_versions_are_sorted(self, feature_install): 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 ): diff --git a/tests/unit/commands/feature/test_feature_publish.py b/tests/unit/commands/feature/test_feature_publish.py index d81cb77..ad6e8a3 100644 --- a/tests/unit/commands/feature/test_feature_publish.py +++ b/tests/unit/commands/feature/test_feature_publish.py @@ -291,3 +291,55 @@ def test_canonical_full_name_preserves_version_when_present( ) == "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