From 57d6ad26e86f79659321f23adc9e2f12061fdfa4 Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Wed, 22 Jul 2026 01:32:00 -0300 Subject: [PATCH] feat(cli): add cache --json output and clean confirmation Machine-readable --json for list/clean/verify/outdated/doctor, plus an interactive confirm before wiping the full cache (CNA parity). Closes #228 Closes #229 Co-authored-by: Cursor --- .../src/create_awesome_python_app/cli.py | 81 ++++++++++++++++++- .../tests/test_cache_cmds.py | 69 ++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index bdc5204..e0086f9 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -4,6 +4,7 @@ import asyncio import os +import sys from pathlib import Path from typing import Any @@ -444,8 +445,29 @@ def cache_dir_cmd() -> None: console.print(str(default_cache_dir())) +def _cache_json_print(payload: Any) -> None: + """Print machine-readable JSON to stdout (not the stderr console).""" + import json + from dataclasses import asdict, is_dataclass + + def _to_jsonable(value: Any) -> Any: + if isinstance(value, Path): + return str(value) + if is_dataclass(value) and not isinstance(value, type): + return {k: _to_jsonable(v) for k, v in asdict(value).items()} + if isinstance(value, list): + return [_to_jsonable(item) for item in value] + if isinstance(value, dict): + return {k: _to_jsonable(v) for k, v in value.items()} + return value + + typer.echo(json.dumps(_to_jsonable(payload), indent=2)) + + @cache_app.command("list") -def cache_list_cmd() -> None: +def cache_list_cmd( + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: from create_awesome_python_app.cache import ( format_age, format_bytes, @@ -454,6 +476,9 @@ def cache_list_cmd() -> None: ) entries = list_cache_entries() + if json_out: + _cache_json_print(entries) + return if not entries: console.print("[dim]No cached templates or extensions.[/dim]") console.print( @@ -488,10 +513,38 @@ def cache_list_cmd() -> None: def cache_clean_cmd( id: str | None = typer.Argument(None), catalog: bool = typer.Option(False, "--catalog"), + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), + force: bool = typer.Option( + False, "--force", "-f", help="Skip interactive confirmation" + ), ) -> None: from create_awesome_python_app.cache import clean_cache + # Targeted cleans (id / --catalog) never prompt. + if not catalog and id is None and not json_out and not force: + if not sys.stdin.isatty(): + console.print( + "[yellow]Non-interactive shell — use --json or --force to skip " + "the prompt, or specify an id to target a specific entry.[/yellow]" + ) + return + import questionary + + from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE + + confirmed = questionary.confirm( + "Remove ALL cached templates and extensions?", + default=False, + style=CPA_PROMPT_STYLE, + ).ask() + if not confirmed: + console.print("[dim]Clean cancelled.[/dim]") + return + result = clean_cache(id, catalog=catalog) + if json_out: + _cache_json_print(result) + return if result.not_found: console.print(f"[yellow]No cache entry found for id: {id}[/yellow]") return @@ -503,10 +556,18 @@ def cache_clean_cmd( @cache_app.command("verify") -def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None: +def cache_verify_cmd( + id: str | None = typer.Argument(None), + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: from create_awesome_python_app.cache import verify_cache results = verify_cache(id) + if json_out: + _cache_json_print(results) + if any(not bool(entry.fsck_ok) for entry in results): + raise typer.Exit(1) + raise typer.Exit(0) if not results: console.print("[dim]No cached entries.[/dim]") raise typer.Exit(0) @@ -527,10 +588,15 @@ def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None: @cache_app.command("outdated") -def cache_outdated_cmd() -> None: +def cache_outdated_cmd( + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: from create_awesome_python_app.cache import check_outdated results = check_outdated() + if json_out: + _cache_json_print(results) + return if not results: console.print("[dim]No cached entries to check.[/dim]") return @@ -590,10 +656,17 @@ def cache_update_cmd(id: str | None = typer.Argument(None)) -> None: @cache_app.command("doctor") -def cache_doctor_cmd() -> None: +def cache_doctor_cmd( + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: from create_awesome_python_app.cache import run_doctor results = run_doctor() + if json_out: + _cache_json_print(results) + if any(not row.ok for row in results): + raise typer.Exit(1) + raise typer.Exit(0) all_ok = True for row in results: mark = "[green]✓[/green]" if row.ok else "[red]✗[/red]" diff --git a/packages/create-awesome-python-app/tests/test_cache_cmds.py b/packages/create-awesome-python-app/tests/test_cache_cmds.py index 44fe091..5238179 100644 --- a/packages/create-awesome-python-app/tests/test_cache_cmds.py +++ b/packages/create-awesome-python-app/tests/test_cache_cmds.py @@ -198,3 +198,72 @@ def test_list_cli_shows_table(cache_root: Path) -> None: text = _out(result) assert "demo" in text assert "SHA" in text + + +def test_cache_list_verify_doctor_json( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + + entry = cache_root / "repos" / "demo" + sha = _init_git_repo(entry) + write_cache_meta( + entry, + CacheMeta( + url="https://example.com/repo.git", + ref="main", + fetched_at=time.time(), + commit=sha, + ), + ) + monkeypatch.setattr( + "create_awesome_python_app.cache._probe_network", + lambda: __import__( + "create_awesome_python_app.cache", fromlist=["DoctorResult"] + ).DoctorResult(check="network", ok=True, detail="mocked"), + ) + + listed = runner.invoke(cache_app, ["list", "--json"]) + assert listed.exit_code == 0, _out(listed) + payload = json.loads(listed.stdout) + assert payload[0]["id"] == "demo" + assert payload[0]["commit"] == sha + + verified = runner.invoke(cache_app, ["verify", "--json"]) + assert verified.exit_code == 0, _out(verified) + assert json.loads(verified.stdout)[0]["fsck_ok"] is True + + doctor = runner.invoke(cache_app, ["doctor", "--json"]) + assert doctor.exit_code == 0, _out(doctor) + assert any(row["check"] == "git" for row in json.loads(doctor.stdout)) + + outdated = runner.invoke(cache_app, ["outdated", "--json"]) + assert outdated.exit_code == 0, _out(outdated) + assert isinstance(json.loads(outdated.stdout), list) + + +def test_cache_clean_json_and_force(cache_root: Path) -> None: + import json + + entry = cache_root / "repos" / "demo" + _init_git_repo(entry) + + blocked = runner.invoke(cache_app, ["clean"]) + assert blocked.exit_code == 0 + assert "Non-interactive" in _out(blocked) + assert entry.exists() + + forced = runner.invoke(cache_app, ["clean", "--json"]) + assert forced.exit_code == 0, _out(forced) + payload = json.loads(forced.stdout) + assert str(entry) in payload["removed"] + assert not entry.exists() + + +def test_cache_clean_force_without_json(cache_root: Path) -> None: + entry = cache_root / "repos" / "demo" + _init_git_repo(entry) + result = runner.invoke(cache_app, ["clean", "--force"]) + assert result.exit_code == 0, _out(result) + assert "Removed" in _out(result) + assert not entry.exists()