{"record":{"id":"a7f5bb3189000d67","repo":"github/spec-kit","slug":"invalid-catalog-url-url","errorCode":null,"errorMessage":"Invalid catalog url: '{url}'.","messagePattern":"Invalid catalog url: '(.+?)'\\.","errorType":"exception","errorClass":"BundlerError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/bundler/commands_impl/catalog_config.py","lineNumber":158,"sourceCode":"    priority: int,\n    source_id: str | None = None,\n) -> CatalogSource:\n    url = url.strip()\n    if not url:\n        raise BundlerError(\"A catalog url is required.\")\n    try:\n        parsed = urlparse(url)\n        # Read .hostname inside the try: a bracketed-but-invalid IPv6 authority\n        # (e.g. \"https://[not-an-ip]/c.json\") parses cleanly under urlparse() on\n        # Python < 3.14 but raises ValueError lazily on the first .hostname access\n        # (the raise moved eager into urlparse() only in 3.14). Reading it here\n        # keeps that ValueError inside the guard instead of leaking a raw\n        # traceback past the CLI's `except BundlerError`. Reuse the value below.\n        hostname = parsed.hostname\n        # Accessing ``port`` performs urllib's syntax/range validation.\n        _ = parsed.port\n    except ValueError as exc:\n        raise BundlerError(f\"Invalid catalog url: '{url}'.\") from exc\n    if not (parsed.scheme or parsed.path):\n        raise BundlerError(f\"Invalid catalog url: '{url}'.\")\n    # Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are\n    # never silently canonicalized as local filesystem paths. Local paths that\n    # merely contain a ':' but no '://' (e.g. Windows drives) are still allowed.\n    if \"://\" in url and parsed.scheme.lower() not in _REMOTE_SCHEMES:\n        raise BundlerError(\n            f\"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. \"\n            \"Use http(s)://, file://, builtin://, or a local path.\"\n        )\n    if parsed.scheme.lower() in {\"http\", \"https\"}:\n        # Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):\n        # HTTPS only (HTTP just for localhost), and check hostname, not\n        # netloc — netloc is truthy for host-less URLs like \"https://:8080\"\n        # or \"https://user@\". Validating here keeps junk out of\n        # bundle-catalogs.yml instead of failing later at fetch time.\n        is_localhost = hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n        if parsed.scheme.lower() != \"https\" and not is_localhost:","sourceCodeStart":140,"sourceCodeEnd":176,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/bundler/commands_impl/catalog_config.py#L140-L176","documentation":"Raised from the `except ValueError` arm in `add_source()` URL parsing: `urlparse(url)` (or the eager `.hostname`/`.port` access performed inside the try) raised `ValueError`, which in practice means a malformed authority such as a bracketed-but-invalid IPv6 literal (`https://[not-an-ip]/c.json`). The chained exception (`from exc`) preserves the underlying cause. Reading `.hostname` inside the guard is deliberate so this lazy ValueError on Python < 3.14 cannot leak a raw traceback past the CLI's `except BundlerError`.","triggerScenarios":"URLs with an invalid bracketed IPv6 authority, a port field that fails urllib's range/syntax validation when `.port` is accessed, or other urlparse-level ValueError conditions.","commonSituations":"Typos in IPv6 literals; unescaped brackets around a hostname; ports out of range (`https://host:99999/`).","solutions":["Fix the malformed authority — proper IPv6 form is `https://[2001:db8::1]/c.json`","Use a plain hostname unless you truly need a literal IP","Check the chained `__cause__` for the precise urllib parse error"],"exampleFix":"# before\nhttps://[not-an-ip]/catalog.json\nhttps://host:99999/catalog.json\n\n# after\nhttps://example.com/catalog.json","handlingStrategy":"try-catch","validationCode":"from urllib.parse import urlparse\n\ndef parses_cleanly(url: str) -> bool:\n    try:\n        p = urlparse(url)\n        _ = p.hostname, p.port  # force urllib's lazy validation\n        return True\n    except ValueError:\n        return False","typeGuard":null,"tryCatchPattern":"from specify_cli.bundler import BundlerError\n\ntry:\n    add_source(project_root, url, policy=policy, priority=50)\nexcept BundlerError as exc:\n    if \"Invalid catalog url\" in str(exc) and exc.__cause__ is not ValueError:\n        # inspect exc.__cause__ for the underlying urllib ValueError detail\n        raise\n    raise","preventionTips":["Url-parse and access .hostname/.port in your own code before passing URLs in","Use proper [v6-literal] bracket syntax for IPv6 hosts"],"tags":["bundler","catalog","url","validation","ipv6"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}