1313import sys
1414import traceback
1515from pathlib import Path
16+ from urllib .parse import urlsplit , urlunsplit
1617
1718import click
1819import yaml
@@ -524,19 +525,28 @@ def add(source, name, ref, branch, host, verbose):
524525 from ...marketplace .registry import add_marketplace
525526 from ...utils .github_host import is_valid_fqdn
526527
528+ source_arg , fragment_ref = _split_source_fragment_ref (source )
529+
527530 # --ref / --branch reconciliation. --branch stays as a hidden alias
528- # for one release so legacy invocations keep working; passing both
529- # is a hard error so we never silently pick one.
531+ # for one release so legacy invocations keep working; passing multiple
532+ # ref sources is a hard error so we never silently pick one.
533+ explicit_ref = ref is not None or branch is not None
530534 if ref is not None and branch is not None :
531535 logger .error (
532536 "--ref and --branch are mutually exclusive. Use --ref (--branch is a deprecated alias)." ,
533537 symbol = "error" ,
534538 )
535539 sys .exit (1 )
536- effective_ref = ref if ref is not None else (branch if branch is not None else "main" )
540+ if fragment_ref and explicit_ref :
541+ logger .error (
542+ "Do not combine a git URL #ref with --ref or --branch. Use one ref source." ,
543+ symbol = "error" ,
544+ )
545+ sys .exit (1 )
546+ effective_ref = fragment_ref or ref or branch or "main"
537547
538548 try :
539- url , kind , resolved_host = _parse_marketplace_source (source , host )
549+ url , kind , resolved_host = _parse_marketplace_source (source_arg , host )
540550 except PathTraversalError :
541551 logger .error (
542552 f"Invalid source '{ source } ': contains a path-traversal sequence. "
@@ -557,6 +567,8 @@ def add(source, name, ref, branch, host, verbose):
557567 # --host is meaningful only for shorthand OWNER/REPO inputs. For URL
558568 # / SSH / local-path inputs the host is already embedded; warn that
559569 # --host is being ignored rather than silently overriding.
570+ is_direct_url = _is_remote_marketplace_json_url (url )
571+
560572 if host is not None and kind == "local" :
561573 logger .warning (
562574 "--host is ignored when SOURCE is a local filesystem path." ,
@@ -566,7 +578,7 @@ def add(source, name, ref, branch, host, verbose):
566578 host is not None
567579 and host .strip ().lower () != (resolved_host or "" ).lower ()
568580 and kind in ("git" , "github" , "gitlab" )
569- and (source .startswith (("https://" , "git@" , "file://" )))
581+ and (source_arg .startswith (("https://" , "git@" , "file://" )))
570582 ):
571583 logger .warning (
572584 "--host is ignored when SOURCE is a full URL." ,
@@ -607,18 +619,32 @@ def add(source, name, ref, branch, host, verbose):
607619
608620 # Surface progress before the slow probe + fetch (5-30s for generic-git)
609621 # so the user sees activity instead of staring at a blank terminal.
610- provisional_label = name or _default_alias_from_url (url )
622+ provisional_label = name or (
623+ _default_alias_from_remote_url (url ) if is_direct_url else _default_alias_from_url (url )
624+ )
611625 logger .start (f"Registering marketplace '{ provisional_label } '..." , symbol = "gear" )
626+ if _should_warn_unpinned_git_url (
627+ source_arg , kind , is_direct_url , fragment_ref , explicit_ref
628+ ):
629+ logger .warning (
630+ "Pin this git marketplace with a #ref (for example, "
631+ f"{ source_arg } #v1.0.0) or --ref to avoid mutable branch updates." ,
632+ symbol = "warning" ,
633+ )
612634
613635 # Probe for marketplace.json location. The probe source's name is a
614636 # placeholder -- _auto_detect_path only consults url/ref/path/kind.
615637 probe_name = provisional_label
616638 probe_source = MarketplaceSource (
617639 name = probe_name ,
618640 url = url ,
619- ref = effective_ref ,
641+ ref = "" if is_direct_url else effective_ref ,
642+ path = "" if is_direct_url else "marketplace.json" ,
620643 )
621- detected_path = _auto_detect_path (probe_source )
644+ if is_direct_url or _local_source_points_to_file (probe_source ):
645+ detected_path = ""
646+ else :
647+ detected_path = _auto_detect_path (probe_source )
622648
623649 if detected_path is None :
624650 logger .error (
@@ -632,7 +658,7 @@ def add(source, name, ref, branch, host, verbose):
632658 fetch_source = MarketplaceSource (
633659 name = probe_name ,
634660 url = url ,
635- ref = effective_ref ,
661+ ref = "" if is_direct_url else effective_ref ,
636662 path = detected_path ,
637663 )
638664 manifest = fetch_marketplace (fetch_source , force_refresh = True )
@@ -663,15 +689,16 @@ def add(source, name, ref, branch, host, verbose):
663689 )
664690
665691 logger .verbose_detail (f" Source: { fetch_source .display_source } " )
666- logger .verbose_detail (f" Kind: { kind } " )
667- logger .verbose_detail (f" Ref: { effective_ref } " )
692+ logger .verbose_detail (f" Kind: { fetch_source .kind } " )
693+ if not is_direct_url :
694+ logger .verbose_detail (f" Ref: { effective_ref } " )
668695 logger .verbose_detail (f" Detected path: { detected_path } " )
669696 logger .verbose_detail (f" Alias source: { alias_source } " )
670697
671698 final_source = MarketplaceSource (
672699 name = display_name ,
673700 url = url ,
674- ref = effective_ref ,
701+ ref = "" if is_direct_url else effective_ref ,
675702 path = detected_path ,
676703 )
677704 add_marketplace (final_source )
@@ -696,6 +723,61 @@ def add(source, name, ref, branch, host, verbose):
696723 sys .exit (1 )
697724
698725
726+ def _split_source_fragment_ref (source : str ) -> tuple [str , str ]:
727+ """Split an HTTPS git URL #ref fragment from the URL stored in the registry."""
728+ raw = (source or "" ).strip ()
729+ if not raw .lower ().startswith ("https://" ):
730+ return raw , ""
731+ parsed = urlsplit (raw )
732+ if not parsed .fragment :
733+ return raw , ""
734+ clean_url = urlunsplit ((parsed .scheme , parsed .netloc , parsed .path , parsed .query , "" ))
735+ return clean_url , parsed .fragment
736+
737+
738+ def _is_remote_marketplace_json_url (url : str ) -> bool :
739+ """Return True when *url* names a hosted marketplace.json document."""
740+ try :
741+ parsed = urlsplit (url )
742+ except ValueError :
743+ return False
744+ path = (parsed .path or "" ).rstrip ("/" )
745+ return parsed .scheme .lower () == "https" and path .endswith ("/marketplace.json" )
746+
747+
748+ def _should_warn_unpinned_git_url (
749+ source : str ,
750+ kind : str ,
751+ is_direct_url : bool ,
752+ fragment_ref : str ,
753+ explicit_ref : bool ,
754+ ) -> bool :
755+ """Return True when a git URL source uses the implicit mutable default ref."""
756+ if is_direct_url or fragment_ref or explicit_ref :
757+ return False
758+ return source .lower ().startswith ("https://" ) and kind in {"github" , "gitlab" , "git" }
759+
760+
761+ def _local_source_points_to_file (source ) -> bool :
762+ """Return True when a local marketplace source points directly to a file."""
763+ if source .kind != "local" :
764+ return False
765+ try :
766+ return Path (source .local_path ).expanduser ().is_file ()
767+ except OSError :
768+ return False
769+
770+
771+ def _default_alias_from_remote_url (url : str ) -> str :
772+ """Derive a stable default alias for a direct remote marketplace.json URL."""
773+ try :
774+ parsed = urlsplit (url )
775+ except ValueError :
776+ return "marketplace"
777+ host = (parsed .hostname or "marketplace" ).lower ()
778+ return host .split (":" , 1 )[0 ]
779+
780+
699781def _default_alias_from_url (url : str ) -> str :
700782 """Derive a default marketplace alias from a parsed URL.
701783
@@ -850,7 +932,7 @@ def update(name, verbose):
850932 if name :
851933 source = get_marketplace_by_name (name )
852934 logger .start (f"Refreshing marketplace '{ name } '..." , symbol = "gear" )
853- clear_marketplace_cache (name , host = source . host )
935+ clear_marketplace_cache (source = source )
854936 manifest = fetch_marketplace (source , force_refresh = True )
855937 logger .success (
856938 f"Marketplace '{ name } ' updated ({ len (manifest .plugins )} plugins)" ,
@@ -864,7 +946,7 @@ def update(name, verbose):
864946 logger .start (f"Refreshing { len (sources )} marketplace(s)..." , symbol = "gear" )
865947 for s in sources :
866948 try :
867- clear_marketplace_cache (s . name , host = s . host )
949+ clear_marketplace_cache (source = s )
868950 manifest = fetch_marketplace (s , force_refresh = True )
869951 logger .tree_item (f" { s .name } ({ len (manifest .plugins )} plugins)" )
870952 except Exception as exc :
@@ -910,7 +992,7 @@ def remove(name, yes, verbose):
910992 return
911993
912994 remove_marketplace (name )
913- clear_marketplace_cache (name , host = source . host )
995+ clear_marketplace_cache (source = source )
914996 logger .success (f"Marketplace '{ name } ' removed" , symbol = "check" )
915997
916998 except Exception as e :
0 commit comments