Skip to content

Commit de526a0

Browse files
feat: add support for user-doc-based help routing + manifest generation
1 parent 9a87faa commit de526a0

10 files changed

Lines changed: 674 additions & 26 deletions

File tree

CONTRIBUTING.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,33 @@ User-facing documentation is [available on GitBook](https://docs.snyk.io/feature
280280
`snyk help` output must also be [edited on GitBook](https://docs.snyk.io/features/snyk-cli/commands). Changes will
281281
automatically be pulled into Snyk CLI as pull requests.
282282

283+
### CLI help command files (`help/cli-commands`)
284+
285+
The Go CLI reads user-facing command help from markdown files under `help/cli-commands/`. These files are synced from
286+
GitBook into this repository (see the `sync-cli-help-to-user-docs` workflow). At build time, the CLI embeds a manifest
287+
of available help files (`cliv2/pkg/helpdocs/manifest.txt`) and uses it to decide whether to show legacy GitBook help
288+
or native Cobra help for a given command.
289+
290+
When you add, remove, or rename files in `help/cli-commands/`, regenerate the manifest and commit the result:
291+
292+
```sh
293+
make -C cliv2 helpdocs-manifest
294+
git add cliv2/pkg/helpdocs/manifest.txt
295+
```
296+
297+
`make -C cliv2 test` and `make build` run this target automatically, but you still need to commit the updated
298+
`manifest.txt` when it changes. Go tests in `cliv2/pkg/helpdocs` verify that the manifest stays in sync with
299+
`help/cli-commands/`.
300+
301+
To test help routing locally after building:
302+
303+
```sh
304+
./binary-releases/snyk-macos-arm64 help test # documented command → GitBook help
305+
./binary-releases/snyk-macos-arm64 help agent-scan # undocumented command → Cobra help
306+
```
307+
308+
Adjust the binary path for your platform (see [Building](#building)).
309+
283310
## Creating a branch
284311

285312
Create a new branch before making any changes. Make sure to give it a descriptive name so that you can find it later.

cliv2/Makefile

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ HASH_STRING = $(HASH)$(HASH_ALGORITHM)
126126
SIGN_SCRIPT = $(WORKING_DIR)/scripts/sign_$(_GO_OS).sh
127127
ISSIGNED_SCRIPT = $(WORKING_DIR)/scripts/issigned_$(_GO_OS).sh
128128
EMBEDDED_DATA_DIR = $(WORKING_DIR)/internal/embedded/_data
129+
HELPDOCS_DIR = $(WORKING_DIR)/pkg/helpdocs
130+
HELPDOCS_MANIFEST = $(HELPDOCS_DIR)/manifest.txt
131+
HELPDOCS_SOURCE = $(WORKING_DIR)/../help/cli-commands/*.md
129132

130133
ifeq ($(GOHOSTOS), windows)
131134
SPECIAL_SHELL = powershell
@@ -188,7 +191,17 @@ summary:
188191
.PHONY: configure
189192
configure: _validate-build-mode summary $(CACHE_DIR) $(CACHE_DIR)/variables.mk $(V1_DIRECTORY)/$(V1_EMBEDDED_FILE_OUTPUT) dependencies $(CACHE_DIR)/prepare-3rd-party-licenses
190193

191-
$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata
194+
.PHONY: helpdocs-manifest
195+
helpdocs-manifest:
196+
@set -e; \
197+
md_count=$$(ls $(HELPDOCS_SOURCE) 2>/dev/null | wc -l | tr -d ' '); \
198+
if [ "$$md_count" -eq 0 ]; then \
199+
echo "$(LOG_PREFIX) ERROR: no .md files found in help/cli-commands ($(HELPDOCS_SOURCE))"; \
200+
exit 1; \
201+
fi; \
202+
ls $(HELPDOCS_SOURCE) | xargs -n1 basename | sort > $(HELPDOCS_MANIFEST)
203+
204+
$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata $(HELPDOCS_MANIFEST)
192205
$(eval LS_PROTOCOL_VERSION := $(shell cat $(LS_PROTOCOL_VERSION_FILE)))
193206
$(eval LS_COMMIT_HASH := $(shell cat $(LS_COMMIT_HASH_FILE)))
194207
$(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:
247260
@$(GOCMD) test -cover ./...
248261

249262
.PHONY: test
250-
test: openboxtest
263+
test: helpdocs-manifest openboxtest
251264

252265
.PHONY: lint
253266
lint: $(TOOLS_BIN)/golangci-lint

cliv2/pkg/core/main.go

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import (
5050

5151
cli_errors "github.com/snyk/cli/cliv2/internal/errors"
5252
"github.com/snyk/cli/cliv2/pkg/basic_workflows"
53+
"github.com/snyk/cli/cliv2/pkg/helprouting"
5354
)
5455

5556
// Option configures the CLI runner.
@@ -225,13 +226,6 @@ func runWorkflowAndProcessData(ctx context.Context, engine workflow.Engine, logg
225226
return err
226227
}
227228

228-
func help(_ *cobra.Command, _ []string) error {
229-
helpProvided = true
230-
args := utils.RemoveSimilar(os.Args[1:], "--") // remove all double dash arguments to avoid issues with the help command
231-
args = append(args, "--help")
232-
return defaultCmd(args)
233-
}
234-
235229
func defaultCmd(args []string) error {
236230
inputDirectory := cliv2.DetermineInputDirectory(args)
237231
if len(inputDirectory) > 0 {
@@ -247,6 +241,11 @@ func defaultCmd(args []string) error {
247241
return err
248242
}
249243

244+
func runLegacyHelp() error {
245+
filteredArgs := utils.RemoveSimilar(os.Args[1:], "--")
246+
return defaultCmd(append(filteredArgs, "--help"))
247+
}
248+
250249
func runTestCommandWithSarifEqualJson(cmd *cobra.Command, args []string, templateFiles []string) error {
251250
// ensure legacy behavior, where sarif and json can be used interchangeably
252251
globalConfiguration.AddAlternativeKeys(output_workflow.OUTPUT_CONFIG_KEY_SARIF, []string{output_workflow.OUTPUT_CONFIG_KEY_JSON})
@@ -378,19 +377,21 @@ func createCommandsForWorkflows(rootCommand *cobra.Command, engine workflow.Engi
378377
}
379378
}
380379

381-
func prepareRootCommand() *cobra.Command {
380+
func prepareRootCommand(router *helprouting.Router) *cobra.Command {
382381
rootCommand := cobra.Command{
383382
Use: "snyk",
384383
RunE: func(cmd *cobra.Command, _ []string) error {
385384
return defaultCmd(os.Args[1:])
386385
},
387386
}
388387

389-
// help for all commands is handled by the legacy cli
390-
// TODO: discuss how to move help to extensions
388+
// Help precedence: synced user-doc Markdown (legacy CLI) when available; otherwise Cobra help.
389+
root := &rootCommand
391390
helpCommand := cobra.Command{
392-
Use: "help",
393-
RunE: help,
391+
Use: "help",
392+
RunE: func(c *cobra.Command, _ []string) error {
393+
return router.Help(c, root, os.Args[1:])
394+
},
394395
}
395396

396397
// some static/global cobra configuration
@@ -399,8 +400,7 @@ func prepareRootCommand() *cobra.Command {
399400
rootCommand.SilenceUsage = true
400401
rootCommand.FParseErrWhitelist.UnknownFlags = true
401402

402-
// ensure that help and usage information comes from the legacy cli instead of cobra's default help
403-
rootCommand.SetHelpFunc(func(c *cobra.Command, args []string) { _ = help(c, args) })
403+
rootCommand.SetHelpFunc(func(c *cobra.Command, _ []string) { _ = router.Help(c, root, os.Args[1:]) })
404404
rootCommand.SetHelpCommand(&helpCommand)
405405
rootCommand.PersistentFlags().AddFlagSet(getGlobalFLags())
406406

@@ -427,7 +427,7 @@ func handleError(err error) HandleError {
427427
if commandError {
428428
resultError = handleErrorFallbackToLegacyCLI
429429
} else if flagError {
430-
// handle flag errors explicitly since we need to delegate the help to the legacy CLI. This includes disabling the cobra default help/usage
430+
// handle flag errors explicitly via help(): user-doc markdown when available, otherwise Cobra help
431431
resultError = handleErrorShowHelp
432432
}
433433
}
@@ -464,16 +464,16 @@ func displayError(err error, userInterface ui.UserInterface, config configuratio
464464
}
465465

466466
func handleRetryNotification(engine workflow.Engine, logger *zerolog.Logger, err error) {
467-
if ui := engine.GetUserInterface(); ui != nil {
468-
if outputErr := ui.OutputError(err); outputErr != nil {
467+
if userInterface := engine.GetUserInterface(); userInterface != nil {
468+
if outputErr := userInterface.OutputError(err); outputErr != nil {
469469
logger.Warn().Err(outputErr).Msg("failed to show rate-limit retry warning")
470470
}
471471
} else {
472472
logger.Warn().Msg("rate-limit retry warning not shown: user interface not attached")
473473
}
474474

475-
if analytics := engine.GetAnalytics(); analytics != nil {
476-
analytics.AddError(err)
475+
if engineAnalytics := engine.GetAnalytics(); engineAnalytics != nil {
476+
engineAnalytics.AddError(err)
477477
} else {
478478
logger.Warn().Msg("rate-limit retry not recorded in analytics: collector not initialized")
479479
}
@@ -543,7 +543,11 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int {
543543
var err error
544544
rInfo := runtimeinfo.New(runtimeinfo.WithName("snyk-cli"), runtimeinfo.WithVersion(cliv2.GetFullVersion()))
545545

546-
rootCommand := prepareRootCommand()
546+
helpRouter := &helprouting.Router{
547+
LegacyHelp: runLegacyHelp,
548+
OnHelpCalled: func() { helpProvided = true },
549+
}
550+
rootCommand := prepareRootCommand(helpRouter)
547551
// omit the first arg which is always `snyk`
548552
_ = rootCommand.ParseFlags(os.Args[1:])
549553

@@ -556,7 +560,7 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int {
556560
)
557561
err = globalConfiguration.AddFlagSet(rootCommand.LocalFlags())
558562
if err != nil {
559-
fmt.Fprintln(os.Stderr, "Failed to add flags to root command", err)
563+
_, _ = fmt.Fprintln(os.Stderr, "Failed to add flags to root command", err)
560564
}
561565

562566
// ensure to init configuration before using it
@@ -670,7 +674,7 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int {
670674
globalLogger.Printf("Using Legacy CLI to serve the command. (reason: %v)", err)
671675
err = defaultCmd(os.Args[1:])
672676
case handleErrorShowHelp:
673-
err = help(nil, []string{})
677+
err = helpRouter.Help(nil, rootCommand, os.Args[1:])
674678
case handleErrorUnhandled:
675679
// ignore
676680
}

cliv2/pkg/core/main_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"github.com/snyk/cli/cliv2/internal/cliv2"
3333
"github.com/snyk/cli/cliv2/internal/constants"
3434
clierrors "github.com/snyk/cli/cliv2/internal/errors"
35+
"github.com/snyk/cli/cliv2/pkg/helprouting"
3536
)
3637

3738
func cleanup() {
@@ -144,7 +145,7 @@ func Test_CreateCommandsForWorkflowWithSubcommands(t *testing.T) {
144145
}
145146

146147
_ = globalEngine.Init()
147-
rootCommand := prepareRootCommand()
148+
rootCommand := prepareRootCommand(testHelpRouter())
148149

149150
// invoke method under test
150151
createCommandsForWorkflows(rootCommand, globalEngine)
@@ -763,3 +764,9 @@ func loadJsonFile(t *testing.T, filename string) []byte {
763764
assert.NoError(t, err)
764765
return byteValue
765766
}
767+
768+
func testHelpRouter() *helprouting.Router {
769+
return &helprouting.Router{
770+
LegacyHelp: func() error { return nil },
771+
}
772+
}

cliv2/pkg/helpdocs/helpdocs.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package helpdocs
2+
3+
import (
4+
_ "embed"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
//go:embed manifest.txt
10+
var manifest string
11+
12+
var docFiles map[string]struct{}
13+
14+
var nonDocChars = regexp.MustCompile(`[^a-zA-Z0-9-]`)
15+
16+
func init() {
17+
docFiles = manifestFileSet(manifest)
18+
}
19+
20+
// manifestFileSet builds the doc filename set from manifest text.
21+
// Trims trailing carriage returns so CRLF-checked-out manifests still match lookups.
22+
func manifestFileSet(manifestText string) map[string]struct{} {
23+
files := make(map[string]struct{})
24+
for _, line := range manifestLines(manifestText) {
25+
files[line] = struct{}{}
26+
}
27+
return files
28+
}
29+
30+
func manifestLines(manifestText string) []string {
31+
var lines []string
32+
for _, line := range strings.Split(strings.TrimSpace(manifestText), "\n") {
33+
line = strings.TrimSuffix(line, "\r")
34+
if line != "" {
35+
lines = append(lines, line)
36+
}
37+
}
38+
return lines
39+
}
40+
41+
// helpFileName mirrors src/cli/commands/help/index.ts join + cleanse.
42+
func helpFileName(segments []string) string {
43+
joined := strings.Join(segments, "-")
44+
cleaned := nonDocChars.ReplaceAllString(joined, "")
45+
return cleaned + ".md"
46+
}
47+
48+
// HasUserDoc reports whether legacy user-doc help should be shown for command segments.
49+
// Empty segments → true (top-level README via legacy help).
50+
// Non-empty segments → true only if a matching .md exists during walk-back (README excluded).
51+
func HasUserDoc(segments []string) bool {
52+
return hasUserDoc(docFiles, segments)
53+
}
54+
55+
func hasUserDoc(files map[string]struct{}, segments []string) bool {
56+
if len(segments) == 0 {
57+
return true
58+
}
59+
if len(files) == 0 {
60+
// Missing or empty manifest at build time: prefer legacy help lookup.
61+
return true
62+
}
63+
args := append([]string(nil), segments...)
64+
for len(args) > 0 {
65+
if _, ok := files[helpFileName(args)]; ok {
66+
return true
67+
}
68+
args = args[:len(args)-1]
69+
}
70+
return false
71+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package helpdocs
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sort"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func testDocFiles() map[string]struct{} {
15+
names := []string{
16+
"README.md",
17+
"test.md",
18+
"container.md",
19+
"container-test.md",
20+
"iac-describe.md",
21+
"redteam.md",
22+
}
23+
files := make(map[string]struct{}, len(names))
24+
for _, name := range names {
25+
files[name] = struct{}{}
26+
}
27+
return files
28+
}
29+
30+
func Test_helpFileName(t *testing.T) {
31+
assert.Equal(t, "container-test.md", helpFileName([]string{"container", "test"}))
32+
assert.Equal(t, "iac-describe.md", helpFileName([]string{"iac", "describe"}))
33+
assert.Equal(t, "secrets-test.md", helpFileName([]string{"secrets", "test"}))
34+
}
35+
36+
func Test_hasUserDoc(t *testing.T) {
37+
files := testDocFiles()
38+
39+
tests := map[string]struct {
40+
segments []string
41+
want bool
42+
}{
43+
"empty uses readme path": {segments: []string{}, want: true},
44+
"test command": {segments: []string{"test"}, want: true},
45+
"container test subcommand": {segments: []string{"container", "test"}, want: true},
46+
"iac describe subcommand": {segments: []string{"iac", "describe"}, want: true},
47+
"unknown command": {segments: []string{"rainmaker"}, want: false},
48+
"undocumented secrets test": {segments: []string{"secrets", "test"}, want: false},
49+
"redteam setup walks back to parent": {segments: []string{"redteam", "setup"}, want: true},
50+
"undocumented agent-scan": {segments: []string{"agent-scan"}, want: false},
51+
}
52+
53+
for name, tc := range tests {
54+
t.Run(name, func(t *testing.T) {
55+
assert.Equal(t, tc.want, hasUserDoc(files, tc.segments))
56+
})
57+
}
58+
}
59+
60+
func Test_HasUserDoc_usesEmbeddedManifest(t *testing.T) {
61+
assert.True(t, HasUserDoc([]string{"test"}))
62+
assert.NotEmpty(t, docFiles)
63+
}
64+
65+
func Test_manifestFileSet_stripsCRLFLineEndings(t *testing.T) {
66+
files := manifestFileSet("test.md\r\ncontainer-test.md\r\n")
67+
68+
assert.True(t, hasUserDoc(files, []string{"test"}))
69+
assert.True(t, hasUserDoc(files, []string{"container", "test"}))
70+
assert.False(t, hasUserDoc(files, []string{"rainmaker"}))
71+
}
72+
73+
func Test_manifestMatchesHelpCLICommands(t *testing.T) {
74+
helpDir := filepath.Join("..", "..", "..", "help", "cli-commands")
75+
entries, err := os.ReadDir(helpDir)
76+
require.NoError(t, err, "help/cli-commands must exist; run from repo root via go test ./pkg/helpdocs")
77+
78+
var fromDisk []string
79+
for _, entry := range entries {
80+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
81+
continue
82+
}
83+
fromDisk = append(fromDisk, entry.Name())
84+
}
85+
sort.Strings(fromDisk)
86+
87+
fromManifest := manifestEntries()
88+
assert.Equal(t, fromDisk, fromManifest,
89+
"embedded manifest.txt is out of sync with help/cli-commands; run: make -C cliv2 helpdocs-manifest")
90+
}
91+
92+
func manifestEntries() []string {
93+
entries := manifestLines(manifest)
94+
sort.Strings(entries)
95+
return entries
96+
}

0 commit comments

Comments
 (0)