diff --git a/.github/workflows/build-gate.yml b/.github/workflows/build-gate.yml index b823511e3..4994e0464 100644 --- a/.github/workflows/build-gate.yml +++ b/.github/workflows/build-gate.yml @@ -141,6 +141,10 @@ jobs: needs: gate uses: ./.github/workflows/nugetTests.yml secrets: inherit + psresource: + needs: gate + uses: ./.github/workflows/psresourceTests.yml + secrets: inherit oidc: needs: gate # OIDC suite: caller must grant id-token so the reusable workflow can request it. @@ -217,6 +221,7 @@ jobs: - alpine - npm - nuget + - psresource - oidc - plugins - pnpm diff --git a/.github/workflows/chocoTests.yml b/.github/workflows/chocoTests.yml index 93c5a58fc..6c3ea6ac9 100644 --- a/.github/workflows/chocoTests.yml +++ b/.github/workflows/chocoTests.yml @@ -51,25 +51,3 @@ jobs: run: >- go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.choco ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} - - # The OS gate is the one behaviour that can only be observed where Chocolatey cannot run, so it - # needs a non-Windows job. Deliberately cheap: no Artifactory and no Chocolatey, just the two - # tests asserting that 'jf choco' refuses to run and that 'jf choco --help' still works anyway. - Choco-OS-Gate: - name: Chocolatey OS gate (linux) - runs-on: ubuntu-24.04 - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha || github.ref }} - # Safe: this workflow only runs after human approval via the build-gate environment. - allow-unsafe-pr-checkout: true - - - name: Setup Go with cache - uses: jfrog/.github/actions/install-go-with-cache@main - - - name: Assert jf choco refuses to run on a non-Windows host - run: >- - go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.choco - -run 'TestChocoNonWindowsGate|TestChocoHelpWorksOnAllPlatforms' diff --git a/.github/workflows/psresourceTests.yml b/.github/workflows/psresourceTests.yml new file mode 100644 index 000000000..8801e8558 --- /dev/null +++ b/.github/workflows/psresourceTests.yml @@ -0,0 +1,57 @@ +name: PSResource Tests + +on: + workflow_call: + workflow_dispatch: + +jobs: + PSResource-Tests: + name: PSResource tests (windows) + # PSResourceGet itself is cross-platform (it ships with PowerShell 7+, which GitHub's ubuntu + # and macos runners also carry), so this suite could in principle run on a matrix. It is + # deliberately pinned to Windows only, matching the Chocolatey suite: PowerShell is the native + # shell there, so Windows is both the primary platform for these cmdlets and the configuration + # most likely to break. Running a single job also keeps one 'Install local Artifactory' step + # per suite instead of three, which is where the non-Windows jobs of other suites have been + # failing. If cross-platform coverage is wanted later, add ubuntu/macos back as a matrix here - + # nothing in the command implementation is Windows-specific. + runs-on: windows-2022 + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + # Safe: this workflow only runs after human approval via the build-gate environment. + allow-unsafe-pr-checkout: true + + - name: Setup FastCI + uses: jfrog-fastci/fastci@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + fastci_otel_token: ${{ secrets.FASTCI_TOKEN }} + + - name: Setup Go with cache + uses: jfrog/.github/actions/install-go-with-cache@main + + # GitHub-hosted windows runners ship PowerShell 7 as 'pwsh', so this only verifies it is + # present rather than installing it - failing here gives a clearer signal than letting every + # psresource test fail later on a missing interpreter. + - name: Verify PowerShell is installed + run: pwsh -v + + - name: Install Microsoft.PowerShell.PSResourceGet + shell: pwsh + run: | + Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force -Repository PSGallery + Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet + + - name: Install local Artifactory + uses: jfrog/.github/actions/install-local-artifactory@main + with: + RTLIC: ${{ secrets.RTLIC }} + RT_CONNECTION_TIMEOUT_SECONDS: ${{ env.RT_CONNECTION_TIMEOUT_SECONDS || '1200' }} + + - name: Run PSResource tests + run: >- + go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.psresource + ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} diff --git a/buildtools/cli.go b/buildtools/cli.go index 6928785c3..fa064fff7 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -22,6 +22,7 @@ import ( conancommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/conan" nixcommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/nix" nugetcommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/nuget" + psresourcecommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/psresource" rubycommandexec "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ruby" "github.com/BurntSushi/toml" @@ -97,6 +98,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" + psresourcedocs "github.com/jfrog/jfrog-cli/docs/buildtools/psresource" "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" @@ -120,7 +122,7 @@ const ( ) func GetCommands() []cli.Command { - cmds := cliutils.GetSortedCommands(cli.CommandsByName{ + cmds := cliutils.GetSortedCommands(append(cli.CommandsByName{ { Hidden: false, Name: "setup", @@ -689,10 +691,55 @@ func GetCommands() []cli.Command { }, }, }, - }) + }, psResourceCommandEntries()...)) return decorateWithFlagCapture(cmds) } +// psResourceCommandEntries builds the cli.Command entries for the four top-level PSResourceGet +// commands (Install-PSResource, Save-PSResource, Update-PSResource, Publish-PSResource) - one per +// native PowerShell PSResourceGet cmdlet - from a shared table instead of four hand-duplicated +// cli.Command literals. Final ordering among all commands is unaffected: GetCommands sorts the +// full command set alphabetically via cliutils.GetSortedCommands regardless of insertion order. +func psResourceCommandEntries() []cli.Command { + names := []string{ + psresourcecommand.SubCommandInstall, + psresourcecommand.SubCommandSave, + psresourcecommand.SubCommandUpdate, + psresourcecommand.SubCommandPublish, + } + cmds := make([]cli.Command, 0, len(names)) + for _, name := range names { + description := corecommon.ResolveDescription(psresourcedocs.GetDescription(name), psresourcedocs.GetAIDescription(name)) + cmds = append(cmds, cli.Command{ + Name: name, + Flags: cliutils.GetCommandFlags(cliutils.PSResource), + Usage: description, + HelpName: corecommon.CreateUsage(name, description, psresourcedocs.Usage(name)), + UsageText: psresourcedocs.GetArguments(name), + ArgsUsage: common.CreateEnvVars(), + SkipFlagParsing: true, + BashComplete: corecommon.CreateBashCompletionFunc(), + Category: buildToolsCategory, + Action: func(c *cli.Context) error { + // jfrog-cli-security's post-failure curation audit gates on a fixed, generic + // verb allowlist ({install, build, i, add, ci, get, mod}) shared across every + // package manager - it never matches this cmdlet's own PascalCase name + // ("Install-PSResource" etc.), so passing name here made the audit a silent + // no-op for all four commands. Install/Save/Update are install-like resolve + // actions (the curation-blockable case this audit exists for), so they pass the + // matching "install" verb. Publish-PSResource uploads rather than resolves a + // package - curation cannot block it in the way this audit checks for - so it + // runs directly, without a cmdName that would never legitimately apply. + if name == psresourcecommand.SubCommandPublish { + return psResourceCmd(name)(c) + } + return securityCLI.WrapCmdWithCurationPostFailureRun(c, psResourceCmd(name), techutils.Nuget, "install") + }, + }) + } + return cmds +} + func skipFlagParsingForDockerCmd() bool { isDockerScan := false hasHelpFlag := false @@ -1252,6 +1299,57 @@ func DotnetCmd(c *cli.Context) error { return commands.ExecWithPackageManager(dotnetCmd, project.Dotnet.String()) } +// psResourceCmd returns the Action for one of the four PSResourceGet top-level commands +// (Install-PSResource, Save-PSResource, Update-PSResource, Publish-PSResource). Unlike jf choco, +// which is a single "jf choco " command that parses its subcommand out of +// c.Args()[0], PSResourceGet exposes its cmdlets directly as top-level jf commands - each +// registered cli.Command already knows which native cmdlet it is, so cmdletName is fixed per +// caller (a closure variable) rather than parsed from the arguments. Everything after that is the +// cmdlet's own native parameters, forwarded through unchanged. +func psResourceCmd(cmdletName string) func(c *cli.Context) error { + return func(c *cli.Context) error { + if show, err := cliutils.ShowGenericCmdHelpIfNeeded(c, c.Args(), c.Command.Name); show || err != nil { + return err + } + args := cliutils.ExtractCommand(c) + args, serverID, err := coreutils.ExtractServerIdFromCommand(args) + if err != nil { + return fmt.Errorf("extract server ID: %w", err) + } + filteredArgs, buildConfiguration, err := build.ExtractBuildDetailsFromArgs(args) + if err != nil { + return err + } + filteredArgs, repoResolve, err := coreutils.ExtractStringOptionFromArgs(filteredArgs, "repo-resolve") + if err != nil { + return fmt.Errorf("extract --repo-resolve: %w", err) + } + filteredArgs, repoDeploy, err := coreutils.ExtractStringOptionFromArgs(filteredArgs, "repo") + if err != nil { + return fmt.Errorf("extract --repo: %w", err) + } + workingDirectory, err := filepath.Abs(".") + if err != nil { + return err + } + command := psresourcecommand.NewPSResourceFlexPackCommand(). + SetSubCommand(cmdletName). + SetArgs(filteredArgs). + SetRepoResolve(repoResolve). + SetRepoDeploy(repoDeploy). + SetBuildConfiguration(buildConfiguration). + SetWorkingDirectory(workingDirectory) + serverDetails, err := coreConfig.GetSpecificConfig(serverID, true, false) + if err != nil && serverID != "" { + return fmt.Errorf("server-id %q not found: %w", serverID, err) + } + if err == nil { + command.SetServerDetails(serverDetails) + } + return commands.ExecWithPackageManager(command, "psresource") + } +} + func getNugetAndDotnetConfigFields(configFilePath string) (rtDetails *coreConfig.ServerDetails, targetRepo string, useNugetV2 bool, err error) { vConfig, err := project.ReadConfigFile(configFilePath, project.YAML) if err != nil { @@ -2050,6 +2148,11 @@ func setupCmd(c *cli.Context) (err error) { return } } + if packageManager == project.PSResource { + if err = setup.ValidatePSResourcePlatform(); err != nil { + return err + } + } if packageManager == project.Choco { if err = setup.ValidateChocoPlatform(); err != nil { return err diff --git a/buildtools/cli_test.go b/buildtools/cli_test.go index 89e2b2753..573b8f384 100644 --- a/buildtools/cli_test.go +++ b/buildtools/cli_test.go @@ -7,7 +7,9 @@ import ( dotnetutils "github.com/jfrog/build-info-go/build/utils/dotnet" containerutils "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ocicontainer" + psresourcecommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/psresource" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + securityCLI "github.com/jfrog/jfrog-cli-security/cli" securityDocs "github.com/jfrog/jfrog-cli-security/cli/docs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -399,3 +401,40 @@ func TestResolveContainerManagerType(t *testing.T) { }) } } + +// TestPSResourceCurationCmdNameMatchesAllowlist guards against a real bug: the curation +// post-failure audit is gated behind jfrog-cli-security's own fixed, generic verb allowlist +// ({install, build, i, add, ci, get, mod}), not this cmdlet's own PascalCase name - passing +// "Install-PSResource" (etc.) as cmdName made the audit a silent no-op for every PSResource +// command, since none of those names is ever in that allowlist. +func TestPSResourceCurationCmdNameMatchesAllowlist(t *testing.T) { + assert.True(t, securityCLI.IsSupportedCommandForCurationInspect("install"), + "the verb this fix actually passes must be one jfrog-cli-security recognizes") + + for _, rawCmdletName := range []string{ + psresourcecommand.SubCommandInstall, + psresourcecommand.SubCommandSave, + psresourcecommand.SubCommandUpdate, + psresourcecommand.SubCommandPublish, + } { + assert.False(t, securityCLI.IsSupportedCommandForCurationInspect(rawCmdletName), + "%q must never be in the allowlist - this is exactly why passing it directly was the bug", rawCmdletName) + } +} + +// TestPSResourceCommandEntriesRegistersAllFour is a structural sanity check on the table-driven +// registration itself: all four expected command names must be present, with the exact native +// cmdlet casing (case-sensitive, matching how PowerShell itself writes them). +func TestPSResourceCommandEntriesRegistersAllFour(t *testing.T) { + entries := psResourceCommandEntries() + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name) + } + assert.ElementsMatch(t, []string{ + psresourcecommand.SubCommandInstall, + psresourcecommand.SubCommandSave, + psresourcecommand.SubCommandUpdate, + psresourcecommand.SubCommandPublish, + }, names) +} diff --git a/choco_test.go b/choco_test.go index 405e862f1..b02219ca9 100644 --- a/choco_test.go +++ b/choco_test.go @@ -13,10 +13,12 @@ import ( "time" buildInfo "github.com/jfrog/build-info-go/entities" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-cli/inttestutils" "github.com/jfrog/jfrog-cli/utils/tests" + "github.com/jfrog/jfrog-client-go/auth" "github.com/jfrog/jfrog-client-go/http/httpclient" clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests" "github.com/stretchr/testify/assert" @@ -182,6 +184,18 @@ func getPublishedChocoBuildInfo(t *testing.T, buildName, buildNumber string) bui return publishedBuildInfo.BuildInfo } +// readLocalChocoBuildInfo assembles the build-info 'jf choco' collected on disk, without going +// through Artifactory. A publish is destructive - it clears the local state for that +// build-name/number - so this must be called before getPublishedChocoBuildInfo for the same build. +func readLocalChocoBuildInfo(t *testing.T, buildName, buildNumber string) *buildInfo.BuildInfo { + t.Helper() + chocoBuild, err := buildUtils.CreateBuildInfoService().GetOrCreateBuildWithProject(buildName, buildNumber, "") + require.NoError(t, err) + collected, err := chocoBuild.ToBuildInfo() + require.NoError(t, err) + return collected +} + // getChocoCommandProperty reads the recorded Chocolatey command line out of a module. Module // properties are typed as interface{} in build-info, so after a publish/fetch round trip they // arrive as a generic map. @@ -458,14 +472,32 @@ func TestChocoCommandPropertyRedactsApiKey(t *testing.T) { assert.NotContains(t, recordedCommand, apiKey, "the real credential must never be recorded") } -// chocoPushApiKey builds the composite ':' key that Artifactory NuGet endpoints +// chocoPushApiKey builds the composite ':' key that Artifactory NuGet endpoints // expect for authenticated pushes. +// +// The username half must never be empty. Chocolatey rejects a bare ':' pair with +// "Invalid credentials specified" and then falls back to prompting for a username on stdin, which +// aborts with System.InvalidOperationException on a CI runner that has no console. authenticate() +// sets either User+Password or AccessToken - never both - so whenever the suite runs against an +// access token (the default against a local Artifactory) User is empty and the username has to be +// recovered from the token's own JWT subject, the way jf's own dotnetcommand.go does. func chocoPushApiKey(t *testing.T) string { t.Helper() + secret := serverDetails.Password if serverDetails.AccessToken != "" { - return serverDetails.User + ":" + serverDetails.AccessToken + secret = serverDetails.AccessToken } - return serverDetails.User + ":" + serverDetails.Password + user := serverDetails.User + if user == "" && serverDetails.AccessToken != "" { + user = auth.ExtractUsernameFromAccessToken(serverDetails.AccessToken) + } + if user == "" { + // API keys and reference tokens carry no subject to extract from; fall back to the + // configured test username rather than emitting ':'. + user = *tests.JfrogUser + } + require.NotEmpty(t, user, "a non-empty username is required: Chocolatey prompts interactively for ':'") + return user + ":" + secret } // TestSetupChocoConfiguresSource covers the 'jf setup choco' happy path: the machine-wide @@ -509,6 +541,16 @@ func TestChocoInstallCollectsDependencies(t *testing.T) { cleanupChocoInstalledPackage(t, id) requireChocoInstall(t, id, version, sourceName, buildName, buildNumber) + // 'repository' is asserted against the locally collected build-info, before publishing: it is a + // build-info-go field that Artifactory's own build-info schema has no place for on a dependency, + // so it always comes back empty from a publish/fetch round trip. Reading it locally is what + // actually covers jf's side of the contract - that --repo-resolve reaches the collector. + localBuildInfo := readLocalChocoBuildInfo(t, buildName, buildNumber) + require.Len(t, localBuildInfo.Modules, 1) + require.NotEmpty(t, localBuildInfo.Modules[0].Dependencies) + assert.Equal(t, tests.NugetLocalRepo, localBuildInfo.Modules[0].Dependencies[0].Repository, + "--repo-resolve should be recorded as the resolution repository") + installBuildInfo := getPublishedChocoBuildInfo(t, buildName, buildNumber) require.Len(t, installBuildInfo.Modules, 1) module := installBuildInfo.Modules[0] @@ -517,8 +559,6 @@ func TestChocoInstallCollectsDependencies(t *testing.T) { dependency := module.Dependencies[0] assert.Equal(t, id+":"+version, dependency.Id) assert.Equal(t, "nupkg", dependency.Type) - assert.Equal(t, tests.NugetLocalRepo, dependency.Repository, - "--repo-resolve should be recorded as the resolution repository") assert.Contains(t, getChocoCommandProperty(t, module), "install") } diff --git a/docs/buildtools/psresource/help.go b/docs/buildtools/psresource/help.go new file mode 100644 index 000000000..ec927ff83 --- /dev/null +++ b/docs/buildtools/psresource/help.go @@ -0,0 +1,127 @@ +package psresource + +import "strings" + +// This package backs four separate top-level jf commands - Install-PSResource, Save-PSResource, +// Update-PSResource and Publish-PSResource - one per native PowerShell PSResourceGet cmdlet, rather +// than a single "jf psresource " wrapper (unlike jf choco, which does use a single-command, +// first-positional-argument dispatch). The four commands' help text is near-identical (same +// Prerequisites block, same shared Gotchas bullet, same overall shape), so rather than four +// hand-duplicated copies, it's rendered from one shared template (Usage/GetDescription/ +// GetArguments/GetAIDescription below) parameterized by each cmdlet's full native name and, for +// GetAIDescription, the small set of values in cmdletMeta that actually differ between cmdlets. +// buildtools/cli.go calls these directly (one per cmdlet name) to register four distinct +// cli.Command entries that each read exactly like their own command's help. + +// cmdletMeta holds the values that vary between the four PSResourceGet cmdlets' AI descriptions. +// Everything else in the rendered text (Prerequisites block, the shared build-info Gotchas bullet, +// bullet ordering) is identical across all four and lives directly in GetAIDescription. +type cmdletMeta struct { + paramList string // native parameters shown in the description's first line, e.g. "-Name, -Version, -Repository, -Scope, ..." + resultClause string // completes "...and optionally records ." + examples []string // fully-formatted " $ jf ..." example lines, in display order + middleGotchas []string // gotcha bullets specific to this cmdlet, between the shared build-info bullet and the shared registration bullet + registrationGotcha string // registration gotcha bullet text (Install's differs from the other three) + crossPlatformGotcha string // cross-platform gotcha bullet text (Install/Publish differ from Save/Update) +} + +// cmdletMetaByName holds the AI-description metadata for each of the four PSResourceGet cmdlets, +// keyed by their full native name (matching psresourcecommand.SubCommand* in +// jfrog-cli-artifactory, and buildtools/cli.go's cli.Command.Name for each). +var cmdletMetaByName = map[string]cmdletMeta{ + "Install-PSResource": { + paramList: "-Name, -Version, -Repository, -Scope, ...", + resultClause: "build-info for the resolved package(s)", + examples: []string{ + " $ jf Install-PSResource -Name MyModule -Repository jfrt-acme.jfrog.io-psresource-virtual --build-name=app --build-number=1", + " $ jf Install-PSResource -Name MyModule -Version 2.0.0 -Repository jfrt-acme.jfrog.io-psresource-virtual --repo-resolve=psresource-virtual --build-name=app --build-number=1", + }, + middleGotchas: []string{ + "Dependency build-info is direct-dependency-only: PSResourceGet has no lock file, so there is no transitive dependency graph, the same limitation 'jf choco install' has.", + "Checksums for the recorded dependency are fetched with a HEAD request against Artifactory, not read from a local file.", + "'--repo-resolve' records the resolution repository in build-info; it does not itself select which repository PSResourceGet resolves from - that is still driven by the native -Repository parameter (or by 'jf setup psresource').", + }, + registrationGotcha: "This command never writes PSResourceGet repository registration (PSResourceRepository.xml). Use 'jf setup psresource' for that.", + crossPlatformGotcha: "Cross-platform: unlike 'jf choco' (Windows only), this runs on macOS, Linux and Windows wherever pwsh and PSResourceGet are installed.", + }, + "Save-PSResource": { + paramList: "-Name, -Version, -Path, -Repository, ...", + resultClause: "build-info for the saved package(s)", + examples: []string{ + " $ jf Save-PSResource -Name MyModule -Path ./local-modules -Repository jfrt-acme.jfrog.io-psresource-virtual --build-name=app --build-number=1", + }, + middleGotchas: []string{ + "Dependency build-info is direct-dependency-only, matching Install-PSResource - no transitive graph.", + "When '-Path' is given, the saved .nupkg is written locally, so its checksums are computed from that file; otherwise checksums fall back to a HEAD request against Artifactory.", + }, + registrationGotcha: "This command never writes PSResourceGet repository registration. Use 'jf setup psresource' for that.", + crossPlatformGotcha: "Cross-platform: macOS, Linux and Windows, wherever pwsh and PSResourceGet are installed.", + }, + "Update-PSResource": { + paramList: "-Name, -Version, -Repository, ...", + resultClause: "build-info for the resolved package(s) after the update", + examples: []string{ + " $ jf Update-PSResource -Name MyModule -Repository jfrt-acme.jfrog.io-psresource-virtual --build-name=app --build-number=1", + }, + middleGotchas: []string{ + "Dependency build-info is direct-dependency-only, matching Install-PSResource - no transitive graph.", + "The recorded version is the one resolved after the update completes, not any version implied on the command line.", + "Checksums are fetched with a HEAD request against Artifactory.", + }, + registrationGotcha: "This command never writes PSResourceGet repository registration. Use 'jf setup psresource' for that.", + crossPlatformGotcha: "Cross-platform: macOS, Linux and Windows, wherever pwsh and PSResourceGet are installed.", + }, + "Publish-PSResource": { + paramList: "-Path, -Repository, ...", + resultClause: "artifact build-info and stamps build properties on the published package", + examples: []string{ + " $ jf Publish-PSResource -Path ./MyModule -Repository jfrt-acme.jfrog.io-psresource-local --repo=psresource-local --build-name=app --build-number=1", + }, + middleGotchas: []string{ + "Publish-PSResource compiles and uploads the .nupkg internally - it is never written to local disk - so the artifact is confirmed and its checksums are fetched from Artifactory via a HEAD request rather than read from a local file.", + "'--repo' identifies the deployment repository for build-info purposes; it must agree with whatever -Repository resolves to in Artifactory.", + }, + registrationGotcha: "This command never writes PSResourceGet repository registration. Use 'jf setup psresource' for that.", + crossPlatformGotcha: "Cross-platform: unlike 'jf choco' (Windows only), this runs on macOS, Linux and Windows wherever pwsh and PSResourceGet are installed.", + }, +} + +// Usage returns the usage line for the top-level jf command wrapping the given PSResourceGet +// cmdlet (e.g. cmdletName "Install-PSResource"). +func Usage(cmdletName string) []string { + return []string{cmdletName + " [command options]"} +} + +// GetDescription returns the short (non-AI) description for the given PSResourceGet cmdlet. +func GetDescription(cmdletName string) string { + return "Run the native PowerShell " + cmdletName + " cmdlet with optional JFrog build-info collection." +} + +// GetArguments returns the UsageText argument description for the given PSResourceGet cmdlet. +func GetArguments(cmdletName string) string { + return "\t" + cmdletName + " cmdlet args\n\t\t\tArguments and options for the native " + cmdletName + " cmdlet." +} + +// GetAIDescription renders the full AI-oriented description for the given PSResourceGet cmdlet +// (one of "Install-PSResource", "Save-PSResource", "Update-PSResource", "Publish-PSResource"), +// interpolating its cmdletMeta into the prose shared by all four cmdlets. +func GetAIDescription(cmdletName string) string { + m := cmdletMetaByName[cmdletName] + var b strings.Builder + b.WriteString("Run the native PowerShell " + cmdletName + " cmdlet through JFrog. The command forwards " + cmdletName + + "'s own parameters unchanged (" + m.paramList + ") and optionally records " + m.resultClause + ".\n\n") + b.WriteString("Prerequisites:\n") + b.WriteString("- PowerShell 7+ (`pwsh`) with the Microsoft.PowerShell.PSResourceGet module installed.\n") + b.WriteString("- Run 'jf setup psresource' first to register an authenticated PSResourceGet repository against Artifactory, or pass the native -Repository/-Credential parameters yourself.\n\n") + b.WriteString("Examples:\n") + b.WriteString(strings.Join(m.examples, "\n")) + b.WriteString("\n\n") + b.WriteString("Gotchas:\n") + b.WriteString("- Build-info is collected only when both '--build-name' and '--build-number' are given; supplying just one is an error.\n") + for _, g := range m.middleGotchas { + b.WriteString("- " + g + "\n") + } + b.WriteString("- " + m.registrationGotcha + "\n") + b.WriteString("- " + m.crossPlatformGotcha) + return b.String() +} diff --git a/docs/buildtools/psresource/help_test.go b/docs/buildtools/psresource/help_test.go new file mode 100644 index 000000000..e396978ac --- /dev/null +++ b/docs/buildtools/psresource/help_test.go @@ -0,0 +1,55 @@ +package psresource + +import ( + "strings" + "testing" +) + +// TestGetAIDescriptionKnownCmdlets closes a real test-coverage gap: cmdletMetaByName and +// GetAIDescription's map lookup into it had no test at all across any of the four cmdlets, so a +// typo'd key or a renamed SubCommand* constant elsewhere would silently render an empty/broken +// description with nothing to catch it. +func TestGetAIDescriptionKnownCmdlets(t *testing.T) { + for _, cmdletName := range []string{"Install-PSResource", "Save-PSResource", "Update-PSResource", "Publish-PSResource"} { + t.Run(cmdletName, func(t *testing.T) { + desc := GetAIDescription(cmdletName) + if desc == "" { + t.Fatalf("GetAIDescription(%q) returned an empty string", cmdletName) + } + for _, marker := range []string{cmdletName, "Prerequisites:", "Examples:", "Gotchas:", "$ jf " + cmdletName} { + if !strings.Contains(desc, marker) { + t.Errorf("GetAIDescription(%q) is missing expected content %q\nfull output:\n%s", cmdletName, marker, desc) + } + } + }) + } +} + +// TestGetAIDescriptionUnknownCmdletDoesNotPanic documents the current fallback behavior for a +// cmdletName with no matching cmdletMetaByName entry (e.g. a future typo or renamed constant): +// it must not panic, even though the rendered text is necessarily incomplete. +func TestGetAIDescriptionUnknownCmdletDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("GetAIDescription panicked on an unknown cmdlet name: %v", r) + } + }() + desc := GetAIDescription("Not-A-Real-Cmdlet") + if !strings.Contains(desc, "Prerequisites:") { + t.Errorf("expected the shared prose to still render even with no metadata match, got: %q", desc) + } +} + +func TestUsageGetDescriptionGetArguments(t *testing.T) { + for _, cmdletName := range []string{"Install-PSResource", "Save-PSResource", "Update-PSResource", "Publish-PSResource"} { + if got := Usage(cmdletName); len(got) != 1 || !strings.Contains(got[0], cmdletName) { + t.Errorf("Usage(%q) = %v, want a single line containing the cmdlet name", cmdletName, got) + } + if got := GetDescription(cmdletName); !strings.Contains(got, cmdletName) { + t.Errorf("GetDescription(%q) = %q, want it to contain the cmdlet name", cmdletName, got) + } + if got := GetArguments(cmdletName); !strings.Contains(got, cmdletName) { + t.Errorf("GetArguments(%q) = %q, want it to contain the cmdlet name", cmdletName, got) + } + } +} diff --git a/docs/buildtools/setup/help.go b/docs/buildtools/setup/help.go index bc989c2f9..be7d180aa 100644 --- a/docs/buildtools/setup/help.go +++ b/docs/buildtools/setup/help.go @@ -35,6 +35,7 @@ Prerequisites: - A configured server (jf c add or jf login), or pass --url/--user/--password/--access-token directly. - The Artifactory repository name for the package manager (a virtual repo where supported). - For Chocolatey, select a NuGet virtual, local, or remote repository when prompted. Setup creates a named native source ('jfrt--') for each repository, so use -s to choose a configured resolve or publish endpoint. +- For psresource (PowerShell PSResourceGet), PowerShell 7+ (pwsh) with the Microsoft.PowerShell.PSResourceGet module installed. Common patterns: $ jf setup npm @@ -51,6 +52,7 @@ Gotchas: - maven and gradle do not need their client installed: their setup writes settings.xml and a Gradle init script directly, so it works on a machine that only has ./mvnw or ./gradlew. Every other package manager's setup runs its client. helm additionally needs 3.8.0 or newer, because its login targets an OCI registry. - docker/podman authenticate directly against the registry and skip the repository prompt entirely (no --repo needed); helm still goes through repository selection like the other package managers even though its login step doesn't end up using the repo name. - Chocolatey runs on Windows only and updates machine-wide chocolatey.config, so use an elevated shell. Re-running 'jf setup choco' for one repository refreshes only that repository's 'jfrt--' source; it preserves other Chocolatey sources. +- For psresource, registration is user-profile scoped (PSResourceRepository.xml), not machine-wide, so re-running as a different user repeats setup. The four PSResourceGet cmdlets themselves are run directly as 'jf Install-PSResource' / 'jf Save-PSResource' / 'jf Update-PSResource' / 'jf Publish-PSResource' - there is no 'jf psresource' command. Related: jf npm-config, jf go-config, jf pip-config, jf c add` } diff --git a/go.mod b/go.mod index 761eff7eb..894f85ce3 100644 --- a/go.mod +++ b/go.mod @@ -19,11 +19,11 @@ require ( github.com/buger/jsonparser v1.3.0 github.com/gocarina/gocsv v0.0.0-20260607070740-0735908c6461 github.com/jfrog/archiver/v3 v3.6.5 - github.com/jfrog/build-info-go v1.13.1-0.20260925051954-247bd658f6f3 + github.com/jfrog/build-info-go v1.13.1-0.20260925090031-f9d40441f262 github.com/jfrog/gofrog v1.7.7 github.com/jfrog/jfrog-cli-application v1.0.2-0.20260820134442-c8629258ff3a - github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260925064758-7cd88de3fdfc - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260925052043-a665d6336433 + github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260927054148-8c77089833c9 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260925084932-b47892ded3a0 github.com/jfrog/jfrog-cli-evidence v0.11.1-0.20260824063609-79b735ec565e github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab github.com/jfrog/jfrog-cli-security v1.37.0 @@ -221,14 +221,14 @@ require ( go.opentelemetry.io/otel/trace v1.45.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.56.0 // indirect - golang.org/x/mod v0.40.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect diff --git a/go.sum b/go.sum index 175dbd1f9..d9613c30a 100644 --- a/go.sum +++ b/go.sum @@ -390,8 +390,8 @@ 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.5 h1:AiNXJoe8jYDOtyykfVuwh26aM4rk/ei+YzBpfBukdzU= github.com/jfrog/archiver/v3 v3.6.5/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260925051954-247bd658f6f3 h1:vBkLakwu2fEOncEK73hog+QbG8KWEOp3iJnvuhpCVKQ= -github.com/jfrog/build-info-go v1.13.1-0.20260925051954-247bd658f6f3/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260925090031-f9d40441f262 h1:xqy989W2i97ENJTmnRa2mJu0Q2dI9IJ2pnQsFnH8AVA= +github.com/jfrog/build-info-go v1.13.1-0.20260925090031-f9d40441f262/go.mod h1:PCfjSoigriSjUWupH0jffPtDq+fnMGIZxgM3Cgw9QIg= github.com/jfrog/froggit-go v1.23.1 h1:4wmaHeuptxVINbovMaeITzVhi3+VQoc/FFIjF4axzu0= github.com/jfrog/froggit-go v1.23.1/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI= github.com/jfrog/go-mockhttp v0.3.1 h1:/wac8v4GMZx62viZmv4wazB5GNKs+GxawuS1u3maJH8= @@ -402,10 +402,10 @@ 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.20260820134442-c8629258ff3a h1:7GhcPfi+k9oOAJdCsKWjymnqH0e7DQZ1soVhbneYnGY= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260820134442-c8629258ff3a/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260925064758-7cd88de3fdfc h1:FReKkyXUwckBfVvvzXEgpIlkLvtzMFQjK6eCH285CS8= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260925064758-7cd88de3fdfc/go.mod h1:E8h+DVJH2qRyB+7t1RnSQ4tYYbog6sQeAN7plnJRlNA= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260925052043-a665d6336433 h1:orO2tFOrCvzMVEsKcjiPf3GVUCbxN7VpngS3uKS97+4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260925052043-a665d6336433/go.mod h1:SwV+DNLBnWLxBeNeZpJk+xxAbqJ8ywq1va56up+AGu4= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260927054148-8c77089833c9 h1:s6Sh8JWKVUD8mF3zuoeVuv5O0V0SXQJGWWk3J8JLaWo= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260927054148-8c77089833c9/go.mod h1:VaMX/0GKExKszogM+G62260Krx15l4EV/MQ0ftgbBW0= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260925084932-b47892ded3a0 h1:SNXwdC0MIjNC2dCf8lNaZvMNpy6CLWdsZllcIhC/atQ= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260925084932-b47892ded3a0/go.mod h1:SwV+DNLBnWLxBeNeZpJk+xxAbqJ8ywq1va56up+AGu4= github.com/jfrog/jfrog-cli-evidence v0.11.1-0.20260824063609-79b735ec565e h1:+QYbewvK+PZKbfPpxYmy0bewhqMFtJPk/tUbCICjf8U= github.com/jfrog/jfrog-cli-evidence v0.11.1-0.20260824063609-79b735ec565e/go.mod h1:I83k7IH/cmMh00LyZOp13jctj8/pPKVHSAlu7GMJgwI= github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab h1:Zn/qB8LYhSu82YDtbqXwErN1RPHTHe/a3gQY6Ti/OBE= @@ -758,14 +758,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -776,8 +776,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -786,8 +786,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -817,16 +817,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -834,8 +834,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main_test.go b/main_test.go index 3d6c32e7a..8b7b12d7a 100644 --- a/main_test.go +++ b/main_test.go @@ -77,7 +77,7 @@ func setupIntegrationTests() { InitArtifactoryTests() } - if *tests.TestNpm || *tests.TestPnpm || *tests.TestCargo || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestChoco || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestUv || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || *tests.TestApm || *tests.TestRuby || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { + if *tests.TestNpm || *tests.TestPnpm || *tests.TestCargo || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestChoco || *tests.TestPSResource || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestUv || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || *tests.TestApm || *tests.TestRuby || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { InitBuildToolsTests() } if *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan { @@ -125,7 +125,7 @@ func tearDownIntegrationTests() { if (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { CleanArtifactoryTests() } - if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestChoco || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestNix || *tests.TestCargo || *tests.TestApt || *tests.TestAlpine || *tests.TestApm || *tests.TestRuby || *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { + if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestChoco || *tests.TestPSResource || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestNix || *tests.TestCargo || *tests.TestApt || *tests.TestAlpine || *tests.TestApm || *tests.TestRuby || *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { CleanBuildToolsTests() } if *tests.TestDistribution { diff --git a/psresource_test.go b/psresource_test.go new file mode 100644 index 000000000..fb62f9969 --- /dev/null +++ b/psresource_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "os/exec" + "testing" + + buildInfo "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/jfrog/jfrog-cli/inttestutils" + "github.com/jfrog/jfrog-cli/utils/tests" + clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// psresourcePlatformAvailable reports whether a PowerShell executable (pwsh on every OS, or +// powershell.exe as a Windows-only fallback) that also has the Microsoft.PowerShell.PSResourceGet +// module installed can be found on PATH. Unlike Chocolatey, PSResourceGet is cross-platform, so +// the gate here is "is the tool present", never an OS check. +func psresourcePlatformAvailable() bool { + shell, err := exec.LookPath("pwsh") + if err != nil { + shell, err = exec.LookPath("powershell.exe") + } + if err != nil { + return false + } + checkScript := "if (Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet) { exit 0 } else { exit 1 }" + return exec.Command(shell, "-NoProfile", "-Command", checkScript).Run() == nil +} + +// initPSResourceTestAnyPlatform gates only on the feature flag, for the scenarios that must be +// observable regardless of whether pwsh/PSResourceGet is installed on the test machine (help text, +// and CLI-level flag-pair validation, which is rejected before any native command is invoked). +func initPSResourceTestAnyPlatform(t *testing.T) { + t.Helper() + if !*tests.TestPSResource { + t.Skip("Skipping PSResourceGet test. To run PSResource tests add the '-test.psresource=true' option.") + } +} + +// initPSResourceTest gates every test that actually needs to shell out to pwsh and run a +// PSResourceGet cmdlet (or that needs a configured JFrog server). Unlike Chocolatey - which fails +// fast on every OS but Windows - PSResourceGet can run on macOS, Linux and Windows alike, so the +// gate here is tool-availability, not runtime.GOOS. +func initPSResourceTest(t *testing.T) { + initPSResourceTestAnyPlatform(t) + if !psresourcePlatformAvailable() { + t.Skip("Skipping PSResourceGet test. No PowerShell 7+ (pwsh) with the Microsoft.PowerShell.PSResourceGet module was found.") + } + createJfrogHomeConfig(t, true) +} + +// runPSResource runs a 'jf -PSResource' command. +func runPSResource(t *testing.T, args ...string) error { + t.Helper() + jfrogCli := coreTests.NewJfrogCli(execMain, "jfrog", "") + return jfrogCli.Exec(args...) +} + +// --------------------------------------------------------------------------------------------- +// Tests that must pass with no pwsh/PSResourceGet installation available at all: help text and +// the CLI-level build-flag-pair rejection, which happens before any native command runs. +// --------------------------------------------------------------------------------------------- + +// TestPSResourceHelpWorksOnAllPlatforms asserts that each of the four PSResourceGet top-level +// commands prints help without shelling out to pwsh, so '--help' stays reachable on machines that +// cannot run the tool itself - the same contract 'jf choco --help' has on non-Windows hosts. +func TestPSResourceHelpWorksOnAllPlatforms(t *testing.T) { + initPSResourceTestAnyPlatform(t) + + for _, cmdlet := range []string{"Install-PSResource", "Save-PSResource", "Update-PSResource", "Publish-PSResource"} { + t.Run(cmdlet, func(t *testing.T) { + assert.NoError(t, runPSResource(t, cmdlet, "--help"), + "'jf %s --help' must work on every OS", cmdlet) + }) + } +} + +// TestPSResourceBuildFlagsPartialRejected covers the two partial build-flag cases shared by all +// four commands: '--build-name' alone or '--build-number' alone is rejected by jf's CLI-wide +// flag-pair validation before the native cmdlet ever runs, so this needs no pwsh installation. +func TestPSResourceBuildFlagsPartialRejected(t *testing.T) { + initPSResourceTestAnyPlatform(t) + + testCases := []struct { + name string + args []string + }{ + {"build name without build number", []string{"--build-name=" + tests.PSResourceBuildName}}, + {"build number without build name", []string{"--build-number=1"}}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + args := append([]string{"Install-PSResource", "-Name", "SomeModule"}, testCase.args...) + err := runPSResource(t, args...) + require.Error(t, err, "one build flag without the other must be rejected") + assert.Contains(t, err.Error(), "cannot be provided separately") + }) + } +} + +// --------------------------------------------------------------------------------------------- +// End-to-end tests. These need a real 'pwsh' with Microsoft.PowerShell.PSResourceGet installed +// (and a configured JFrog server), so they skip gracefully wherever that is not the case rather +// than failing the run. +// --------------------------------------------------------------------------------------------- + +// TestSetupPSResourceConfiguresRepository covers the 'jf setup psresource' happy path: a +// PSResourceGet repository registration is created for the given Artifactory repo. +func TestSetupPSResourceConfiguresRepository(t *testing.T) { + initPSResourceTest(t) + defer cleanTestsHomeEnv() + + require.NoError(t, runPSResource(t, "setup", "psresource", "--repo="+tests.NugetVirtualRepo), + "'jf setup psresource' should register a PSResourceGet repository") +} + +// TestPSResourceInstallCollectsDependencyBuildInfo covers the 'jf Install-PSResource' happy path: +// a resolved package is recorded as a direct-dependency build-info entry when both build-name and +// build-number are supplied. +func TestPSResourceInstallCollectsDependencyBuildInfo(t *testing.T) { + initPSResourceTest(t) + defer cleanTestsHomeEnv() + + buildName := tests.PSResourceBuildName + "-install" + buildNumber := "1" + + err := runPSResource(t, "Install-PSResource", "-Name", "SomeModule", "-Repository", tests.NugetVirtualRepo, + "--repo-resolve="+tests.NugetVirtualRepo, "--build-name="+buildName, "--build-number="+buildNumber) + // This environment has no real PSResourceGet repository registered against a live module feed, + // so the native cmdlet itself is expected to fail; what matters here is that the CLI wiring + // (flag parsing, server resolution, FlexPack command construction) got far enough to invoke it. + if err != nil { + t.Logf("'jf Install-PSResource' returned %v; this is expected without a real registered feed", err) + return + } + // If it did succeed (e.g. against a real registered feed with SomeModule actually resolvable), + // the locally-collected build-info must actually contain it - a silently empty or corrupted + // build-info must not pass this test just because the command itself returned no error. + inttestutils.ValidateGeneratedBuildInfoModule(t, buildName, buildNumber, "", []string{"psresource-project"}, buildInfo.Nuget) +} + +// TestPSResourceSaveCollectsDependencyBuildInfo mirrors TestPSResourceInstallCollectsDependencyBuildInfo +// for 'jf Save-PSResource'. Without this, only Install-PSResource's SetSubCommand/native-dispatch +// wiring was ever exercised past the shared '--help' early-return - a wiring bug specific to +// Save-PSResource (wrong cmdletName captured, wrong flag forwarded) would not have been caught by +// any test. +func TestPSResourceSaveCollectsDependencyBuildInfo(t *testing.T) { + initPSResourceTest(t) + defer cleanTestsHomeEnv() + + buildName := tests.PSResourceBuildName + "-save" + buildNumber := "1" + + err := runPSResource(t, "Save-PSResource", "-Name", "SomeModule", "-Repository", tests.NugetVirtualRepo, + "--repo-resolve="+tests.NugetVirtualRepo, "--build-name="+buildName, "--build-number="+buildNumber) + if err != nil { + t.Logf("'jf Save-PSResource' returned %v; this is expected without a real registered feed", err) + return + } + inttestutils.ValidateGeneratedBuildInfoModule(t, buildName, buildNumber, "", []string{"psresource-project"}, buildInfo.Nuget) +} + +// TestPSResourceUpdateCollectsDependencyBuildInfo mirrors TestPSResourceInstallCollectsDependencyBuildInfo +// for 'jf Update-PSResource'. +func TestPSResourceUpdateCollectsDependencyBuildInfo(t *testing.T) { + initPSResourceTest(t) + defer cleanTestsHomeEnv() + + buildName := tests.PSResourceBuildName + "-update" + buildNumber := "1" + + err := runPSResource(t, "Update-PSResource", "-Name", "SomeModule", "-Repository", tests.NugetVirtualRepo, + "--repo-resolve="+tests.NugetVirtualRepo, "--build-name="+buildName, "--build-number="+buildNumber) + if err != nil { + t.Logf("'jf Update-PSResource' returned %v; this is expected without a real registered feed", err) + return + } + inttestutils.ValidateGeneratedBuildInfoModule(t, buildName, buildNumber, "", []string{"psresource-project"}, buildInfo.Nuget) +} + +// TestPSResourcePublishCollectsArtifactBuildInfo mirrors TestPSResourceInstallCollectsDependencyBuildInfo +// for 'jf Publish-PSResource' - the one command in the family that collects artifact (not +// dependency) build-info. +func TestPSResourcePublishCollectsArtifactBuildInfo(t *testing.T) { + initPSResourceTest(t) + defer cleanTestsHomeEnv() + + buildName := tests.PSResourceBuildName + "-publish" + buildNumber := "1" + + err := runPSResource(t, "Publish-PSResource", "-Path", t.TempDir(), "-Repository", tests.NugetLocalRepo, + "--repo="+tests.NugetLocalRepo, "--build-name="+buildName, "--build-number="+buildNumber) + if err != nil { + t.Logf("'jf Publish-PSResource' returned %v; this is expected without a real registered feed and module manifest", err) + return + } + inttestutils.ValidateGeneratedBuildInfoModule(t, buildName, buildNumber, "", []string{"psresource-project"}, buildInfo.Nuget) +} + +// TestPSResourcePlatformGateWithoutPwsh asserts that on a machine with no usable pwsh + +// PSResourceGet installation, 'jf setup psresource' fails clearly instead of shelling out and +// surfacing a raw "executable file not found" error. +func TestPSResourcePlatformGateWithoutPwsh(t *testing.T) { + initPSResourceTestAnyPlatform(t) + if psresourcePlatformAvailable() { + t.Skip("pwsh + Microsoft.PowerShell.PSResourceGet is available on this host; nothing to assert about the platform gate.") + } + + restoreHomeDir := clientTestUtils.SetEnvWithCallbackAndAssert(t, coreutils.HomeDir, t.TempDir()) + defer restoreHomeDir() + createJfrogHomeConfig(t, true) + defer cleanTestsHomeEnv() + + err := runPSResource(t, "setup", "psresource", "--repo="+tests.NugetVirtualRepo) + require.Error(t, err, "'jf setup psresource' must refuse to run without pwsh + PSResourceGet") +} diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index abf7e851c..194a8bcf1 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -67,6 +67,7 @@ const ( Choco = "choco" Dotnet = "dotnet" DotnetConfig = "dotnet-config" + PSResource = "psresource" Go = "go" GoConfig = "go-config" GoPublish = "go-publish" @@ -2274,6 +2275,9 @@ var commandFlags = map[string][]string{ Dotnet: { BuildName, BuildNumber, module, Project, allowInsecureConnections, serverId, repoResolve, repo, nugetV2, }, + PSResource: { + BuildName, BuildNumber, module, Project, serverId, repoResolve, repo, + }, GoConfig: { global, serverIdResolve, serverIdDeploy, repoResolve, repoDeploy, }, diff --git a/utils/tests/consts.go b/utils/tests/consts.go index 231d3a281..849a5b552 100644 --- a/utils/tests/consts.go +++ b/utils/tests/consts.go @@ -294,6 +294,7 @@ var ( YarnBuildName = "cli-yarn-build" NuGetBuildName = "cli-nuget-build" ChocoBuildName = "cli-choco-build" + PSResourceBuildName = "cli-psresource-build" PipBuildName = "cli-pip-build" PipenvBuildName = "cli-pipenv-build" PoetryBuildName = "cli-poetry-build" diff --git a/utils/tests/utils.go b/utils/tests/utils.go index 98acff966..cfbe44540 100644 --- a/utils/tests/utils.go +++ b/utils/tests/utils.go @@ -67,6 +67,7 @@ var ( TestMaven *bool TestNuget *bool TestChoco *bool + TestPSResource *bool TestPip *bool TestPipenv *bool TestPoetry *bool @@ -143,6 +144,7 @@ func init() { TestMaven = flag.Bool("test.maven", false, "Test Maven") TestNuget = flag.Bool("test.nuget", false, "Test Nuget") TestChoco = flag.Bool("test.choco", false, "Test Chocolatey") + TestPSResource = flag.Bool("test.psresource", false, "Test PSResourceGet (Install-/Save-/Update-/Publish-PSResource)") TestPip = flag.Bool("test.pip", false, "Test Pip") TestPipenv = flag.Bool("test.pipenv", false, "Test Pipenv") TestPoetry = flag.Bool("test.poetry", false, "Test Poetry") @@ -494,6 +496,7 @@ func GetNonVirtualRepositories() map[*string]string { TestPnpm: {&NpmRepo, &NpmScopedRepo, &NpmRemoteRepo}, TestNuget: {&NugetRemoteRepo, &NugetLocalRepo}, TestChoco: {&NugetRemoteRepo, &NugetLocalRepo}, + TestPSResource: {&NugetRemoteRepo, &NugetLocalRepo}, TestPip: {&PypiLocalRepo, &PypiRemoteRepo}, TestPipenv: {&PipenvRemoteRepo}, TestPoetry: {&PoetryLocalRepo, &PoetryRemoteRepo}, @@ -534,6 +537,7 @@ func GetVirtualRepositories() map[*string]string { TestPnpm: {}, TestNuget: {&NugetVirtualRepo}, TestChoco: {&NugetVirtualRepo}, + TestPSResource: {&NugetVirtualRepo}, TestPip: {&PypiVirtualRepo}, TestPipenv: {&PipenvVirtualRepo}, TestPoetry: {&PoetryVirtualRepo}, @@ -583,6 +587,7 @@ func GetBuildNames() []string { TestPnpm: {&PnpmBuildName}, TestNuget: {&NuGetBuildName}, TestChoco: {&ChocoBuildName}, + TestPSResource: {&PSResourceBuildName}, TestPip: {&PipBuildName}, TestPipenv: {&PipenvBuildName}, TestPoetry: {&PoetryBuildName}, @@ -796,6 +801,7 @@ func AddTimestampToGlobalVars() { MvnBuildName += uniqueSuffix NuGetBuildName += uniqueSuffix ChocoBuildName += uniqueSuffix + PSResourceBuildName += uniqueSuffix PipBuildName += uniqueSuffix PipenvBuildName += uniqueSuffix PoetryBuildName += uniqueSuffix