diff --git a/artifactory/commands/mvn/mvn.go b/artifactory/commands/mvn/mvn.go
index 153abfb4..2e6012fd 100644
--- a/artifactory/commands/mvn/mvn.go
+++ b/artifactory/commands/mvn/mvn.go
@@ -146,17 +146,17 @@ func (mc *MvnCommand) isDeploymentRequested() bool {
if strings.HasSuffix(goal, ":help") || goal == "help" {
continue
}
-
+
// Exact match for standard Maven phases (most common case)
if goal == "install" || goal == "deploy" {
return true
}
-
+
// Prefix match for plugin:goal format (e.g., deploy:deploy-file, install:install-file)
if strings.HasPrefix(goal, "deploy:") || strings.HasPrefix(goal, "install:") {
return true
}
-
+
// Suffix match for full plugin name format (e.g., maven-deploy-plugin:deploy, maven-install-plugin:install)
// Note: Using suffix instead of Contains() to avoid false positives like "uninstall", "reinstall"
if strings.HasSuffix(goal, ":deploy") || strings.HasSuffix(goal, ":install") {
@@ -199,7 +199,8 @@ func (mc *MvnCommand) Run() error {
SetGoals(mc.goals).
SetInsecureTls(mc.insecureTls).
SetDisableDeploy(mc.deploymentDisabled).
- SetThreads(mc.threads)
+ SetThreads(mc.threads).
+ SetServerDetails(mc.serverDetails)
if err = RunMvn(mvnParams); err != nil {
return err
}
diff --git a/artifactory/commands/mvn/utils.go b/artifactory/commands/mvn/utils.go
index 2888358a..a6ca25ed 100644
--- a/artifactory/commands/mvn/utils.go
+++ b/artifactory/commands/mvn/utils.go
@@ -1,6 +1,8 @@
package mvn
import (
+ "context"
+ "errors"
"io"
"os"
"os/exec"
@@ -34,6 +36,7 @@ type MvnUtils struct {
insecureTls bool
disableDeploy bool
outputWriter io.Writer
+ serverDetails *config.ServerDetails
}
func NewMvnUtils() *MvnUtils {
@@ -85,6 +88,11 @@ func (mu *MvnUtils) SetOutputWriter(writer io.Writer) *MvnUtils {
return mu
}
+func (mu *MvnUtils) SetServerDetails(serverDetails *config.ServerDetails) *MvnUtils {
+ mu.serverDetails = serverDetails
+ return mu
+}
+
func RunMvn(mu *MvnUtils) error {
// FlexPack completely bypasses traditional Maven Build Info Extractor
if utils.ShouldRunNative(mu.configPath) {
@@ -156,6 +164,20 @@ func RunMvn(mu *MvnUtils) error {
if err != nil {
return err
}
+ workingDir, wdErr := os.Getwd()
+ if wdErr != nil {
+ return errorutils.CheckError(wdErr)
+ }
+ var restoreHealing func() error
+ restoreHealing, _, err = mu.runXrayComponentHealing(context.Background(), workingDir, mu.serverDetails)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil && restoreHealing != nil {
+ err = errors.Join(err, restoreHealing())
+ }
+ }()
var mvnOpts []string
if v := os.Getenv("MAVEN_OPTS"); v != "" {
mvnOpts = strings.Fields(v)
diff --git a/artifactory/commands/mvn/xrayheal.go b/artifactory/commands/mvn/xrayheal.go
new file mode 100644
index 00000000..916ee9fa
--- /dev/null
+++ b/artifactory/commands/mvn/xrayheal.go
@@ -0,0 +1,55 @@
+package mvn
+
+import (
+ "context"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+ cmaven "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents/maven"
+ buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build"
+ "github.com/jfrog/jfrog-cli-core/v2/common/project"
+ "github.com/jfrog/jfrog-cli-core/v2/utils/config"
+ "github.com/jfrog/jfrog-cli-core/v2/utils/xray"
+)
+
+func (mu *MvnUtils) runXrayComponentHealing(ctx context.Context, workingDir string, serverDetails *config.ServerDetails) (restore func() error, healed bool, err error) {
+ if healcomponents.IsComponentResolutionDisabled() {
+ return healcomponents.SkipHealing("Xray component healing disabled", nil)
+ }
+ command := cmaven.DeriveResolutionCommand(mu.goals)
+ if command == "" || mu.vConfig == nil || !mu.vConfig.IsSet("resolver") {
+ return healcomponents.SkipHealing("Xray component healing skipped", nil)
+ }
+ resolverRepo, resolverErr := mu.resolverRepoForHealing()
+ if resolverErr != nil {
+ return healcomponents.SkipHealing("Xray component healing skipped: could not determine resolver repo: ", resolverErr)
+ }
+ if resolverRepo == "" {
+ return healcomponents.SkipHealing("Xray component healing skipped: resolver repo is empty", nil)
+ }
+ if serverDetails == nil {
+ serverDetails, err = buildUtils.GetServerDetails(mu.vConfig)
+ if err != nil {
+ return healcomponents.SkipHealing("Xray component healing skipped: could not determine server details: ", err)
+ }
+ if serverDetails == nil {
+ return healcomponents.SkipHealing("Xray component healing skipped: could not determine server details", nil)
+ }
+ }
+ var projectKey string
+ if mu.buildConf != nil {
+ projectKey = mu.buildConf.GetProject()
+ }
+ xrayManager, xrayErr := xray.CreateXrayServiceManager(serverDetails, xray.WithScopedProjectKey(projectKey))
+ if xrayErr != nil {
+ return healcomponents.SkipHealing("Xray component healing skipped: could not create Xray service manager: ", xrayErr)
+ }
+ return healcomponents.RunIfEnabled(ctx, xrayManager, resolverRepo, cmaven.NewBuildToolWithGoals(mu.goals), command, workingDir, nil)
+}
+
+func (mu *MvnUtils) resolverRepoForHealing() (string, error) {
+ repoConfig, err := project.GetRepoConfigByPrefix(mu.configPath, project.ProjectConfigResolverPrefix, mu.vConfig)
+ if err != nil {
+ return "", err
+ }
+ return repoConfig.TargetRepo(), nil
+}
diff --git a/artifactory/commands/mvn/xrayheal_test.go b/artifactory/commands/mvn/xrayheal_test.go
new file mode 100644
index 00000000..2a65b3eb
--- /dev/null
+++ b/artifactory/commands/mvn/xrayheal_test.go
@@ -0,0 +1,17 @@
+package mvn
+
+import (
+ "testing"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+ "github.com/spf13/viper"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRunXrayComponentHealing_RespectsDisabledEnv(t *testing.T) {
+ t.Setenv(healcomponents.HealComponentsDisabledEnvVar, "true")
+ mu := NewMvnUtils().SetGoals([]string{"install"}).SetConfig(viper.New())
+ _, healed, err := mu.runXrayComponentHealing(t.Context(), t.TempDir(), nil)
+ assert.NoError(t, err)
+ assert.False(t, healed)
+}
diff --git a/artifactory/commands/npm/common.go b/artifactory/commands/npm/common.go
index ef1acde7..4aa59e5f 100644
--- a/artifactory/commands/npm/common.go
+++ b/artifactory/commands/npm/common.go
@@ -14,6 +14,8 @@ type CommonArgs struct {
npmArgs []string
serverDetails *config.ServerDetails
useNative bool
+ configFilePath string
+ executablePath string
}
func (ca *CommonArgs) SetServerDetails(serverDetails *config.ServerDetails) *CommonArgs {
@@ -45,6 +47,16 @@ func (ca *CommonArgs) SetUseNative(useNpmRc bool) *CommonArgs {
return ca
}
+func (ca *CommonArgs) SetConfigFilePath(configFilePath string) *CommonArgs {
+ ca.configFilePath = configFilePath
+ return ca
+}
+
+func (ca *CommonArgs) SetExecutablePath(executablePath string) *CommonArgs {
+ ca.executablePath = executablePath
+ return ca
+}
+
// CheckIsNativeAndFetchFilteredArgs checks if native mode should be enabled.
// It first checks the JFROG_RUN_NATIVE environment variable (preferred),
// then falls back to the deprecated --run-native flag for backward compatibility.
diff --git a/artifactory/commands/npm/npmcommand.go b/artifactory/commands/npm/npmcommand.go
index 47ae45e5..ed93b760 100644
--- a/artifactory/commands/npm/npmcommand.go
+++ b/artifactory/commands/npm/npmcommand.go
@@ -2,6 +2,7 @@ package npm
import (
"bufio"
+ "context"
"errors"
"fmt"
"net/http"
@@ -56,9 +57,8 @@ var (
type NpmCommand struct {
CommonArgs
- cmdName string
- jsonOutput bool
- executablePath string
+ cmdName string
+ jsonOutput bool
// Function to be called to restore the user's old npmrc and delete the one we created.
restoreNpmrcFunc func() error
workingDirectory string
@@ -73,6 +73,8 @@ type NpmCommand struct {
collectBuildInfo bool
buildInfoModule *build.NpmModule
installHandler *NpmInstallStrategy
+ // When true, the subsequent install uses npm ci to honor healed lockfile integrity.
+ healedLockfile bool
// When true, skips the 404 error handling that checks if packages are blocked by curation
disableCVSCheck bool
}
@@ -135,7 +137,7 @@ func (nc *NpmCommand) Init() error {
if err != nil {
return err
}
-
+
repoConfig, err := nc.getRepoConfig(vConfig)
if err != nil {
return err
@@ -348,6 +350,16 @@ func (nc *NpmCommand) Run() (err error) {
if err = nc.PreparePrerequisites(nc.repo); err != nil {
return
}
+ var restoreResolution func() error
+ restoreResolution, nc.healedLockfile, err = nc.runXrayComponentHealing(context.Background(), nc.cmdName, nc.workingDirectory, nc.npmArgs)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil && restoreResolution != nil {
+ err = errors.Join(err, restoreResolution())
+ }
+ }()
defer func() {
err = errors.Join(err, nc.installHandler.RestoreNpmrc())
}()
@@ -508,8 +520,19 @@ func (nc *NpmCommand) prepareBuildInfoModule() error {
return nil
}
+func (nc *NpmCommand) effectiveNpmCommand() string {
+ if nc.healedLockfile && nc.cmdName == "install" {
+ return "ci"
+ }
+ return nc.cmdName
+}
+
func (nc *NpmCommand) collectDependencies() error {
- nc.buildInfoModule.SetNpmArgs(append([]string{nc.cmdName}, nc.npmArgs...))
+ npmCommand := nc.effectiveNpmCommand()
+ if npmCommand != nc.cmdName {
+ log.Info("Using npm ci after component resolution to install from the healed lockfile")
+ }
+ nc.buildInfoModule.SetNpmArgs(append([]string{npmCommand}, nc.npmArgs...))
return errorutils.CheckError(nc.buildInfoModule.Build())
}
diff --git a/artifactory/commands/npm/publish.go b/artifactory/commands/npm/publish.go
index 2624a435..3b3176c8 100644
--- a/artifactory/commands/npm/publish.go
+++ b/artifactory/commands/npm/publish.go
@@ -3,6 +3,7 @@ package npm
import (
"archive/tar"
"compress/gzip"
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -37,7 +38,6 @@ const (
type NpmPublishCommandArgs struct {
CommonArgs
- executablePath string
workingDirectory string
collectBuildInfo bool
packedFilePaths []string
@@ -79,6 +79,7 @@ func (npc *NpmPublishCommand) ServerDetails() (*config.ServerDetails, error) {
func (npc *NpmPublishCommand) SetConfigFilePath(configFilePath string) *NpmPublishCommand {
npc.configFilePath = configFilePath
+ npc.CommonArgs.SetConfigFilePath(configFilePath)
return npc
}
@@ -172,6 +173,16 @@ func (npc *NpmPublishCommand) Run() (err error) {
if err != nil {
return err
}
+ var restoreResolution func() error
+ restoreResolution, _, err = npc.runXrayComponentHealingForPublish(context.Background(), "publish", npc.workingDirectory, npc.publishPath, npc.npmArgs)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil && restoreResolution != nil {
+ err = errors.Join(err, restoreResolution())
+ }
+ }()
var npmBuild *build.Build
var buildName, buildNumber, projectKey string
diff --git a/artifactory/commands/npm/xrayheal.go b/artifactory/commands/npm/xrayheal.go
new file mode 100644
index 00000000..643f40d7
--- /dev/null
+++ b/artifactory/commands/npm/xrayheal.go
@@ -0,0 +1,112 @@
+package npm
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ gofrogcmd "github.com/jfrog/gofrog/io"
+
+ "github.com/jfrog/jfrog-cli-core/v2/common/project"
+ "github.com/jfrog/jfrog-cli-core/v2/utils/xray"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+ cnpm "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents/npm"
+)
+
+func (ca *CommonArgs) runXrayComponentHealing(ctx context.Context, command, workingDir string, npmArgs []string) (restore func() error, healed bool, err error) {
+ if command == "install" && isSinglePackageInstall(npmArgs) {
+ return func() error { return nil }, false, nil
+ }
+ return ca.runXrayComponentHealingWithTool(ctx, command, workingDir, cnpm.NewBuildToolWithArgs(npmArgs), cnpm.BootstrapArgsFrom(npmArgs)...)
+}
+
+func (ca *CommonArgs) runXrayComponentHealingForPublish(ctx context.Context, command, workingDir, publishPath string, npmArgs []string) (restore func() error, healed bool, err error) {
+ return ca.runXrayComponentHealingWithTool(ctx, command, workingDir, cnpm.NewBuildToolForPublish(workingDir, publishPath, npmArgs), cnpm.BootstrapArgsFrom(npmArgs)...)
+}
+
+func (ca *CommonArgs) runXrayComponentHealingWithTool(ctx context.Context, command, workingDir string, tool cnpm.BuildTool, bootstrapArgs ...string) (restore func() error, healed bool, err error) {
+ if healcomponents.IsComponentResolutionDisabled() {
+ return healcomponents.SkipHealing("Xray component healing disabled", nil)
+ }
+ resolverRepo, resolverErr := ca.resolverRepoForResolution(command)
+ if resolverErr != nil {
+ return healcomponents.SkipHealing("Xray component healing skipped: could not determine resolver repo: ", resolverErr)
+ }
+ if resolverRepo == "" {
+ return healcomponents.SkipHealing("Xray component healing skipped: resolver repo is empty", nil)
+ }
+ var projectKey string
+ if ca.buildConfiguration != nil {
+ projectKey = ca.buildConfiguration.GetProject()
+ }
+ xrayManager, xrayErr := xray.CreateXrayServiceManager(ca.serverDetails, xray.WithScopedProjectKey(projectKey))
+ if xrayErr != nil {
+ return healcomponents.SkipHealing("Xray component healing skipped: could not create Xray service manager: ", xrayErr)
+ }
+ return healcomponents.RunIfEnabled(ctx, xrayManager, resolverRepo, tool, command, workingDir, ca.npmBootstrapRunner(), bootstrapArgs...)
+}
+
+// resolverRepoForResolution returns the Artifactory virtual repo for dependency policy scope.
+func (ca *CommonArgs) resolverRepoForResolution(command string) (string, error) {
+ if command != "publish" && ca.repo != "" {
+ return ca.repo, nil
+ }
+ if ca.configFilePath != "" {
+ vConfig, err := project.ReadConfigFile(ca.configFilePath, project.YAML)
+ if err != nil {
+ return "", fmt.Errorf("failed to read config file: %w", err)
+ }
+ resolverConfig, err := project.GetRepoConfigByPrefix(ca.configFilePath, project.ProjectConfigResolverPrefix, vConfig)
+ if err != nil {
+ return "", fmt.Errorf("failed to get resolver config: %w", err)
+ }
+ return resolverConfig.TargetRepo(), nil
+ }
+ if ca.executablePath != "" {
+ registryURL, err := ca.getNpmRegistryURL()
+ if err != nil {
+ return "", fmt.Errorf("failed to get registry URL: %w", err)
+ }
+ return extractRepoName(registryURL)
+ }
+ return ca.repo, nil
+}
+
+func (ca *CommonArgs) getNpmRegistryURL() (string, error) {
+ configCommand := gofrogcmd.Command{
+ Executable: ca.executablePath,
+ CmdName: "config",
+ CmdArgs: []string{"get", "registry"},
+ }
+ data, err := configCommand.RunWithOutput()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(data)), nil
+}
+
+func (ca *CommonArgs) npmBootstrapRunner() healcomponents.CommandRunner {
+ return func(ctx context.Context, projectRoot string, args ...string) error {
+ return runNpmAt(ctx, ca.executablePath, projectRoot, args...)
+ }
+}
+
+func runNpmAt(_ context.Context, executablePath, projectRoot string, args ...string) error {
+ if len(args) == 0 {
+ return nil
+ }
+ cmd := gofrogcmd.NewCommand(executablePath, args[0], args[1:])
+ cmd.Dir = projectRoot
+ _, err := cmd.RunWithOutput()
+ return err
+}
+
+func isSinglePackageInstall(npmArgs []string) bool {
+ for _, arg := range npmArgs {
+ if !strings.HasPrefix(arg, "-") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/artifactory/commands/npm/xrayheal_test.go b/artifactory/commands/npm/xrayheal_test.go
new file mode 100644
index 00000000..1b5569e0
--- /dev/null
+++ b/artifactory/commands/npm/xrayheal_test.go
@@ -0,0 +1,37 @@
+package npm
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+)
+
+func TestRunComponentResolution_RespectsDisabledEnv(t *testing.T) {
+ t.Setenv(healcomponents.HealComponentsDisabledEnvVar, "true")
+ ca := &CommonArgs{}
+ ca.SetRepo("npm-virtual").SetServerDetails(nil)
+ _, healed, err := ca.runXrayComponentHealing(t.Context(), "install", t.TempDir(), nil)
+ assert.NoError(t, err)
+ assert.False(t, healed)
+}
+
+func TestEffectiveNpmCommandAfterHeal(t *testing.T) {
+ nc := &NpmCommand{cmdName: "install", healedLockfile: true}
+ assert.Equal(t, "ci", nc.effectiveNpmCommand())
+
+ nc.healedLockfile = false
+ assert.Equal(t, "install", nc.effectiveNpmCommand())
+
+ nc.cmdName = "ci"
+ nc.healedLockfile = true
+ assert.Equal(t, "ci", nc.effectiveNpmCommand())
+}
+
+func TestIsSinglePackageInstall(t *testing.T) {
+ assert.True(t, isSinglePackageInstall([]string{"lodash"}))
+ assert.True(t, isSinglePackageInstall([]string{"--save", "lodash"}))
+ assert.False(t, isSinglePackageInstall([]string{"--verbose"}))
+ assert.False(t, isSinglePackageInstall(nil))
+}
diff --git a/artifactory/healcomponents/buildtool.go b/artifactory/healcomponents/buildtool.go
new file mode 100644
index 00000000..470be2fa
--- /dev/null
+++ b/artifactory/healcomponents/buildtool.go
@@ -0,0 +1,26 @@
+package healcomponents
+
+import (
+ "context"
+ "slices"
+)
+
+// CommandRunner runs a build-tool subprocess (injected for tests).
+type CommandRunner func(ctx context.Context, projectRoot string, args ...string) error
+
+// BuildTool describes a package manager for the generic resolution flow.
+type BuildTool interface {
+ ToolName() string
+ RelevantCommands() []string
+ // ProjectRoot resolves monorepo/reactor/solution root from workingDir.
+ ProjectRoot(workingDir string) (string, error)
+ // EnsureLockfiles materializes expected lock artifacts when absent and the command allows it.
+ // Returns paths (relative to project root) that were bootstrapped and did not exist before.
+ EnsureLockfiles(ctx context.Context, projectRoot, command string, runner CommandRunner, bootstrapArgs ...string) (bootstrapped []string, err error)
+ // DiscoverLockfiles returns all lockfiles (paths relative to project root).
+ DiscoverLockfiles(workingDir string) ([]Lockfile, error)
+}
+
+func IsRelevantCommand(tool BuildTool, command string) bool {
+ return slices.Contains(tool.RelevantCommands(), command)
+}
diff --git a/artifactory/healcomponents/buildtool_test.go b/artifactory/healcomponents/buildtool_test.go
new file mode 100644
index 00000000..9dcaf3e9
--- /dev/null
+++ b/artifactory/healcomponents/buildtool_test.go
@@ -0,0 +1,30 @@
+package healcomponents
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type fakeTool struct {
+ name string
+ commands []string
+}
+
+func (f fakeTool) ToolName() string { return f.name }
+func (f fakeTool) RelevantCommands() []string { return f.commands }
+func (f fakeTool) ProjectRoot(dir string) (string, error) { return dir, nil }
+func (f fakeTool) EnsureLockfiles(_ context.Context, _, _ string, _ CommandRunner, _ ...string) ([]string, error) {
+ return nil, nil
+}
+func (f fakeTool) DiscoverLockfiles(_ string) ([]Lockfile, error) {
+ return []Lockfile{{Path: "lock.json", Content: []byte(`{}`)}}, nil
+}
+
+func TestIsRelevantCommand(t *testing.T) {
+ tool := fakeTool{name: "fake", commands: []string{"install", "ci"}}
+ assert.True(t, IsRelevantCommand(tool, "install"))
+ assert.True(t, IsRelevantCommand(tool, "ci"))
+ assert.False(t, IsRelevantCommand(tool, "publish"))
+}
diff --git a/artifactory/healcomponents/maven/discover.go b/artifactory/healcomponents/maven/discover.go
new file mode 100644
index 00000000..13a3e9f5
--- /dev/null
+++ b/artifactory/healcomponents/maven/discover.go
@@ -0,0 +1,147 @@
+package maven
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/jfrog/jfrog-client-go/utils/errorutils"
+)
+
+const pomFileName = "pom.xml"
+
+type discoveryOptions struct {
+ pomFile string
+ projectList []string
+}
+
+func discoverProjectRoot(workingDir string) (string, error) {
+ return discoverProjectRootWithOptions(workingDir, discoveryOptions{})
+}
+
+func discoverProjectRootWithOptions(workingDir string, opts discoveryOptions) (string, error) {
+ startDir, err := effectiveStartDir(workingDir, opts.pomFile)
+ if err != nil {
+ return "", err
+ }
+ dir := startDir
+ var firstPomDir string
+ var topAggregator string
+ for {
+ pomPath := filepath.Join(dir, pomFileName)
+ if _, statErr := os.Stat(pomPath); statErr == nil {
+ if firstPomDir == "" {
+ firstPomDir = dir
+ }
+ data, readErr := os.ReadFile(pomPath)
+ if readErr == nil {
+ pom, parseErr := parsePom(data)
+ if parseErr == nil && pom.isAggregator() {
+ topAggregator = dir
+ }
+ }
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ if topAggregator != "" {
+ return topAggregator, nil
+ }
+ if firstPomDir != "" {
+ return firstPomDir, nil
+ }
+ return "", errorutils.CheckErrorf("no %s found from %s", pomFileName, startDir)
+}
+
+func effectiveStartDir(workingDir, pomFile string) (string, error) {
+ abs, err := filepath.Abs(workingDir)
+ if err != nil {
+ return "", errorutils.CheckError(err)
+ }
+ if pomFile == "" {
+ return abs, nil
+ }
+ pomPath := pomFile
+ if !filepath.IsAbs(pomPath) {
+ pomPath = filepath.Join(abs, pomPath)
+ }
+ pomPath = filepath.Clean(pomPath)
+ if _, statErr := os.Stat(pomPath); statErr != nil {
+ return "", errorutils.CheckErrorf("%s not found: %s", pomFileName, pomPath)
+ }
+ return filepath.Dir(pomPath), nil
+}
+
+func discoverPomPaths(projectRoot string) ([]string, error) {
+ return discoverPomPathsWithOptions(projectRoot, discoveryOptions{})
+}
+
+func discoverPomPathsWithOptions(projectRoot string, opts discoveryOptions) ([]string, error) {
+ rootPom := filepath.Join(projectRoot, pomFileName)
+ if _, err := os.Stat(rootPom); err != nil {
+ return nil, errorutils.CheckErrorf("expected %s under %s", pomFileName, projectRoot)
+ }
+ all, err := collectReactorPoms(projectRoot, pomFileName)
+ if err != nil {
+ return nil, err
+ }
+ if len(opts.projectList) == 0 {
+ return all, nil
+ }
+ return filterByProjectList(all, opts.projectList)
+}
+
+func collectReactorPoms(projectRoot, relPom string) ([]string, error) {
+ full := filepath.Join(projectRoot, relPom)
+ data, err := os.ReadFile(full)
+ if err != nil {
+ return nil, errorutils.CheckError(err)
+ }
+ pom, err := parsePom(data)
+ if err != nil {
+ return nil, errorutils.CheckError(err)
+ }
+ paths := []string{relPom}
+ if !pom.isAggregator() {
+ return paths, nil
+ }
+ baseDir := filepath.Dir(relPom)
+ for _, mod := range pom.modulePaths() {
+ mod = filepath.FromSlash(strings.TrimSpace(mod))
+ if mod == "" {
+ continue
+ }
+ childRel := filepath.Join(baseDir, mod, pomFileName)
+ childRel = filepath.Clean(childRel)
+ sub, err := collectReactorPoms(projectRoot, childRel)
+ if err != nil {
+ return nil, err
+ }
+ paths = append(paths, sub...)
+ }
+ return paths, nil
+}
+
+func filterByProjectList(relPoms []string, projectList []string) ([]string, error) {
+ want := make(map[string]bool, len(projectList))
+ for _, p := range projectList {
+ want[filepath.Clean(filepath.FromSlash(p))] = true
+ }
+ var out []string
+ for _, rel := range relPoms {
+ modDir := filepath.Dir(rel)
+ if modDir == "." {
+ modDir = ""
+ }
+ if want[modDir] {
+ out = append(out, rel)
+ }
+ }
+ if len(out) == 0 {
+ return nil, errorutils.CheckErrorf("no reactor modules matched -pl: %s", strings.Join(projectList, ","))
+ }
+ return out, nil
+}
diff --git a/artifactory/healcomponents/maven/discover_test.go b/artifactory/healcomponents/maven/discover_test.go
new file mode 100644
index 00000000..95ddfaff
--- /dev/null
+++ b/artifactory/healcomponents/maven/discover_test.go
@@ -0,0 +1,107 @@
+package maven
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiscoverProjectRoot_FromSubmodule(t *testing.T) {
+ root := t.TempDir()
+ sub := filepath.Join(root, "module-a")
+ require.NoError(t, os.MkdirAll(sub, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommodule-a`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "pom.xml"), []byte(`module-a`), 0644))
+
+ got, err := discoverProjectRoot(sub)
+ require.NoError(t, err)
+ assert.Equal(t, root, got)
+}
+
+func TestDiscoverPomPaths_MultiModule(t *testing.T) {
+ root := t.TempDir()
+ modA := filepath.Join(root, "module-a")
+ require.NoError(t, os.MkdirAll(modA, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommodule-a`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(modA, "pom.xml"), []byte(``), 0644))
+ require.NoError(t, os.MkdirAll(filepath.Join(modA, "target"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(modA, "target", "pom.xml"), []byte(``), 0644))
+
+ paths, err := discoverPomPaths(root)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"pom.xml", filepath.Join("module-a", "pom.xml")}, paths)
+}
+
+func TestDiscoverPomPaths_ExcludesStrayPomNotInReactor(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "mod-a"), 0755))
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "src", "test", "resources"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommod-a`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "mod-a", "pom.xml"), []byte(``), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "src", "test", "resources", "pom.xml"), []byte(``), 0644))
+
+ paths, err := discoverPomPaths(root)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"pom.xml", filepath.Join("mod-a", "pom.xml")}, paths)
+}
+
+func TestDiscoverPomPaths_NestedAggregator(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "services", "api"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pomservices`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "services", "pom.xml"), []byte(`pomapi`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "services", "api", "pom.xml"), []byte(``), 0644))
+
+ paths, err := discoverPomPaths(root)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{
+ "pom.xml",
+ filepath.Join("services", "pom.xml"),
+ filepath.Join("services", "api", "pom.xml"),
+ }, paths)
+}
+
+func TestDiscoverProjectRoot_FromFileFlag(t *testing.T) {
+ root := t.TempDir()
+ sub := filepath.Join(root, "sub", "mod")
+ require.NoError(t, os.MkdirAll(sub, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pomsub`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "pom.xml"), []byte(`pommod`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "pom.xml"), []byte(``), 0644))
+
+ cwd := t.TempDir()
+ got, err := discoverProjectRootWithOptions(cwd, discoveryOptions{pomFile: filepath.Join(root, "sub", "pom.xml")})
+ require.NoError(t, err)
+ assert.Equal(t, root, got)
+}
+
+func TestDiscoverPomPaths_ProjectListFilter(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "mod-a"), 0755))
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "mod-b"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommod-amod-b`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "mod-a", "pom.xml"), []byte(``), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "mod-b", "pom.xml"), []byte(``), 0644))
+
+ paths, err := discoverPomPathsWithOptions(root, discoveryOptions{projectList: []string{"mod-a"}})
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{filepath.Join("mod-a", "pom.xml")}, paths)
+}
+
+func TestDiscoverPomPaths_MavenExampleTestdata(t *testing.T) {
+ root := filepath.Join("..", "..", "..", "tests", "testdata", "maven-example")
+ if _, err := os.Stat(root); err != nil {
+ t.Skip("testdata not available")
+ }
+ paths, err := discoverPomPaths(root)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{
+ "pom.xml",
+ filepath.Join("multi1", "pom.xml"),
+ filepath.Join("multi2", "pom.xml"),
+ filepath.Join("multi3", "pom.xml"),
+ }, paths)
+}
diff --git a/artifactory/healcomponents/maven/goals.go b/artifactory/healcomponents/maven/goals.go
new file mode 100644
index 00000000..c8ac45dd
--- /dev/null
+++ b/artifactory/healcomponents/maven/goals.go
@@ -0,0 +1,100 @@
+package maven
+
+import "strings"
+
+type mavenCLIArgs struct {
+ pomFile string // relative or absolute path from -f/--file
+ projectList []string // from -pl/--projects (comma-separated segments already split)
+}
+
+func parseMavenCLIArgs(goals []string) (pomFile string, projectList []string) {
+ a := parseMavenCLIArgsStruct(goals)
+ return a.pomFile, a.projectList
+}
+
+func parseMavenCLIArgsStruct(goals []string) mavenCLIArgs {
+ var out mavenCLIArgs
+ for i := 0; i < len(goals); i++ {
+ g := goals[i]
+ switch {
+ case g == "-f" || g == "--file":
+ if i+1 < len(goals) {
+ i++
+ out.pomFile = goals[i]
+ }
+ case strings.HasPrefix(g, "-f"):
+ out.pomFile = strings.TrimPrefix(g, "-f")
+ case strings.HasPrefix(g, "--file="):
+ out.pomFile = strings.TrimPrefix(g, "--file=")
+ case g == "-pl" || g == "--projects":
+ if i+1 < len(goals) {
+ i++
+ out.projectList = splitProjectList(goals[i])
+ }
+ case strings.HasPrefix(g, "-pl"):
+ out.projectList = splitProjectList(strings.TrimPrefix(g, "-pl"))
+ case strings.HasPrefix(g, "--projects="):
+ out.projectList = splitProjectList(strings.TrimPrefix(g, "--projects="))
+ }
+ }
+ return out
+}
+
+func splitProjectList(s string) []string {
+ parts := strings.Split(s, ",")
+ var out []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func DeriveResolutionCommand(goals []string) string {
+ if ShouldSkipResolution(goals) {
+ return ""
+ }
+ for _, g := range goals {
+ if isDependencyResolvingGoal(g) {
+ return "resolve"
+ }
+ }
+ return ""
+}
+
+func ShouldSkipResolution(goals []string) bool {
+ if len(goals) == 0 {
+ return true
+ }
+ if len(goals) == 1 && goals[0] == "clean" {
+ return true
+ }
+ for _, g := range goals {
+ lower := strings.ToLower(g)
+ switch {
+ case lower == "help", lower == "-v", lower == "-version", lower == "version":
+ return true
+ case lower == "dependency:tree", strings.HasSuffix(lower, ":tree"):
+ return true
+ }
+ }
+ return false
+}
+
+func isDependencyResolvingGoal(goal string) bool {
+ lower := strings.ToLower(goal)
+ phases := []string{"validate", "initialize", "generate-sources", "process-sources",
+ "generate-resources", "process-resources", "compile", "process-classes",
+ "generate-test-sources", "process-test-sources", "generate-test-resources",
+ "process-test-resources", "test-compile", "process-test-classes", "test",
+ "prepare-package", "package", "pre-integration-test", "integration-test",
+ "post-integration-test", "verify", "install", "deploy"}
+ for _, phase := range phases {
+ if lower == phase || strings.HasSuffix(lower, ":"+phase) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/artifactory/healcomponents/maven/goals_test.go b/artifactory/healcomponents/maven/goals_test.go
new file mode 100644
index 00000000..4f9e0bdb
--- /dev/null
+++ b/artifactory/healcomponents/maven/goals_test.go
@@ -0,0 +1,43 @@
+package maven
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDeriveResolutionCommand(t *testing.T) {
+ assert.Equal(t, "resolve", DeriveResolutionCommand([]string{"install"}))
+ assert.Equal(t, "resolve", DeriveResolutionCommand([]string{"clean", "verify"}))
+ assert.Equal(t, "", DeriveResolutionCommand([]string{"help"}))
+ assert.Equal(t, "", DeriveResolutionCommand([]string{"clean"}))
+ assert.Equal(t, "", DeriveResolutionCommand([]string{"dependency:tree"}))
+}
+
+func TestShouldSkipResolution(t *testing.T) {
+ assert.True(t, ShouldSkipResolution([]string{"help"}))
+ assert.False(t, ShouldSkipResolution([]string{"install"}))
+}
+
+func TestParseMavenCLIArgs_FileFlag(t *testing.T) {
+ pom, projects := parseMavenCLIArgs([]string{"-f", "sub/pom.xml", "install"})
+ assert.Equal(t, "sub/pom.xml", pom)
+ assert.Nil(t, projects)
+
+ pom, _ = parseMavenCLIArgs([]string{"--file", "other/pom.xml", "clean", "install"})
+ assert.Equal(t, "other/pom.xml", pom)
+}
+
+func TestParseMavenCLIArgs_ProjectsFlag(t *testing.T) {
+ _, projects := parseMavenCLIArgs([]string{"-pl", "mod-a,mod-b", "install"})
+ assert.Equal(t, []string{"mod-a", "mod-b"}, projects)
+
+ _, projects = parseMavenCLIArgs([]string{"--projects", "services/api", "verify"})
+ assert.Equal(t, []string{"services/api"}, projects)
+}
+
+func TestParseMavenCLIArgs_NoFlags(t *testing.T) {
+ pom, projects := parseMavenCLIArgs([]string{"clean", "install"})
+ assert.Empty(t, pom)
+ assert.Nil(t, projects)
+}
diff --git a/artifactory/healcomponents/maven/integration_test.go b/artifactory/healcomponents/maven/integration_test.go
new file mode 100644
index 00000000..28ca8bf5
--- /dev/null
+++ b/artifactory/healcomponents/maven/integration_test.go
@@ -0,0 +1,46 @@
+package maven_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+ cmaven "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents/maven"
+ "github.com/jfrog/jfrog-client-go/xray/services"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type integrationMockClient struct {
+ resp services.ComponentResolutionResponse
+}
+
+func (m *integrationMockClient) GetVersion() (string, error) {
+ return healcomponents.HealComponentsMinVersion, nil
+}
+
+func (m *integrationMockClient) HealComponents(_ services.ComponentResolutionRequest) (*services.ComponentResolutionResponse, bool, error) {
+ return &m.resp, false, nil
+}
+
+func TestIntegration_MavenHealingWritesPOM(t *testing.T) {
+ dir := t.TempDir()
+ orig := `before`
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(orig), 0644))
+
+ healed := `after`
+ client := &integrationMockClient{resp: services.ComponentResolutionResponse{
+ Lockfile: healed,
+ Changes: []services.Change{{Package: "com.demo:lib:1.0", BeforeIntegrity: "a", AfterIntegrity: "b"}},
+ }}
+ _, healedFlag, err := healcomponents.RunIfEnabled(context.Background(), client, "maven-virtual",
+ cmaven.NewBuildTool(), "resolve", dir, nil)
+ require.NoError(t, err)
+ assert.True(t, healedFlag)
+
+ data, err := os.ReadFile(filepath.Join(dir, "pom.xml"))
+ require.NoError(t, err)
+ assert.Equal(t, healed, string(data))
+}
diff --git a/artifactory/healcomponents/maven/maven.go b/artifactory/healcomponents/maven/maven.go
new file mode 100644
index 00000000..e0a3c87e
--- /dev/null
+++ b/artifactory/healcomponents/maven/maven.go
@@ -0,0 +1,69 @@
+package maven
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+ "github.com/jfrog/jfrog-client-go/utils/errorutils"
+)
+
+const toolName = "maven"
+
+type BuildTool struct {
+ goals []string
+}
+
+func NewBuildTool() BuildTool { return BuildTool{} }
+
+func NewBuildToolWithGoals(goals []string) BuildTool {
+ return BuildTool{goals: goals}
+}
+
+func (t BuildTool) discoveryOptions() discoveryOptions {
+ pomFile, projectList := parseMavenCLIArgs(t.goals)
+ return discoveryOptions{pomFile: pomFile, projectList: projectList}
+}
+
+func (BuildTool) ToolName() string { return toolName }
+
+func (BuildTool) RelevantCommands() []string { return []string{"resolve"} }
+
+func (t BuildTool) ProjectRoot(workingDir string) (string, error) {
+ return discoverProjectRootWithOptions(workingDir, t.discoveryOptions())
+}
+
+func (t BuildTool) EnsureLockfiles(_ context.Context, projectRoot, _ string, _ healcomponents.CommandRunner, _ ...string) ([]string, error) {
+ paths, err := discoverPomPathsWithOptions(projectRoot, t.discoveryOptions())
+ if err != nil {
+ return nil, err
+ }
+ if len(paths) == 0 {
+ return nil, errorutils.CheckErrorf("component resolution requires %s under %s", pomFileName, projectRoot)
+ }
+ return nil, nil
+}
+
+func (t BuildTool) DiscoverLockfiles(workingDir string) ([]healcomponents.Lockfile, error) {
+ root, err := discoverProjectRootWithOptions(workingDir, t.discoveryOptions())
+ if err != nil {
+ return nil, err
+ }
+ relPaths, err := discoverPomPathsWithOptions(root, t.discoveryOptions())
+ if err != nil {
+ return nil, err
+ }
+ if len(relPaths) == 0 {
+ return nil, errorutils.CheckErrorf("expected %s under %s", pomFileName, root)
+ }
+ var lockfiles []healcomponents.Lockfile
+ for _, rel := range relPaths {
+ data, readErr := os.ReadFile(filepath.Join(root, rel))
+ if readErr != nil {
+ return nil, errorutils.CheckError(readErr)
+ }
+ lockfiles = append(lockfiles, healcomponents.Lockfile{Path: rel, Content: data})
+ }
+ return lockfiles, nil
+}
diff --git a/artifactory/healcomponents/maven/maven_test.go b/artifactory/healcomponents/maven/maven_test.go
new file mode 100644
index 00000000..aa030187
--- /dev/null
+++ b/artifactory/healcomponents/maven/maven_test.go
@@ -0,0 +1,55 @@
+package maven
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMavenBuildTool_DiscoverLockfiles_MultiModule(t *testing.T) {
+ root := t.TempDir()
+ modA := filepath.Join(root, "module-a")
+ require.NoError(t, os.MkdirAll(modA, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommodule-a`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(modA, "pom.xml"), []byte(`mod`), 0644))
+
+ tool := NewBuildTool()
+ files, err := tool.DiscoverLockfiles(root)
+ require.NoError(t, err)
+ require.Len(t, files, 2)
+}
+
+func TestMavenBuildTool_EnsureLockfiles_ErrorsWhenNoPom(t *testing.T) {
+ dir := t.TempDir()
+ tool := NewBuildTool()
+ _, err := tool.EnsureLockfiles(context.Background(), dir, "resolve", nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "pom.xml")
+}
+
+func TestMavenBuildTool_DiscoverLockfiles_RespectsFileFlag(t *testing.T) {
+ repoRoot := t.TempDir()
+ sub := filepath.Join(repoRoot, "child")
+ require.NoError(t, os.MkdirAll(sub, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "pom.xml"), []byte(`child`), 0644))
+
+ cwd := t.TempDir()
+ tool := NewBuildToolWithGoals([]string{"-f", filepath.Join(sub, "pom.xml"), "install"})
+ files, err := tool.DiscoverLockfiles(cwd)
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ assert.Equal(t, "pom.xml", files[0].Path)
+}
+
+func TestMavenBuildTool_EnsureLockfiles_NoOpWhenPomsExist(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(``), 0644))
+ tool := NewBuildTool()
+ bootstrapped, err := tool.EnsureLockfiles(context.Background(), dir, "resolve", nil)
+ require.NoError(t, err)
+ assert.Empty(t, bootstrapped)
+}
diff --git a/artifactory/healcomponents/maven/pom.go b/artifactory/healcomponents/maven/pom.go
new file mode 100644
index 00000000..a1b10d80
--- /dev/null
+++ b/artifactory/healcomponents/maven/pom.go
@@ -0,0 +1,37 @@
+package maven
+
+import "encoding/xml"
+
+type pomDocument struct {
+ XMLName xml.Name `xml:"project"`
+ Packaging string `xml:"packaging"`
+ Modules struct {
+ Module []string `xml:"module"`
+ } `xml:"modules"`
+}
+
+func parsePom(data []byte) (*pomDocument, error) {
+ var p pomDocument
+ if err := xml.Unmarshal(data, &p); err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+
+func (p *pomDocument) isAggregator() bool {
+ if p == nil {
+ return false
+ }
+ if len(p.Modules.Module) == 0 {
+ return false
+ }
+ // Maven requires packaging=pom for aggregators; treat missing packaging as jar (not aggregator).
+ return p.Packaging == "" || p.Packaging == "pom"
+}
+
+func (p *pomDocument) modulePaths() []string {
+ if p == nil {
+ return nil
+ }
+ return append([]string(nil), p.Modules.Module...)
+}
diff --git a/artifactory/healcomponents/maven/pom_test.go b/artifactory/healcomponents/maven/pom_test.go
new file mode 100644
index 00000000..80fddfd5
--- /dev/null
+++ b/artifactory/healcomponents/maven/pom_test.go
@@ -0,0 +1,38 @@
+package maven
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePom_Aggregator(t *testing.T) {
+ data := []byte(`
+
+ pom
+
+ mod-a
+ services/api
+
+`)
+ p, err := parsePom(data)
+ require.NoError(t, err)
+ assert.True(t, p.isAggregator())
+ assert.Equal(t, []string{"mod-a", "services/api"}, p.modulePaths())
+}
+
+func TestParsePom_SingleModule(t *testing.T) {
+ data := []byte(`app`)
+ p, err := parsePom(data)
+ require.NoError(t, err)
+ assert.False(t, p.isAggregator())
+ assert.Empty(t, p.modulePaths())
+}
+
+func TestParsePom_IgnoresCommentModules(t *testing.T) {
+ data := []byte(`jar`)
+ p, err := parsePom(data)
+ require.NoError(t, err)
+ assert.False(t, p.isAggregator())
+}
diff --git a/artifactory/healcomponents/npm/args_parse.go b/artifactory/healcomponents/npm/args_parse.go
new file mode 100644
index 00000000..9237fa0c
--- /dev/null
+++ b/artifactory/healcomponents/npm/args_parse.go
@@ -0,0 +1,84 @@
+package npm
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/jfrog/jfrog-client-go/utils/errorutils"
+)
+
+type discoveryOptions struct {
+ prefixDir string
+ publishPath string
+}
+
+type npmCLIArgs struct {
+ prefixDir string
+ bootstrapArgs []string
+}
+
+func parseNpmCLIArgs(args []string) npmCLIArgs {
+ var out npmCLIArgs
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "--prefix" || arg == "--cwd" || arg == "-C":
+ if i+1 < len(args) {
+ i++
+ out.prefixDir = args[i]
+ }
+ case strings.HasPrefix(arg, "--prefix="):
+ out.prefixDir = strings.TrimPrefix(arg, "--prefix=")
+ case strings.HasPrefix(arg, "--cwd="):
+ out.prefixDir = strings.TrimPrefix(arg, "--cwd=")
+ case strings.HasPrefix(arg, "-C"):
+ if arg == "-C" {
+ continue
+ }
+ out.prefixDir = strings.TrimPrefix(arg, "-C")
+ case arg == "--workspaces" || arg == "-w":
+ out.bootstrapArgs = append(out.bootstrapArgs, arg)
+ case strings.HasPrefix(arg, "--workspace="):
+ out.bootstrapArgs = append(out.bootstrapArgs, arg)
+ case arg == "--workspace" && i+1 < len(args):
+ out.bootstrapArgs = append(out.bootstrapArgs, arg, args[i+1])
+ i++
+ }
+ }
+ return out
+}
+
+// BootstrapArgsFrom extracts workspace flags to pass to npm install --package-lock-only.
+func BootstrapArgsFrom(npmArgs []string) []string {
+ return parseNpmCLIArgs(npmArgs).bootstrapArgs
+}
+
+func effectiveStartDir(workingDir string, opts discoveryOptions) (string, error) {
+ abs, err := filepath.Abs(workingDir)
+ if err != nil {
+ return "", errorutils.CheckError(err)
+ }
+ if opts.publishPath != "" {
+ return resolveDiscoveryPath(abs, opts.publishPath)
+ }
+ if opts.prefixDir != "" {
+ return resolveDiscoveryPath(abs, opts.prefixDir)
+ }
+ return abs, nil
+}
+
+// resolveDiscoveryPath joins base and p unless p is already absolute.
+// On Windows, Unix-style paths (e.g. /repo/pkg) are not filepath.IsAbs but must not be joined with base.
+func resolveDiscoveryPath(base, p string) (string, error) {
+ if filepath.IsAbs(p) {
+ return filepath.Clean(p), nil
+ }
+ if strings.HasPrefix(filepath.ToSlash(p), "/") {
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ return "", errorutils.CheckError(err)
+ }
+ return filepath.Clean(abs), nil
+ }
+ return filepath.Clean(filepath.Join(base, p)), nil
+}
diff --git a/artifactory/healcomponents/npm/args_parse_test.go b/artifactory/healcomponents/npm/args_parse_test.go
new file mode 100644
index 00000000..e63019a6
--- /dev/null
+++ b/artifactory/healcomponents/npm/args_parse_test.go
@@ -0,0 +1,63 @@
+package npm
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseNpmCLIArgs_Prefix(t *testing.T) {
+ opts := parseNpmCLIArgs([]string{"install", "--prefix", "sub/pkg"})
+ assert.Equal(t, "sub/pkg", opts.prefixDir)
+}
+
+func TestParseNpmCLIArgs_CShort(t *testing.T) {
+ opts := parseNpmCLIArgs([]string{"-C", "services/api", "ci"})
+ assert.Equal(t, "services/api", opts.prefixDir)
+}
+
+func TestParseNpmCLIArgs_PrefixEquals(t *testing.T) {
+ opts := parseNpmCLIArgs([]string{"install", "--prefix=frontend"})
+ assert.Equal(t, "frontend", opts.prefixDir)
+}
+
+func TestParseNpmCLIArgs_WorkspaceBootstrap(t *testing.T) {
+ opts := parseNpmCLIArgs([]string{"install", "--workspace", "@scope/pkg", "-w"})
+ assert.Equal(t, []string{"--workspace", "@scope/pkg", "-w"}, opts.bootstrapArgs)
+}
+
+func TestEffectiveStartDir_PublishPathOverridesCwd(t *testing.T) {
+ root := t.TempDir()
+ publishPath := filepath.Join(root, "packages", "foo")
+ got, err := effectiveStartDir(root, discoveryOptions{publishPath: publishPath})
+ assert.NoError(t, err)
+ assert.Equal(t, publishPath, got)
+}
+
+func TestEffectiveStartDir_PublishPathUnixAbsolute(t *testing.T) {
+ got, err := effectiveStartDir("/repo", discoveryOptions{publishPath: "/repo/packages/foo"})
+ assert.NoError(t, err)
+ want, err := filepath.Abs("/repo/packages/foo")
+ assert.NoError(t, err)
+ assert.Equal(t, want, got)
+}
+
+func TestEffectiveStartDir_PrefixFromArgs(t *testing.T) {
+ root := t.TempDir()
+ got, err := effectiveStartDir(root, discoveryOptions{prefixDir: "sub"})
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(root, "sub"), got)
+}
+
+func TestEffectiveStartDir_PrefixFromArgsUnixRoot(t *testing.T) {
+ got, err := effectiveStartDir("/repo", discoveryOptions{prefixDir: "sub"})
+ assert.NoError(t, err)
+ root, err := filepath.Abs("/repo")
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(root, "sub"), got)
+}
+
+func TestBootstrapArgsFrom(t *testing.T) {
+ assert.Equal(t, []string{"-w"}, BootstrapArgsFrom([]string{"install", "-w"}))
+}
diff --git a/artifactory/healcomponents/npm/discover.go b/artifactory/healcomponents/npm/discover.go
new file mode 100644
index 00000000..832c943b
--- /dev/null
+++ b/artifactory/healcomponents/npm/discover.go
@@ -0,0 +1,68 @@
+package npm
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/jfrog/jfrog-client-go/utils/errorutils"
+)
+
+const (
+ lockfileName = "package-lock.json"
+ shrinkwrapFileName = "npm-shrinkwrap.json"
+)
+
+func discoverProjectRoot(workingDir string) (string, error) {
+ return discoverProjectRootWithOptions(workingDir, discoveryOptions{})
+}
+
+func discoverProjectRootWithOptions(workingDir string, opts discoveryOptions) (string, error) {
+ startDir, err := effectiveStartDir(workingDir, opts)
+ if err != nil {
+ return "", err
+ }
+ dir := startDir
+ var firstPackageJSON string
+ var topWorkspaceRoot string
+ for {
+ pkgPath := filepath.Join(dir, "package.json")
+ if _, statErr := os.Stat(pkgPath); statErr == nil {
+ if firstPackageJSON == "" {
+ firstPackageJSON = dir
+ }
+ if data, readErr := os.ReadFile(pkgPath); readErr == nil {
+ if pkg, parseErr := parsePackageJSON(data); parseErr == nil && pkg.hasWorkspaces() {
+ topWorkspaceRoot = dir
+ }
+ }
+ }
+ if _, statErr := os.Stat(filepath.Join(dir, shrinkwrapFileName)); statErr == nil {
+ return dir, nil
+ }
+ if _, statErr := os.Stat(filepath.Join(dir, lockfileName)); statErr == nil {
+ return dir, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ if topWorkspaceRoot != "" {
+ return topWorkspaceRoot, nil
+ }
+ if firstPackageJSON != "" {
+ return firstPackageJSON, nil
+ }
+ return "", errorutils.CheckErrorf("no %s or lockfile found from %s", "package.json", startDir)
+}
+
+func lockfileNameInDir(dir string) (string, error) {
+ if _, err := os.Stat(filepath.Join(dir, shrinkwrapFileName)); err == nil {
+ return shrinkwrapFileName, nil
+ }
+ if _, err := os.Stat(filepath.Join(dir, lockfileName)); err == nil {
+ return lockfileName, nil
+ }
+ return "", errorutils.CheckErrorf("no %s or %s under %s", shrinkwrapFileName, lockfileName, dir)
+}
diff --git a/artifactory/healcomponents/npm/discover_test.go b/artifactory/healcomponents/npm/discover_test.go
new file mode 100644
index 00000000..2ba27bb2
--- /dev/null
+++ b/artifactory/healcomponents/npm/discover_test.go
@@ -0,0 +1,92 @@
+package npm
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiscoverProjectRoot_FromWorkspacePackage(t *testing.T) {
+ root := t.TempDir()
+ pkgDir := filepath.Join(root, "packages", "app")
+ require.NoError(t, os.MkdirAll(pkgDir, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"workspaces":["packages/*"]}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "package-lock.json"), []byte(`{}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"name":"app"}`), 0644))
+
+ got, err := discoverProjectRoot(pkgDir)
+ require.NoError(t, err)
+ assert.Equal(t, root, got)
+}
+
+func TestDiscoverProjectRoot_PrefixFlag(t *testing.T) {
+ root := t.TempDir()
+ sub := filepath.Join(root, "frontend")
+ require.NoError(t, os.MkdirAll(sub, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "package.json"), []byte(`{"name":"fe"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "package-lock.json"), []byte(`{}`), 0644))
+
+ got, err := discoverProjectRootWithOptions(root, discoveryOptions{prefixDir: "frontend"})
+ require.NoError(t, err)
+ assert.Equal(t, sub, got)
+}
+
+func TestDiscoverProjectRoot_ShrinkwrapPrecedence(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lock":1}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-shrinkwrap.json"), []byte(`{"shrink":1}`), 0644))
+
+ name, err := lockfileNameInDir(dir)
+ require.NoError(t, err)
+ assert.Equal(t, shrinkwrapFileName, name)
+}
+
+func TestDiscoverProjectRoot_IndependentPackageLock(t *testing.T) {
+ root := t.TempDir()
+ svc := filepath.Join(root, "svc-a")
+ require.NoError(t, os.MkdirAll(svc, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"name":"root-no-ws"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(svc, "package.json"), []byte(`{"name":"svc-a"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(svc, "package-lock.json"), []byte(`{}`), 0644))
+
+ got, err := discoverProjectRoot(svc)
+ require.NoError(t, err)
+ assert.Equal(t, svc, got)
+}
+
+func TestDiscoverProjectRoot_NoProjectFound(t *testing.T) {
+ dir := t.TempDir()
+ _, err := discoverProjectRoot(dir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "package.json")
+}
+
+func TestDiscoverProjectRoot_IgnoresStrayPackageJSONWhenLockAbove(t *testing.T) {
+ root := t.TempDir()
+ svc := filepath.Join(root, "svc")
+ stray := filepath.Join(svc, "samples")
+ require.NoError(t, os.MkdirAll(stray, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(svc, "package.json"), []byte(`{"name":"svc"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(svc, "package-lock.json"), []byte(`{}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(stray, "package.json"), []byte(`{"name":"sample"}`), 0644))
+
+ got, err := discoverProjectRoot(stray)
+ require.NoError(t, err)
+ assert.Equal(t, svc, got)
+}
+
+func TestDiscoverProjectRoot_PublishPath(t *testing.T) {
+ root := t.TempDir()
+ pkg := filepath.Join(root, "packages", "lib")
+ require.NoError(t, os.MkdirAll(pkg, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{"name":"lib"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(pkg, "package-lock.json"), []byte(`{}`), 0644))
+
+ got, err := discoverProjectRootWithOptions(root, discoveryOptions{publishPath: filepath.Join("packages", "lib")})
+ require.NoError(t, err)
+ assert.Equal(t, pkg, got)
+}
diff --git a/artifactory/healcomponents/npm/npm.go b/artifactory/healcomponents/npm/npm.go
new file mode 100644
index 00000000..f47eb56f
--- /dev/null
+++ b/artifactory/healcomponents/npm/npm.go
@@ -0,0 +1,91 @@
+package npm
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ "github.com/jfrog/jfrog-client-go/utils/errorutils"
+ "github.com/jfrog/jfrog-client-go/utils/log"
+
+ "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents"
+)
+
+const toolName = "npm"
+
+type BuildTool struct {
+ opts discoveryOptions
+}
+
+func NewBuildTool() BuildTool {
+ return BuildTool{}
+}
+
+func NewBuildToolWithArgs(npmArgs []string) BuildTool {
+ parsed := parseNpmCLIArgs(npmArgs)
+ return BuildTool{opts: discoveryOptions{prefixDir: parsed.prefixDir}}
+}
+
+func NewBuildToolForPublish(workingDir, publishPath string, npmArgs []string) BuildTool {
+ parsed := parseNpmCLIArgs(npmArgs)
+ return BuildTool{opts: discoveryOptions{
+ prefixDir: parsed.prefixDir,
+ publishPath: publishPath,
+ }}
+}
+
+func (BuildTool) ToolName() string { return toolName }
+
+func (BuildTool) RelevantCommands() []string {
+ return []string{"install", "ci", "publish"}
+}
+
+func (t BuildTool) ProjectRoot(workingDir string) (string, error) {
+ return discoverProjectRootWithOptions(workingDir, t.opts)
+}
+
+func (t BuildTool) EnsureLockfiles(ctx context.Context, projectRoot, command string, runner healcomponents.CommandRunner, bootstrapArgs ...string) ([]string, error) {
+ if _, err := os.Stat(filepath.Join(projectRoot, shrinkwrapFileName)); err == nil {
+ return nil, nil
+ } else if !os.IsNotExist(err) {
+ return nil, errorutils.CheckError(err)
+ }
+ lockPath := filepath.Join(projectRoot, lockfileName)
+ if _, err := os.Stat(lockPath); err == nil {
+ return nil, nil
+ } else if !os.IsNotExist(err) {
+ return nil, errorutils.CheckError(err)
+ }
+ switch command {
+ case "ci":
+ return nil, errorutils.CheckErrorf("component resolution requires %s or %s for npm ci (generate with npm install first)", lockfileName, shrinkwrapFileName)
+ case "install", "publish":
+ if runner == nil {
+ return nil, errorutils.CheckErrorf("npm runner required to bootstrap %s", lockfileName)
+ }
+ log.Info("Component resolution: generating ", lockfileName, " (lockfile was missing)")
+ args := append([]string{"install", "--package-lock-only"}, bootstrapArgs...)
+ if err := runner(ctx, projectRoot, args...); err != nil {
+ return nil, errorutils.CheckError(err)
+ }
+ return []string{lockfileName}, nil
+ default:
+ return nil, nil
+ }
+}
+
+func (t BuildTool) DiscoverLockfiles(workingDir string) ([]healcomponents.Lockfile, error) {
+ root, err := discoverProjectRootWithOptions(workingDir, t.opts)
+ if err != nil {
+ return nil, err
+ }
+ name, err := lockfileNameInDir(root)
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(filepath.Join(root, name))
+ if err != nil {
+ return nil, errorutils.CheckError(err)
+ }
+ return []healcomponents.Lockfile{{Path: name, Content: data}}, nil
+}
diff --git a/artifactory/healcomponents/npm/npm_test.go b/artifactory/healcomponents/npm/npm_test.go
new file mode 100644
index 00000000..04aeb13b
--- /dev/null
+++ b/artifactory/healcomponents/npm/npm_test.go
@@ -0,0 +1,125 @@
+package npm
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNpmBuildTool_DiscoverLockfiles_SingleRootLock(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lock":true}`), 0644))
+
+ tool := NewBuildTool()
+ files, err := tool.DiscoverLockfiles(dir)
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ assert.Equal(t, "package-lock.json", files[0].Path)
+ assert.JSONEq(t, `{"lock":true}`, string(files[0].Content))
+}
+
+func TestNpmBuildTool_DiscoverLockfiles_Shrinkwrap(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"a"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-shrinkwrap.json"), []byte(`{"s":1}`), 0644))
+
+ tool := NewBuildTool()
+ files, err := tool.DiscoverLockfiles(dir)
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ assert.Equal(t, "npm-shrinkwrap.json", files[0].Path)
+}
+
+func TestNpmBuildTool_EnsureLockfiles_SkipsWhenShrinkwrapExists(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"a"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-shrinkwrap.json"), []byte(`{}`), 0644))
+
+ called := false
+ runner := func(context.Context, string, ...string) error { called = true; return nil }
+
+ tool := NewBuildTool()
+ _, err := tool.EnsureLockfiles(context.Background(), dir, "install", runner)
+ require.NoError(t, err)
+ assert.False(t, called)
+}
+
+func TestNpmBuildTool_EnsureLockfiles_CiRequiresExistingLock(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644))
+
+ tool := NewBuildTool()
+ _, err := tool.EnsureLockfiles(context.Background(), dir, "ci", nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "package-lock.json")
+}
+
+func TestNpmBuildTool_EnsureLockfiles_InstallBootstrapsWhenMissing(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644))
+
+ var ran []string
+ runner := func(_ context.Context, root string, args ...string) error {
+ ran = append(ran, root+":"+strings.Join(args, " "))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "package-lock.json"), []byte(`{"bootstrapped":true}`), 0644))
+ return nil
+ }
+
+ tool := NewBuildTool()
+ bootstrapped, err := tool.EnsureLockfiles(context.Background(), dir, "install", runner)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"package-lock.json"}, bootstrapped)
+ assert.Contains(t, ran[0], "install --package-lock-only")
+}
+
+func TestNpmBuildTool_EnsureLockfiles_SkipsBootstrapWhenLockExists(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{}`), 0644))
+
+ called := false
+ runner := func(context.Context, string, ...string) error {
+ called = true
+ return nil
+ }
+
+ tool := NewBuildTool()
+ _, err := tool.EnsureLockfiles(context.Background(), dir, "install", runner)
+ require.NoError(t, err)
+ assert.False(t, called)
+}
+
+func TestNpmBuildTool_EnsureLockfiles_PassesWorkspaceFlags(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644))
+
+ var ran []string
+ runner := func(_ context.Context, root string, args ...string) error {
+ ran = append(ran, strings.Join(args, " "))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "package-lock.json"), []byte(`{}`), 0644))
+ return nil
+ }
+
+ tool := NewBuildTool()
+ _, err := tool.EnsureLockfiles(context.Background(), dir, "publish", runner, "--workspaces")
+ require.NoError(t, err)
+ assert.Contains(t, ran[0], "--workspaces")
+}
+
+func TestNewBuildToolWithArgs_Prefix(t *testing.T) {
+ tool := NewBuildToolWithArgs([]string{"install", "--prefix", "frontend"})
+ root := t.TempDir()
+ sub := filepath.Join(root, "frontend")
+ require.NoError(t, os.MkdirAll(sub, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "package.json"), []byte(`{"name":"fe"}`), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(sub, "package-lock.json"), []byte(`{}`), 0644))
+
+ got, err := tool.ProjectRoot(root)
+ require.NoError(t, err)
+ assert.Equal(t, sub, got)
+}
diff --git a/artifactory/healcomponents/npm/package_json.go b/artifactory/healcomponents/npm/package_json.go
new file mode 100644
index 00000000..6e66210a
--- /dev/null
+++ b/artifactory/healcomponents/npm/package_json.go
@@ -0,0 +1,35 @@
+package npm
+
+import "encoding/json"
+
+type packageJSON struct {
+ Workspaces json.RawMessage `json:"workspaces"`
+}
+
+func parsePackageJSON(data []byte) (packageJSON, error) {
+ var p packageJSON
+ if err := json.Unmarshal(data, &p); err != nil {
+ return packageJSON{}, err
+ }
+ return p, nil
+}
+
+func (p packageJSON) hasWorkspaces() bool {
+ if len(p.Workspaces) == 0 || string(p.Workspaces) == "null" {
+ return false
+ }
+ if string(p.Workspaces) == "[]" {
+ return false
+ }
+ var arr []any
+ if json.Unmarshal(p.Workspaces, &arr) == nil {
+ return len(arr) > 0
+ }
+ var obj struct {
+ Packages []any `json:"packages"`
+ }
+ if json.Unmarshal(p.Workspaces, &obj) == nil {
+ return len(obj.Packages) > 0
+ }
+ return true
+}
diff --git a/artifactory/healcomponents/npm/package_json_test.go b/artifactory/healcomponents/npm/package_json_test.go
new file mode 100644
index 00000000..92247ffc
--- /dev/null
+++ b/artifactory/healcomponents/npm/package_json_test.go
@@ -0,0 +1,32 @@
+package npm
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePackageJSON_WorkspacesArray(t *testing.T) {
+ p, err := parsePackageJSON([]byte(`{"workspaces":["packages/*"]}`))
+ require.NoError(t, err)
+ assert.True(t, p.hasWorkspaces())
+}
+
+func TestParsePackageJSON_WorkspacesObject(t *testing.T) {
+ p, err := parsePackageJSON([]byte(`{"workspaces":{"packages":["apps/*"],"nohoist":["**/react-native"]}}`))
+ require.NoError(t, err)
+ assert.True(t, p.hasWorkspaces())
+}
+
+func TestParsePackageJSON_NoWorkspaces(t *testing.T) {
+ p, err := parsePackageJSON([]byte(`{"name":"app"}`))
+ require.NoError(t, err)
+ assert.False(t, p.hasWorkspaces())
+}
+
+func TestParsePackageJSON_EmptyWorkspacesArray(t *testing.T) {
+ p, err := parsePackageJSON([]byte(`{"workspaces":[]}`))
+ require.NoError(t, err)
+ assert.False(t, p.hasWorkspaces())
+}
diff --git a/artifactory/healcomponents/service.go b/artifactory/healcomponents/service.go
new file mode 100644
index 00000000..e29f876c
--- /dev/null
+++ b/artifactory/healcomponents/service.go
@@ -0,0 +1,186 @@
+package healcomponents
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ clientutils "github.com/jfrog/jfrog-client-go/utils"
+ "github.com/jfrog/jfrog-client-go/utils/errorutils"
+ "github.com/jfrog/jfrog-client-go/utils/log"
+ "github.com/jfrog/jfrog-client-go/xray/services"
+)
+
+// HealComponentsDisabledEnvVar disables Xray heal components when set to "true".
+// Feature is enabled by default; users run normal jf commands with no extra setup.
+const HealComponentsDisabledEnvVar = "JFROG_CLI_HEAL_COMPONENTS_DISABLED"
+
+const HealComponentsMinVersion = "3.133.0"
+
+var noopRestore = func() error { return nil }
+
+// SkipHealing logs and returns without error. Component healing is best-effort and must not fail the caller's build.
+func SkipHealing(message string, cause error) (func() error, bool, error) {
+ if cause != nil {
+ log.Debug(message + cause.Error())
+ } else {
+ log.Debug(message)
+ }
+ return noopRestore, false, nil
+}
+
+func skipHealingWarn(message string, cause error) (func() error, bool, error) {
+ if cause != nil {
+ log.Warn(message + cause.Error())
+ } else {
+ log.Warn(message)
+ }
+ return noopRestore, false, nil
+}
+
+func IsComponentResolutionDisabled() bool {
+ return os.Getenv(HealComponentsDisabledEnvVar) == "true"
+}
+
+// Lockfile is a CLI-side lock artifact. Path is relative to project root (read/write only — not sent to Xray).
+type Lockfile struct {
+ Path string
+ Content []byte
+}
+
+type lockfileBackup struct {
+ path string
+ content []byte // nil means the file did not exist before apply
+}
+
+// ComponentResolutionClient resolves a single lockfile via Xray.
+type ComponentResolutionClient interface {
+ GetVersion() (string, error)
+ HealComponents(req services.ComponentResolutionRequest) (*services.ComponentResolutionResponse, bool, error)
+}
+
+// RunIfEnabled ensures lockfiles exist, discovers them, calls Xray once per file, writes healed lockfiles when changes returned.
+// Returns a restore function to revert lockfile writes if the subsequent build-tool command fails, and healed=true when at least one lockfile was updated.
+func RunIfEnabled(ctx context.Context, client ComponentResolutionClient, repo string, tool BuildTool, command, workingDir string, runner CommandRunner, bootstrapArgs ...string) (restore func() error, healed bool, err error) {
+ if IsComponentResolutionDisabled() || !IsRelevantCommand(tool, command) {
+ log.Debug("Xray heal components disabled or not relevant command: ", command)
+ return noopRestore, false, nil
+ }
+ version, err := client.GetVersion()
+ if err != nil {
+ return noopRestore, false, err
+ }
+ log.Debug("Xray version: ", version)
+ if versionErr := clientutils.ValidateMinimumVersion(clientutils.Xray, version, HealComponentsMinVersion); versionErr != nil {
+ return skipHealingWarn("Xray heal components are not supported on the current Xray version. ", versionErr)
+ }
+ log.Debug("Running Xray heal components at '"+repo+"' RT repository for tool:", tool.ToolName())
+ projectRoot, err := tool.ProjectRoot(workingDir)
+ if err != nil {
+ return noopRestore, false, err
+ }
+ log.Debug("Ensuring lockfiles in project root: ", projectRoot)
+ bootstrapped, err := tool.EnsureLockfiles(ctx, projectRoot, command, runner, bootstrapArgs...)
+ if err != nil {
+ return noopRestore, false, err
+ }
+ lockfiles, err := tool.DiscoverLockfiles(workingDir)
+ if err != nil {
+ return noopRestore, false, err
+ }
+ log.Debug("Discovered lockfiles: ", getLockfilePaths(lockfiles))
+ var toWrite []Lockfile
+ var totalChanges int
+ for _, lf := range lockfiles {
+ resp, disabled, err := client.HealComponents(services.ComponentResolutionRequest{
+ BuildTool: tool.ToolName(),
+ Repo: repo,
+ Lockfile: string(lf.Content),
+ })
+ if err != nil {
+ return noopRestore, false, errorutils.CheckError(err)
+ }
+ if disabled {
+ log.Debug("Xray component healing skipped: self-heal is disabled on the server")
+ return noopRestore, false, nil
+ }
+ if len(resp.Changes) == 0 {
+ log.Debug("No changes for ", lf.Path)
+ continue
+ }
+ toWrite = append(toWrite, Lockfile{Path: lf.Path, Content: []byte(resp.Lockfile)})
+ totalChanges += len(resp.Changes)
+ log.Debug("Healed", lf.Path, "with", len(resp.Changes), "package change(s)")
+ for _, ch := range resp.Changes {
+ log.Debug(" ", ch.Package, ":", ch.BeforeIntegrity, "→", ch.AfterIntegrity)
+ }
+ }
+ if len(toWrite) == 0 {
+ return noopRestore, false, nil
+ }
+ log.Debug("Applying", len(toWrite), "healed lockfile(s)...")
+ restore, err = ApplyLockfiles(projectRoot, toWrite, bootstrapped)
+ if err != nil {
+ return noopRestore, false, err
+ }
+ log.Info("Xray component resolution healed", totalChanges, "package change(s) across", len(toWrite), "lockfile(s)")
+ return restore, true, nil
+}
+
+func getLockfilePaths(lockfiles []Lockfile) []string {
+ paths := make([]string, 0, len(lockfiles))
+ for _, lf := range lockfiles {
+ paths = append(paths, lf.Path)
+ }
+ return paths
+}
+
+// ApplyLockfiles backs up existing lockfiles under projectRoot, writes updates, returns restore func.
+// Paths listed in treatAsAbsent are restored by deletion even if they exist on disk (bootstrapped locks).
+func ApplyLockfiles(projectRoot string, lockfiles []Lockfile, treatAsAbsent []string) (restore func() error, err error) {
+ if len(lockfiles) == 0 {
+ return func() error { return nil }, nil
+ }
+
+ absent := make(map[string]bool, len(treatAsAbsent))
+ for _, path := range treatAsAbsent {
+ absent[path] = true
+ }
+
+ var backups []lockfileBackup
+ for _, lf := range lockfiles {
+ fullPath := filepath.Join(projectRoot, lf.Path)
+ backup := lockfileBackup{path: fullPath}
+ if !absent[lf.Path] {
+ data, readErr := os.ReadFile(fullPath)
+ if readErr == nil {
+ backup.content = data
+ } else if !os.IsNotExist(readErr) {
+ return nil, errorutils.CheckError(readErr)
+ }
+ }
+ backups = append(backups, backup)
+
+ if err = os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
+ return nil, errorutils.CheckError(err)
+ }
+ if err = os.WriteFile(fullPath, lf.Content, 0644); err != nil {
+ return nil, errorutils.CheckError(err)
+ }
+ }
+
+ return func() error {
+ for _, backup := range backups {
+ if backup.content == nil {
+ if err = os.Remove(backup.path); err != nil && !os.IsNotExist(err) {
+ return errorutils.CheckError(err)
+ }
+ continue
+ }
+ if err = os.WriteFile(backup.path, backup.content, 0644); err != nil {
+ return errorutils.CheckError(err)
+ }
+ }
+ return nil
+ }, nil
+}
diff --git a/artifactory/healcomponents/service_test.go b/artifactory/healcomponents/service_test.go
new file mode 100644
index 00000000..767a2d4d
--- /dev/null
+++ b/artifactory/healcomponents/service_test.go
@@ -0,0 +1,322 @@
+package healcomponents
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ testsutils "github.com/jfrog/jfrog-client-go/utils/tests"
+ "github.com/jfrog/jfrog-client-go/xray/services"
+)
+
+type mockClient struct {
+ callCount int
+ lastReq services.ComponentResolutionRequest
+ resp services.ComponentResolutionResponse
+ disabled bool
+ version string
+ versionErr error
+}
+
+func (m *mockClient) GetVersion() (string, error) {
+ if m.versionErr != nil {
+ return "", m.versionErr
+ }
+ if m.version != "" {
+ return m.version, nil
+ }
+ return HealComponentsMinVersion, nil
+}
+
+func (m *mockClient) HealComponents(req services.ComponentResolutionRequest) (*services.ComponentResolutionResponse, bool, error) {
+ m.callCount++
+ m.lastReq = req
+ return &m.resp, m.disabled, nil
+}
+
+type mockTool struct {
+ name string
+ commands []string
+ root string
+ lockfiles []Lockfile
+ ensureErr error
+}
+
+func (m mockTool) ToolName() string {
+ if m.name != "" {
+ return m.name
+ }
+ return "npm"
+}
+func (m mockTool) RelevantCommands() []string {
+ if len(m.commands) > 0 {
+ return m.commands
+ }
+ return []string{"install", "ci"}
+}
+func (m mockTool) ProjectRoot(_ string) (string, error) {
+ return m.root, nil
+}
+func (m mockTool) EnsureLockfiles(_ context.Context, _, _ string, _ CommandRunner, _ ...string) ([]string, error) {
+ if m.ensureErr != nil {
+ return nil, m.ensureErr
+ }
+ return nil, nil
+}
+func (m mockTool) DiscoverLockfiles(_ string) ([]Lockfile, error) {
+ return m.lockfiles, nil
+}
+
+func TestIsComponentResolutionDisabled(t *testing.T) {
+ t.Run("enabled by default", func(t *testing.T) {
+ t.Setenv(HealComponentsDisabledEnvVar, "")
+ assert.False(t, IsComponentResolutionDisabled())
+ })
+ t.Run("disabled when env true", func(t *testing.T) {
+ t.Setenv(HealComponentsDisabledEnvVar, "true")
+ assert.True(t, IsComponentResolutionDisabled())
+ })
+ t.Run("not disabled for other values", func(t *testing.T) {
+ t.Setenv(HealComponentsDisabledEnvVar, "false")
+ assert.False(t, IsComponentResolutionDisabled())
+ })
+}
+
+func TestRunIfEnabled_WritesHealedLockfiles(t *testing.T) {
+ dir := t.TempDir()
+ lockPath := filepath.Join(dir, "package-lock.json")
+ require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644))
+
+ client := &mockClient{resp: services.ComponentResolutionResponse{
+ Lockfile: "healed",
+ Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}},
+ }}
+ tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}}
+
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.True(t, healed)
+ assert.Equal(t, 1, client.callCount)
+ assert.Equal(t, "npm", client.lastReq.BuildTool)
+ assert.Equal(t, "npm-virtual", client.lastReq.Repo)
+ assert.Equal(t, "orig", client.lastReq.Lockfile)
+
+ data, err := os.ReadFile(lockPath)
+ require.NoError(t, err)
+ assert.Equal(t, "healed", string(data))
+}
+
+func TestRunIfEnabled_WritesHealedNpmLockAsString(t *testing.T) {
+ dir := t.TempDir()
+ lockPath := filepath.Join(dir, "package-lock.json")
+ orig := `{"lockfileVersion":3,"name":"app"}`
+ require.NoError(t, os.WriteFile(lockPath, []byte(orig), 0644))
+
+ healed := `{"lockfileVersion":3,"name":"app","healed":true}`
+ client := &mockClient{resp: services.ComponentResolutionResponse{
+ Lockfile: healed,
+ Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}},
+ }}
+ tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte(orig)}}}
+
+ _, healedFlag, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.True(t, healedFlag)
+
+ data, err := os.ReadFile(lockPath)
+ require.NoError(t, err)
+ assert.JSONEq(t, healed, string(data))
+ assert.Equal(t, orig, client.lastReq.Lockfile)
+}
+
+func TestRunIfEnabled_SkipsWhenServiceDisabled(t *testing.T) {
+ dir := t.TempDir()
+ lockPath := filepath.Join(dir, "package-lock.json")
+ require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644))
+
+ client := &mockClient{disabled: true}
+ tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}}
+
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.False(t, healed)
+ assert.Equal(t, 1, client.callCount)
+
+ data, err := os.ReadFile(lockPath)
+ require.NoError(t, err)
+ assert.Equal(t, "orig", string(data))
+}
+
+func TestRunIfEnabled_SkipsWhenDisabled(t *testing.T) {
+ t.Setenv(HealComponentsDisabledEnvVar, "true")
+ client := &mockClient{}
+ tool := mockTool{}
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", t.TempDir(), nil)
+ require.NoError(t, err)
+ assert.False(t, healed)
+ assert.Equal(t, 0, client.callCount)
+}
+
+func TestRunIfEnabled_SkipsWhenNoChanges(t *testing.T) {
+ dir := t.TempDir()
+ lockPath := filepath.Join(dir, "package-lock.json")
+ require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644))
+
+ client := &mockClient{resp: services.ComponentResolutionResponse{
+ Lockfile: "orig",
+ Changes: nil,
+ }}
+ tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}}
+
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.False(t, healed)
+
+ data, err := os.ReadFile(lockPath)
+ require.NoError(t, err)
+ assert.Equal(t, "orig", string(data))
+}
+
+func TestRunIfEnabled_LoopsPerDiscoveredFile(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "a.lock"), []byte("a"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "b.lock"), []byte("b"), 0644))
+
+ client := &mockClient{resp: services.ComponentResolutionResponse{Lockfile: "x", Changes: nil}}
+ tool := mockTool{root: dir, lockfiles: []Lockfile{
+ {Path: "a.lock", Content: []byte("a")},
+ {Path: "b.lock", Content: []byte("b")},
+ }}
+ _, _, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 2, client.callCount)
+}
+
+func TestRunIfEnabled_SkipsIrrelevantCommand(t *testing.T) {
+ client := &mockClient{}
+ tool := mockTool{}
+ _, _, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "version", t.TempDir(), nil)
+ require.NoError(t, err)
+ assert.Equal(t, 0, client.callCount)
+}
+
+func TestRunIfEnabled_WritesHealedMavenPOM(t *testing.T) {
+ dir := t.TempDir()
+ pomPath := filepath.Join(dir, "pom.xml")
+ orig := []byte(`before`)
+ require.NoError(t, os.WriteFile(pomPath, orig, 0644))
+
+ healedXML := `after`
+ client := &mockClient{resp: services.ComponentResolutionResponse{
+ Lockfile: healedXML,
+ Changes: []services.Change{{Package: "com.demo:lib:1.0", BeforeIntegrity: "a", AfterIntegrity: "b"}},
+ }}
+ tool := mockTool{
+ name: "maven",
+ commands: []string{"resolve"},
+ root: dir,
+ lockfiles: []Lockfile{{Path: "pom.xml", Content: orig}},
+ }
+
+ _, healed, err := RunIfEnabled(context.Background(), client, "maven-virtual", tool, "resolve", dir, nil)
+ require.NoError(t, err)
+ assert.True(t, healed)
+ assert.Equal(t, string(orig), client.lastReq.Lockfile)
+
+ data, err := os.ReadFile(pomPath)
+ require.NoError(t, err)
+ assert.Equal(t, healedXML, string(data))
+}
+
+func TestRunIfEnabled_PropagatesEnsureLockfilesError(t *testing.T) {
+ client := &mockClient{}
+ tool := mockTool{ensureErr: errors.New("package-lock.json is required for npm ci")}
+ _, _, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "ci", t.TempDir(), nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "package-lock.json")
+ assert.Equal(t, 0, client.callCount)
+}
+
+func TestRunIfEnabled_PropagatesGetVersionError(t *testing.T) {
+ client := &mockClient{versionErr: errors.New("xray unavailable")}
+ tool := mockTool{root: t.TempDir(), lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}}
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", t.TempDir(), nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "xray unavailable")
+ assert.False(t, healed)
+ assert.Equal(t, 0, client.callCount)
+}
+
+func TestRunIfEnabled_SkipsWhenXrayVersionTooLow(t *testing.T) {
+ dir := t.TempDir()
+ lockPath := filepath.Join(dir, "package-lock.json")
+ require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644))
+
+ client := &mockClient{
+ version: "3.100.0",
+ resp: services.ComponentResolutionResponse{
+ Lockfile: "healed",
+ Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}},
+ },
+ }
+ tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}}
+
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.False(t, healed)
+ assert.Equal(t, 0, client.callCount)
+
+ data, err := os.ReadFile(lockPath)
+ require.NoError(t, err)
+ assert.Equal(t, "orig", string(data))
+}
+
+func TestRunIfEnabled_AllowsXrayDevVersion(t *testing.T) {
+ dir := t.TempDir()
+ lockPath := filepath.Join(dir, "package-lock.json")
+ require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644))
+
+ client := &mockClient{
+ version: "3.x-dev",
+ resp: services.ComponentResolutionResponse{
+ Lockfile: "healed",
+ Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}},
+ },
+ }
+ tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}}
+
+ _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil)
+ require.NoError(t, err)
+ assert.True(t, healed)
+ assert.Equal(t, 1, client.callCount)
+
+ data, err := os.ReadFile(lockPath)
+ require.NoError(t, err)
+ assert.Equal(t, "healed", string(data))
+}
+
+func TestApplyLockfiles_WritesMultipleFiles(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("a"), 0644))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "app"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "app/gradle.lockfile"), []byte("b"), 0644))
+
+ restore, err := ApplyLockfiles(dir, []Lockfile{
+ {Path: "package-lock.json", Content: []byte("a-healed")},
+ {Path: "app/gradle.lockfile", Content: []byte("b-healed")},
+ }, nil)
+ require.NoError(t, err)
+ defer testsutils.RemoveAllAndAssert(t, dir)
+
+ a, _ := os.ReadFile(filepath.Join(dir, "package-lock.json"))
+ b, _ := os.ReadFile(filepath.Join(dir, "app/gradle.lockfile"))
+ assert.Equal(t, "a-healed", string(a))
+ assert.Equal(t, "b-healed", string(b))
+
+ require.NoError(t, restore())
+}
diff --git a/go.mod b/go.mod
index 118c7b34..a93c4c3e 100644
--- a/go.mod
+++ b/go.mod
@@ -198,6 +198,16 @@ require (
sigs.k8s.io/yaml v1.6.0 // indirect
)
+// replace github.com/jfrog/jfrog-client-go => /Users/assafa/code/jfrog/jfrog-client-go
+
+// replace github.com/jfrog/jfrog-cli-core/v2 => /Users/assafa/code/jfrog/jfrog-cli-core
+
+// attiasas:xray_component_resolution
+replace github.com/jfrog/jfrog-client-go => github.com/attiasas/jfrog-client-go v0.0.0-20260623101014-8f8f09cbd450
+
+// attiasas:expend_xray_manager
+replace github.com/jfrog/jfrog-cli-core/v2 => github.com/attiasas/jfrog-cli-core/v2 v2.0.0-20260623101547-db7e26e572b3
+
// replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260604085947-7c110b77b4b4
// replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab
diff --git a/go.sum b/go.sum
index 359c541c..f9feaadc 100644
--- a/go.sum
+++ b/go.sum
@@ -59,6 +59,10 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/attiasas/jfrog-cli-core/v2 v2.0.0-20260623101547-db7e26e572b3 h1:3zg6St+tq0Mt6NsmrY9PlTvD5y5YgdRfxL2qf5pI7/c=
+github.com/attiasas/jfrog-cli-core/v2 v2.0.0-20260623101547-db7e26e572b3/go.mod h1:X4RdJAWdHMXZLBcvBeC9dYR12JKysbVNIRngtk+N7M8=
+github.com/attiasas/jfrog-client-go v0.0.0-20260623101014-8f8f09cbd450 h1:Kk04jzpBs31RdhaHVZ+WBY8WSQgCKZCDcymc2M/5IoI=
+github.com/attiasas/jfrog-client-go v0.0.0-20260623101014-8f8f09cbd450/go.mod h1:FHpjN1nTDoj96xd6obe27EOgGErqzU0rQgC96L3Ch9E=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
@@ -384,12 +388,8 @@ github.com/jfrog/froggit-go v1.21.1 h1:I/XUOO6GQ1d/rmBlM361F8T654C3ohIWrpw23xNL9
github.com/jfrog/froggit-go v1.21.1/go.mod h1:umBiakJB0CSPFfe0AHVaC3n9xsmUT7NGkDCny3bRchI=
github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s=
github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4=
-github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260609101026-df3091b39d06 h1:A8hWKHyvqzGXfWmh+8lXv3waAkim4xiucBfGhl7ZOeQ=
-github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260609101026-df3091b39d06/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE=
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.20260508101905-a17af78a38d7 h1:o8fk4yWLqNMldarXyh/4NbmdbYbuM+lKYobdJK7shqM=
-github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7/go.mod h1:sCE06+GngPoyrGO0c+vmhgMoVSP83UMNiZnIuNPzU8U=
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=