Skip to content
Open
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
28 changes: 18 additions & 10 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,23 @@ 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-clean
_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)/*; \
cp $(HELPDOCS_SOURCE)/*.md $(HELPDOCS_EMBED_DIR)/

$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata $(HELPDOCS_MANIFEST)
_helpdocs-clean:

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: Now that we have _helpdocs-prepare and _helpdocs-clean. Could it be possible just to generate the manifest.txt with the filenames, embed that, and then remove it?

Will the contents of .md files end up being embedded twice in the binary as //go:embed cli-commands doesn't include strictly just the file names, and I believe they're also embedded by the legacy TS version?

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.

We are not generating the manifest.txt due to the user-docs hook issue; that's the whole purpose of this PR.

I do not follow your second comment :-)

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.

On the manifest point, I think we're aligned that a committed manifest.txt is the problem: because of the weekly sync-cli-help-to-user-docs action, the manifest could drift.

What I was wondering is whether a transient manifest (generated in _helpdocs-prepare, embedded for compile, removed in _helpdocs-clean, never committed) would still have that problem. It feels like it would solve the same drift issue, just with a smaller Go-side embed than copying all the .md files. Does that match your thinking, or am I missing something about why even a build-time-only manifest is undesirable?

On the second part, Go's //go:embed cli-commands pulls in the full markdown files (with content), but HasUserDoc only uses their filenames for routing.

Mostly curious whether I'm reasoning about the transient-manifest alternative correctly, or if there's a constraint I'm not seeing. Happy to resolve the thread either way once I've understood your take. 🙂

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.

But what's the point of generating a manifest.txt in _helpdocs-prepare. I think that's adding extra complexity:

  1. Iterate over all user-docs
  2. Create manifest.txt
  3. At the end, removing the manifest

Vs what we have here. embed.FS is basically an iterable construct which does not imply any complexity other than iterating it.

I do not think it brings any advantage. If you could name any real, tangible advantage we may be in the same line ;-)

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.

With this approach we're embedding the docs content twice in the shipped binary: Go embeds the full .md files but only uses filenames for routing, and the legacy TS binary already embeds the same content for rendering. With a transient manifest we'd only have the filenames on the Go side. The difference isn't big though 😅 .

@robertolopezlopez robertolopezlopez Jun 22, 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.

You may be right on one side: yes, double embed. The TS bundle will be there for long time I believe.

Your proposal may work and shrink the Go side; but reopens the initial approach in CLI-1596 and adds more problems to the PR, which is already large.

Planned scope: preparing+cleaning changes, embed, getting a green pipeline (heh), multiple new review threads.

Do you really think it is worth to start changing the whole PR? :)

@octavian-snyk octavian-snyk Jun 22, 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.

Fair point. Thank you 😀

Approval already given. I believe you can merge this PR.

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.

thank youuuu

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.

just to add a closing thought here, we will be removing the TS implementation in the future, so the double embed is only temporary.

@git clean -fdX -- $(HELPDOCS_EMBED_DIR)
@git checkout -- $(HELPDOCS_EMBED_DIR)

$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata
@$(MAKE) _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 All @@ -211,7 +217,9 @@ $(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-me
@echo "$(LOG_PREFIX) EXTRA_FLAGS: "
@$(foreach flag,$(filter -X%,$(subst -X ,$(space)-X,$(EXTRA_FLAGS))),echo "$(LOG_PREFIX) $(flag)";)
@echo "$(LOG_PREFIX) GCFLAGS: $(GCFLAGS)"
@cd $(BUILD_ROOT_DIR) && CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=$(FIPS_CRYPTO_BACKEND) MS_GO_NOSYSTEMCRYPTO=$(MS_GO_NOSYSTEMCRYPTO) GOOS=$(_GO_OS) GOARCH=$(GOARCH) $(GOCMD) build -tags=application -ldflags="$(LDFLAGS) $(EXTRA_FLAGS)" $(GCFLAGS) -o $(BUILD_DIR)/$(V2_EXECUTABLE_NAME) ./cmd/cliv2
@cd $(BUILD_ROOT_DIR) && CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=$(FIPS_CRYPTO_BACKEND) MS_GO_NOSYSTEMCRYPTO=$(MS_GO_NOSYSTEMCRYPTO) GOOS=$(_GO_OS) GOARCH=$(GOARCH) $(GOCMD) build -tags=application -ldflags="$(LDFLAGS) $(EXTRA_FLAGS)" $(GCFLAGS) -o $(BUILD_DIR)/$(V2_EXECUTABLE_NAME) ./cmd/cliv2 \
|| ($(MAKE) _helpdocs-clean; exit 1)
@$(MAKE) _helpdocs-clean

.PHONY: fips
fips:
Expand Down Expand Up @@ -260,7 +268,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

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

import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/rs/zerolog"
)

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
}

// NewCommandHelpFromFiles builds a lookup from an existing filename set.
func NewCommandHelpFromFiles(files map[string]struct{}) *CommandHelp {

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.

Suggestion: Maybe use https://pkg.go.dev/golang.org/x/tools/godoc/vfs/mapfs and remove this New function.

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.

it is deprecated! from your own link :-)

return &CommandHelp{files: files}
}

// 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
}

// commandHelpLogger returns a stderr logger when sourceDir is the snyk/cli help tree.
func commandHelpLogger(helpCLICommandsDir string) *zerolog.Logger {
repoRoot := filepath.Clean(filepath.Join(helpCLICommandsDir, "..", ".."))
if _, err := os.Stat(filepath.Join(repoRoot, "cliv2", "go.mod")); err != nil {
return nil
}
return new(zerolog.New(os.Stderr).With().Timestamp().Str("component", "helpdocs").Logger())
}
64 changes: 64 additions & 0 deletions cliv2/internal/helpdocs/command_help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package helpdocs

import (
"testing"
"testing/fstest"

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

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()

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")}
}

var help *CommandHelp
var err error
if help, err = NewCommandHelp(fsMap, "."); err != nil {
t.Fatal(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 CommandHelpFromRepo()
}
42 changes: 42 additions & 0 deletions cliv2/internal/helpdocs/fixture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package helpdocs

import (
"os"
"path/filepath"
)

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

// fixtureCommandHelp returns a minimal doc index for unit tests without compile-time embed.
func fixtureCommandHelp() *CommandHelp {
return NewCommandHelpFromFiles(fixtureCommandHelpFiles)
}

const repoCLICommandsDir = "../../../help/cli-commands"

// CommandHelpFromRepo prefers help/cli-commands on disk, else fixtureCommandHelp.
func CommandHelpFromRepo() *CommandHelp {
sourceDir, err := filepath.Abs(repoCLICommandsDir)
if err != nil {
return fixtureCommandHelp()
}
matches, err := filepath.Glob(filepath.Join(sourceDir, "*.md"))
if err != nil || len(matches) == 0 {
return fixtureCommandHelp()
}
commandHelp, err := NewCommandHelp(os.DirFS(sourceDir), ".")
if err != nil {
if logger := commandHelpLogger(sourceDir); logger != nil {
logger.Warn().Err(err).Str("dir", sourceDir).Msg("failed to load command help from repo")
}
return fixtureCommandHelp()
}
return commandHelp
}
Loading