diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a47931..71cb91f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "src/**" + - "scripts/generate.py" # Allow manual re-generation of all packs from the Actions tab workflow_dispatch: @@ -19,8 +20,12 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Regenerate packs/*.json and index.json - run: python3 scripts/generate.py + run: python scripts/generate.py - name: Commit generated files run: | diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3192829 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,27 @@ +name: Validate packs + +on: + pull_request: + paths: + - "src/**" + - "scripts/**" + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate all packs + run: python scripts/generate.py --validate-only + + - name: Install test dependencies + run: python -m pip install pytest + + - name: Run tests + run: python -m pytest scripts/test_generate.py -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e30f246 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +scripts/__pycache__/ diff --git a/scripts/generate.py b/scripts/generate.py index ab2856a..0124091 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -14,9 +14,11 @@ set to the highest semver version present in each packs/{name}.json. """ +import argparse import json import re import sys +import tomllib from pathlib import Path # Schema version for all generated registry files (index.json, packs/*.json). @@ -26,41 +28,169 @@ REGISTRY_SCHEMA_VERSION = 1 -def parse_pack_toml(text: str) -> dict: - """Extract scalar fields from the [pack] section of a pack.toml. +_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") +_VALID_TRANSPORTS = {"stdio", "http"} - Uses simple regex — a full TOML parser is not needed here because pack.toml - fields are always plain strings, booleans, or inline arrays. - """ - # Find the [pack] section (everything up to the next [] section or EOF) - pack_section_match = re.search( - r"^\[pack\](.*?)(?=^\[[^\[]|\Z)", text, re.DOTALL | re.MULTILINE - ) - if not pack_section_match: - raise ValueError("No [pack] section found in pack.toml") - section = pack_section_match.group(1) - def get(key: str): - m = re.search(rf'^{key}\s*=\s*"([^"]*)"', section, re.MULTILINE) - return m.group(1) if m else None +def parse_pack_toml_full(pack_toml_path: Path) -> dict: + """Parse a pack.toml file and return the full TOML dict.""" + with open(pack_toml_path, "rb") as f: + data = tomllib.load(f) + if "pack" not in data: + raise ValueError("No [pack] section found in pack.toml") + if not isinstance(data["pack"], dict): + raise ValueError("[pack] must be a table, not a scalar") + return data - def get_array(key: str) -> list[str]: - m = re.search(rf"^{key}\s*=\s*\[([^\]]*)\]", section, re.MULTILINE) - if not m: - return [] - return [v.strip().strip('"') for v in m.group(1).split(",") if v.strip().strip('"')] +def parse_pack_toml(pack_toml_path: Path) -> dict: + """Parse a pack.toml file and extract metadata from the [pack] section.""" + data = parse_pack_toml_full(pack_toml_path) + pack = data["pack"] return { - "name": get("name"), - "version": get("version"), - "description": get("description"), - "authors": get_array("authors"), - "license": get("license"), - "repository": get("repository"), - "keywords": get_array("keywords"), + "name": pack.get("name"), + "version": pack.get("version"), + "description": pack.get("description"), + "authors": pack.get("authors", []), + "license": pack.get("license"), + "repository": pack.get("repository"), + "keywords": pack.get("keywords", []), } +def validate_pack(pack_toml_path: Path, pack_dir_name: str) -> list[str]: + """Validate a pack.toml file. Returns a list of error messages (empty = valid).""" + errors: list[str] = [] + + try: + data = parse_pack_toml_full(pack_toml_path) + except (ValueError, tomllib.TOMLDecodeError) as e: + return [f"{pack_dir_name}: {e}"] + + pack = data["pack"] + + # Required fields — must be present and must be strings + for field in ("name", "version", "description"): + val = pack.get(field) + if not val: + errors.append(f"{pack_dir_name}: missing required field '{field}' in [pack]") + elif not isinstance(val, str): + errors.append(f"{pack_dir_name}: '{field}' must be a string") + + name = pack.get("name", "") + if isinstance(name, str) and name and not _NAME_RE.match(name): + errors.append( + f"{pack_dir_name}: invalid pack name '{name}' — " + "must be lowercase letters, digits, and hyphens only" + ) + if isinstance(name, str) and name and name != pack_dir_name: + errors.append( + f"{pack_dir_name}: pack name '{name}' does not match directory name" + ) + + version = pack.get("version", "") + if isinstance(version, str) and version and not _SEMVER_RE.match(version): + errors.append( + f"{pack_dir_name}: invalid version '{version}' — must be X.Y.Z" + ) + + # Type checks for optional list fields + for field in ("authors", "keywords"): + val = pack.get(field) + if val is not None: + if not isinstance(val, list) or not all(isinstance(v, str) for v in val): + errors.append(f"{pack_dir_name}: '{field}' must be a list of strings") + + # Server validation + servers = data.get("servers", []) + if not isinstance(servers, list): + errors.append( + f"{pack_dir_name}: 'servers' must use [[servers]] (array of tables), " + "not [servers]" + ) + return errors + + seen_server_names: set[str] = set() + for i, server in enumerate(servers): + if not isinstance(server, dict): + errors.append( + f"{pack_dir_name}: server #{i + 1} must be a table " + f"(got {type(server).__name__})" + ) + continue + srv_name = server.get("name") + if not srv_name: + errors.append(f"{pack_dir_name}: server #{i + 1} is missing 'name'") + elif not _NAME_RE.match(srv_name): + errors.append( + f"{pack_dir_name}: invalid server name '{srv_name}' — " + "must be lowercase letters, digits, and hyphens only" + ) + elif srv_name in seen_server_names: + errors.append( + f"{pack_dir_name}: duplicate server name '{srv_name}' within pack" + ) + else: + seen_server_names.add(srv_name) + + transport = server.get("transport", "stdio") + if transport not in _VALID_TRANSPORTS: + errors.append( + f"{pack_dir_name}: server '{srv_name or f'#{i + 1}'}' has invalid " + f"transport '{transport}' — must be 'stdio' or 'http'" + ) + + # Validate command/url based on transport + if transport == "stdio" and not server.get("command"): + errors.append( + f"{pack_dir_name}: server '{srv_name or f'#{i + 1}'}' uses stdio " + "transport but is missing 'command'" + ) + elif transport == "http" and not server.get("url"): + errors.append( + f"{pack_dir_name}: server '{srv_name or f'#{i + 1}'}' uses http " + "transport but is missing 'url'" + ) + + return errors + + +def validate_unique_server_names(src_dir: Path) -> list[str]: + """Check that server names are globally unique across all packs.""" + errors: list[str] = [] + seen: dict[str, str] = {} # server_name -> pack_name + + for pack_dir in sorted(src_dir.iterdir()): + pack_toml_path = pack_dir / "pack.toml" + if not pack_dir.is_dir() or not pack_toml_path.exists(): + continue + try: + data = parse_pack_toml_full(pack_toml_path) + except (ValueError, tomllib.TOMLDecodeError): + continue + + servers = data.get("servers") + if not isinstance(servers, list): + continue + + for server in servers: + if not isinstance(server, dict): + continue + srv_name = server.get("name") + if not srv_name: + continue + if srv_name in seen: + errors.append( + f"{pack_dir.name}: server name '{srv_name}' conflicts with " + f"pack '{seen[srv_name]}'" + ) + else: + seen[srv_name] = pack_dir.name + + return errors + + def semver_key(version_str: str) -> tuple[int, ...]: """Return a sortable tuple for semver comparison.""" try: @@ -88,7 +218,7 @@ def process_pack(src_dir: Path, packs_dir: Path, pack_name: str) -> dict: print(f" SKIP {pack_name}: no pack.toml found", file=sys.stderr) return {} - meta = parse_pack_toml(pack_toml_path.read_text(encoding="utf-8")) + meta = parse_pack_toml(pack_toml_path) # Ensure pack.toml name matches the directory name to prevent registry inconsistencies. if meta["name"] and meta["name"] != pack_name: @@ -194,17 +324,54 @@ def regenerate_index(packs_dir: Path) -> dict: def main() -> None: + parser = argparse.ArgumentParser(description="Generate or validate the pack registry.") + parser.add_argument( + "--validate-only", + action="store_true", + help="Validate all packs without writing any files.", + ) + args = parser.parse_args() + repo_root = Path(__file__).parent.parent src_dir = repo_root / "src" packs_dir = repo_root / "packs" index_path = repo_root / "index.json" - packs_dir.mkdir(exist_ok=True) - pack_names = sorted( p.name for p in src_dir.iterdir() if p.is_dir() and (p / "pack.toml").exists() ) + # --- Validation pass --- + print(f"Validating {len(pack_names)} pack(s):") + all_errors: list[str] = [] + for name in pack_names: + pack_errors = validate_pack(src_dir / name / "pack.toml", name) + if pack_errors: + all_errors.extend(pack_errors) + for err in pack_errors: + print(f" ERROR: {err}", file=sys.stderr) + else: + print(f" {name}: OK") + + # Cross-pack checks (skip if per-pack validation already failed) + if not all_errors: + cross_errors = validate_unique_server_names(src_dir) + all_errors.extend(cross_errors) + for err in cross_errors: + print(f" ERROR: {err}", file=sys.stderr) + + if all_errors: + print(f"\nValidation failed with {len(all_errors)} error(s).", file=sys.stderr) + sys.exit(1) + + print("Validation passed.\n") + + if args.validate_only: + return + + # --- Generation pass --- + packs_dir.mkdir(exist_ok=True) + print(f"Processing {len(pack_names)} pack(s):") for name in pack_names: process_pack(src_dir, packs_dir, name) diff --git a/scripts/test_generate.py b/scripts/test_generate.py new file mode 100644 index 0000000..ff0f39c --- /dev/null +++ b/scripts/test_generate.py @@ -0,0 +1,426 @@ +"""Tests for generate.py — pack registry generation and validation.""" + +import json +import sys +from pathlib import Path + +import pytest + +# Ensure scripts/ is importable +sys.path.insert(0, str(Path(__file__).parent)) + +from generate import ( + build_files_map, + parse_pack_toml, + process_pack, + regenerate_index, + semver_key, + validate_pack, + validate_unique_server_names, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VALID_PACK_TOML = """\ +[pack] +name = "my-pack" +version = "0.1.0" +description = "A test pack" +authors = ["Test Author"] +license = "MIT" +repository = "https://github.com/test/test" +keywords = ["test", "example"] +""" + +VALID_PACK_WITH_SERVER = """\ +[pack] +name = "server-pack" +version = "1.0.0" +description = "A pack with a server" +authors = ["Test"] +license = "MIT" +keywords = ["mcp"] + +[[servers]] +name = "my-server" +command = "npx" +args = ["-y", "@org/pkg@latest"] +transport = "stdio" +""" + + +def _write_pack(tmp_path: Path, name: str, toml_content: str) -> Path: + """Create a pack directory with a pack.toml and return the pack dir.""" + pack_dir = tmp_path / name + pack_dir.mkdir(parents=True) + (pack_dir / "pack.toml").write_text(toml_content, encoding="utf-8") + return pack_dir + + +# --------------------------------------------------------------------------- +# parse_pack_toml +# --------------------------------------------------------------------------- + + +class TestParsePackToml: + def test_valid(self, tmp_path: Path): + pack_dir = _write_pack(tmp_path, "my-pack", VALID_PACK_TOML) + meta = parse_pack_toml(pack_dir / "pack.toml") + assert meta["name"] == "my-pack" + assert meta["version"] == "0.1.0" + assert meta["description"] == "A test pack" + assert meta["authors"] == ["Test Author"] + assert meta["license"] == "MIT" + assert meta["keywords"] == ["test", "example"] + + def test_missing_pack_section(self, tmp_path: Path): + pack_dir = _write_pack(tmp_path, "bad", "[other]\nfoo = 1\n") + with pytest.raises(ValueError, match="No \\[pack\\] section"): + parse_pack_toml(pack_dir / "pack.toml") + + def test_missing_optional_fields(self, tmp_path: Path): + minimal = '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n' + pack_dir = _write_pack(tmp_path, "x", minimal) + meta = parse_pack_toml(pack_dir / "pack.toml") + assert meta["name"] == "x" + assert meta["license"] is None + assert meta["repository"] is None + assert meta["authors"] == [] + assert meta["keywords"] == [] + + +# --------------------------------------------------------------------------- +# semver_key +# --------------------------------------------------------------------------- + + +class TestSemverKey: + def test_basic(self): + assert semver_key("1.2.3") == (1, 2, 3) + + def test_sorting(self): + versions = ["0.1.0", "1.0.0", "0.2.0", "0.1.1", "2.0.0"] + sorted_versions = sorted(versions, key=semver_key, reverse=True) + assert sorted_versions == ["2.0.0", "1.0.0", "0.2.0", "0.1.1", "0.1.0"] + + def test_invalid_fallback(self): + assert semver_key("not-a-version") == (0, 0, 0) + + +# --------------------------------------------------------------------------- +# build_files_map +# --------------------------------------------------------------------------- + + +class TestBuildFilesMap: + def test_basic(self, tmp_path: Path): + pack_dir = tmp_path / "my-pack" + pack_dir.mkdir() + (pack_dir / "pack.toml").write_text("content", encoding="utf-8") + prompts = pack_dir / "prompts" + prompts.mkdir() + (prompts / "claude.md").write_text("prompt", encoding="utf-8") + + files = build_files_map(pack_dir) + assert "pack.toml" in files + assert "prompts/claude.md" in files + assert files["pack.toml"] == "content" + assert files["prompts/claude.md"] == "prompt" + + def test_skips_directories(self, tmp_path: Path): + pack_dir = tmp_path / "my-pack" + pack_dir.mkdir() + (pack_dir / "subdir").mkdir() + (pack_dir / "file.txt").write_text("hi", encoding="utf-8") + + files = build_files_map(pack_dir) + assert len(files) == 1 + assert "file.txt" in files + + +# --------------------------------------------------------------------------- +# validate_pack +# --------------------------------------------------------------------------- + + +class TestValidatePack: + def test_valid_pack(self, tmp_path: Path): + _write_pack(tmp_path, "my-pack", VALID_PACK_TOML) + errors = validate_pack(tmp_path / "my-pack" / "pack.toml", "my-pack") + assert errors == [] + + def test_valid_pack_with_server(self, tmp_path: Path): + _write_pack(tmp_path, "server-pack", VALID_PACK_WITH_SERVER) + errors = validate_pack(tmp_path / "server-pack" / "pack.toml", "server-pack") + assert errors == [] + + def test_missing_required_field(self, tmp_path: Path): + toml = '[pack]\nname = "x"\nversion = "0.1.0"\n' + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("description" in e for e in errors) + + def test_invalid_name_format(self, tmp_path: Path): + toml = '[pack]\nname = "My_Pack"\nversion = "0.1.0"\ndescription = "d"\n' + _write_pack(tmp_path, "My_Pack", toml) + errors = validate_pack(tmp_path / "My_Pack" / "pack.toml", "My_Pack") + assert any("invalid pack name" in e for e in errors) + + def test_trailing_hyphen_rejected(self, tmp_path: Path): + toml = '[pack]\nname = "bad-"\nversion = "0.1.0"\ndescription = "d"\n' + _write_pack(tmp_path, "bad-", toml) + errors = validate_pack(tmp_path / "bad-" / "pack.toml", "bad-") + assert any("invalid pack name" in e for e in errors) + + def test_name_dir_mismatch(self, tmp_path: Path): + toml = '[pack]\nname = "foo"\nversion = "0.1.0"\ndescription = "d"\n' + _write_pack(tmp_path, "bar", toml) + errors = validate_pack(tmp_path / "bar" / "pack.toml", "bar") + assert any("does not match directory" in e for e in errors) + + def test_invalid_version(self, tmp_path: Path): + toml = '[pack]\nname = "x"\nversion = "abc"\ndescription = "d"\n' + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("invalid version" in e for e in errors) + + def test_keywords_not_list(self, tmp_path: Path): + toml = '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\nkeywords = "bad"\n' + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("keywords" in e and "list of strings" in e for e in errors) + + def test_server_missing_name(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\ncommand = "npx"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("missing 'name'" in e for e in errors) + + def test_server_invalid_transport(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "s"\ncommand = "npx"\ntransport = "sse"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("invalid transport" in e for e in errors) + + def test_server_stdio_missing_command(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "s"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("missing 'command'" in e for e in errors) + + def test_server_http_missing_url(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "s"\ntransport = "http"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("missing 'url'" in e for e in errors) + + def test_duplicate_server_names_within_pack(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "dup"\ncommand = "a"\ntransport = "stdio"\n\n' + '[[servers]]\nname = "dup"\ncommand = "b"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("duplicate server name" in e for e in errors) + + def test_servers_table_not_array(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[servers]\nname = "s"\ncommand = "npx"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("[[servers]]" in e for e in errors) + + def test_server_invalid_name_format(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "My_Server"\ncommand = "npx"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("invalid server name" in e for e in errors) + + def test_malformed_toml(self, tmp_path: Path): + _write_pack(tmp_path, "bad", "this is not valid TOML [[[") + errors = validate_pack(tmp_path / "bad" / "pack.toml", "bad") + assert len(errors) == 1 + assert "bad" in errors[0] + + +# --------------------------------------------------------------------------- +# validate_unique_server_names +# --------------------------------------------------------------------------- + + +class TestValidateUniqueServerNames: + def test_no_conflicts(self, tmp_path: Path): + _write_pack(tmp_path, "a", VALID_PACK_WITH_SERVER) + toml_b = VALID_PACK_WITH_SERVER.replace("server-pack", "b").replace( + "my-server", "other-server" + ) + _write_pack(tmp_path, "b", toml_b) + errors = validate_unique_server_names(tmp_path) + assert errors == [] + + def test_conflict_detected(self, tmp_path: Path): + _write_pack(tmp_path, "a", VALID_PACK_WITH_SERVER) + toml_b = VALID_PACK_WITH_SERVER.replace("server-pack", "b") + # Keep same server name "my-server" + _write_pack(tmp_path, "b", toml_b) + errors = validate_unique_server_names(tmp_path) + assert len(errors) == 1 + assert "my-server" in errors[0] + assert "conflicts" in errors[0] + + +# --------------------------------------------------------------------------- +# version immutability (process_pack) +# --------------------------------------------------------------------------- + + +class TestVersionImmutability: + def test_same_content_ok(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + _write_pack(src, "my-pack", VALID_PACK_TOML) + + # First generation + result1 = process_pack(src, packs, "my-pack") + assert result1["latest_version"] == "0.1.0" + + # Second generation with same content — should not fail + result2 = process_pack(src, packs, "my-pack") + assert result2["latest_version"] == "0.1.0" + + def test_different_content_fails(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + _write_pack(src, "my-pack", VALID_PACK_TOML) + + # First generation + process_pack(src, packs, "my-pack") + + # Modify a file without bumping version + (src / "my-pack" / "extra.txt").write_text("new file", encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + process_pack(src, packs, "my-pack") + assert exc_info.value.code == 1 + + def test_no_pack_toml_skipped(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + (src / "empty-pack").mkdir(parents=True) + + result = process_pack(src, packs, "empty-pack") + assert result == {} + assert not (packs / "empty-pack.json").exists() + + def test_name_mismatch_skipped(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + toml = VALID_PACK_TOML.replace('name = "my-pack"', 'name = "other-name"') + _write_pack(src, "my-pack", toml) + + result = process_pack(src, packs, "my-pack") + assert result == {} + + +# --------------------------------------------------------------------------- +# Full end-to-end generation +# --------------------------------------------------------------------------- + + +class TestRegenerateIndex: + def test_empty_packs_dir(self, tmp_path: Path): + packs = tmp_path / "packs" + packs.mkdir() + index = regenerate_index(packs) + assert index["schema_version"] == 1 + assert index["packs"] == {} + + def test_pack_with_no_versions_skipped(self, tmp_path: Path): + packs = tmp_path / "packs" + packs.mkdir() + # Write a pack JSON with empty versions + (packs / "empty.json").write_text( + json.dumps({"name": "empty", "versions": []}), encoding="utf-8" + ) + index = regenerate_index(packs) + assert "empty" not in index["packs"] + + +class TestFullGeneration: + def test_end_to_end(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + + _write_pack(src, "alpha", VALID_PACK_TOML.replace("my-pack", "alpha")) + toml_beta = ( + VALID_PACK_TOML.replace("my-pack", "beta") + .replace("0.1.0", "0.2.0") + ) + _write_pack(src, "beta", toml_beta) + + # Generate both packs + for name in ["alpha", "beta"]: + process_pack(src, packs, name) + + # Verify pack JSONs + alpha_json = json.loads((packs / "alpha.json").read_text(encoding="utf-8")) + assert alpha_json["name"] == "alpha" + assert alpha_json["schema_version"] == 1 + assert len(alpha_json["versions"]) == 1 + assert alpha_json["versions"][0]["version"] == "0.1.0" + assert "pack.toml" in alpha_json["versions"][0]["files"] + + beta_json = json.loads((packs / "beta.json").read_text(encoding="utf-8")) + assert beta_json["versions"][0]["version"] == "0.2.0" + + # Regenerate index + index = regenerate_index(packs) + assert index["schema_version"] == 1 + assert "alpha" in index["packs"] + assert "beta" in index["packs"] + assert index["packs"]["alpha"]["latest_version"] == "0.1.0" + assert index["packs"]["beta"]["latest_version"] == "0.2.0" + + def test_multi_version(self, tmp_path: Path): + """Verify that bumping the version adds a new entry without removing the old.""" + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + + _write_pack(src, "my-pack", VALID_PACK_TOML) + process_pack(src, packs, "my-pack") + + # Bump version + toml_v2 = VALID_PACK_TOML.replace("0.1.0", "0.2.0") + (src / "my-pack" / "pack.toml").write_text(toml_v2, encoding="utf-8") + process_pack(src, packs, "my-pack") + + pack_json = json.loads((packs / "my-pack.json").read_text(encoding="utf-8")) + versions = [v["version"] for v in pack_json["versions"]] + assert versions == ["0.2.0", "0.1.0"] # sorted descending