diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 705d4d2..5349936 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -38,6 +38,8 @@ const ( testFlagNoExtra = "--no-extra-files" testFlagVersion = "--version" testFlagVerbose = "--verbose" + testFlagHelp = "--help" + testCmdHelp = "help" ) var errSimulatedWrite = errors.New("simulated write failure") @@ -354,7 +356,7 @@ func TestHelpCommand(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() - opts := testOpts([]string{testApp, "--help"}, fs) + opts := testOpts([]string{testApp, testFlagHelp}, fs) exitCode := runCLI(opts) @@ -1839,6 +1841,87 @@ func TestGenerateReadsPathAfterDoubleDash(t *testing.T) { assert.Equal(t, testFileTxt, files[0].GetPath()) } +// TestPathNamedHelp runs gen, then check with --require-signature, on a +// directory named help and on one named h, the two names of urfave/cli's +// help command. gen must write the manifest into the directory, and check +// must refuse it as unsigned. +// +//nolint:paralleltest // changes the process-global working directory +func TestPathNamedHelp(t *testing.T) { + for _, name := range []string{testCmdHelp, "h"} { + t.Run(name, func(t *testing.T) { + chdirTemp(t) + + fs := afero.NewOsFs() + require.NoError(t, fs.Mkdir(name, 0o755)) + writeTestFile(t, fs, filepath.Join(name, testFileTxt), "content") + + opts := testOpts([]string{testApp, cmdGenerate, "-q", name}, fs) + require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) + + files := manifestFiles(t, fs, filepath.Join(name, defaultManifestName)) + require.Len(t, files, 1) + + opts = testOpts([]string{ + testApp, cmdCheck, "-q", name, "--" + flagRequireSignature, msgFpA, + }, fs) + assert.Equal(t, 1, runCLI(opts)) + assert.Contains(t, testStderr(t, opts), "manifest is not signed") + }) + } +} + +// TestCommandsReadHelpAsArgument runs each command that takes a path or URL +// on help and on h where nothing by that name exists. Each must fail on the +// missing path or bad URL instead of printing its help. +func TestCommandsReadHelpAsArgument(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch, + } { + for _, name := range []string{testCmdHelp, "h"} { + t.Run(command+" "+name, func(t *testing.T) { + t.Parallel() + + opts := testOpts([]string{testApp, command, name}, afero.NewMemMapFs()) + assert.Equal(t, 1, runCLI(opts)) + assert.NotContains(t, testStdout(t, opts), "USAGE:") + }) + } + } +} + +// TestHelpBesideArgument runs each command that takes a path or URL with +// --help or -h before and after a directory, and runs help on that command. +// Each must print what the command's own --help prints. +func TestHelpBesideArgument(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch, + } { + want := testOpts([]string{testApp, command, testFlagHelp}, afero.NewMemMapFs()) + require.Equal(t, 0, runCLI(want)) + require.Contains(t, testStdout(t, want), testApp+" "+command) + + for _, args := range [][]string{ + {command, testDir, testFlagHelp}, + {command, testFlagHelp, testDir}, + {command, testDir, "-h"}, + {testCmdHelp, command}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + opts := testOpts(slices.Concat([]string{testApp}, args), afero.NewMemMapFs()) + assert.Equal(t, 0, runCLI(opts), testStderr(t, opts)) + assert.Equal(t, testStdout(t, want), testStdout(t, opts)) + }) + } + } +} + func TestCheckDetectsManifestCorruption(t *testing.T) { t.Parallel() diff --git a/internal/cli/mfer.go b/internal/cli/mfer.go index d609710..13fa71e 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -179,6 +179,17 @@ func refuseExtraArgs(_ context.Context, cmd *cli.Command) error { return nil } +// showCommandHelp is the CommandNotFound of the commands that take a path or +// URL. Those commands set HideHelpCommand, so that a path or URL named help or +// h is an argument rather than urfave/cli's help command; each sets it itself, +// because on the root it would also remove `mfer help`. With --help, +// urfave/cli looks the command's first argument up as a help topic, so +// `check DIR --help` asks for a topic DIR and finds none; this prints the +// command's own help instead, as `check --help` does. +func showCommandHelp(ctx context.Context, cmd *cli.Command, _ string) { + _ = cli.ShowCommandHelp(ctx, cmd.Root(), cmd.Name) +} + // requireSignatureFlag returns the --require-signature flag taken by the // check and fetch subcommands. func requireSignatureFlag() *cli.StringFlag { @@ -202,10 +213,12 @@ func includePermissionsFlag() *cli.BoolFlag { func (mfa *CLIApp) generateCommand() *cli.Command { return &cli.Command{ - Name: cmdGenerate, - Aliases: []string{"gen"}, - Usage: "Generate manifest file", - ArgsUsage: "[path ...]", + Name: cmdGenerate, + Aliases: []string{"gen"}, + Usage: "Generate manifest file", + ArgsUsage: "[path ...]", + HideHelpCommand: true, + CommandNotFound: showCommandHelp, Action: func(ctx context.Context, cmd *cli.Command) error { mfa.setVerbosity(cmd) mfa.printBanner() @@ -264,10 +277,12 @@ func (mfa *CLIApp) generateCommand() *cli.Command { func (mfa *CLIApp) checkCommand() *cli.Command { return &cli.Command{ - Name: cmdCheck, - Usage: "Validate files using manifest file", - ArgsUsage: manifestArgsUsage, - ArgValidator: refuseExtraArgs, + Name: cmdCheck, + Usage: "Validate files using manifest file", + ArgsUsage: manifestArgsUsage, + ArgValidator: refuseExtraArgs, + HideHelpCommand: true, + CommandNotFound: showCommandHelp, Action: func(ctx context.Context, cmd *cli.Command) error { mfa.setVerbosity(cmd) mfa.printBanner() @@ -298,10 +313,12 @@ func (mfa *CLIApp) checkCommand() *cli.Command { func (mfa *CLIApp) freshenCommand() *cli.Command { return &cli.Command{ - Name: cmdFreshen, - Usage: "Update manifest with changed, new, and removed files", - ArgsUsage: manifestArgsUsage, - ArgValidator: refuseExtraArgs, + Name: cmdFreshen, + Usage: "Update manifest with changed, new, and removed files", + ArgsUsage: manifestArgsUsage, + ArgValidator: refuseExtraArgs, + HideHelpCommand: true, + CommandNotFound: showCommandHelp, Action: func(ctx context.Context, cmd *cli.Command) error { mfa.setVerbosity(cmd) mfa.printBanner() @@ -349,10 +366,12 @@ func (mfa *CLIApp) freshenCommand() *cli.Command { func (mfa *CLIApp) exportCommand() *cli.Command { return &cli.Command{ - Name: cmdExport, - Usage: "Export manifest contents as JSON", - ArgsUsage: "[manifest file or URL]", - ArgValidator: refuseExtraArgs, + Name: cmdExport, + Usage: "Export manifest contents as JSON", + ArgsUsage: "[manifest file or URL]", + ArgValidator: refuseExtraArgs, + HideHelpCommand: true, + CommandNotFound: showCommandHelp, Action: func(ctx context.Context, cmd *cli.Command) error { mfa.setVerbosity(cmd) @@ -375,12 +394,14 @@ func (mfa *CLIApp) versionCommand() *cli.Command { func (mfa *CLIApp) listCommand() *cli.Command { return &cli.Command{ - Name: cmdList, - Aliases: []string{"ls"}, - Usage: "List files in manifest", - ArgsUsage: manifestArgsUsage, - ArgValidator: refuseExtraArgs, - Action: mfa.listManifestOperation, + Name: cmdList, + Aliases: []string{"ls"}, + Usage: "List files in manifest", + ArgsUsage: manifestArgsUsage, + ArgValidator: refuseExtraArgs, + HideHelpCommand: true, + CommandNotFound: showCommandHelp, + Action: mfa.listManifestOperation, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "long", @@ -397,10 +418,12 @@ func (mfa *CLIApp) listCommand() *cli.Command { func (mfa *CLIApp) fetchCommand() *cli.Command { return &cli.Command{ - Name: cmdFetch, - Usage: "fetch manifest and referenced files", - ArgsUsage: "URL", - ArgValidator: refuseExtraArgs, + Name: cmdFetch, + Usage: "fetch manifest and referenced files", + ArgsUsage: "URL", + ArgValidator: refuseExtraArgs, + HideHelpCommand: true, + CommandNotFound: showCommandHelp, Action: func(ctx context.Context, cmd *cli.Command) error { mfa.setVerbosity(cmd) mfa.printBanner()