diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fed94c20ec..ea7c67a457 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -280,6 +280,33 @@ User-facing documentation is [available on GitBook](https://docs.snyk.io/feature `snyk help` output must also be [edited on GitBook](https://docs.snyk.io/features/snyk-cli/commands). Changes will automatically be pulled into Snyk CLI as pull requests. +### CLI help command files (`help/cli-commands`) + +The Go CLI reads user-facing command help from markdown files under `help/cli-commands/`. These files are synced from +GitBook into this repository (see the `sync-cli-help-to-user-docs` workflow). At build time, the CLI embeds a manifest +of available help files (`cliv2/pkg/helpdocs/manifest.txt`) and uses it to decide whether to show legacy GitBook help +or native Cobra help for a given command. + +When you add, remove, or rename files in `help/cli-commands/`, regenerate the manifest and commit the result: + +```sh +make -C cliv2 helpdocs-manifest +git add cliv2/pkg/helpdocs/manifest.txt +``` + +`make -C cliv2 test` and `make build` run this target automatically, but you still need to commit the updated +`manifest.txt` when it changes. Go tests in `cliv2/pkg/helpdocs` verify that the manifest stays in sync with +`help/cli-commands/`. + +To test help routing locally after building: + +```sh +./binary-releases/snyk-macos-arm64 help test # documented command → GitBook help +./binary-releases/snyk-macos-arm64 help agent-scan # undocumented command → Cobra help +``` + +Adjust the binary path for your platform (see [Building](#building)). + ## Creating a branch Create a new branch before making any changes. Make sure to give it a descriptive name so that you can find it later. diff --git a/cliv2/Makefile b/cliv2/Makefile index b5653e1019..9668aa34c0 100644 --- a/cliv2/Makefile +++ b/cliv2/Makefile @@ -126,6 +126,9 @@ HASH_STRING = $(HASH)$(HASH_ALGORITHM) SIGN_SCRIPT = $(WORKING_DIR)/scripts/sign_$(_GO_OS).sh ISSIGNED_SCRIPT = $(WORKING_DIR)/scripts/issigned_$(_GO_OS).sh EMBEDDED_DATA_DIR = $(WORKING_DIR)/internal/embedded/_data +HELPDOCS_DIR = $(WORKING_DIR)/internal/helpdocs +HELPDOCS_MANIFEST = $(HELPDOCS_DIR)/manifest.txt +HELPDOCS_SOURCE = $(WORKING_DIR)/../help/cli-commands/*.md ifeq ($(GOHOSTOS), windows) SPECIAL_SHELL = powershell @@ -188,7 +191,17 @@ summary: .PHONY: configure configure: _validate-build-mode summary $(CACHE_DIR) $(CACHE_DIR)/variables.mk $(V1_DIRECTORY)/$(V1_EMBEDDED_FILE_OUTPUT) dependencies $(CACHE_DIR)/prepare-3rd-party-licenses -$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata +.PHONY: helpdocs-manifest +helpdocs-manifest: + @set -e; \ + md_count=$$(ls $(HELPDOCS_SOURCE) 2>/dev/null | wc -l | tr -d ' '); \ + if [ "$$md_count" -eq 0 ]; then \ + echo "$(LOG_PREFIX) ERROR: no .md files found in help/cli-commands ($(HELPDOCS_SOURCE))"; \ + exit 1; \ + fi; \ + ls $(HELPDOCS_SOURCE) | xargs -n1 basename | sort > $(HELPDOCS_MANIFEST) + +$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata $(HELPDOCS_MANIFEST) $(eval LS_PROTOCOL_VERSION := $(shell cat $(LS_PROTOCOL_VERSION_FILE))) $(eval LS_COMMIT_HASH := $(shell cat $(LS_COMMIT_HASH_FILE))) $(eval EXTRA_FLAGS := -X github.com/snyk/snyk-ls/application/config.Version=$(LS_COMMIT_HASH) -X github.com/snyk/snyk-ls/application/config.LsProtocolVersion=$(LS_PROTOCOL_VERSION) -X github.com/snyk/cli/cliv2/pkg/core.internalOS=$(GOOS) -X github.com/snyk/cli/cliv2/internal/embedded/cliv1.snykCLIVersion=$(CLI_V1_VERSION_TAG) -X github.com/snyk/cli-extension-iac/internal/commands/iactest.internalRulesClientURL=$(IAC_RULES_URL) -X github.com/snyk/cli/cliv2/internal/constants.StaticNodeJsBinary=$(STATIC_NODE_BINARY)) @@ -247,7 +260,7 @@ openboxtest: @$(GOCMD) test -cover ./... .PHONY: test -test: openboxtest +test: helpdocs-manifest openboxtest .PHONY: lint lint: $(TOOLS_BIN)/golangci-lint diff --git a/cliv2/internal/helpdocs/helpdocs.go b/cliv2/internal/helpdocs/helpdocs.go new file mode 100644 index 0000000000..4d021b14d6 --- /dev/null +++ b/cliv2/internal/helpdocs/helpdocs.go @@ -0,0 +1,71 @@ +package helpdocs + +import ( + _ "embed" + "regexp" + "strings" +) + +//go:embed manifest.txt +var manifest string + +var docFiles map[string]struct{} + +var nonDocChars = regexp.MustCompile(`[^a-zA-Z0-9-]`) + +func init() { + docFiles = manifestFileSet(manifest) +} + +// manifestFileSet builds the doc filename set from manifest text. +// Trims trailing carriage returns so CRLF-checked-out manifests still match lookups. +func manifestFileSet(manifestText string) map[string]struct{} { + files := make(map[string]struct{}) + for _, line := range manifestLines(manifestText) { + files[line] = struct{}{} + } + return files +} + +func manifestLines(manifestText string) []string { + var lines []string + for _, line := range strings.Split(strings.TrimSpace(manifestText), "\n") { + line = strings.TrimSuffix(line, "\r") + if line != "" { + lines = append(lines, line) + } + } + return lines +} + +// helpFileName mirrors src/cli/commands/help/index.ts join + cleanse. +func helpFileName(segments []string) string { + joined := strings.Join(segments, "-") + cleaned := nonDocChars.ReplaceAllString(joined, "") + return cleaned + ".md" +} + +// HasUserDoc reports whether legacy user-doc help should be shown for command segments. +// Empty segments → true (top-level README via legacy help). +// Non-empty segments → true only if a matching .md exists during walk-back (README excluded). +func HasUserDoc(segments []string) bool { + return hasUserDoc(docFiles, segments) +} + +func hasUserDoc(files map[string]struct{}, segments []string) bool { + if len(segments) == 0 { + return true + } + if len(files) == 0 { + // Missing or empty manifest at build time: prefer legacy help lookup. + return true + } + args := append([]string(nil), segments...) + for len(args) > 0 { + if _, ok := files[helpFileName(args)]; ok { + return true + } + args = args[:len(args)-1] + } + return false +} diff --git a/cliv2/internal/helpdocs/helpdocs_test.go b/cliv2/internal/helpdocs/helpdocs_test.go new file mode 100644 index 0000000000..8428089971 --- /dev/null +++ b/cliv2/internal/helpdocs/helpdocs_test.go @@ -0,0 +1,96 @@ +package helpdocs + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testDocFiles() map[string]struct{} { + names := []string{ + "README.md", + "test.md", + "container.md", + "container-test.md", + "iac-describe.md", + "redteam.md", + } + files := make(map[string]struct{}, len(names)) + for _, name := range names { + files[name] = struct{}{} + } + return files +} + +func Test_helpFileName(t *testing.T) { + assert.Equal(t, "container-test.md", helpFileName([]string{"container", "test"})) + assert.Equal(t, "iac-describe.md", helpFileName([]string{"iac", "describe"})) + assert.Equal(t, "secrets-test.md", helpFileName([]string{"secrets", "test"})) +} + +func Test_hasUserDoc(t *testing.T) { + files := testDocFiles() + + tests := map[string]struct { + segments []string + want bool + }{ + "empty uses readme path": {segments: []string{}, want: true}, + "test command": {segments: []string{"test"}, want: true}, + "container test subcommand": {segments: []string{"container", "test"}, want: true}, + "iac describe subcommand": {segments: []string{"iac", "describe"}, want: true}, + "unknown command": {segments: []string{"rainmaker"}, want: false}, + "undocumented secrets test": {segments: []string{"secrets", "test"}, want: false}, + "redteam setup walks back to parent": {segments: []string{"redteam", "setup"}, want: true}, + "undocumented agent-scan": {segments: []string{"agent-scan"}, want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, hasUserDoc(files, tc.segments)) + }) + } +} + +func Test_HasUserDoc_usesEmbeddedManifest(t *testing.T) { + assert.True(t, HasUserDoc([]string{"test"})) + assert.NotEmpty(t, docFiles) +} + +func Test_manifestFileSet_stripsCRLFLineEndings(t *testing.T) { + files := manifestFileSet("test.md\r\ncontainer-test.md\r\n") + + assert.True(t, hasUserDoc(files, []string{"test"})) + assert.True(t, hasUserDoc(files, []string{"container", "test"})) + assert.False(t, hasUserDoc(files, []string{"rainmaker"})) +} + +func Test_manifestMatchesHelpCLICommands(t *testing.T) { + helpDir := filepath.Join("..", "..", "..", "help", "cli-commands") + entries, err := os.ReadDir(helpDir) + require.NoError(t, err, "help/cli-commands must exist; run from repo root via go test ./pkg/helpdocs") + + var fromDisk []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + fromDisk = append(fromDisk, entry.Name()) + } + sort.Strings(fromDisk) + + fromManifest := manifestEntries() + assert.Equal(t, fromDisk, fromManifest, + "embedded manifest.txt is out of sync with help/cli-commands; run: make -C cliv2 helpdocs-manifest") +} + +func manifestEntries() []string { + entries := manifestLines(manifest) + sort.Strings(entries) + return entries +} diff --git a/cliv2/internal/helpdocs/manifest.txt b/cliv2/internal/helpdocs/manifest.txt new file mode 100644 index 0000000000..119fc0e94c --- /dev/null +++ b/cliv2/internal/helpdocs/manifest.txt @@ -0,0 +1,32 @@ +README.md +ai-red-teaming.md +aibom-test.md +aibom.md +apps.md +auth.md +code-test.md +code.md +config-environment.md +config.md +container-monitor.md +container-sbom.md +container-test.md +container.md +iac-capture.md +iac-describe.md +iac-report.md +iac-rules-init.md +iac-rules-push.md +iac-rules-test.md +iac-test.md +iac-update-exclude-policy.md +iac.md +ignore.md +log4shell.md +monitor.md +policy.md +redteam.md +sbom-monitor.md +sbom-test.md +sbom.md +test.md diff --git a/cliv2/internal/helprouting/helprouting.go b/cliv2/internal/helprouting/helprouting.go new file mode 100644 index 0000000000..c6190395c0 --- /dev/null +++ b/cliv2/internal/helprouting/helprouting.go @@ -0,0 +1,171 @@ +package helprouting + +import ( + "bytes" + "fmt" + "strings" + + "github.com/snyk/cli/cliv2/internal/helpdocs" + "github.com/spf13/cobra" +) + +const ( + helpCommand = "help" + helpFlag = "--help" +) + +var defaultCobraHelpFunc func(*cobra.Command, []string) + +func init() { + defaultCobraHelpFunc = (&cobra.Command{}).HelpFunc() +} + +// Router decides whether to show legacy user-doc help or Cobra help. +type Router struct { + LegacyHelp func() error + OnHelpCalled func() +} + +// Help picks legacy user-doc help or Cobra help for the given context. +// argv is typically os.Args[1:] (without the binary name). When c is nil, +// command context is derived from argv and root (flag-error path). +func (r *Router) Help(c *cobra.Command, root *cobra.Command, argv []string) error { + if r.OnHelpCalled != nil { + r.OnHelpCalled() + } + + if root == nil && c != nil { + root = c.Root() + } + if c == nil && root != nil { + c = resolveHelpCommand(root, argv) + } + + segments := commandSegments(c, root, argv) + if helpdocs.HasUserDoc(segments) { + return r.LegacyHelp() + } + + target := resolveCobraTarget(c, root, argv) + if target == nil { + return r.LegacyHelp() + } + + if helpdocs.HasUserDoc(commandSegmentsFromCobra(target)) { + return r.LegacyHelp() + } + + return renderCobraHelp(target) +} + +func commandSegments(c *cobra.Command, root *cobra.Command, argv []string) []string { + if root == nil { + return commandSegmentsFromCobra(c) + } + + args := pathArgs(argv) + if len(args) == 0 { + return commandSegmentsFromCobra(c) + } + if cmd, _, err := root.Find(args); err == nil && cmd != nil && cmd != root { + return commandSegmentsFromCobra(cmd) + } + return args +} + +func resolveCobraTarget(c *cobra.Command, root *cobra.Command, argv []string) *cobra.Command { + if root == nil { + return nil + } + + args := pathArgs(argv) + if len(args) > 0 { + cmd, _, err := root.Find(args) + if err == nil && cmd != nil && cmd != root && cmd.Name() != helpCommand { + return cmd + } + return nil + } + + if c != nil && c != root && c.Name() != helpCommand { + return c + } + + return nil +} + +func resolveHelpCommand(root *cobra.Command, argv []string) *cobra.Command { + if root == nil { + return nil + } + args := targetArgs(argv) + if len(args) == 0 { + return nil + } + cmd, _, err := root.Find(args) + if err != nil { + return nil + } + return cmd +} + +func commandSegmentsFromCobra(cmd *cobra.Command) []string { + if cmd == nil { + return nil + } + root := cmd.Root() + if cmd == root || cmd.Name() == helpCommand { + return nil + } + var segments []string + for cur := cmd; cur != nil && cur != root; cur = cur.Parent() { + if cur.Name() == helpCommand { + continue + } + segments = append([]string{cur.Name()}, segments...) + } + return segments +} + +// pathArgs returns command path tokens from argv, stripping help flags and a leading "help" subcommand. +func pathArgs(argv []string) []string { + args := targetArgs(argv) + if len(args) > 0 && args[0] == helpCommand { + return args[1:] + } + return args +} + +func targetArgs(argv []string) []string { + var out []string + for _, arg := range argv { + if isHelpFlag(arg) { + continue + } + if strings.HasPrefix(arg, fmt.Sprintf("%s=", helpFlag)) { + out = append(out, strings.TrimPrefix(arg, fmt.Sprintf("%s=", helpFlag))) + continue + } + if strings.HasPrefix(arg, "-") { + continue + } + out = append(out, arg) + } + return out +} + +func isHelpFlag(arg string) bool { + return arg == helpFlag || arg == "-h" || arg == "-help" +} + +func renderCobraHelp(cmd *cobra.Command) error { + cmd.SetHelpFunc(defaultCobraHelpFunc) + return cmd.Help() +} + +func renderCobraHelpToBuffer(cmd *cobra.Command) (string, error) { + var buf bytes.Buffer + cmd.SetOut(&buf) + err := renderCobraHelp(cmd) + return buf.String(), err +} diff --git a/cliv2/internal/helprouting/helprouting_test.go b/cliv2/internal/helprouting/helprouting_test.go new file mode 100644 index 0000000000..fad92b27ef --- /dev/null +++ b/cliv2/internal/helprouting/helprouting_test.go @@ -0,0 +1,225 @@ +package helprouting + +import ( + "bytes" + "testing" + + "github.com/snyk/cli/cliv2/internal/helpdocs" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Router_Help(t *testing.T) { + root := setupTestRoot(t) + + type testStruct struct { + name string + argv []string + command func(t *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) + wantLegacy bool + cobraOut []string + } + + documented := []testStruct{ + { + name: "top-level --help uses legacy readme", + argv: []string{"--help"}, + command: func(_ *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return root, nil + }, + wantLegacy: true, + }, + { + name: "help subcommand uses legacy", + argv: []string{"help"}, + command: func(_ *testing.T, _ *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return helpSubcommand(), nil + }, + wantLegacy: true, + }, + { + name: "help test uses legacy user doc", + argv: []string{"help", "test"}, + command: func(_ *testing.T, _ *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return helpSubcommand(), nil + }, + wantLegacy: true, + }, + { + name: "documented test --help uses legacy", + argv: []string{"test", "--help"}, + command: func(_ *testing.T, _ *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return nil, nil + }, + wantLegacy: true, + }, + { + name: "unknown command --help falls back to legacy", + argv: []string{"rainmaker", "--help"}, + command: func(_ *testing.T, _ *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return nil, nil + }, + wantLegacy: true, + }, + { + name: "-h container uses legacy user doc", + argv: []string{"-h", "container"}, + command: func(_ *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return root, nil + }, + wantLegacy: true, + }, + { + name: "iac -help uses legacy user doc", + argv: []string{"iac", "-help"}, + command: func(_ *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return root, nil + }, + wantLegacy: true, + }, + { + name: "--help=iac uses legacy user doc", + argv: []string{"--help=iac"}, + command: func(_ *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return root, nil + }, + wantLegacy: true, + }, + { + name: "--help hello uses legacy readme", + argv: []string{"--help", "hello"}, + command: func(_ *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + return root, nil + }, + wantLegacy: true, + }, + } + + undocumented := []testStruct{ + { + name: "undocumented secrets test --help uses cobra", + argv: []string{"secrets", "test", "--help"}, + command: func(t *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + t.Helper() + cmd := findCommand(t, root, "secrets", "test") + buf := &bytes.Buffer{} + cmd.SetOut(buf) + return cmd, buf + }, + cobraOut: []string{"Usage:"}, + }, + { + name: "unknown flag on undocumented command uses cobra", + argv: []string{"secrets", "test", "--bad-flag"}, + command: func(t *testing.T, root *cobra.Command) (*cobra.Command, *bytes.Buffer) { + t.Helper() + cmd := findCommand(t, root, "secrets", "test") + buf := &bytes.Buffer{} + cmd.SetOut(buf) + return cmd, buf + }, + cobraOut: []string{"Usage:"}, + }, + } + + for _, tc := range append(documented, undocumented...) { + t.Run(tc.name, func(t *testing.T) { + legacyCalled := false + router := &Router{ + LegacyHelp: func() error { + legacyCalled = true + return nil + }, + } + + cmd, buf := tc.command(t, root) + err := router.Help(cmd, root, tc.argv) + require.NoError(t, err) + assert.Equal(t, tc.wantLegacy, legacyCalled, "legacy help routing") + if tc.wantLegacy { + return + } + require.NotNil(t, buf) + for _, want := range tc.cobraOut { + assert.Contains(t, buf.String(), want) + } + }) + } +} + +func Test_targetArgs(t *testing.T) { + assert.Equal(t, []string{"secrets", "test"}, targetArgs([]string{"secrets", "test", "--help"})) + assert.Equal(t, []string{"secrets", "test"}, targetArgs([]string{"secrets", "test", "--bad-flag"})) + assert.Equal(t, []string{"help", "test"}, targetArgs([]string{"help", "test"})) +} + +func Test_commandSegmentsFromCobra(t *testing.T) { + root := setupTestRoot(t) + cmd := findCommand(t, root, "secrets", "test") + assert.Equal(t, []string{"secrets", "test"}, commandSegmentsFromCobra(cmd)) +} + +func Test_commandSegments(t *testing.T) { + root := setupTestRoot(t) + + assert.Equal(t, []string{"container"}, commandSegments(root, root, []string{"-h", "container"})) + assert.True(t, helpdocs.HasUserDoc([]string{"container"})) + + assert.Equal(t, []string{"container"}, commandSegments(root, root, []string{"--help=container"})) +} + +func Test_resolveCobraTarget(t *testing.T) { + root := setupTestRoot(t) + helpCmd := &cobra.Command{Use: "help"} + secretsTestCmd := findCommand(t, root, "secrets", "test") + + assert.Nil(t, resolveCobraTarget(helpCmd, root, []string{"help", "hello"})) + assert.Equal(t, secretsTestCmd, resolveCobraTarget(secretsTestCmd, root, []string{"secrets", "test", "--help"})) + assert.Equal(t, secretsTestCmd, resolveCobraTarget(helpCmd, root, []string{"help", "secrets", "test"})) +} + +func Test_renderCobraHelpToBuffer(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Short: "Scan for secrets", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + cmd.Flags().String("org", "", "Organization ID") + + output, err := renderCobraHelpToBuffer(cmd) + require.NoError(t, err) + assert.Contains(t, output, "Usage:") + assert.Contains(t, output, "Flags:") + assert.Contains(t, output, "--org") +} + +func setupTestRoot(t *testing.T) *cobra.Command { + t.Helper() + + root := &cobra.Command{Use: "snyk"} + secrets := &cobra.Command{Use: "secrets"} + test := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + secrets.AddCommand(test) + root.AddCommand(secrets) + return root +} + +func findCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command { + t.Helper() + + cmd, _, err := root.Find(path) + require.NoError(t, err) + return cmd +} + +func helpSubcommand() *cobra.Command { + return &cobra.Command{Use: "help"} +} diff --git a/cliv2/pkg/core/main.go b/cliv2/pkg/core/main.go index c38611673e..26c58162a1 100644 --- a/cliv2/pkg/core/main.go +++ b/cliv2/pkg/core/main.go @@ -1,7 +1,10 @@ package core // !!! This import needs to be the first import, please do not change this !!! -import _ "github.com/snyk/go-application-framework/pkg/networking/fips_enable" +import ( + "github.com/snyk/cli/cliv2/internal/helprouting" + _ "github.com/snyk/go-application-framework/pkg/networking/fips_enable" +) import ( "context" @@ -225,13 +228,6 @@ func runWorkflowAndProcessData(ctx context.Context, engine workflow.Engine, logg return err } -func help(_ *cobra.Command, _ []string) error { - helpProvided = true - args := utils.RemoveSimilar(os.Args[1:], "--") // remove all double dash arguments to avoid issues with the help command - args = append(args, "--help") - return defaultCmd(args) -} - func defaultCmd(args []string) error { inputDirectory := cliv2.DetermineInputDirectory(args) if len(inputDirectory) > 0 { @@ -247,6 +243,11 @@ func defaultCmd(args []string) error { return err } +func runLegacyHelp() error { + filteredArgs := utils.RemoveSimilar(os.Args[1:], "--") + return defaultCmd(append(filteredArgs, "--help")) +} + func runTestCommandWithSarifEqualJson(cmd *cobra.Command, args []string, templateFiles []string) error { // ensure legacy behavior, where sarif and json can be used interchangeably globalConfiguration.AddAlternativeKeys(output_workflow.OUTPUT_CONFIG_KEY_SARIF, []string{output_workflow.OUTPUT_CONFIG_KEY_JSON}) @@ -378,7 +379,7 @@ func createCommandsForWorkflows(rootCommand *cobra.Command, engine workflow.Engi } } -func prepareRootCommand() *cobra.Command { +func prepareRootCommand(router *helprouting.Router) *cobra.Command { rootCommand := cobra.Command{ Use: "snyk", RunE: func(cmd *cobra.Command, _ []string) error { @@ -386,11 +387,13 @@ func prepareRootCommand() *cobra.Command { }, } - // help for all commands is handled by the legacy cli - // TODO: discuss how to move help to extensions + // Help precedence: synced user-doc Markdown (legacy CLI) when available; otherwise Cobra help. + root := &rootCommand helpCommand := cobra.Command{ - Use: "help", - RunE: help, + Use: "help", + RunE: func(c *cobra.Command, _ []string) error { + return router.Help(c, root, os.Args[1:]) + }, } // some static/global cobra configuration @@ -399,8 +402,7 @@ func prepareRootCommand() *cobra.Command { rootCommand.SilenceUsage = true rootCommand.FParseErrWhitelist.UnknownFlags = true - // ensure that help and usage information comes from the legacy cli instead of cobra's default help - rootCommand.SetHelpFunc(func(c *cobra.Command, args []string) { _ = help(c, args) }) + rootCommand.SetHelpFunc(func(c *cobra.Command, _ []string) { _ = router.Help(c, root, os.Args[1:]) }) rootCommand.SetHelpCommand(&helpCommand) rootCommand.PersistentFlags().AddFlagSet(getGlobalFLags()) @@ -427,7 +429,7 @@ func handleError(err error) HandleError { if commandError { resultError = handleErrorFallbackToLegacyCLI } else if flagError { - // handle flag errors explicitly since we need to delegate the help to the legacy CLI. This includes disabling the cobra default help/usage + // handle flag errors explicitly via help(): user-doc markdown when available, otherwise Cobra help resultError = handleErrorShowHelp } } @@ -464,16 +466,16 @@ func displayError(err error, userInterface ui.UserInterface, config configuratio } func handleRetryNotification(engine workflow.Engine, logger *zerolog.Logger, err error) { - if ui := engine.GetUserInterface(); ui != nil { - if outputErr := ui.OutputError(err); outputErr != nil { + if userInterface := engine.GetUserInterface(); userInterface != nil { + if outputErr := userInterface.OutputError(err); outputErr != nil { logger.Warn().Err(outputErr).Msg("failed to show rate-limit retry warning") } } else { logger.Warn().Msg("rate-limit retry warning not shown: user interface not attached") } - if analytics := engine.GetAnalytics(); analytics != nil { - analytics.AddError(err) + if engineAnalytics := engine.GetAnalytics(); engineAnalytics != nil { + engineAnalytics.AddError(err) } else { logger.Warn().Msg("rate-limit retry not recorded in analytics: collector not initialized") } @@ -543,7 +545,11 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { var err error rInfo := runtimeinfo.New(runtimeinfo.WithName("snyk-cli"), runtimeinfo.WithVersion(cliv2.GetFullVersion())) - rootCommand := prepareRootCommand() + helpRouter := &helprouting.Router{ + LegacyHelp: runLegacyHelp, + OnHelpCalled: func() { helpProvided = true }, + } + rootCommand := prepareRootCommand(helpRouter) // omit the first arg which is always `snyk` _ = rootCommand.ParseFlags(os.Args[1:]) @@ -556,7 +562,7 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { ) err = globalConfiguration.AddFlagSet(rootCommand.LocalFlags()) if err != nil { - fmt.Fprintln(os.Stderr, "Failed to add flags to root command", err) + _, _ = fmt.Fprintln(os.Stderr, "Failed to add flags to root command", err) } // ensure to init configuration before using it @@ -670,7 +676,7 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { globalLogger.Printf("Using Legacy CLI to serve the command. (reason: %v)", err) err = defaultCmd(os.Args[1:]) case handleErrorShowHelp: - err = help(nil, []string{}) + err = helpRouter.Help(nil, rootCommand, os.Args[1:]) case handleErrorUnhandled: // ignore } diff --git a/cliv2/pkg/core/main_test.go b/cliv2/pkg/core/main_test.go index 943c811d16..e2d7454afe 100644 --- a/cliv2/pkg/core/main_test.go +++ b/cliv2/pkg/core/main_test.go @@ -13,6 +13,7 @@ import ( "github.com/golang/mock/gomock" "github.com/rs/zerolog" + "github.com/snyk/cli/cliv2/internal/helprouting" "github.com/snyk/error-catalog-golang-public/code" "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/go-application-framework/pkg/apiclients/testapi" @@ -144,7 +145,7 @@ func Test_CreateCommandsForWorkflowWithSubcommands(t *testing.T) { } _ = globalEngine.Init() - rootCommand := prepareRootCommand() + rootCommand := prepareRootCommand(testHelpRouter()) // invoke method under test createCommandsForWorkflows(rootCommand, globalEngine) @@ -763,3 +764,9 @@ func loadJsonFile(t *testing.T, filename string) []byte { assert.NoError(t, err) return byteValue } + +func testHelpRouter() *helprouting.Router { + return &helprouting.Router{ + LegacyHelp: func() error { return nil }, + } +} diff --git a/test/jest/acceptance/help.spec.ts b/test/jest/acceptance/help.spec.ts index 4398ef6dbd..e5e66d324e 100644 --- a/test/jest/acceptance/help.spec.ts +++ b/test/jest/acceptance/help.spec.ts @@ -72,7 +72,7 @@ describe('help', () => { ); expect(stdout).toContain( - 'tests container images for any known vulnerabilities', + 'The snyk container test command tests container images for any known vulnerabilities.', ); expect(code).toBe(0); // TODO: Test for stderr when docker issues are resolved