package cli import ( "errors" "fmt" "io" "os" "time" "github.com/spf13/afero" "github.com/urfave/cli/v2" "sneak.berlin/go/mfer/internal/log" "sneak.berlin/go/mfer/mfer" ) // Command and flag names shared across command definitions and tests. const ( cmdGenerate = "generate" cmdCheck = "check" cmdFreshen = "freshen" cmdExport = "export" cmdFetch = "fetch" cmdVersion = "version" flagProgress = "progress" flagTimeout = "timeout" flagDest = "dest" flagRequireSignature = "require-signature" manifestArgsUsage = "[manifest file]" // defaultManifestName is the filename gen writes by default, the one // looked for when a command is given a directory, and the one fetch // appends to a directory URL. defaultManifestName = "index.mf" ) // manifestTempPath returns the temp file gen and freshen write a manifest // to before renaming it to out. func manifestTempPath(out string) string { return out + ".tmp" } // errUnknownCommand indicates an unrecognized command argument. var errUnknownCommand = errors.New("unknown command") // CLIApp is the main CLI application container. It holds configuration, // I/O streams, and filesystem abstraction to enable testing and flexibility. // //nolint:revive // established name used throughout the codebase and tests type CLIApp struct { appname string version string gitrev string startupTime time.Time exitCode int app *cli.App Stdin io.Reader // Standard input stream Stdout io.Writer // Standard output stream for normal output Stderr io.Writer // Standard error stream for diagnostics Fs afero.Fs // Filesystem abstraction for all file operations } const banner = ` ___ ___ ___ ___ /__/\ / /\ / /\ / /\ | |::\ / /:/_ / /:/_ / /::\ | |:|:\ / /:/ /\ / /:/ /\ / /:/\:\ __|__|:|\:\ / /:/ /:/ / /:/ /:/_ / /:/~/:/ /__/::::| \:\ /__/:/ /:/ /__/:/ /:/ /\ /__/:/ /:/___ \ \:\~~\__\/ \ \:\/:/ \ \:\/:/ /:/ \ \:\/:::::/ \ \:\ \ \::/ \ \::/ /:/ \ \::/~~~~ \ \:\ \ \:\ \ \:\/:/ \ \:\ \ \:\ \ \:\ \ \::/ \ \:\ \__\/ \__\/ \__\/ \__\/` // VersionString returns the version and git revision formatted for display. func (mfa *CLIApp) VersionString() string { if mfa.gitrev != "" { return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev) } return mfer.Version } // printVersion writes the version line shared by the --version flag and the // version subcommand, so both produce identical output. func (mfa *CLIApp) printVersion() { _, _ = fmt.Fprintf(mfa.Stdout, "%s version %s\n", mfa.appname, mfa.VersionString()) } func (mfa *CLIApp) printBanner() { if log.GetLevel() <= log.InfoLevel { _, _ = fmt.Fprintln(mfa.Stdout, banner) _, _ = fmt.Fprintf(mfa.Stdout, " mfer by @sneak: v%s released %s\n", mfer.Version, mfer.ReleaseDate) _, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer") } } // setVerbosity sets the log level from -v and -q, given before the subcommand // name (the root's copies), after it (the subcommand's own copies), or both. // urfave/cli reads a flag from the nearest command that defines it, so each // command in the lineage is asked. The highest -v count wins rather than the // sum, because a subcommand without its own copies reads the root's. func (mfa *CLIApp) setVerbosity(c *cli.Context) { _, present := os.LookupEnv("MFER_DEBUG") verbosity := 0 quiet := false for _, ctx := range c.Lineage() { verbosity = max(verbosity, ctx.Count("verbose")) quiet = quiet || ctx.Bool("quiet") } switch { case present: log.EnableDebugLogging() case quiet: log.SetLevel(log.ErrorLevel) default: log.SetLevelFromVerbosity(verbosity) } } // commonFlags returns the -v and -q flags taken by the root and by the // generate, check, freshen and fetch subcommands. func commonFlags() []cli.Flag { return []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Usage: "Increase verbosity (-v for verbose, -v -v for debug)", Count: new(int), }, &cli.BoolFlag{ Name: "quiet", Aliases: []string{"q"}, Usage: "Suppress output except errors", }, } } // requireSignatureFlag returns the --require-signature flag taken by the // check and fetch subcommands. func requireSignatureFlag() *cli.StringFlag { return &cli.StringFlag{ Name: flagRequireSignature, Aliases: []string{"S"}, Usage: "Require manifest to be signed by the specified GPG key ID", EnvVars: []string{"MFER_REQUIRE_SIGNATURE"}, } } func (mfa *CLIApp) generateCommand() *cli.Command { return &cli.Command{ Name: cmdGenerate, Aliases: []string{"gen"}, Usage: "Generate manifest file", ArgsUsage: "[path ...]", Action: func(c *cli.Context) error { mfa.setVerbosity(c) mfa.printBanner() return mfa.generateManifestOperation(c) }, Flags: append(commonFlags(), &cli.BoolFlag{ Name: "follow-symlinks", Aliases: []string{"L"}, Usage: "Resolve encountered symlinks", }, &cli.BoolFlag{ Name: "include-dotfiles", Aliases: []string{"IncludeDotfiles"}, Usage: "Include dot (hidden) files (excluded by default)", }, &cli.StringFlag{ Name: "output", Value: defaultManifestName, Aliases: []string{"o"}, Usage: "Specify output filename", }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "Overwrite output file if it exists", }, &cli.BoolFlag{ Name: flagProgress, Aliases: []string{"P"}, Usage: "Show progress during enumeration and scanning", }, &cli.StringFlag{ Name: "sign-key", Aliases: []string{"s"}, Usage: "GPG key ID to sign the manifest with", EnvVars: []string{"MFER_SIGN_KEY"}, }, &cli.StringFlag{ Name: "seed", Usage: "Seed value for deterministic manifest UUID", EnvVars: []string{"MFER_SEED"}, }, &cli.BoolFlag{ Name: "include-timestamps", Usage: "Include createdAt timestamp in manifest " + "(omitted by default for determinism)", }, ), } } func (mfa *CLIApp) checkCommand() *cli.Command { return &cli.Command{ Name: cmdCheck, Usage: "Validate files using manifest file", ArgsUsage: manifestArgsUsage, Action: func(c *cli.Context) error { mfa.setVerbosity(c) mfa.printBanner() return mfa.checkManifestOperation(c) }, Flags: append(commonFlags(), &cli.StringFlag{ Name: "base", Aliases: []string{"b"}, Value: ".", Usage: "Base directory for resolving relative paths from manifest", }, &cli.BoolFlag{ Name: flagProgress, Aliases: []string{"P"}, Usage: "Show progress during checking", }, &cli.BoolFlag{ Name: "no-extra-files", Usage: "Fail, instead of warning, if files in base directory are not in manifest", }, requireSignatureFlag(), ), } } func (mfa *CLIApp) freshenCommand() *cli.Command { return &cli.Command{ Name: cmdFreshen, Usage: "Update manifest with changed, new, and removed files", ArgsUsage: manifestArgsUsage, Action: func(c *cli.Context) error { mfa.setVerbosity(c) mfa.printBanner() return mfa.freshenManifestOperation(c) }, Flags: append(commonFlags(), &cli.StringFlag{ Name: "base", Aliases: []string{"b"}, Value: ".", Usage: "Base directory for resolving relative paths", }, &cli.BoolFlag{ Name: "follow-symlinks", Aliases: []string{"L"}, Usage: "Resolve encountered symlinks", }, &cli.BoolFlag{ Name: "include-dotfiles", Aliases: []string{"IncludeDotfiles"}, Usage: "Include dot (hidden) files (excluded by default)", }, &cli.BoolFlag{ Name: flagProgress, Aliases: []string{"P"}, Usage: "Show progress during scanning and hashing", }, &cli.StringFlag{ Name: "sign-key", Aliases: []string{"s"}, Usage: "GPG key ID to sign the manifest with", EnvVars: []string{"MFER_SIGN_KEY"}, }, &cli.BoolFlag{ Name: "include-timestamps", Usage: "Include createdAt timestamp in manifest " + "(omitted by default for determinism)", }, ), } } func (mfa *CLIApp) exportCommand() *cli.Command { return &cli.Command{ Name: cmdExport, Usage: "Export manifest contents as JSON", ArgsUsage: "[manifest file or URL]", Action: func(c *cli.Context) error { mfa.setVerbosity(c) return mfa.exportManifestOperation(c) }, } } func (mfa *CLIApp) versionCommand() *cli.Command { return &cli.Command{ Name: cmdVersion, Usage: "Show version", Action: func(_ *cli.Context) error { mfa.printVersion() return nil }, } } func (mfa *CLIApp) listCommand() *cli.Command { return &cli.Command{ Name: "list", Aliases: []string{"ls"}, Usage: "List files in manifest", ArgsUsage: manifestArgsUsage, Action: func(c *cli.Context) error { return mfa.listManifestOperation(c) }, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "long", Aliases: []string{"l"}, Usage: "Show size and mtime", }, &cli.BoolFlag{ Name: "print0", Usage: "Separate entries with NUL character (for xargs -0)", }, }, } } func (mfa *CLIApp) fetchCommand() *cli.Command { return &cli.Command{ Name: cmdFetch, Usage: "fetch manifest and referenced files", ArgsUsage: "URL", Action: func(c *cli.Context) error { mfa.setVerbosity(c) mfa.printBanner() return mfa.fetchManifestOperation(c) }, Flags: append(commonFlags(), &cli.DurationFlag{ Name: flagTimeout, Value: httpTimeout, Usage: "Time limit for each HTTP request, including the download " + "of its body", }, &cli.StringFlag{ Name: flagDest, Aliases: []string{"d"}, Value: ".", Usage: "Directory to download the files and the manifest into", }, requireSignatureFlag(), ), } } func (mfa *CLIApp) run(args []string) { mfa.startupTime = time.Now() if NoColor { // shoutout to rob pike who thinks it's juvenile log.DisableStyling() } // Configure log package to use our I/O streams log.SetOutput(mfa.Stdout, mfa.Stderr) log.Init() // -v means verbose, not version. urfave/cli's built-in version flag // claims -v by default, which made "mfer -v --version" fail to parse and // gave -v a different meaning at the root than on the generate, check, // freshen and fetch subcommands, where it means verbose. Verbose is the // more common meaning of -v in tools that offer both, so -v means verbose // at the root too and the version flag takes the capital -V. // VersionFlag and VersionPrinter are urfave/cli package globals; run() is // serialized in tests, so assigning them here is safe. cli.VersionFlag = &cli.BoolFlag{ Name: cmdVersion, Aliases: []string{"V"}, Usage: "print the version", } cli.VersionPrinter = func(_ *cli.Context) { mfa.printVersion() } mfa.app = &cli.App{ Name: mfa.appname, Usage: "Manifest generator", Version: mfa.VersionString(), EnableBashCompletion: true, Writer: mfa.Stdout, ErrWriter: mfa.Stderr, Flags: commonFlags(), Action: func(c *cli.Context) error { if c.Args().Len() > 0 { return fmt.Errorf("%w %q", errUnknownCommand, c.Args().First()) } mfa.setVerbosity(c) mfa.printBanner() return cli.ShowAppHelp(c) }, Commands: []*cli.Command{ mfa.generateCommand(), mfa.checkCommand(), mfa.freshenCommand(), mfa.exportCommand(), mfa.versionCommand(), mfa.listCommand(), mfa.fetchCommand(), }, } mfa.app.HideVersion = false err := mfa.app.Run(args) if err != nil { mfa.exitCode = 1 log.Errorf("%s", err) } }