diff --git a/agent/skills/commands/delete/delete.go b/agent/skills/commands/delete/delete.go index 5a792a07..85c0a107 100644 --- a/agent/skills/commands/delete/delete.go +++ b/agent/skills/commands/delete/delete.go @@ -67,12 +67,18 @@ func (dc *DeleteCommand) Run() error { if dc.serverDetails != nil { exists, err := common.VersionExists(dc.serverDetails, dc.repoKey, dc.slug, dc.version) if err != nil { - if agentcommon.IsHTTPNotFound(err) { - return fmt.Errorf("repository '%s' or skill '%s' not found", dc.repoKey, dc.slug) - } return fmt.Errorf("failed to verify skill existence: %w", err) } if !exists { + // VersionExists doesn't say why. Check the skill itself with one more + // targeted request instead of paginating ListVersions. + skillExists, err := common.SkillExists(dc.serverDetails, dc.repoKey, dc.slug) + if err != nil { + return fmt.Errorf("failed to verify skill existence: %w", err) + } + if !skillExists { + return fmt.Errorf("repository '%s' or skill '%s' not found", dc.repoKey, dc.slug) + } return fmt.Errorf("skill '%s' v%s not found in repository '%s'", dc.slug, dc.version, dc.repoKey) } } diff --git a/agent/skills/common/skills_api.go b/agent/skills/common/skills_api.go index 6d59ff3c..2cfcc2fc 100644 --- a/agent/skills/common/skills_api.go +++ b/agent/skills/common/skills_api.go @@ -7,9 +7,16 @@ import ( agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory" "github.com/jfrog/jfrog-client-go/artifactory/services" + "github.com/jfrog/jfrog-client-go/utils/log" ) +// skillsAPIPageSize is the limit we request per page for any paginated Skills API list +// call (ListSkills, ListVersions). Kept as our own constant (rather than relying on +// services.DefaultSkillVersionsLimit) so this repo controls its own page size independently. +const skillsAPIPageSize = 200 + func ListSkills(serverDetails *config.ServerDetails, repoKey string, limit int, sortBy string) ([]services.SkillListItem, error) { serviceManager, err := utils.CreateServiceManager(serverDetails, 3, 0, false) if err != nil { @@ -17,9 +24,8 @@ func ListSkills(serverDetails *config.ServerDetails, repoKey string, limit int, } var allItems []services.SkillListItem cursor := "" - pageSize := 100 for { - items, nextCursor, err := serviceManager.ListSkills(repoKey, pageSize, cursor, sortBy) + items, nextCursor, err := serviceManager.ListSkills(repoKey, skillsAPIPageSize, cursor, sortBy) if err != nil { return nil, err } @@ -27,7 +33,7 @@ func ListSkills(serverDetails *config.ServerDetails, repoKey string, limit int, if limit > 0 && len(allItems) >= limit { return allItems[:limit], nil } - if nextCursor == "" || len(items) < pageSize { + if nextCursor == "" || len(items) < skillsAPIPageSize { break } cursor = nextCursor @@ -35,11 +41,12 @@ func ListSkills(serverDetails *config.ServerDetails, repoKey string, limit int, return allItems, nil } -// ListVersions returns the version folders published under // using -// the generic Artifactory storage API, bypassing any Skills API filtering. -// This queries raw storage instead of the filtered Skills API endpoint. -// On 404, it disambiguates between missing repo and missing skill to provide -// users with actionable error messages. +// ListVersions returns the versions published for / via the Skills API +// (api/skills/{repoKey}/api/v1/skills/{slug}/versions). +// +// It requests skillsAPIPageSize versions per page and follows nextCursor for as many additional calls as needed +// (verified against live instance: nextCursor is omitted entirely when last page is served), ensuring a skill with +// more versions than one page is listed in full. On 404, it disambiguates between missing repo and missing skill. func ListVersions(serverDetails *config.ServerDetails, repoKey, slug string) ([]services.SkillVersion, error) { if serverDetails == nil { return nil, fmt.Errorf("server details are required to list skill versions") @@ -57,31 +64,40 @@ func ListVersions(serverDetails *config.ServerDetails, repoKey, slug string) ([] if err != nil { return nil, err } + return listVersionsFromManager(serviceManager, repoKey, slug) +} - // Query raw storage layer instead of filtered Skills API - info, err := serviceManager.FolderInfo(fmt.Sprintf("%s/%s", repoKey, slug)) - if err != nil { - if agentcommon.IsHTTPNotFound(err) { - return nil, agentcommon.DisambiguateFolderError(serviceManager, repoKey, err, fmt.Errorf("skill '%s' not found in repository '%s': %w", slug, repoKey, err)) - } - return nil, fmt.Errorf("list skill versions: %w", err) - } - - versions := make([]services.SkillVersion, 0, len(info.Children)) - for _, child := range info.Children { - if !child.Folder { - continue +// listVersionsFromManager holds the actual pagination/error-disambiguation logic, +// taking the ArtifactoryServicesManager interface directly so it can be unit tested +// with a mock instead of a live server. +func listVersionsFromManager(serviceManager artifactory.ArtifactoryServicesManager, repoKey, slug string) ([]services.SkillVersion, error) { + var allVersions []services.SkillVersion + cursor := "" + for { + log.Debug(fmt.Sprintf("list skill versions: calling ListSkillVersions for skill '%s' in repo '%s' with cursor '%s'", slug, repoKey, cursor)) + versions, nextCursor, err := serviceManager.ListSkillVersions(repoKey, slug, skillsAPIPageSize, cursor) + if err != nil { + // Only disambiguate on the first page: a 404 mid-pagination means the skill + // was deleted concurrently, not that the repo/skill never existed. + if cursor == "" && agentcommon.IsHTTPNotFound(err) { + return nil, agentcommon.DisambiguateFolderError(serviceManager, repoKey, err, fmt.Errorf("skill '%s' not found in repository '%s': %w", slug, repoKey, err)) + } + return nil, fmt.Errorf("list skill versions: %w", err) } - name := child.Uri - if len(name) > 0 && name[0] == '/' { - name = name[1:] + allVersions = append(allVersions, versions...) + log.Debug(fmt.Sprintf("list skill versions: received %d versions, next cursor: '%s'", len(versions), nextCursor)) + if nextCursor == "" { + log.Debug(fmt.Sprintf("list skill versions: no more pages for skill '%s', %d version(s) total", slug, len(allVersions))) + break } - if name == "" { - continue + if nextCursor == cursor { + // Guard against a server bug returning a non-advancing cursor: without this, + // a stuck cursor spins forever, growing allVersions unbounded. + return nil, fmt.Errorf("list skill versions: server returned a non-advancing cursor for skill '%s'", slug) } - versions = append(versions, services.SkillVersion{Version: name}) + cursor = nextCursor } - return versions, nil + return allVersions, nil } func SearchSkills(serverDetails *config.ServerDetails, repoKey, query string, limit int) ([]services.SkillSearchResult, error) { @@ -92,17 +108,68 @@ func SearchSkills(serverDetails *config.ServerDetails, repoKey, query string, li return serviceManager.SearchSkills(repoKey, query, limit) } +// VersionExists reports whether version is published for repoKey/slug, via a single +// version-detail request instead of paginating ListVersions. A "false" result doesn't +// say why (version missing vs skill/repo missing) - callers that need that, like +// `skills delete --dry-run`, should follow up with ListVersions instead. func VersionExists(serverDetails *config.ServerDetails, repoKey, slug, version string) (bool, error) { - versions, err := ListVersions(serverDetails, repoKey, slug) + if serverDetails == nil { + return false, fmt.Errorf("server details are required to check skill version existence") + } + repoKey = strings.TrimSpace(repoKey) + if repoKey == "" { + return false, fmt.Errorf("repository is required to check skill version existence") + } + slug = strings.TrimSpace(slug) + if slug == "" { + return false, fmt.Errorf("skill name is required to check skill version existence") + } + + serviceManager, err := utils.CreateServiceManager(serverDetails, 3, 0, false) if err != nil { return false, err } - for _, v := range versions { - if v.Version == version { - return true, nil - } + return versionExistsFromManager(serviceManager, repoKey, slug, version) +} + +// versionExistsFromManager takes the ArtifactoryServicesManager interface directly so it can be unit tested with a mock. +func versionExistsFromManager(serviceManager artifactory.ArtifactoryServicesManager, repoKey, slug, version string) (bool, error) { + exists, err := serviceManager.SkillVersionExists(repoKey, slug, version) + if err != nil { + return false, fmt.Errorf("check skill version existence: %w", err) + } + return exists, nil +} + +// SkillExists reports whether slug is published in repoKey, via a single skill-metadata +// request. Used to get a precise not-found reason without paginating ListVersions. +func SkillExists(serverDetails *config.ServerDetails, repoKey, slug string) (bool, error) { + if serverDetails == nil { + return false, fmt.Errorf("server details are required to check skill existence") + } + repoKey = strings.TrimSpace(repoKey) + if repoKey == "" { + return false, fmt.Errorf("repository is required to check skill existence") + } + slug = strings.TrimSpace(slug) + if slug == "" { + return false, fmt.Errorf("skill name is required to check skill existence") + } + + serviceManager, err := utils.CreateServiceManager(serverDetails, 3, 0, false) + if err != nil { + return false, err + } + return skillExistsFromManager(serviceManager, repoKey, slug) +} + +// skillExistsFromManager takes the ArtifactoryServicesManager interface directly so it can be unit tested with a mock. +func skillExistsFromManager(serviceManager artifactory.ArtifactoryServicesManager, repoKey, slug string) (bool, error) { + exists, err := serviceManager.SkillExists(repoKey, slug) + if err != nil { + return false, fmt.Errorf("check skill existence: %w", err) } - return false, nil + return exists, nil } func SearchSkillsByProperty(serverDetails *config.ServerDetails, query, repoKey string) ([]services.SkillPropertySearchResult, error) { diff --git a/agent/skills/common/skills_api_test.go b/agent/skills/common/skills_api_test.go new file mode 100644 index 00000000..89430b0c --- /dev/null +++ b/agent/skills/common/skills_api_test.go @@ -0,0 +1,302 @@ +package common + +import ( + "errors" + "fmt" + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory" + "github.com/jfrog/jfrog-client-go/artifactory/services" + servicesutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// statusCodeError mimics jfrog-client-go response errors with HTTP status codes. +type statusCodeError struct{ code int } + +func (e statusCodeError) Error() string { return fmt.Sprintf("http status %d", e.code) } +func (e statusCodeError) StatusCode() int { return e.code } + +var err404 = statusCodeError{code: 404} + +// mockSkillsServicesManager mocks ListSkillVersions, SkillVersionExists, SkillExists, and FolderInfo for testing. +type mockSkillsServicesManager struct { + artifactory.EmptyArtifactoryServicesManager + listSkillVersionsFunc func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) + folderInfoFunc func(path string) (*servicesutils.FolderInfo, error) + skillVersionExistsFunc func(repoKey, slug, version string) (bool, error) + skillExistsFunc func(repoKey, slug string) (bool, error) +} + +func (m *mockSkillsServicesManager) ListSkillVersions(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + return m.listSkillVersionsFunc(repoKey, slug, limit, cursor) +} + +func (m *mockSkillsServicesManager) SkillVersionExists(repoKey, slug, version string) (bool, error) { + return m.skillVersionExistsFunc(repoKey, slug, version) +} + +func (m *mockSkillsServicesManager) SkillExists(repoKey, slug string) (bool, error) { + return m.skillExistsFunc(repoKey, slug) +} + +func (m *mockSkillsServicesManager) FolderInfo(path string) (*servicesutils.FolderInfo, error) { + if m.folderInfoFunc != nil { + return m.folderInfoFunc(path) + } + return nil, nil +} + +func TestListVersionsFromManager_SinglePage_OneCall(t *testing.T) { + calls := 0 + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + calls++ + assert.Equal(t, "my-repo", repoKey) + assert.Equal(t, "my-skill", slug) + assert.Equal(t, skillsAPIPageSize, limit) + assert.Equal(t, "", cursor) + return []services.SkillVersion{{Version: "1.0.1"}}, "", nil + }, + } + + versions, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.NoError(t, err) + assert.Equal(t, []services.SkillVersion{{Version: "1.0.1"}}, versions) + assert.Equal(t, 1, calls, "a skill within the page limit must resolve in exactly one call") +} + +func TestListVersionsFromManager_PaginatesUntilCursorEmpty(t *testing.T) { + pages := [][]services.SkillVersion{ + {{Version: "1.0.3"}, {Version: "1.0.2"}}, + {{Version: "1.0.1"}}, + } + nextCursors := []string{"1.0.2", ""} + call := 0 + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + if call == 0 { + assert.Equal(t, "", cursor, "first call must start from the beginning") + } else { + assert.Equal(t, nextCursors[call-1], cursor, "must resume from the previous page's cursor") + } + page, next := pages[call], nextCursors[call] + call++ + return page, next, nil + }, + } + + versions, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.NoError(t, err) + assert.Equal(t, []services.SkillVersion{{Version: "1.0.3"}, {Version: "1.0.2"}, {Version: "1.0.1"}}, versions) + assert.Equal(t, 2, call, "must stop as soon as nextCursor is empty") +} + +func TestListVersionsFromManager_StopsOnNonAdvancingCursor(t *testing.T) { + // Guard against infinite loops on stuck cursors. + calls := 0 + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + calls++ + if calls > 3 { + t.Fatalf("listVersionsFromManager did not stop on a non-advancing cursor") + } + return []services.SkillVersion{{Version: "1.0.1"}}, "stuck-cursor", nil + }, + } + + _, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.Error(t, err) + assert.Contains(t, err.Error(), "non-advancing cursor") + assert.LessOrEqual(t, calls, 2, "must stop as soon as the cursor repeats") +} + +func TestListVersionsFromManager_NotFound_RepoMissing(t *testing.T) { + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + return nil, "", err404 + }, + folderInfoFunc: func(path string) (*servicesutils.FolderInfo, error) { + assert.Equal(t, "my-repo", path) + return nil, err404 + }, + } + + _, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.Error(t, err) + assert.Contains(t, err.Error(), "repository 'my-repo' not found") +} + +func TestListVersionsFromManager_NotFound_SkillMissing(t *testing.T) { + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + return nil, "", err404 + }, + folderInfoFunc: func(path string) (*servicesutils.FolderInfo, error) { + return &servicesutils.FolderInfo{}, nil + }, + } + + _, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.Error(t, err) + assert.Contains(t, err.Error(), "skill 'my-skill' not found in repository 'my-repo'") +} + +func TestListVersionsFromManager_NotFoundMidPagination_NotDisambiguated(t *testing.T) { + // 404 mid-pagination should not trigger disambiguation (only first-page 404s do). + call := 0 + folderInfoCalled := false + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + call++ + if call == 1 { + return []services.SkillVersion{{Version: "1.0.2"}}, "1.0.2", nil + } + return nil, "", err404 + }, + folderInfoFunc: func(path string) (*servicesutils.FolderInfo, error) { + folderInfoCalled = true + return &servicesutils.FolderInfo{}, nil + }, + } + + _, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.Error(t, err) + assert.NotContains(t, err.Error(), "not found in repository") + assert.False(t, folderInfoCalled, "disambiguation must only run on the first page's 404") +} + +func TestListVersionsFromManager_NonNotFoundError_Propagated(t *testing.T) { + boom := errors.New("network exploded") + mock := &mockSkillsServicesManager{ + listSkillVersionsFunc: func(repoKey, slug string, limit int, cursor string) ([]services.SkillVersion, string, error) { + return nil, "", boom + }, + } + + _, err := listVersionsFromManager(mock, "my-repo", "my-skill") + require.Error(t, err) + assert.ErrorIs(t, err, boom) +} + +func TestListVersions_ValidationErrors(t *testing.T) { + t.Run("nil server details", func(t *testing.T) { + _, err := ListVersions(nil, "repo", "slug") + require.Error(t, err) + assert.Contains(t, err.Error(), "server details are required") + }) + + t.Run("empty repo key", func(t *testing.T) { + _, err := ListVersions(&config.ServerDetails{Url: "https://example.com/"}, " ", "slug") + require.Error(t, err) + assert.Contains(t, err.Error(), "repository is required") + }) + + t.Run("empty slug", func(t *testing.T) { + _, err := ListVersions(&config.ServerDetails{Url: "https://example.com/"}, "repo", " ") + require.Error(t, err) + assert.Contains(t, err.Error(), "skill name is required") + }) +} + +func TestVersionExistsFromManager(t *testing.T) { + t.Run("exists", func(t *testing.T) { + mock := &mockSkillsServicesManager{ + skillVersionExistsFunc: func(repoKey, slug, version string) (bool, error) { return true, nil }, + } + exists, err := versionExistsFromManager(mock, "repo", "my-skill", "1.0.1") + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("not found", func(t *testing.T) { + mock := &mockSkillsServicesManager{ + skillVersionExistsFunc: func(repoKey, slug, version string) (bool, error) { return false, nil }, + } + exists, err := versionExistsFromManager(mock, "repo", "my-skill", "9.9.9") + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("error propagated", func(t *testing.T) { + boom := errors.New("network exploded") + mock := &mockSkillsServicesManager{ + skillVersionExistsFunc: func(repoKey, slug, version string) (bool, error) { return false, boom }, + } + _, err := versionExistsFromManager(mock, "repo", "my-skill", "1.0.1") + require.Error(t, err) + assert.ErrorIs(t, err, boom) + }) +} + +func TestVersionExists_ValidationErrors(t *testing.T) { + t.Run("nil server details", func(t *testing.T) { + _, err := VersionExists(nil, "repo", "slug", "1.0.0") + require.Error(t, err) + assert.Contains(t, err.Error(), "server details are required") + }) + + t.Run("empty repo key", func(t *testing.T) { + _, err := VersionExists(&config.ServerDetails{Url: "https://example.com/"}, " ", "slug", "1.0.0") + require.Error(t, err) + assert.Contains(t, err.Error(), "repository is required") + }) + + t.Run("empty slug", func(t *testing.T) { + _, err := VersionExists(&config.ServerDetails{Url: "https://example.com/"}, "repo", " ", "1.0.0") + require.Error(t, err) + assert.Contains(t, err.Error(), "skill name is required") + }) +} + +func TestSkillExistsFromManager(t *testing.T) { + t.Run("exists", func(t *testing.T) { + mock := &mockSkillsServicesManager{ + skillExistsFunc: func(repoKey, slug string) (bool, error) { return true, nil }, + } + exists, err := skillExistsFromManager(mock, "repo", "my-skill") + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("not found", func(t *testing.T) { + mock := &mockSkillsServicesManager{ + skillExistsFunc: func(repoKey, slug string) (bool, error) { return false, nil }, + } + exists, err := skillExistsFromManager(mock, "repo", "missing-skill") + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("error propagated", func(t *testing.T) { + boom := errors.New("network exploded") + mock := &mockSkillsServicesManager{ + skillExistsFunc: func(repoKey, slug string) (bool, error) { return false, boom }, + } + _, err := skillExistsFromManager(mock, "repo", "my-skill") + require.Error(t, err) + assert.ErrorIs(t, err, boom) + }) +} + +func TestSkillExists_ValidationErrors(t *testing.T) { + t.Run("nil server details", func(t *testing.T) { + _, err := SkillExists(nil, "repo", "slug") + require.Error(t, err) + assert.Contains(t, err.Error(), "server details are required") + }) + + t.Run("empty repo key", func(t *testing.T) { + _, err := SkillExists(&config.ServerDetails{Url: "https://example.com/"}, " ", "slug") + require.Error(t, err) + assert.Contains(t, err.Error(), "repository is required") + }) + + t.Run("empty slug", func(t *testing.T) { + _, err := SkillExists(&config.ServerDetails{Url: "https://example.com/"}, "repo", " ") + require.Error(t, err) + assert.Contains(t, err.Error(), "skill name is required") + }) +} diff --git a/go.mod b/go.mod index 846a8719..28291aa8 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260827111619-bee4d60fbdc7 github.com/jfrog/jfrog-cli-evidence v0.9.0 - github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049 + github.com/jfrog/jfrog-client-go v1.55.1-0.20260901055737-a2269c4dba7b github.com/pkg/errors v0.9.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -202,5 +202,3 @@ require ( // replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260729061834-1c527b8abaa6 // replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab - -// replace github.com/ktrysmt/go-bitbucket => github.com/ktrysmt/go-bitbucket v0.9.80 diff --git a/go.sum b/go.sum index 8e5b2fa6..8b8b5594 100644 --- a/go.sum +++ b/go.sum @@ -388,8 +388,12 @@ github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260827111619-bee4d60fbdc7 h1:4ytB github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260827111619-bee4d60fbdc7/go.mod h1:vuARjRZopsCqVcZmWzCgw5Pr9QD1FWvwFxijV4bvJJI= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= github.com/jfrog/jfrog-cli-evidence v0.9.0/go.mod h1:R9faPfyQESBmKrdZCmHvlpmYSHmffswjNnFeT3RMq8I= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049 h1:eogwWAzZFir1suYEgZ4ZrYrch8fhWs7ma2dxv06p/z8= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260831180834-6a912962f0c1 h1:J5SqAbrp8fNeNW0wNsa84OQ5MYWunWYUfj8dbct6jLc= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260831180834-6a912962f0c1/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260831185650-2a7af5f8edcf h1:uZiowhtCQPipmXMHKYp35pDbpyjMTH4Bs4LHureb21g= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260831185650-2a7af5f8edcf/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260901055737-a2269c4dba7b h1:ww6zhnS27A9/NB7kcIR+XPiIWYo+ZEMQaVYaaW5xcS0= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260901055737-a2269c4dba7b/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=