All checks were successful
check / check (push) Successful in 35s
- Add canonical .golangci.yml (v2 schema, default: all, project thresholds for lll/funlen/cyclop/dupl) - Bump golangci-lint pins from v2.0.2 to v2.12.2 in Makefile (go install, new /v2 module path) and Dockerfile (tagged+digest Debian image pin) - Fix all lint findings surfaced by the new linter set across cmd/mfer, internal/bork, internal/cli, internal/log, and mfer: static sentinel errors (err113), context-aware HTTP and exec (noctx), guarded integer conversions and stricter permissions (gosec), named constants (mnd, goconst), function decomposition (funlen, cyclop, gocognit, nestif), declaration ordering (funcorder), t.Parallel/t.TempDir/t.Setenv adoption in tests (paralleltest, usetesting), protobuf getters (protogetter), plus formatting and style cleanups (wsl_v5, nlreturn, lll, revive, testifylint, and others) - Serialize CLI runs in tests behind a mutex so parallel tests do not cross-wire the process-global logger's captured output The decompositions are behavior-preserving. In particular: - REPO_POLICIES.md is untouched and stays byte-identical to the authoritative copy in the prompts repo - the mfer.manifest type stays unexported; whether to export it is an open owner design question (README question 13) - directories created by fetch keep mode 0755, because fetched trees are content meant to be readable by other uids - an absent MFFilePath.Mtime is handled explicitly and identically in freshen, list, and export rather than being read as the Unix epoch, which would classify every entry as changed and rewrite the manifest on every freshen - every user-visible error message renders byte-identically to what it did before, with the err113 sentinels wrapped mid-sentence where needed; the rendered strings are now pinned by tests Also fixes an argument-injection defect the lint pass surfaced: key IDs reach gpg as bare positional arguments, so a key ID beginning with "-" was parsed by gpg as an option. All positional arguments now follow an explicit "--" end-of-options marker. The symlink-escape gap in fetch's path handling, which sanitizePath does not and cannot address, is filed separately as #86.
363 lines
8.9 KiB
Go
363 lines
8.9 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"
|
|
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")
|
|
}
|
|
}
|