Skip to content

Commit fa229bf

Browse files
fix: replace manifest.txt help with embedded user docs directory
1 parent 6895c48 commit fa229bf

18 files changed

Lines changed: 288 additions & 231 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ scripts/Brewfile.lock.json
5555
test/fixtures/**/go.sum
5656
.cursor
5757
.windsurf
58-
.claude
58+
.claude

CONTRIBUTING.md

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -283,20 +283,14 @@ automatically be pulled into Snyk CLI as pull requests.
283283
### CLI help command files (`help/cli-commands`)
284284

285285
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/`.
286+
GitBook into this repository (see the `sync-cli-help-to-user-docs` workflow). At **build** time, the Makefile copies
287+
`help/cli-commands/` into `cliv2/internal/helpdocs/cli-commands/` so the Go embed can read them, then removes the copy
288+
afterward. Go unit tests read `help/cli-commands/` from disk (or use a small in-memory fixture when that directory is
289+
unavailable), so `make -C cliv2 test` does not run that copy step. The embedded filenames decide whether to show legacy
290+
GitBook help or native Cobra help for a given command.
291+
292+
When you add, remove, or rename files in `help/cli-commands/`, no extra manifest step is required. Help routing tests
293+
pick up the changes on the next `make -C cliv2 test`; the shipped binary picks them up on the next `make build`.
300294

301295
To test help routing locally after building:
302296

cliv2/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ _cache
1212
bin
1313
internal/embedded/_data
1414
/.bin/
15+
internal/helpdocs/cli-commands

cliv2/Makefile

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ 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
129129
HELPDOCS_DIR = $(WORKING_DIR)/internal/helpdocs
130-
HELPDOCS_MANIFEST = $(HELPDOCS_DIR)/manifest.txt
131-
HELPDOCS_SOURCE = $(WORKING_DIR)/../help/cli-commands/*.md
130+
HELPDOCS_EMBED_DIR = $(HELPDOCS_DIR)/cli-commands
131+
HELPDOCS_SOURCE = $(WORKING_DIR)/../help/cli-commands
132132

133133
ifeq ($(GOHOSTOS), windows)
134134
SPECIAL_SHELL = powershell
@@ -191,17 +191,23 @@ summary:
191191
.PHONY: configure
192192
configure: _validate-build-mode summary $(CACHE_DIR) $(CACHE_DIR)/variables.mk $(V1_DIRECTORY)/$(V1_EMBEDDED_FILE_OUTPUT) dependencies $(CACHE_DIR)/prepare-3rd-party-licenses
193193

194-
.PHONY: helpdocs-manifest
195-
helpdocs-manifest:
194+
.PHONY: _helpdocs-prepare _helpdocs-clean
195+
_helpdocs-prepare:
196196
@set -e; \
197-
md_count=$$(ls $(HELPDOCS_SOURCE) 2>/dev/null | wc -l | tr -d ' '); \
197+
md_count=$$(ls $(HELPDOCS_SOURCE)/*.md 2>/dev/null | wc -l | tr -d ' '); \
198198
if [ "$$md_count" -eq 0 ]; then \
199-
echo "$(LOG_PREFIX) ERROR: no .md files found in help/cli-commands ($(HELPDOCS_SOURCE))"; \
199+
echo "$(LOG_PREFIX) ERROR: no .md files found in $(HELPDOCS_SOURCE)"; \
200200
exit 1; \
201201
fi; \
202-
ls $(HELPDOCS_SOURCE) | xargs -n1 basename | sort > $(HELPDOCS_MANIFEST)
202+
rm -rf $(HELPDOCS_EMBED_DIR)/*; \
203+
cp $(HELPDOCS_SOURCE)/*.md $(HELPDOCS_EMBED_DIR)/
203204

204-
$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata $(HELPDOCS_MANIFEST)
205+
_helpdocs-clean:
206+
@git clean -fdX -- $(HELPDOCS_EMBED_DIR)
207+
@git checkout -- $(HELPDOCS_EMBED_DIR)
208+
209+
$(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-metadata
210+
@$(MAKE) _helpdocs-prepare
205211
$(eval LS_PROTOCOL_VERSION := $(shell cat $(LS_PROTOCOL_VERSION_FILE)))
206212
$(eval LS_COMMIT_HASH := $(shell cat $(LS_COMMIT_HASH_FILE)))
207213
$(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))
@@ -211,7 +217,9 @@ $(BUILD_DIR)/$(V2_EXECUTABLE_NAME): $(BUILD_DIR) $(SRCS) generate-ls-protocol-me
211217
@echo "$(LOG_PREFIX) EXTRA_FLAGS: "
212218
@$(foreach flag,$(filter -X%,$(subst -X ,$(space)-X,$(EXTRA_FLAGS))),echo "$(LOG_PREFIX) $(flag)";)
213219
@echo "$(LOG_PREFIX) GCFLAGS: $(GCFLAGS)"
214-
@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
220+
@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 \
221+
|| ($(MAKE) _helpdocs-clean; exit 1)
222+
@$(MAKE) _helpdocs-clean
215223

216224
.PHONY: fips
217225
fips:
@@ -260,7 +268,7 @@ openboxtest:
260268
@$(GOCMD) test -cover ./...
261269

262270
.PHONY: test
263-
test: helpdocs-manifest openboxtest
271+
test: openboxtest
264272

265273
.PHONY: lint
266274
lint: $(TOOLS_BIN)/golangci-lint
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Adding this file to the project will prevent IDE errors between builds
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package helpdocs
2+
3+
import (
4+
"io/fs"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/rs/zerolog"
11+
)
12+
13+
var nonDocChars = regexp.MustCompile(`[^a-zA-Z0-9-]`)
14+
15+
// CommandHelp indexes embedded or test-supplied CLI command help markdown files.
16+
type CommandHelp struct {
17+
files map[string]struct{}
18+
}
19+
20+
// NewCommandHelp builds a lookup by walking root on fsys for *.md files.
21+
func NewCommandHelp(fsys fs.FS, root string) (*CommandHelp, error) {
22+
docFiles, err := docFilesFromEmbed(fsys, root)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
return &CommandHelp{files: docFiles}, nil
28+
}
29+
30+
// NewCommandHelpFromFiles builds a lookup from an existing filename set.
31+
func NewCommandHelpFromFiles(files map[string]struct{}) *CommandHelp {
32+
return &CommandHelp{files: files}
33+
}
34+
35+
// HasUserDoc reports whether legacy user-doc help should be shown for command segments.
36+
// Empty segments -> true (top-level README via legacy help).
37+
// Non-empty segments -> true only if a matching .md exists (README excluded).
38+
func (h *CommandHelp) HasUserDoc(segments []string) bool {
39+
return hasUserDoc(segments, h.files)
40+
}
41+
42+
func docFilesFromEmbed(fsys fs.FS, root string) (map[string]struct{}, error) {
43+
files := make(map[string]struct{})
44+
err := fs.WalkDir(fsys, root, func(_ string, d fs.DirEntry, err error) error {
45+
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
46+
return err
47+
}
48+
files[d.Name()] = struct{}{}
49+
return nil
50+
})
51+
if err != nil {
52+
return nil, err
53+
}
54+
return files, nil
55+
}
56+
57+
// helpFileName mirrors src/cli/commands/help/index.ts findHelpFile() join + replace.
58+
func helpFileName(segments []string) string {
59+
joined := strings.Join(segments, "-")
60+
cleaned := nonDocChars.ReplaceAllString(joined, "")
61+
return cleaned + ".md"
62+
}
63+
64+
func hasUserDoc(segments []string, files map[string]struct{}) bool {
65+
if len(segments) == 0 {
66+
return true
67+
}
68+
if len(files) == 0 {
69+
// Missing or empty embed at build time: prefer legacy help lookup.
70+
return true
71+
}
72+
for len(segments) > 0 {
73+
if _, ok := files[helpFileName(segments)]; ok {
74+
return true
75+
}
76+
segments = segments[:len(segments)-1]
77+
}
78+
return false
79+
}
80+
81+
// commandHelpLogger returns a stderr logger when sourceDir is the snyk/cli help tree.
82+
func commandHelpLogger(helpCLICommandsDir string) *zerolog.Logger {
83+
repoRoot := filepath.Clean(filepath.Join(helpCLICommandsDir, "..", ".."))
84+
if _, err := os.Stat(filepath.Join(repoRoot, "cliv2", "go.mod")); err != nil {
85+
return nil
86+
}
87+
return new(zerolog.New(os.Stderr).With().Timestamp().Str("component", "helpdocs").Logger())
88+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package helpdocs
2+
3+
import (
4+
"testing"
5+
"testing/fstest"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func Test_helpFileName(t *testing.T) {
11+
assert.Equal(t, "container-test.md", helpFileName([]string{"container", "test"}))
12+
assert.Equal(t, "iac-describe.md", helpFileName([]string{"iac", "describe"}))
13+
assert.Equal(t, "secrets-test.md", helpFileName([]string{"secrets", "test"}))
14+
}
15+
16+
func Test_HasUserDoc(t *testing.T) {
17+
help := CommandHelpForTest(t)
18+
19+
tests := map[string]struct {
20+
segments []string
21+
want bool
22+
}{
23+
"empty uses readme path": {segments: []string{}, want: true},
24+
"test command": {segments: []string{"test"}, want: true},
25+
"container test subcommand": {segments: []string{"container", "test"}, want: true},
26+
"iac describe subcommand": {segments: []string{"iac", "describe"}, want: true},
27+
"unknown command": {segments: []string{"rainmaker"}, want: false},
28+
"undocumented secrets test": {segments: []string{"secrets", "test"}, want: false},
29+
"redteam setup walks back to parent": {segments: []string{"redteam", "setup"}, want: true},
30+
"undocumented agent-scan": {segments: []string{"agent-scan"}, want: false},
31+
}
32+
33+
for name, tc := range tests {
34+
t.Run(name, func(t *testing.T) {
35+
assert.Equal(t, tc.want, help.HasUserDoc(tc.segments))
36+
})
37+
}
38+
}
39+
40+
func Test_NewCommandHelpFromFS(t *testing.T) {
41+
expected := fixtureCommandHelp()
42+
43+
fsMap := fstest.MapFS{
44+
"do-not-delete": {Data: []byte("placeholder")},
45+
"nested/ignored.md": {Data: []byte("# nested")},
46+
}
47+
for name := range fixtureCommandHelpFiles {
48+
fsMap[name] = &fstest.MapFile{Data: []byte("# doc")}
49+
}
50+
51+
var help *CommandHelp
52+
var err error
53+
if help, err = NewCommandHelp(fsMap, "."); err != nil {
54+
t.Fatal(err)
55+
}
56+
57+
for _, segments := range [][]string{
58+
{"test"},
59+
{"container", "test"},
60+
{"rainmaker"},
61+
} {
62+
assert.Equal(t, expected.HasUserDoc(segments), help.HasUserDoc(segments), segments)
63+
}
64+
}

cliv2/internal/helpdocs/embed.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package helpdocs
2+
3+
import "embed"
4+
5+
const cliCommandsDir = "cli-commands"
6+
7+
//go:embed cli-commands
8+
var cliCommands embed.FS
9+
10+
var defaultCommandHelp *CommandHelp
11+
12+
// DefaultCommandHelp returns the compile-time embedded CLI command help lookup.
13+
func DefaultCommandHelp() *CommandHelp {
14+
if defaultCommandHelp == nil {
15+
var err error
16+
if defaultCommandHelp, err = NewCommandHelp(cliCommands, cliCommandsDir); err != nil {
17+
panic("helpdocs: index cli-commands" + err.Error())
18+
}
19+
}
20+
return defaultCommandHelp
21+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package helpdocs
2+
3+
import "testing"
4+
5+
func CommandHelpForTest(t *testing.T) *CommandHelp {
6+
t.Helper()
7+
return CommandHelpFromRepo()
8+
}

cliv2/internal/helpdocs/fixture.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package helpdocs
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
)
7+
8+
var fixtureCommandHelpFiles = map[string]struct{}{
9+
"test.md": {},
10+
"container.md": {},
11+
"container-test.md": {},
12+
"iac.md": {},
13+
"iac-describe.md": {},
14+
"redteam.md": {},
15+
}
16+
17+
// fixtureCommandHelp returns a minimal doc index for unit tests without compile-time embed.
18+
func fixtureCommandHelp() *CommandHelp {
19+
return NewCommandHelpFromFiles(fixtureCommandHelpFiles)
20+
}
21+
22+
const repoCLICommandsDir = "../../../help/cli-commands"
23+
24+
// CommandHelpFromRepo prefers help/cli-commands on disk, else fixtureCommandHelp.
25+
func CommandHelpFromRepo() *CommandHelp {
26+
sourceDir, err := filepath.Abs(repoCLICommandsDir)
27+
if err != nil {
28+
return fixtureCommandHelp()
29+
}
30+
matches, err := filepath.Glob(filepath.Join(sourceDir, "*.md"))
31+
if err != nil || len(matches) == 0 {
32+
return fixtureCommandHelp()
33+
}
34+
commandHelp, err := NewCommandHelp(os.DirFS(sourceDir), ".")
35+
if err != nil {
36+
if logger := commandHelpLogger(sourceDir); logger != nil {
37+
logger.Warn().Err(err).Str("dir", sourceDir).Msg("failed to load command help from repo")
38+
}
39+
return fixtureCommandHelp()
40+
}
41+
return commandHelp
42+
}

0 commit comments

Comments
 (0)