Skip to content

Commit f29cbc8

Browse files
committed
fix merge conflicts
2 parents 303272e + 7fc8a5f commit f29cbc8

40 files changed

Lines changed: 866 additions & 422 deletions

agent/cli/cli_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestGetCommands_HasPluginsAndSkillsNamespaces(t *testing.T) {
1919
assert.NotNil(t, sub.Action, "plugins subcommand %q must have an Action", sub.Name)
2020
pluginsNames = append(pluginsNames, sub.Name)
2121
}
22-
assert.ElementsMatch(t, []string{"publish", "install", "update", "delete", "list"}, pluginsNames)
22+
assert.ElementsMatch(t, []string{"publish", "install", "update", "delete", "list", "search"}, pluginsNames)
2323

2424
skills := commands[1]
2525
assert.Equal(t, "skills", skills.Name)
@@ -38,6 +38,6 @@ func TestGetCommands_HasPluginsAndSkillsNamespaces(t *testing.T) {
3838
func TestGetCommands_PluginsPublishDescription(t *testing.T) {
3939
commands := GetCommands()
4040
publish := commands[0].Subcommands[0]
41-
assert.Contains(t, publish.Description, "Publish an agent plugin to Artifactory")
42-
assert.Contains(t, publish.Description, "Signs and attaches evidence")
41+
assert.Equal(t, "publish", publish.Name)
42+
assert.Equal(t, "Publish an agent plugin to Artifactory.", publish.Description)
4343
}

agent/common/file_operations_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func TestEnsureDestinationDir_CreatesUnderExistingParent(t *testing.T) {
8383

8484
func TestEnsureDestinationDir_CreatesNestedPath(t *testing.T) {
8585
root := t.TempDir()
86-
dest := filepath.Join(root, ".cursor", "plugins", "alpha")
86+
dest := filepath.Join(root, "nested", "plugins", "alpha")
8787
require.NoError(t, EnsureDestinationDir(dest))
8888
info, err := os.Stat(dest)
8989
require.NoError(t, err)

agent/common/property_search.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package common
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"strings"
9+
10+
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
11+
"github.com/jfrog/jfrog-cli-core/v2/utils/config"
12+
"github.com/jfrog/jfrog-client-go/artifactory"
13+
"github.com/jfrog/jfrog-client-go/artifactory/services"
14+
clientutils "github.com/jfrog/jfrog-client-go/utils"
15+
"github.com/jfrog/jfrog-client-go/utils/errorutils"
16+
"github.com/jfrog/jfrog-client-go/utils/log"
17+
)
18+
19+
// artifactoryPropertySearchAPI is the Artifactory REST path for GET property search.
20+
// See https://jfrog.com/help/r/jfrog-rest-apis/property-search
21+
const artifactoryPropertySearchAPI = "api/search/prop"
22+
23+
// artifactoryStorageURIInfix is the "/api/storage/" segment in item URIs returned by property search.
24+
// Built from jfrog-client-go StorageRestApi (Artifactory Storage REST API path).
25+
const artifactoryStorageURIInfix = "/" + services.StorageRestApi
26+
27+
// PropertySearchResult is one artifact hit from Artifactory GET api/search/prop.
28+
type PropertySearchResult struct {
29+
Repo string
30+
Name string
31+
Version string
32+
URI string
33+
}
34+
35+
// PropertySearchOptions configures a property search by package name key.
36+
type PropertySearchOptions struct {
37+
NamePropertyKey string
38+
Query string
39+
RepoKey string
40+
}
41+
42+
type propSearchResponse struct {
43+
Results []propSearchResultItem `json:"results"`
44+
}
45+
46+
type propSearchResultItem struct {
47+
URI string `json:"uri"`
48+
}
49+
50+
// HTTP client settings for property search: same defaults as jfrog-client-go config.NewConfigBuilder
51+
// (3 retries, 0 ms retry wait) and standard CLI lightweight API calls via utils.CreateServiceManager.
52+
const (
53+
propertySearchHTTPRetries = 3
54+
propertySearchHTTPRetryWaitMilliSecs = 0
55+
)
56+
57+
func createPropertySearchServiceManager(serverDetails *config.ServerDetails) (artifactory.ArtifactoryServicesManager, error) {
58+
return utils.CreateServiceManager(
59+
serverDetails,
60+
propertySearchHTTPRetries,
61+
propertySearchHTTPRetryWaitMilliSecs,
62+
false,
63+
)
64+
}
65+
66+
// SearchByProperty calls GET api/search/prop?{namePropertyKey}={query}[&repos={repoKey}].
67+
func SearchByProperty(serverDetails *config.ServerDetails, opts PropertySearchOptions) ([]PropertySearchResult, error) {
68+
query, err := validatePropertySearchOpts(opts)
69+
if err != nil {
70+
return nil, err
71+
}
72+
serviceManager, err := createPropertySearchServiceManager(serverDetails)
73+
if err != nil {
74+
return nil, err
75+
}
76+
artURL := clientutils.AddTrailingSlashIfNeeded(serviceManager.GetConfig().GetServiceDetails().GetUrl())
77+
searchURL := propertySearchRequestURL(artURL, opts, query)
78+
uris, err := fetchPropertySearchURIs(serviceManager, searchURL)
79+
if err != nil {
80+
return nil, err
81+
}
82+
return propertySearchResultsFromURIs(uris), nil
83+
}
84+
85+
func validatePropertySearchOpts(opts PropertySearchOptions) (string, error) {
86+
if strings.TrimSpace(opts.NamePropertyKey) == "" {
87+
return "", fmt.Errorf("name property key is required for property search")
88+
}
89+
query := strings.TrimSpace(opts.Query)
90+
if query == "" {
91+
return "", fmt.Errorf("search query is required for property search")
92+
}
93+
return query, nil
94+
}
95+
96+
func propertySearchRequestURL(artURL string, opts PropertySearchOptions, query string) string {
97+
searchURL := fmt.Sprintf("%s%s?%s=%s", artURL, artifactoryPropertySearchAPI, opts.NamePropertyKey, url.QueryEscape(query))
98+
if strings.TrimSpace(opts.RepoKey) != "" {
99+
searchURL += "&repos=" + url.QueryEscape(opts.RepoKey)
100+
}
101+
return searchURL
102+
}
103+
104+
func fetchPropertySearchURIs(serviceManager artifactory.ArtifactoryServicesManager, searchURL string) ([]string, error) {
105+
log.Debug("Property search request:", searchURL)
106+
107+
httpDetails := serviceManager.GetConfig().GetServiceDetails().CreateHttpClientDetails()
108+
resp, body, _, err := serviceManager.Client().SendGet(searchURL, true, &httpDetails)
109+
if err != nil {
110+
return nil, err
111+
}
112+
if err = errorutils.CheckResponseStatusWithBody(resp, body, http.StatusOK); err != nil {
113+
return nil, err
114+
}
115+
var wrapper propSearchResponse
116+
if err = json.Unmarshal(body, &wrapper); err != nil {
117+
return nil, errorutils.CheckErrorf("failed to parse property search response: %s", err.Error())
118+
}
119+
uris := make([]string, len(wrapper.Results))
120+
for i, item := range wrapper.Results {
121+
uris[i] = item.URI
122+
}
123+
return uris, nil
124+
}
125+
126+
func propertySearchResultsFromURIs(uris []string) []PropertySearchResult {
127+
results := make([]PropertySearchResult, 0, len(uris))
128+
for _, uri := range uris {
129+
parsed, ok := parsePropertySearchURI(uri)
130+
if !ok {
131+
log.Warn(fmt.Sprintf("Skipping property search result with unparseable URI: %s", uri))
132+
continue
133+
}
134+
results = append(results, parsed)
135+
}
136+
return results
137+
}
138+
139+
// parsePropertySearchURI extracts repo, slug, and version from a storage URI like:
140+
// https://host/artifactory/api/storage/{repo}/{slug}/{version}/{slug}-{version}.zip
141+
func parsePropertySearchURI(uri string) (PropertySearchResult, bool) {
142+
idx := strings.Index(uri, artifactoryStorageURIInfix)
143+
if idx == -1 {
144+
return PropertySearchResult{}, false
145+
}
146+
path := uri[idx+len(artifactoryStorageURIInfix):]
147+
parts := strings.SplitN(path, "/", 4)
148+
if len(parts) < 3 {
149+
return PropertySearchResult{}, false
150+
}
151+
return PropertySearchResult{
152+
Repo: parts[0],
153+
Name: parts[1],
154+
Version: parts[2],
155+
URI: uri,
156+
}, true
157+
}
158+
159+
// GetItemPropertyDescription returns the first non-empty value among descriptionPropertyKeys on repoPath.
160+
func GetItemPropertyDescription(
161+
serverDetails *config.ServerDetails,
162+
repoPath string,
163+
descriptionPropertyKeys []string,
164+
) (string, error) {
165+
serviceManager, err := createPropertySearchServiceManager(serverDetails)
166+
if err != nil {
167+
return "", err
168+
}
169+
props, err := serviceManager.GetItemProps(repoPath)
170+
if err != nil {
171+
return "", err
172+
}
173+
for _, key := range descriptionPropertyKeys {
174+
if descs, ok := props.Properties[key]; ok && len(descs) > 0 {
175+
return descs[0], nil
176+
}
177+
}
178+
return "", nil
179+
}
180+
181+
// SearchRowsByProperty runs property search and resolves optional description properties per hit.
182+
func SearchRowsByProperty(
183+
serverDetails *config.ServerDetails,
184+
opts PropertySearchOptions,
185+
descriptionPropertyKeys []string,
186+
) ([]SearchResultRow, error) {
187+
hits, err := SearchByProperty(serverDetails, opts)
188+
if err != nil {
189+
return nil, err
190+
}
191+
rows := make([]SearchResultRow, 0, len(hits))
192+
for _, hit := range hits {
193+
desc := ""
194+
repoPath := fmt.Sprintf("%s/%s/%s/%s-%s.zip", hit.Repo, hit.Name, hit.Version, hit.Name, hit.Version)
195+
d, err := GetItemPropertyDescription(serverDetails, repoPath, descriptionPropertyKeys)
196+
if err != nil {
197+
log.Debug(fmt.Sprintf("Could not fetch description for %s: %s", repoPath, err.Error()))
198+
} else {
199+
desc = d
200+
}
201+
rows = append(rows, SearchResultRow{
202+
Name: hit.Name,
203+
Version: hit.Version,
204+
Repository: hit.Repo,
205+
Description: desc,
206+
})
207+
}
208+
return rows, nil
209+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package common
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParsePropertySearchURI_Valid(t *testing.T) {
11+
uri := "https://example.com/artifactory/api/storage/plugins-local/my-plugin/1.0.0/my-plugin-1.0.0.zip"
12+
got, ok := parsePropertySearchURI(uri)
13+
require.True(t, ok)
14+
assert.Equal(t, "plugins-local", got.Repo)
15+
assert.Equal(t, "my-plugin", got.Name)
16+
assert.Equal(t, "1.0.0", got.Version)
17+
assert.Equal(t, uri, got.URI)
18+
}
19+
20+
func TestParsePropertySearchURI_Invalid(t *testing.T) {
21+
_, ok := parsePropertySearchURI("https://example.com/artifactory/plugins-local/foo")
22+
assert.False(t, ok)
23+
}
24+
25+
func TestValidatePropertySearchOpts(t *testing.T) {
26+
_, err := validatePropertySearchOpts(PropertySearchOptions{})
27+
require.Error(t, err)
28+
29+
got, err := validatePropertySearchOpts(PropertySearchOptions{
30+
NamePropertyKey: "agentplugins.name",
31+
Query: " my-plugin ",
32+
})
33+
require.NoError(t, err)
34+
assert.Equal(t, "my-plugin", got)
35+
}
36+
37+
func TestPropertySearchRequestURL(t *testing.T) {
38+
opts := PropertySearchOptions{
39+
NamePropertyKey: "agentplugins.name",
40+
Query: "demo",
41+
RepoKey: "plugins-local",
42+
}
43+
got := propertySearchRequestURL("https://example.com/artifactory/", opts, "demo")
44+
assert.Contains(t, got, "https://example.com/artifactory/"+artifactoryPropertySearchAPI+"?agentplugins.name=demo")
45+
assert.Contains(t, got, "repos=plugins-local")
46+
}

agent/common/search_output.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package common
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
9+
"github.com/jfrog/jfrog-client-go/utils/log"
10+
)
11+
12+
// SearchResultRow is one row in agent skills/plugins search output.
13+
type SearchResultRow struct {
14+
Name string `json:"name" col-name:"Name"`
15+
Version string `json:"version" col-name:"Version"`
16+
Repository string `json:"repository" col-name:"Repository"`
17+
Description string `json:"description" col-name:"Description"`
18+
}
19+
20+
// PrintSearchResultsOptions configures table/json output for search commands.
21+
type PrintSearchResultsOptions struct {
22+
Query string
23+
Format string
24+
TableTitle string
25+
EmptyTableLabel string
26+
NotFoundMessage string
27+
}
28+
29+
// PrintSearchResults prints search rows as a table or JSON.
30+
func PrintSearchResults(rows []SearchResultRow, opts PrintSearchResultsOptions) error {
31+
if len(rows) == 0 {
32+
log.Info(fmt.Sprintf(opts.NotFoundMessage, opts.Query))
33+
return nil
34+
}
35+
if strings.EqualFold(opts.Format, "json") {
36+
data, err := json.MarshalIndent(rows, "", " ")
37+
if err != nil {
38+
return fmt.Errorf("failed to marshal results: %w", err)
39+
}
40+
fmt.Println(string(data))
41+
return nil
42+
}
43+
return coreutils.PrintTable(rows, opts.TableTitle, opts.EmptyTableLabel, false)
44+
}

agent/common/search_output_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package common
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestPrintSearchResults_Table(t *testing.T) {
14+
rows := []SearchResultRow{
15+
{Name: "my-item", Version: "1.0.0", Repository: "repo-a", Description: "desc"},
16+
}
17+
old := os.Stdout
18+
r, w, _ := os.Pipe()
19+
os.Stdout = w
20+
21+
err := PrintSearchResults(rows, PrintSearchResultsOptions{
22+
Query: "q",
23+
Format: "table",
24+
TableTitle: "Items",
25+
EmptyTableLabel: "No items found",
26+
NotFoundMessage: "No items for '%s'.",
27+
})
28+
require.NoError(t, err)
29+
30+
_ = w.Close()
31+
os.Stdout = old
32+
33+
var buf bytes.Buffer
34+
_, _ = buf.ReadFrom(r)
35+
assert.Contains(t, buf.String(), "my-item")
36+
}
37+
38+
func TestPrintSearchResults_JSON(t *testing.T) {
39+
rows := []SearchResultRow{{Name: "a", Version: "1.0.0", Repository: "r"}}
40+
old := os.Stdout
41+
r, w, _ := os.Pipe()
42+
os.Stdout = w
43+
44+
err := PrintSearchResults(rows, PrintSearchResultsOptions{Query: "a", Format: "json", NotFoundMessage: "%s"})
45+
require.NoError(t, err)
46+
47+
_ = w.Close()
48+
os.Stdout = old
49+
50+
var buf bytes.Buffer
51+
_, _ = buf.ReadFrom(r)
52+
var parsed []SearchResultRow
53+
require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed))
54+
assert.Len(t, parsed, 1)
55+
}

0 commit comments

Comments
 (0)