diff --git a/buildtools/cli.go b/buildtools/cli.go index 4daff389c..c528de271 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -5,6 +5,7 @@ import ( "fmt" conancommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/conan" nixcommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/nix" + rubycommandexec "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ruby" "io/fs" "os" "os/exec" @@ -81,6 +82,7 @@ import ( "github.com/jfrog/jfrog-cli/docs/buildtools/pnpmconfig" "github.com/jfrog/jfrog-cli/docs/buildtools/poetry" "github.com/jfrog/jfrog-cli/docs/buildtools/poetryconfig" + "github.com/jfrog/jfrog-cli/docs/buildtools/rubycommand" uvcommand "github.com/jfrog/jfrog-cli/docs/buildtools/uvcommand" yarndocs "github.com/jfrog/jfrog-cli/docs/buildtools/yarn" "github.com/jfrog/jfrog-cli/docs/buildtools/yarnconfig" @@ -443,6 +445,19 @@ func GetCommands() []cli.Command { return cliutils.CreateConfigCmd(c, project.Ruby) }, }, + { + Name: "ruby", + Hidden: false, + Flags: cliutils.GetCommandFlags(cliutils.Ruby), + Usage: rubycommand.GetDescription(), + HelpName: corecommon.CreateUsage("ruby", rubycommand.GetDescription(), rubycommand.Usage), + UsageText: rubycommand.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + SkipFlagParsing: true, + BashComplete: corecommon.CreateBashCompletionFunc(), + Category: buildToolsCategory, + Action: RubyCmd, + }, { Name: "npm-config", Flags: cliutils.GetCommandFlags(cliutils.NpmConfig), @@ -2118,6 +2133,66 @@ func NixCmd(c *cli.Context) error { return commands.ExecWithPackageManager(cmd, "nix") } +// RubyCmd wraps the native 'gem' and 'bundle' tools with Artifactory auth and +// build-info support. The first argument selects the native tool (gem/bundle); +// the remainder is passed through. Only --server-id and the build flags are +// interpreted by jf (config-less native flow, like 'jf uv'). +func RubyCmd(c *cli.Context) error { + if show, err := cliutils.ShowCmdHelpIfNeeded(c, c.Args()); show || err != nil { + return err + } + if c.NArg() < 1 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + + args := cliutils.ExtractCommand(c) + + // First arg selects the native tool: "gem" or "bundle". + nativeTool, remainingArgs := getCommandName(args) + + // Extract --server-id before passing args to the native tool. + var serverID string + var err error + remainingArgs, serverID, err = coreutils.ExtractServerIdFromCommand(remainingArgs) + if err != nil { + return fmt.Errorf("failed to extract server ID: %w", err) + } + + // Extract --repo (Artifactory repository name for URL construction). + var repo string + remainingArgs, repo = extractRubyRepoFromArgs(remainingArgs) + + // Extract build flags (--build-name, --build-number, --module, --project). + filteredArgs, buildConfiguration, err := build.ExtractBuildDetailsFromArgs(remainingArgs) + if err != nil { + return err + } + + cmd := rubycommandexec.NewRubyCommand(). + SetNativeTool(nativeTool). + SetArgs(filteredArgs). + SetServerID(serverID). + SetRepo(repo). + SetBuildConfiguration(buildConfiguration) + + return commands.ExecWithPackageManager(cmd, project.Ruby.String()) +} + +// extractRubyRepoFromArgs extracts and consumes --repo from the args slice. +func extractRubyRepoFromArgs(args []string) (cleanArgs []string, repo string) { + for i := 0; i < len(args); i++ { + if args[i] == "--repo" && i+1 < len(args) { + repo = args[i+1] + i++ // skip the value + } else if strings.HasPrefix(args[i], "--repo=") { + repo = strings.TrimPrefix(args[i], "--repo=") + } else { + cleanArgs = append(cleanArgs, args[i]) + } + } + return cleanArgs, repo +} + func pythonCmd(c *cli.Context, projectType project.ProjectType) error { if show, err := cliutils.ShowCmdHelpIfNeeded(c, c.Args()); show || err != nil { return err diff --git a/docs/buildtools/rubycommand/help.go b/docs/buildtools/rubycommand/help.go new file mode 100644 index 000000000..30f91c72b --- /dev/null +++ b/docs/buildtools/rubycommand/help.go @@ -0,0 +1,33 @@ +package rubycommand + +var Usage = []string{"ruby [command options]"} + +func GetDescription() string { + return "Run native RubyGems (gem) and Bundler (bundle) commands with Artifactory authentication and build-info support." +} + +func GetArguments() string { + return ` ruby + Wraps the native 'gem' and 'bundle' tools. The first argument selects the + native tool; everything after it is passed straight through. Only + --build-name, --build-number, --module, --project, --server-id and --repo + are interpreted by jf. + + Authentication is injected automatically from your jf server config and + respects credentials you have already configured natively (Gemfile source, + .bundle/config, ~/.gem/credentials, BUNDLE_* / GEM_HOST_API_KEY env vars). + + The --repo flag specifies the Artifactory repository name and constructs + the full URL from your server config (eliminating the need to pass full + Artifactory URLs). When --repo is used with gem install/push, the + --source/--host argument is injected automatically. + + Examples: + - jf ruby bundle install --build-name=my-build --build-number=1 + - jf ruby bundle update rake --server-id=my-rt + - jf ruby gem install rails --repo gems-virtual + - jf ruby gem install rails --source https://server/artifactory/api/gems/gems-remote/ + - jf ruby gem push my_gem-1.0.0.gem --repo gems-local --build-name=my-build --build-number=1 + - jf ruby gem push my_gem-1.0.0.gem --host https://server/artifactory/api/gems/gems-local/ --build-name=my-build --build-number=1 + - jf ruby gem install rake --repo gems-virtual --server-id my-rt` +} diff --git a/go.mod b/go.mod index 01570e815..825df3397 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ replace ( github.com/CycloneDX/cyclonedx-go => github.com/CycloneDX/cyclonedx-go v0.10.0 // Should not be updated to 0.2.6 due to a bug (https://github.com/jfrog/jfrog-cli-core/pull/372) github.com/c-bata/go-prompt => github.com/c-bata/go-prompt v0.2.5 + github.com/jfrog/build-info-go => ../build-info-go + github.com/jfrog/jfrog-cli-artifactory => ../jfrog-cli-artifactory // Should not be updated to 0.2.0-beta.2 due to a bug (https://github.com/jfrog/jfrog-cli-core/pull/372) github.com/pkg/term => github.com/pkg/term v1.1.0 ) diff --git a/go.sum b/go.sum index 381622e55..668977831 100644 --- a/go.sum +++ b/go.sum @@ -394,8 +394,6 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260615080618-42488b58c305 h1:q7/hTPm6ibQf45CztScTgPb8cAmKIeQ9im0ClISsq7Y= -github.com/jfrog/build-info-go v1.13.1-0.20260615080618-42488b58c305/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.22.0 h1:eeN5F8sOUo+h2cXkzArAu4nvSdjkDTAZtgqwrct70qg= github.com/jfrog/froggit-go v1.22.0/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI= github.com/jfrog/go-mockhttp v0.3.1 h1:/wac8v4GMZx62viZmv4wazB5GNKs+GxawuS1u3maJH8= @@ -406,8 +404,6 @@ github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYL github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260617073349-d68ee3120aa8 h1:FG+SfgPgrIuBHSos4sw4KNZq2MKxebbCZ6KZZRfaYcs= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260617073349-d68ee3120aa8/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260618051529-1b76b6ad2606 h1:hlc8XoqySjbrvKKjxswyXQ/q5I0Px9FcZpVZUTd+T3M= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260618051529-1b76b6ad2606/go.mod h1:VqV0Bed11HoBlugAEGa3RumbwnDVslEf0gKocTzLs9s= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260615072209-8ccac4f0072e h1:E3B8OyEkCsdEdGsZifTphBDUPrd00yKoemL9+l25Qj8= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260615072209-8ccac4f0072e/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.5-0.20260601141509-8df6c9a4bc9b h1:V0FxnU3xh29y8yJHWymm6rPr1MrjG1DdPQlr3ckImwk= diff --git a/packagealias/dispatch.go b/packagealias/dispatch.go index 7adb2b405..b1e9cdaa2 100644 --- a/packagealias/dispatch.go +++ b/packagealias/dispatch.go @@ -116,10 +116,15 @@ func runJFMode(tool string, args []string) error { return fmt.Errorf("%s could not determine executable path: %w", ghostFrogLogPrefix, err) } - newArgs := make([]string, 0, len(os.Args)+1) + newArgs := make([]string, 0, len(os.Args)+2) newArgs = append(newArgs, execPath) // Use actual executable path - newArgs = append(newArgs, tool) // Add tool name as first argument - newArgs = append(newArgs, args...) // Add remaining arguments + // RubyGems (gem) and Bundler (bundle) are exposed under the single + // `jf ruby ` dispatcher rather than top-level `jf gem`/`jf bundle`. + if tool == "gem" || tool == "bundle" { + newArgs = append(newArgs, "ruby") + } + newArgs = append(newArgs, tool) // Add tool name as first argument + newArgs = append(newArgs, args...) // Add remaining arguments os.Args = newArgs diff --git a/ruby_test.go b/ruby_test.go new file mode 100644 index 000000000..d0351b2ab --- /dev/null +++ b/ruby_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "os/exec" + "testing" + + coretests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/stretchr/testify/assert" +) + +// These tests exercise the `jf ruby ` command wiring end-to-end without +// requiring a live Artifactory: help/version sub-commands bypass auth injection and +// Artifactory calls, so they validate the dispatcher, flag extraction and native exec. + +func rubyToolAvailable(t *testing.T, tool string) { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("'%s' is not installed; skipping ruby command test", tool) + } +} + +func TestRubyGemVersionPassthrough(t *testing.T) { + rubyToolAvailable(t, "gem") + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // `jf ruby gem --version` must pass straight through to the gem binary. + assert.NoError(t, jfrogCli.Exec("ruby", "gem", "--version")) +} + +func TestRubyBundleVersionPassthrough(t *testing.T) { + rubyToolAvailable(t, "bundle") + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + assert.NoError(t, jfrogCli.Exec("ruby", "bundle", "--version")) +} + +func TestRubyGemHelpPassthrough(t *testing.T) { + rubyToolAvailable(t, "gem") + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // `help` sub-command bypasses auth and must not error. + assert.NoError(t, jfrogCli.Exec("ruby", "gem", "help")) +} + +func TestRubyUnsupportedToolErrors(t *testing.T) { + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // An unsupported native tool must be rejected by RubyCommand.Run. + err := jfrogCli.Exec("ruby", "npm", "install") + assert.Error(t, err) +} + +func TestRubyNoArgsErrors(t *testing.T) { + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // `jf ruby` with no native tool must report a usage error, not panic. + err := jfrogCli.Exec("ruby") + assert.Error(t, err) +} + +func TestRubyRepoFlagPassthrough(t *testing.T) { + rubyToolAvailable(t, "gem") + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // --repo is consumed by jf and should not be passed to gem. + // `gem help` should still work fine with --repo being stripped. + assert.NoError(t, jfrogCli.Exec("ruby", "gem", "help", "--repo", "gems-virtual")) +} diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 4cf45994e..fb8273625 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -89,6 +89,7 @@ const ( ConanConfig = "conan-config" Conan = "conan" Nix = "nix" + Ruby = "ruby" Ping = "ping" RtCurl = "rt-curl" TemplateConsumer = "template-consumer" @@ -2243,6 +2244,9 @@ var commandFlags = map[string][]string{ Nix: { BuildName, BuildNumber, module, Project, serverId, }, + Ruby: { + BuildName, BuildNumber, module, Project, serverId, repo, + }, Stats: { XrFormat, accessToken, serverId, },