Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions artifactory/commands/npm/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 28 additions & 5 deletions artifactory/commands/npm/npmcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package npm

import (
"bufio"
"context"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -135,7 +137,7 @@ func (nc *NpmCommand) Init() error {
if err != nil {
return err
}

repoConfig, err := nc.getRepoConfig(vConfig)
if err != nil {
return err
Expand Down Expand Up @@ -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())
}()
Expand Down Expand Up @@ -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())
}

Expand Down
13 changes: 12 additions & 1 deletion artifactory/commands/npm/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package npm
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -37,7 +38,6 @@ const (

type NpmPublishCommandArgs struct {
CommonArgs
executablePath string
workingDirectory string
collectBuildInfo bool
packedFilePaths []string
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions artifactory/commands/npm/xrayheal.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions artifactory/commands/npm/xrayheal_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
26 changes: 26 additions & 0 deletions artifactory/healcomponents/buildtool.go
Original file line number Diff line number Diff line change
@@ -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)
}
30 changes: 30 additions & 0 deletions artifactory/healcomponents/buildtool_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
Loading
Loading