check / check (push) Failing after 2s
Ported per the library's v2-to-v3 migration guide: the app is a root cli.Command, actions take a context and the command, and flag environment variables become value sources. -v, -q and the version flag are local so, as before, only the commands defining them accept them. Every command stops reading flags at its first argument, as v2 did. ErrWriter is stdout so usage errors print with their help. The action's context reaches the manifest download in check, export and list. testify rises to v1.12.1, which v3 requires; the urfave_cli_no_docs build tag, which v3 lacks, is dropped. v3 accepts a flag given under two names, so -v --verbose gives debug output, and the test pinning the refusal becomes a TestVerboseCount case. Model: opus-5-5
462 lines
13 KiB
Go
462 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/urfave/cli/v3"
|
|
"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.Command
|
|
|
|
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(cmd *cli.Command) {
|
|
_, present := os.LookupEnv("MFER_DEBUG")
|
|
|
|
verbosity := 0
|
|
quiet := false
|
|
|
|
for _, c := range cmd.Lineage() {
|
|
verbosity = max(verbosity, c.Count("verbose"))
|
|
quiet = quiet || c.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. They are local, so the
|
|
// root's copies are not inherited by the subcommands that do not take them.
|
|
func commonFlags() []cli.Flag {
|
|
return []cli.Flag{
|
|
&cli.BoolFlag{
|
|
Name: "verbose",
|
|
Aliases: []string{"v"},
|
|
Usage: "Increase verbosity (-v for verbose, -v -v for debug)",
|
|
Local: true,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "quiet",
|
|
Aliases: []string{"q"},
|
|
Usage: "Suppress output except errors",
|
|
Local: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// stopOnFirstArg returns the StopOnNthArg setting every command uses: flags
|
|
// are read only before the command's first argument, and everything after it
|
|
// is an argument, as with urfave/cli v2. So `mfer gen d -v` names a path "-v".
|
|
func stopOnFirstArg() *int {
|
|
n := 1
|
|
|
|
return &n
|
|
}
|
|
|
|
// 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",
|
|
Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"),
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) generateCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdGenerate,
|
|
Aliases: []string{"gen"},
|
|
Usage: "Generate manifest file",
|
|
ArgsUsage: "[path ...]",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.generateManifestOperation(ctx, cmd)
|
|
},
|
|
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",
|
|
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "seed",
|
|
Usage: "Seed value for deterministic manifest UUID",
|
|
Sources: cli.EnvVars("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,
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.checkManifestOperation(ctx, cmd)
|
|
},
|
|
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,
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.freshenManifestOperation(ctx, cmd)
|
|
},
|
|
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",
|
|
Sources: cli.EnvVars("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]",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
|
|
return mfa.exportManifestOperation(ctx, cmd)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) versionCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdVersion,
|
|
Usage: "Show version",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(context.Context, *cli.Command) 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,
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: mfa.listManifestOperation,
|
|
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",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.fetchManifestOperation(ctx, cmd)
|
|
},
|
|
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, at the root as on the generate, check,
|
|
// freshen and fetch subcommands: verbose is the more common meaning of -v
|
|
// in tools that offer both. urfave/cli's built-in version flag claims -v
|
|
// by default, so the version flag takes the capital -V instead.
|
|
// 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",
|
|
Local: true,
|
|
}
|
|
cli.VersionPrinter = func(*cli.Command) { mfa.printVersion() }
|
|
|
|
mfa.app = &cli.Command{
|
|
Name: mfa.appname,
|
|
Usage: "Manifest generator",
|
|
Version: mfa.VersionString(),
|
|
EnableShellCompletion: true,
|
|
Writer: mfa.Stdout,
|
|
// urfave/cli writes only its "Incorrect Usage" line to ErrWriter. It
|
|
// goes to stdout with the help printed after it; run logs the error
|
|
// itself to stderr. Tests rely on the logger being the only writer to
|
|
// a run's stderr.
|
|
ErrWriter: mfa.Stdout,
|
|
Flags: commonFlags(),
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(_ context.Context, cmd *cli.Command) error {
|
|
if cmd.Args().Len() > 0 {
|
|
return fmt.Errorf("%w %q", errUnknownCommand, cmd.Args().First())
|
|
}
|
|
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return cli.ShowRootCommandHelp(cmd)
|
|
},
|
|
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(context.Background(), args)
|
|
if err != nil {
|
|
mfa.exitCode = 1
|
|
|
|
log.Errorf("%s", err)
|
|
}
|
|
}
|