Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions agent/common/select_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package common

import (
"fmt"
"strings"

prompt "github.com/c-bata/go-prompt"
"github.com/jfrog/jfrog-cli-core/v2/utils/ioutils"
"github.com/jfrog/jfrog-client-go/utils/log"
)

// SelectPackageVersion resolves "" / "latest" / exact match / interactive prompt for install and update.
func SelectPackageVersion(available []string, requested, repoKey string, quiet bool) (string, error) {
if requested == "" || requested == "latest" {
latest, err := LatestVersion(available)
if err != nil {
return "", fmt.Errorf("failed to determine latest version: %w", err)
}
log.Info(fmt.Sprintf("Using latest version: %s", latest))
Comment thread
udaykb2 marked this conversation as resolved.
Outdated
return latest, nil
}

for _, version := range available {
if version == requested {
return requested, nil
}
}

if quiet || IsNonInteractive() {
return "", fmt.Errorf(
Comment thread
udaykb2 marked this conversation as resolved.
"version '%s' not found in repository '%s'.\nAvailable versions: %s",
requested, repoKey, strings.Join(available, ", "),
)
}

log.Warn(fmt.Sprintf("Version '%s' not found. Please select from the available versions below.", requested))
Comment thread
udaykb2 marked this conversation as resolved.

options := make([]prompt.Suggest, len(available))
for idx, version := range available {
options[idx] = prompt.Suggest{Text: version}
}
selected := ioutils.AskFromListWithMismatchConfirmation(
"Select a version:",
fmt.Sprintf("'%s' is not in the list of available versions.", requested),
options,
)
return selected, nil
}
42 changes: 42 additions & 0 deletions agent/common/select_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package common

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSelectPackageVersion_ExactMatchReturnsIt(t *testing.T) {
available := []string{"1.0.0", "1.1.0", "2.0.0"}
got, err := SelectPackageVersion(available, "1.1.0", "skills-local", true)
require.NoError(t, err)
assert.Equal(t, "1.1.0", got)
}

func TestSelectPackageVersion_LatestEmpty(t *testing.T) {
available := []string{"1.0.0", "1.1.0", "2.0.0"}
got, err := SelectPackageVersion(available, "", "plugins-local", true)
require.NoError(t, err)
assert.Equal(t, "2.0.0", got)
}

func TestSelectPackageVersion_LatestKeyword(t *testing.T) {
available := []string{"1.0.0", "3.0.0", "2.0.0"}
got, err := SelectPackageVersion(available, "latest", "plugins-local", true)
require.NoError(t, err)
assert.Equal(t, "3.0.0", got)
}

func TestSelectPackageVersion_NotFoundQuiet(t *testing.T) {
available := []string{"1.0.0", "1.1.0"}
_, err := SelectPackageVersion(available, "9.9.9", "plugins-local", true)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}

func TestSelectPackageVersion_EmptyAvailableList(t *testing.T) {
_, err := SelectPackageVersion([]string{}, "", "plugins-local", true)
require.Error(t, err)
assert.Contains(t, err.Error(), "latest version")
}
15 changes: 6 additions & 9 deletions agent/plugins/commands/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
"github.com/jfrog/jfrog-client-go/utils/log"
)

// resolveLatestPluginVersion is swappable in tests.
var resolveLatestPluginVersion = plugincommon.ResolveLatestPluginVersion
// resolvePluginVersion is swappable in tests.
var resolvePluginVersion = plugincommon.ResolvePluginVersion

// InstallCommand installs an agent plugin for configured agents or a direct --path target.
type InstallCommand struct {
Expand Down Expand Up @@ -160,20 +160,17 @@ func (ic *InstallCommand) Run() error {

// resolveVersion picks the version to install based on the requested --version and --harness.
//
// - --version 1.0.0 (exact) → use directly
// - --version 1.0.0 (exact) → verify in repository; prompt to pick if missing (interactive)
// - --version latest → ListPluginVersions on Artifactory, pick latest semver
// - --version "" + harness → download <harness>-marketplace.json, look up slug, use that version,
// then delete marketplace.json (deferred cleanup)
// - --version "" + path-only → ListPluginVersions on Artifactory, pick latest semver (same as skills)
func (ic *InstallCommand) resolveVersion() (string, error) {
requested := strings.TrimSpace(ic.version)
if requested != "" && requested != "latest" {
return requested, nil
if requested == "" && len(ic.agents) > 0 {
return ic.resolveVersionFromMarketplaces()
}
if requested == "latest" || len(ic.agents) == 0 {
return resolveLatestPluginVersion(ic.serverDetails, ic.repoKey, ic.slug)
}
return ic.resolveVersionFromMarketplaces()
return resolvePluginVersion(ic.serverDetails, ic.repoKey, ic.slug, requested, ic.quiet)
}

func (ic *InstallCommand) resolveVersionFromMarketplaces() (string, error) {
Expand Down
17 changes: 14 additions & 3 deletions agent/plugins/commands/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ func TestResolveAgentTargetDirectories_LegacyInstallPath(t *testing.T) {
}

func TestResolveVersion_ExplicitOverridesMarketplace(t *testing.T) {
restore := resolvePluginVersion
resolvePluginVersion = func(_ *config.ServerDetails, _, slug, requested string, quiet bool) (string, error) {
assert.Equal(t, "my-plugin", slug)
assert.Equal(t, "1.0.0", requested)
assert.False(t, quiet)
return "1.0.0", nil
}
t.Cleanup(func() { resolvePluginVersion = restore })

cmd := NewInstallCommand().
SetSlug("my-plugin").
SetAgents([]plugincommon.AgentSpec{{Name: "claude"}}).
Expand All @@ -62,13 +71,15 @@ func TestResolveVersion_ExplicitOverridesMarketplace(t *testing.T) {
}

func TestResolveVersion_EmptyVersionWithPathResolvesLatest(t *testing.T) {
restore := resolveLatestPluginVersion
resolveLatestPluginVersion = func(_ *config.ServerDetails, repoKey, slug string) (string, error) {
restore := resolvePluginVersion
resolvePluginVersion = func(_ *config.ServerDetails, repoKey, slug, requested string, quiet bool) (string, error) {
assert.Equal(t, "plugins-repo", repoKey)
assert.Equal(t, "my-plugin", slug)
assert.Equal(t, "", requested)
assert.False(t, quiet)
return "1.2.3", nil
}
t.Cleanup(func() { resolveLatestPluginVersion = restore })
t.Cleanup(func() { resolvePluginVersion = restore })

cmd := NewInstallCommand().
SetSlug("my-plugin").
Expand Down
86 changes: 49 additions & 37 deletions agent/plugins/commands/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ var askYesNo = coreutils.AskYesNo
var isNonInteractive = agentcommon.IsNonInteractive

const updateAllConfirmPrompt = "Update all discovered plugins under the given harness(es) to their latest version in the repository? " +
"Each install folder name is used as the repository slug (same as update --slug). " +
"Matching packages will be updated, including installs that were not made with JFrog CLI."

// pluginBackupDirName is the directory under the plugins parent where update backups are stored.
Expand All @@ -33,6 +32,9 @@ const pluginBackupDirName = ".plugin-backup"
// resolveLatestPluginVersion is swappable in tests.
var resolveLatestPluginVersion = plugincommon.ResolveLatestPluginVersion

// resolvePluginVersion is swappable in tests.
var resolvePluginVersion = plugincommon.ResolvePluginVersion

// updateSlugAcrossTargetsFn is swappable in tests.
var updateSlugAcrossTargetsFn = updateSlugAcrossTargets

Expand All @@ -47,45 +49,66 @@ type preUpdate struct {
func RunUpdate(c *components.Context) error {
all := c.GetBoolFlagValue("all")
slugFlag := strings.TrimSpace(c.GetStringFlagValue("slug"))
if err := validateUpdateArgs(c, all, slugFlag); err != nil {
return err
}

opts, err := newUpdate(c)
if err != nil {
return err
}
if all {
return runUpdateAllMode(opts)
}
return runSingleSlugUpdate(c, opts, slugFlag)
}

func validateUpdateArgs(c *components.Context, all bool, slugFlag string) error {
if !all && slugFlag == "" {
if c.GetNumberOfArgs() > 0 {
return fmt.Errorf("unexpected positional argument(s); use --slug to specify the plugin")
}
return fmt.Errorf("usage: jf agent plugins update --slug <slug> (--harness <name[,name...]> [--global] [--project-dir <dir>] | --path <dir>) [--repo <repo>] [--version <ver>] [--dry-run] [--force] [--format <table|json>]\n jf agent plugins update --all --harness <name[,name...]> [--global] [--project-dir <dir>] [--repo <repo>] [--dry-run] [--force] [--format <table|json>]")
}
if all {
if slugFlag != "" {
return fmt.Errorf("--all cannot be combined with --slug; it updates every installed plugin for the given --harness list")
}
if c.GetNumberOfArgs() > 0 {
return fmt.Errorf("unexpected positional argument(s); use --slug or --all")
}
if strings.TrimSpace(c.GetStringFlagValue("version")) != "" {
return fmt.Errorf("--all cannot be combined with --version; it always updates to the latest version")
}
if strings.TrimSpace(c.GetStringFlagValue("path")) != "" {
return fmt.Errorf("--all cannot be combined with --path; --path targets a single install directory")
}
if !all {
return nil
}

opts, err := newUpdate(c)
if err != nil {
return err
if slugFlag != "" {
return fmt.Errorf("--all cannot be combined with --slug; it updates every installed plugin for the given --harness list")
}
if c.GetNumberOfArgs() > 0 {
return fmt.Errorf("unexpected positional argument(s); use --slug or --all")
}
if strings.TrimSpace(c.GetStringFlagValue("version")) != "" {
return fmt.Errorf("--all cannot be combined with --version; it always updates to the latest version")
}
if strings.TrimSpace(c.GetStringFlagValue("path")) != "" {
return fmt.Errorf("--all cannot be combined with --path; --path targets a single install directory")
}
if all && opts.flags.AbsoluteInstallBaseDir != "" {
return nil
}

func validateUpdateAllTargets(flags agentcommon.InstallFlagsResult) error {
if flags.AbsoluteInstallBaseDir != "" {
return fmt.Errorf("--all requires --harness; --path is not supported")
}
if all && len(opts.flags.Specs) == 0 {
if len(flags.Specs) == 0 {
return fmt.Errorf("--all requires --harness <name[,name...]>")
}
return nil
}

if all {
if err := confirmUpdateAll(opts); err != nil {
return err
}
return runUpdateAll(opts)
func runUpdateAllMode(opts update) error {
if err := validateUpdateAllTargets(opts.flags); err != nil {
return err
}
if err := confirmUpdateAll(opts); err != nil {
return err
}
return runUpdateAll(opts)
}

func runSingleSlugUpdate(c *components.Context, opts update, slugFlag string) error {
if c.GetNumberOfArgs() > 0 {
return fmt.Errorf("unexpected positional argument(s); use --slug to specify the plugin")
}
Expand Down Expand Up @@ -142,7 +165,7 @@ func runUpdateOnSlug(opts update, slug, requestedVersion string) error {
return err
}

targetVersion, err := resolveTargetVersion(opts.serverDetails, opts.repoKey, slug, requestedVersion)
targetVersion, err := resolvePluginVersion(opts.serverDetails, opts.repoKey, slug, requestedVersion, opts.quiet)
if err != nil {
return err
}
Expand Down Expand Up @@ -291,17 +314,6 @@ func finalizeUpdateAll(combined []agentcommon.UpdateAllSummaryRow, outcome updat
return nil
}

func resolveTargetVersion(serverDetails *config.ServerDetails, repoKey, slug, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" && requested != "latest" {
if err := plugincommon.ValidateVersion(requested); err != nil {
return "", err
}
return requested, nil
}
return resolveLatestPluginVersion(serverDetails, repoKey, slug)
}

// updateSlugAcrossTargets fetches the slug once and runs the backup+copy loop per target.
// Returns the per-target summary rows. Targets that are not installed or already at the
// target version are reported without performing a download.
Expand Down
18 changes: 14 additions & 4 deletions agent/plugins/commands/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,24 @@ func TestUpdateOnePlugin_SuccessRemovesBackup(t *testing.T) {
assert.Contains(t, string(data), "2.0.0")
}

func TestResolveTargetVersion_ExplicitUsedDirectly(t *testing.T) {
got, err := resolveTargetVersion(nil, "repo", "slug", "1.2.3")
func TestResolvePluginVersion_ExplicitUsedDirectly(t *testing.T) {
restore := resolvePluginVersion
resolvePluginVersion = func(_ *config.ServerDetails, repoKey, slug, requested string, quiet bool) (string, error) {
assert.Equal(t, "repo", repoKey)
assert.Equal(t, "slug", slug)
assert.Equal(t, "1.2.3", requested)
assert.True(t, quiet)
return "1.2.3", nil
}
t.Cleanup(func() { resolvePluginVersion = restore })

got, err := resolvePluginVersion(nil, "repo", "slug", "1.2.3", true)
require.NoError(t, err)
assert.Equal(t, "1.2.3", got)
}

func TestResolveTargetVersion_RejectsInvalid(t *testing.T) {
_, err := resolveTargetVersion(nil, "repo", "slug", "not-a-version")
func TestResolvePluginVersion_RejectsInvalid(t *testing.T) {
_, err := resolvePluginVersion(nil, "repo", "slug", "not-a-version", true)
require.Error(t, err)
}

Expand Down
19 changes: 19 additions & 0 deletions agent/plugins/common/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,22 @@ func ResolveLatestPluginVersion(serverDetails *config.ServerDetails, repoKey, sl
}
return agentcommon.LatestVersion(versions)
}

// ResolvePluginVersion lists remote versions then applies SelectPackageVersion rules.
// Used by install and update when --version is set or when resolving latest from Artifactory.
func ResolvePluginVersion(serverDetails *config.ServerDetails, repoKey, slug, requested string, quiet bool) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" && requested != "latest" {
if err := ValidateVersion(requested); err != nil {
return "", err
}
}
versions, err := listPluginVersions(serverDetails, repoKey, slug)
if err != nil {
if strings.Contains(err.Error(), "404 Not Found") {
return "", fmt.Errorf("plugin '%s' not found in repository '%s'", slug, repoKey)
}
return "", fmt.Errorf("failed to list versions: %w", err)
}
return agentcommon.SelectPackageVersion(versions, requested, repoKey, quiet)
}
5 changes: 5 additions & 0 deletions agent/plugins/common/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ func TestListPluginVersions_EmptyRepoKey(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "repository is required")
}

func TestResolvePluginVersion_RejectsInvalidSemver(t *testing.T) {
_, err := ResolvePluginVersion(nil, "repo", "slug", "not-a-version", true)
require.Error(t, err)
}
23 changes: 9 additions & 14 deletions agent/skills/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ func GetSubCommands() []components.Command {
Action: install.RunInstall,
},
{
Name: "update",
Flags: flagkit.GetCommandFlags(flagkit.SkillsUpdate),
Description: "Update an installed skill to the latest (or a specific) version. Same targeting flags as install: use --harness (comma-separated) with --project-dir (default: current directory) or --global, or --path <dir> for a direct update at <dir>/<slug>. Pre-update checks skip targets that are not installed or already at the target version (use --force to re-download). Logs skip and failure reasons when not quiet. Downloads once for all targets. Use --dry-run to preview, --format json for machine-readable summaries.",
Arguments: getUpdateArguments(),
Action: update.RunUpdate,
Name: "update",
Flags: flagkit.GetCommandFlags(flagkit.SkillsUpdate),
Description: "Update an installed skill to the latest (or a specific) version. " +
Comment thread
udaykb2 marked this conversation as resolved.
Outdated
"Use --slug with --harness (comma-separated) and --project-dir or --global; or --slug with --path <dir>. " +
"With --all (requires --harness), updates every discovered skill under those harnesses to latest in one summary table " +
"(interactive confirmation before proceeding; folder name is the slug, same as --slug). " +
"Skips targets not installed or already at the target version (use --force to re-download). " +
"Use --dry-run to preview, --format json for machine-readable summaries.",
Action: update.RunUpdate,
},
{
Name: "search",
Expand Down Expand Up @@ -85,15 +89,6 @@ func getInstallArguments() []components.Argument {
}
}

func getUpdateArguments() []components.Argument {
return []components.Argument{
{
Name: "slug",
Description: "Skill name/slug to update.",
},
}
}

func getDeleteArguments() []components.Argument {
return []components.Argument{
{
Expand Down
Loading
Loading