Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ scripts/Brewfile.lock.json
test/fixtures/**/go.sum
.cursor
.windsurf
.claude
.claude
22 changes: 8 additions & 14 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,20 +283,14 @@ 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/`.
GitBook into this repository (see the `sync-cli-help-to-user-docs` workflow). At **build** time, the Makefile copies
`help/cli-commands/` into `cliv2/internal/helpdocs/cli-commands/` so the Go embed can read them, then removes the copy
afterward. Go unit tests read `help/cli-commands/` from disk (or use a small in-memory fixture when that directory is
unavailable), so `make -C cliv2 test` does not run that copy step. The embedded filenames 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/`, no extra manifest step is required. Help routing tests
pick up the changes on the next `make -C cliv2 test`; the shipped binary picks them up on the next `make build`.

To test help routing locally after building:

Expand Down
1 change: 1 addition & 0 deletions cliv2/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ _cache
bin
internal/embedded/_data
/.bin/
internal/helpdocs/cli-commands
19 changes: 10 additions & 9 deletions cliv2/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ 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
HELPDOCS_EMBED_DIR = $(HELPDOCS_DIR)/cli-commands
HELPDOCS_SOURCE = $(WORKING_DIR)/../help/cli-commands

ifeq ($(GOHOSTOS), windows)
SPECIAL_SHELL = powershell
Expand Down Expand Up @@ -191,17 +191,18 @@ 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

.PHONY: helpdocs-manifest
helpdocs-manifest:
.PHONY: _helpdocs-prepare
_helpdocs-prepare:
@set -e; \
md_count=$$(ls $(HELPDOCS_SOURCE) 2>/dev/null | wc -l | tr -d ' '); \
md_count=$$(ls $(HELPDOCS_SOURCE)/*.md 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))"; \
echo "$(LOG_PREFIX) ERROR: no .md files found in $(HELPDOCS_SOURCE)"; \
exit 1; \
fi; \
ls $(HELPDOCS_SOURCE) | xargs -n1 basename | sort > $(HELPDOCS_MANIFEST)
rm -rf $(HELPDOCS_EMBED_DIR)/*.md; \
cp $(HELPDOCS_SOURCE)/*.md $(HELPDOCS_EMBED_DIR)/

$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata $(HELPDOCS_MANIFEST)
$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata _helpdocs-prepare
$(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))
Expand Down Expand Up @@ -260,7 +261,7 @@ openboxtest:
@$(GOCMD) test -cover ./...

.PHONY: test
test: helpdocs-manifest openboxtest
test: openboxtest

.PHONY: lint
lint: $(TOOLS_BIN)/golangci-lint
Expand Down
1 change: 1 addition & 0 deletions cliv2/internal/helpdocs/cli-commands/do-not-delete
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adding this file to the project will prevent IDE errors between builds

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: does this directory need to go into gitignore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmmmh good one, adding - but you get the reasoning behind this file, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we perhaps ignore the directory contents entirely in case an interrupted make build/test leaves a dirty tree such that they cannot be committed by accident?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I meant, gitignore the whole directory and just have this one file.

but you get the reasoning behind this file, right?
I think this file is here so that a build would work even without using make build for it?!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok! for sure

70 changes: 70 additions & 0 deletions cliv2/internal/helpdocs/command_help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package helpdocs

import (
"io/fs"
"regexp"
"strings"
)

var nonDocChars = regexp.MustCompile(`[^a-zA-Z0-9-]`)

// CommandHelp indexes embedded or test-supplied CLI command help markdown files.
type CommandHelp struct {
files map[string]struct{}
}

// NewCommandHelp builds a lookup by walking root on fsys for *.md files.
func NewCommandHelp(fsys fs.FS, root string) (*CommandHelp, error) {
docFiles, err := docFilesFromEmbed(fsys, root)
if err != nil {
return nil, err
}

return &CommandHelp{files: docFiles}, nil
}

// 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 (README excluded).
func (h *CommandHelp) HasUserDoc(segments []string) bool {
return hasUserDoc(segments, h.files)
}

func docFilesFromEmbed(fsys fs.FS, root string) (map[string]struct{}, error) {
files := make(map[string]struct{})
err := fs.WalkDir(fsys, root, func(_ string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
return err
}
files[d.Name()] = struct{}{}
return nil
})
if err != nil {
return nil, err
}
return files, nil
}

// helpFileName mirrors src/cli/commands/help/index.ts findHelpFile() join + replace.
func helpFileName(segments []string) string {
joined := strings.Join(segments, "-")
cleaned := nonDocChars.ReplaceAllString(joined, "")
return cleaned + ".md"
}

func hasUserDoc(segments []string, files map[string]struct{}) bool {
if len(segments) == 0 {
return true
}
if len(files) == 0 {
// Missing or empty embed at build time: prefer legacy help lookup.
return true
}
for len(segments) > 0 {
if _, ok := files[helpFileName(segments)]; ok {
return true
}
segments = segments[:len(segments)-1]
}
return false
}
53 changes: 53 additions & 0 deletions cliv2/internal/helpdocs/command_help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package helpdocs

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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) {
help := CommandHelpForTest(t)

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, help.HasUserDoc(tc.segments))
})
}
}

func Test_NewCommandHelpFromFS(t *testing.T) {
expected := FixtureCommandHelp()

help, err := NewCommandHelp(fixtureCommandHelpFS(), ".")
require.NoError(t, err)

for _, segments := range [][]string{
{"test"},
{"container", "test"},
{"rainmaker"},
} {
assert.Equal(t, expected.HasUserDoc(segments), help.HasUserDoc(segments), segments)
}
}
21 changes: 21 additions & 0 deletions cliv2/internal/helpdocs/embed.go

@octavian-snyk octavian-snyk Jun 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: For readability it might be better to integrate these small files (embed.go, and export_test.go) into other relevant files; perhaps command_help.go, and fixture.go, respectively.

Other than this, LGTM :)

@robertolopezlopez robertolopezlopez Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack :-) about embed.go: the Go convention is that //go:embed sits in a minimal file. I may merge it with command_help.go if you prefer but it is getting dangerously large IMHO and this is a sleepery road ;-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

about export_test.go: export_help.go is a kind of standard for test only package helpers. Example: os/export_test.go, fmt/export_test.go, etc.

@octavian-snyk octavian-snyk Jun 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL, thanks for the explanation!

I noticed CommandHelpFromRepo is already used directly in a couple of test files (helprouting_test.go:233 and externally at main_test.go:770), while CommandHelpForTest is only used internally at command_help_test.go:17. Do we need the extra re-export via CommandHelpForTest?

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package helpdocs

import "embed"

const cliCommandsDir = "cli-commands"

//go:embed cli-commands
var cliCommands embed.FS

var defaultCommandHelp *CommandHelp

// DefaultCommandHelp returns the compile-time embedded CLI command help lookup.
func DefaultCommandHelp() *CommandHelp {
if defaultCommandHelp == nil {
var err error
if defaultCommandHelp, err = NewCommandHelp(cliCommands, cliCommandsDir); err != nil {
panic("helpdocs: index cli-commands: " + err.Error())
}
}
return defaultCommandHelp
}
8 changes: 8 additions & 0 deletions cliv2/internal/helpdocs/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package helpdocs

import "testing"

func CommandHelpForTest(t *testing.T) *CommandHelp {
t.Helper()
return FixtureCommandHelp()
}
32 changes: 32 additions & 0 deletions cliv2/internal/helpdocs/fixture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package helpdocs

import "testing/fstest"

var fixtureCommandHelpFiles = map[string]struct{}{
"test.md": {},
"container.md": {},
"container-test.md": {},
"iac.md": {},
"iac-describe.md": {},
"redteam.md": {},
}

func fixtureCommandHelpFS() fstest.MapFS {
fsMap := fstest.MapFS{
"do-not-delete": {Data: []byte("placeholder")},
"nested/ignored.md": {Data: []byte("# nested")},
}
for name := range fixtureCommandHelpFiles {
fsMap[name] = &fstest.MapFile{Data: []byte("# doc")}
}
return fsMap
}

// FixtureCommandHelp returns a minimal CommandHelp built from fstest.MapFS for unit tests.
func FixtureCommandHelp() *CommandHelp {
help, err := NewCommandHelp(fixtureCommandHelpFS(), ".")
if err != nil {
panic("helpdocs: fixture command help: " + err.Error())
}
return help
}
71 changes: 0 additions & 71 deletions cliv2/internal/helpdocs/helpdocs.go

This file was deleted.

Loading