check / check (push) Failing after 2s
check now always looks under the base directory for files the manifest does not list, hidden files and directories included, and prints one warning per file. The result still depends only on the listed files; --no-extra-files turns each unlisted file into a failure, and --quiet hides the warnings but not the failures. A directory that cannot be listed is reported the same way, and the search goes on past it. The manifest itself is left out by file identity, as gen and freshen do; a symlink under the base is compared by what it points to. A base directory named through a symlink is resolved before the search. Model: opus-5-5
426 lines
11 KiB
Go
426 lines
11 KiB
Go
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"
|
|
|
|
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",
|
|
},
|
|
}
|
|
}
|
|
|
|
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",
|
|
},
|
|
&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: 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",
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|