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" cmdExport = "export" flagProgress = "progress" manifestArgsUsage = "[manifest file]" ) // 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 } 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") } } func (mfa *CLIApp) setVerbosity(c *cli.Context) { _, present := os.LookupEnv("MFER_DEBUG") switch { case present: log.EnableDebugLogging() case c.Bool("quiet"): log.SetLevel(log.ErrorLevel) default: log.SetLevelFromVerbosity(c.Count("verbose")) } } // commonFlags returns the flags shared by most commands (-v, -q) func commonFlags() []cli.Flag { return []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Usage: "Increase verbosity (-v for verbose, -vv for debug)", Count: new(int), }, &cli.BoolFlag{ Name: "quiet", Aliases: []string{"q"}, Usage: "Suppress output except errors", }, } } func (mfa *CLIApp) generateCommand() *cli.Command { return &cli.Command{ Name: cmdGenerate, Aliases: []string{"gen"}, Usage: "Generate manifest file", 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: "./.index.mf", 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 if files exist in base directory that are not in manifest", }, &cli.StringFlag{ Name: "require-signature", Aliases: []string{"S"}, Usage: "Require manifest to be signed by the specified GPG key ID", EnvVars: []string{"MFER_REQUIRE_SIGNATURE"}, }, ), } } func (mfa *CLIApp) freshenCommand() *cli.Command { return &cli.Command{ Name: "freshen", 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 { return mfa.exportManifestOperation(c) }, } } func (mfa *CLIApp) versionCommand() *cli.Command { return &cli.Command{ Name: "version", Usage: "Show version", Action: func(_ *cli.Context) error { _, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString()) 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: "fetch", Usage: "fetch manifest and referenced files", Action: func(c *cli.Context) error { mfa.setVerbosity(c) mfa.printBanner() return mfa.fetchManifestOperation(c) }, Flags: commonFlags(), } } 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() mfa.app = &cli.App{ Name: mfa.appname, Usage: "Manifest generator", Version: mfa.VersionString(), EnableBashCompletion: true, Writer: mfa.Stdout, ErrWriter: mfa.Stderr, Action: func(c *cli.Context) error { if c.Args().Len() > 0 { return fmt.Errorf("%w %q", errUnknownCommand, c.Args().First()) } 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.WithError(err).Debugf("exiting") } }