@@ -163,18 +163,23 @@ func (c *NativeUVCommand) injectCredentials(workingDir, deployerRepo string, ser
163163
164164 injectedAny := false
165165
166- // UV_INDEX_URL and UV_DEFAULT_INDEX are UV's global default index env vars.
167- // Log them for visibility; per-named-index injection still proceeds because
168- // these vars only affect the default/fallback index, not named [[tool.uv.index]] entries.
169- if os .Getenv ("UV_INDEX_URL" ) != "" || os .Getenv ("UV_DEFAULT_INDEX" ) != "" {
170- log .Info ("UV auth: global index env var set (UV_INDEX_URL or UV_DEFAULT_INDEX); per-index credential injection still proceeds below" )
166+ // UV_INDEX_URL is the legacy single-URL form with no name= grammar, so credentials
167+ // cannot be injected via UV's per-index env vars. Warn the user explicitly.
168+ if os .Getenv ("UV_INDEX_URL" ) != "" {
169+ log .Warn ("UV auth: UV_INDEX_URL is set but has no name form; " +
170+ "credentials cannot be injected via UV per-index env vars. " +
171+ "Use UV_INDEX or UV_DEFAULT_INDEX with name=url format, " +
172+ "embed credentials in the URL, or use ~/.netrc." )
171173 }
172174
173175 // Use script inline indexes when --script is active; fall back to pyproject.toml entries.
174176 indexes := scriptIndexes
175177 if len (indexes ) == 0 {
176178 indexes = uvReadIndexesFromToml (workingDir )
177179 }
180+ // Merge UV_DEFAULT_INDEX / UV_INDEX entries — env vars override config-file entries
181+ // to match UV's own precedence rules.
182+ indexes = uvMergeIndexes (indexes , uvReadIndexesFromEnv ())
178183 for _ , idx := range indexes {
179184 envName := uvIndexEnvName (idx .Name )
180185 userKey := uvIndexUsernameKey (envName )
@@ -454,6 +459,110 @@ func uvReadIndexesFromToml(workingDir string) []uvIndexEntry {
454459 return entries
455460}
456461
462+ // uvParseIndexEnvEntry parses a single "[name=]url" entry from UV_INDEX / UV_DEFAULT_INDEX.
463+ // Splits on the first '=' only (URL query strings can contain '=', names cannot).
464+ // Returns ok=false for empty, unnamed, or malformed entries.
465+ func uvParseIndexEnvEntry (raw string ) (uvIndexEntry , bool ) {
466+ raw = strings .TrimSpace (raw )
467+ if raw == "" {
468+ return uvIndexEntry {}, false
469+ }
470+ parts := strings .SplitN (raw , "=" , 2 )
471+ if len (parts ) != 2 {
472+ return uvIndexEntry {}, false
473+ }
474+ name := strings .TrimSpace (parts [0 ])
475+ indexURL := strings .TrimSpace (parts [1 ])
476+ if name == "" || indexURL == "" {
477+ return uvIndexEntry {}, false
478+ }
479+ return uvIndexEntry {Name : name , URL : indexURL }, true
480+ }
481+
482+ // uvReadIndexesFromEnv parses UV_DEFAULT_INDEX and UV_INDEX into named index entries.
483+ // UV's per-index credential env vars (UV_INDEX_<NAME>_USERNAME/PASSWORD) only work for
484+ // named entries, so unnamed entries are skipped with a warning.
485+ func uvReadIndexesFromEnv () []uvIndexEntry {
486+ var entries []uvIndexEntry
487+ warnUnnamed := func (envVar , raw string ) {
488+ log .Warn (fmt .Sprintf (
489+ "UV auth: %s entry %q has no name; credentials cannot be injected via UV per-index env vars. " +
490+ "Use name=url format, embed credentials in the URL, or use ~/.netrc." ,
491+ envVar , raw ))
492+ }
493+ if def := strings .TrimSpace (os .Getenv ("UV_DEFAULT_INDEX" )); def != "" {
494+ if entry , ok := uvParseIndexEnvEntry (def ); ok {
495+ entry .Default = true
496+ entries = append (entries , entry )
497+ } else {
498+ warnUnnamed ("UV_DEFAULT_INDEX" , def )
499+ }
500+ }
501+ if list := os .Getenv ("UV_INDEX" ); list != "" {
502+ for _ , raw := range strings .Fields (list ) {
503+ if entry , ok := uvParseIndexEnvEntry (raw ); ok {
504+ entries = append (entries , entry )
505+ } else {
506+ warnUnnamed ("UV_INDEX" , raw )
507+ }
508+ }
509+ }
510+ return entries
511+ }
512+
513+ // uvResolveBuildInfoIndexURL returns the (envVarName, url) pair to use as a fallback
514+ // index URL for build-info dependency enrichment when no pyproject.toml index is
515+ // configured. Walks UV_DEFAULT_INDEX → first UV_INDEX entry → UV_INDEX_URL (legacy).
516+ // For UV_DEFAULT_INDEX / UV_INDEX, the optional "name=" prefix is stripped.
517+ func uvResolveBuildInfoIndexURL () (envVar , indexURL string ) {
518+ if val := strings .TrimSpace (os .Getenv ("UV_DEFAULT_INDEX" )); val != "" {
519+ if entry , ok := uvParseIndexEnvEntry (val ); ok {
520+ return "UV_DEFAULT_INDEX" , entry .URL
521+ }
522+ return "UV_DEFAULT_INDEX" , val
523+ }
524+ if list := os .Getenv ("UV_INDEX" ); list != "" {
525+ for _ , raw := range strings .Fields (list ) {
526+ if entry , ok := uvParseIndexEnvEntry (raw ); ok {
527+ return "UV_INDEX" , entry .URL
528+ }
529+ }
530+ }
531+ if val := strings .TrimSpace (os .Getenv ("UV_INDEX_URL" )); val != "" {
532+ return "UV_INDEX_URL" , val
533+ }
534+ return "" , ""
535+ }
536+
537+ // uvMergeIndexes returns the union of base and override entries, deduped by
538+ // uvIndexEnvName(name) (UV's canonical env-var suffix). Override entries appear first
539+ // in their declared order, then non-conflicting base entries — matching UV's own
540+ // precedence rule that env vars override pyproject.toml.
541+ func uvMergeIndexes (base , override []uvIndexEntry ) []uvIndexEntry {
542+ if len (override ) == 0 {
543+ return base
544+ }
545+ seen := make (map [string ]bool , len (override )+ len (base ))
546+ result := make ([]uvIndexEntry , 0 , len (override )+ len (base ))
547+ for _ , idx := range override {
548+ envName := uvIndexEnvName (idx .Name )
549+ if seen [envName ] {
550+ continue
551+ }
552+ seen [envName ] = true
553+ result = append (result , idx )
554+ }
555+ for _ , idx := range base {
556+ envName := uvIndexEnvName (idx .Name )
557+ if seen [envName ] {
558+ continue
559+ }
560+ seen [envName ] = true
561+ result = append (result , idx )
562+ }
563+ return result
564+ }
565+
457566// ── Credential helpers ────────────────────────────────────────────────────────
458567
459568// uvIndexHasNativeCredentials returns true when any native UV mechanism
@@ -676,20 +785,17 @@ func uvGetBuildInfo(workingDir string, buildConfiguration *buildUtils.BuildConfi
676785 case "sync" , "install" , "lock" , "add" , "remove" , "run" :
677786 if len (bi .Modules ) > 0 && len (bi .Modules [0 ].Dependencies ) > 0 {
678787 // Resolve enrichment repo: prefer [[tool.uv.index]] in pyproject.toml,
679- // fall back to UV_DEFAULT_INDEX / UV_INDEX_URL env vars (used when no
680- // pyproject.toml index is configured, e.g. CI workflows that inject creds
681- // via environment).
788+ // fall back to UV_DEFAULT_INDEX, then the first UV_INDEX entry, then the
789+ // legacy UV_INDEX_URL (used when no pyproject.toml index is configured,
790+ // e.g. CI workflows that inject creds via environment).
682791 repoKey := uvResolverRepoFromToml (workingDir )
683792 indexURL := uvIndexURLFromToml (workingDir )
684793 if repoKey == "" {
685- for _ , envVar := range [] string { "UV_DEFAULT_INDEX" , "UV_INDEX_URL" } {
686- if val := os . Getenv ( envVar ); val != "" {
687- repoKey = uvExtractRepoKeyFromURL ( val )
794+ if envVar , val := uvResolveBuildInfoIndexURL (); val != "" {
795+ if rk := uvExtractRepoKeyFromURL ( val ); rk != "" {
796+ repoKey = rk
688797 indexURL = val
689- if repoKey != "" {
690- log .Debug (fmt .Sprintf ("UV build-info: using %s for dependency enrichment repo: %s" , envVar , repoKey ))
691- break
692- }
798+ log .Debug (fmt .Sprintf ("UV build-info: using %s for dependency enrichment repo: %s" , envVar , repoKey ))
693799 }
694800 }
695801 }
0 commit comments