diff --git a/.golangci.yml b/.golangci.yml index 4629e666..f39ff1d9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,22 +1,124 @@ version: '2' -linters: - exclusions: - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ + +run: + go: '1.26.3' + timeout: 5m + +issues: + max-same-issues: 0 + max-issues-per-linter: 0 + formatters: enable: + - gci - gofmt + - gofumpt + settings: + gci: + sections: + - standard + - default + - prefix(go.wpm.so/cli) + gofmt: + simplify: true + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + gofumpt: + extra-rules: true + exclusions: + generated: strict + +linters: + enable: + - asasalint # Detects "[]any" used as argument for variadic "func(...any)". + - bodyclose + - copyloopvar # Detects places where loop variables are copied. + - depguard + - dogsled # Detects assignments with too many blank identifiers. + - dupword # Detects duplicate words. + - durationcheck # Detect cases where two time.Duration values are being multiplied in possibly erroneous ways. + - errcheck + - errchkjson # Detects unsupported types passed to json encoding functions and reports if checks for the returned error can be omitted. + - exhaustive # Detects missing options in enum switch statements. + - exptostd # Detects functions from golang.org/x/exp/ that can be replaced by std functions. + - fatcontext # Detects nested contexts in loops and function literals. + - forbidigo + - gocheckcompilerdirectives # Detects invalid go compiler directive comments (//go:). + - gocritic # Metalinter; detects bugs, performance, and styling issues. + - gocyclo + - gosec # Detects security problems. + - govet + - iface # Detects incorrect use of interfaces. Currently only used for "identical" interfaces in the same package. + - importas # Enforces consistent import aliases. + - ineffassign + - makezero # Finds slice declarations with non-zero initial length. + - mirror # Detects wrong mirror patterns of bytes/strings usage. + - misspell # Detects commonly misspelled English words in comments. + - nakedret # Detects uses of naked returns. + - nilnesserr # Detects returning nil errors. It combines the features of nilness and nilerr, + - nosprintfhostport # Detects misuse of Sprintf to construct a host with port in a URL. + - nolintlint # Detects ill-formed or insufficient nolint directives. + - perfsprint # Detects fmt.Sprintf uses that can be replaced with a faster alternative. + - prealloc # Detects slice declarations that could potentially be pre-allocated. + - predeclared # Detects code that shadows one of Go's predeclared identifiers + - reassign # Detects reassigning a top-level variable in another package. + - revive # Metalinter; drop-in replacement for golint. + - spancheck # Detects mistakes with OpenTelemetry/Census spans. + - staticcheck + - thelper # Detects test helpers without t.Helper(). + - tparallel # Detects inappropriate usage of t.Parallel(). + - unconvert # Detects unnecessary type conversions. + - unparam + - unused + - usestdlibvars # Detects the possibility to use variables/constants from the Go standard library. + - usetesting # Reports uses of functions with replacement inside the testing package. + - wastedassign # Detects wasted assignment statements. + settings: + staticcheck: + checks: + - all + gocyclo: + min-complexity: 16 + gosec: + excludes: + - G306 # G306: Expect WriteFile permissions to be 0600 or less (too restrictive; also flags "0o644" permissions) + govet: + enable: + - shadow + settings: + shadow: + strict: true + nakedret: + max-func-lines: 0 + depguard: + rules: + main: + deny: + - pkg: 'log' + desc: 'Use logrus for logging instead of the standard log package.' + - pkg: 'io/ioutil' + desc: "The io/ioutil package has been deprecated, use 'os' or 'io' directly." + revive: + rules: + - name: empty-block # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-block + - name: empty-lines # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-lines + - name: import-shadowing # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#import-shadowing + - name: line-length-limit # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#line-length-limit + arguments: [200] + - name: unused-receiver # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-receiver + - name: use-any # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#use-any + - name: use-errors-new # https://github.com/mgechev/revive/blob/HEAD/RULES_DESCRIPTIONS.md#use-errors-new exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ + generated: strict + warn-unused: true + rules: + - text: 'ST1000: at least one file in a package should have a package comment' + linters: + - staticcheck + - text: '^ST1003: ' + linters: + - staticcheck + - text: '^shadow: declaration of "(err|ok)" shadows declaration' + linters: + - govet diff --git a/.vscode/settings.json b/.vscode/settings.json index a2b705b9..7d3fc59d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { // Go "go.lintTool": "golangci-lint-v2", - "go.lintFlags": ["--path-mode=abs", "--fast-only"], + "go.lintFlags": ["--path-mode=abs"], "go.formatTool": "custom", "go.alternateTools": { "customFormatter": "golangci-lint-v2" diff --git a/cli/cobra.go b/cli/cobra.go index 2a4c98dd..1b993588 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -6,16 +6,16 @@ import ( "sort" "strings" - "go.wpm.so/cli/cli/command" - "go.wpm.so/cli/cli/command/completion" - cliflags "go.wpm.so/cli/cli/flags" - "github.com/fvbommel/sortorder" "github.com/moby/term" "github.com/morikuni/aec" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" + + "go.wpm.so/cli/cli/command" + "go.wpm.so/cli/cli/command/completion" + cliflags "go.wpm.so/cli/cli/flags" ) // setupCommonRootCommand contains the setup common to @@ -151,9 +151,13 @@ func commandAliases(cmd *cobra.Command) string { parentPath = cmd.Parent().CommandPath() + " " } aliases := cmd.CommandPath() + var builder strings.Builder for _, alias := range cmd.Aliases { - aliases += ", " + parentPath + alias + builder.WriteString(", ") + builder.WriteString(parentPath) + builder.WriteString(alias) } + aliases += builder.String() return aliases } diff --git a/cli/command/auth/cmd.go b/cli/command/auth/cmd.go index 66aa02bc..e60f6b63 100644 --- a/cli/command/auth/cmd.go +++ b/cli/command/auth/cmd.go @@ -1,10 +1,10 @@ package auth import ( + "github.com/spf13/cobra" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" - - "github.com/spf13/cobra" ) func NewAuthCommand(wpmCli command.Cli) *cobra.Command { diff --git a/cli/command/auth/login.go b/cli/command/auth/login.go index 1492eb21..4776c4d8 100644 --- a/cli/command/auth/login.go +++ b/cli/command/auth/login.go @@ -4,13 +4,13 @@ import ( "context" "fmt" - "go.wpm.so/cli/cli" - "go.wpm.so/cli/cli/command" - "go.wpm.so/cli/pkg/output" - "github.com/morikuni/aec" "github.com/pkg/errors" "github.com/spf13/cobra" + + "go.wpm.so/cli/cli" + "go.wpm.so/cli/cli/command" + "go.wpm.so/cli/pkg/output" ) type loginOptions struct { diff --git a/cli/command/auth/logout.go b/cli/command/auth/logout.go index 953781f7..0a5af349 100644 --- a/cli/command/auth/logout.go +++ b/cli/command/auth/logout.go @@ -3,11 +3,11 @@ package auth import ( "fmt" - "go.wpm.so/cli/cli" - "go.wpm.so/cli/cli/command" - "github.com/pkg/errors" "github.com/spf13/cobra" + + "go.wpm.so/cli/cli" + "go.wpm.so/cli/cli/command" ) func NewLogoutCommand(wpmCli command.Cli) *cobra.Command { @@ -35,7 +35,7 @@ func runLogout(wpmCli command.Cli) error { return err } - fmt.Fprintf(wpmCli.Out(), "user logged out successfully\n") + _, _ = fmt.Fprintf(wpmCli.Out(), "user logged out successfully\n") return nil } diff --git a/cli/command/cli.go b/cli/command/cli.go index 7ee7d8ed..8762887f 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -5,6 +5,8 @@ import ( "os" "runtime" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli/debug" cliflags "go.wpm.so/cli/cli/flags" "go.wpm.so/cli/cli/version" @@ -14,8 +16,6 @@ import ( "go.wpm.so/cli/pkg/pm/registry" "go.wpm.so/cli/pkg/progress" "go.wpm.so/cli/pkg/streams" - - "github.com/spf13/cobra" ) // Streams is an interface which exposes the standard input and output streams @@ -54,9 +54,8 @@ type WpmCli struct { // It applies by default the standard streams, and the content trust from // environment. func NewWpmCli(ops ...CLIOption) (*WpmCli, error) { - defaultOps := []CLIOption{ - WithStandardStreams(), - } + defaultOps := make([]CLIOption, 0, len(ops)+1) + defaultOps = append(defaultOps, WithStandardStreams()) ops = append(defaultOps, ops...) cli := &WpmCli{} diff --git a/cli/command/cli_options.go b/cli/command/cli_options.go index 568f568f..d22cc3f7 100644 --- a/cli/command/cli_options.go +++ b/cli/command/cli_options.go @@ -3,9 +3,9 @@ package command import ( "io" - "go.wpm.so/cli/pkg/streams" - "github.com/moby/term" + + "go.wpm.so/cli/pkg/streams" ) // CLIOption is a functional argument to apply options to a [WpmCli]. These diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index a31d58e4..cdefeea5 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -1,6 +1,8 @@ package commands import ( + "github.com/spf13/cobra" + "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/command/auth" pmInit "go.wpm.so/cli/cli/command/init" @@ -11,8 +13,6 @@ import ( "go.wpm.so/cli/cli/command/uninstall" "go.wpm.so/cli/cli/command/whoami" "go.wpm.so/cli/cli/command/why" - - "github.com/spf13/cobra" ) func AddCommands(cmd *cobra.Command, wpmCli command.Cli) { diff --git a/cli/command/completion/functions.go b/cli/command/completion/functions.go index 9f38a175..3f9f5d97 100644 --- a/cli/command/completion/functions.go +++ b/cli/command/completion/functions.go @@ -4,11 +4,11 @@ import ( "os" "sort" + "github.com/spf13/cobra" + "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmlock" - - "github.com/spf13/cobra" ) // PackagesFromWpmJson offers completion for package names declared in diff --git a/cli/command/init/init.go b/cli/command/init/init.go index 88583d3c..7cedaf16 100644 --- a/cli/command/init/init.go +++ b/cli/command/init/init.go @@ -10,6 +10,11 @@ import ( "strings" "unicode" + "github.com/Masterminds/semver/v3" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/command/completion" "go.wpm.so/cli/pkg/output" @@ -18,11 +23,6 @@ import ( "go.wpm.so/cli/pkg/pm/wpmjson/validator" "go.wpm.so/cli/pkg/version" "go.wpm.so/cli/pkg/wp/parser" - - "github.com/Masterminds/semver/v3" - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" ) const ( @@ -133,6 +133,83 @@ func runNewInit(ctx context.Context, wpmCli command.Cli, opts *initOptions) erro return nil } +// extractPackageHeaders inspects the working directory for the package's main +// file (style.css for themes, main plugin .php for plugins) and returns the +// parsed headers plus the version string it found. Returns (nil, "", nil) when +// the main file is missing but --version was supplied. +func extractPackageHeaders(wpmCli command.Cli, cwd string, opts *initOptions) (mainFileHeaders any, extractedVersion string, err error) { + switch opts.packageType { + case "theme": + mainFilePath := filepath.Join(cwd, "style.css") + if _, err := os.Stat(mainFilePath); err != nil { + if os.IsNotExist(err) && opts.version == "" { + return nil, "", errors.Errorf("style.css not found in %s", cwd) + } + if !os.IsNotExist(err) { + return nil, "", errors.Wrapf(err, "failed to stat style.css") + } + return nil, "", nil + } + headers, hErr := parser.GetThemeHeaders(mainFilePath) + if hErr != nil { + if opts.version == "" { + return nil, "", errors.Wrapf(hErr, "failed to parse theme headers from style.css") + } + return nil, "", nil + } + return headers, headers.Version, nil + + case "plugin": + dirEntries, dErr := os.ReadDir(cwd) + if dErr != nil { + return nil, "", errors.Wrap(dErr, "failed to read current directory for plugin files") + } + + foundPath, headers, fErr := findMainPluginFile(cwd, dirEntries) + if fErr != nil { + if opts.version == "" { + return nil, "", errors.Wrap(fErr, "failed to identify main plugin file") + } + return nil, "", nil + } + _, _ = fmt.Fprintf(wpmCli.Out(), "main plugin file found: %s\n", foundPath) + return headers, headers.Version, nil + + default: + return nil, "", errors.Errorf("unsupported package type for existing project init: %s", opts.packageType) + } +} + +// resolveConfigVersion fills wpmCfg.Version from --version or the extracted +// header, warns on mismatch, and normalizes to strict semver. +func resolveConfigVersion(wpmCli command.Cli, wpmCfg *wpmjson.Config, opts *initOptions, extractedVersion string) error { + switch { + case opts.version != "": + wpmCfg.Version = opts.version + if extractedVersion != "" && extractedVersion != opts.version { + wpmCli.Output().PrettyErrorln(output.Text{ + Plain: fmt.Sprintf("warn: provided version (%s) differs from version in parsed headers (%s)", opts.version, extractedVersion), + Fancy: fmt.Sprintf( + "%s provided version (%s) differs from version in parsed headers (%s)", + aec.YellowF.Apply("warn:"), + aec.LightBlueF.Apply(opts.version), aec.LightBlueF.Apply(extractedVersion), + ), + }) + } + case extractedVersion == "": + return errors.New("unable to determine version; please specify it with --version") + default: + wpmCfg.Version = extractedVersion + } + + v, err := version.Normalize(wpmCfg.Version) + if err != nil { + return errors.New("invalid version format: " + err.Error()) + } + wpmCfg.Version = v + return nil +} + func runExistingInit(wpmCli command.Cli, opts *initOptions) error { cwd, err := os.Getwd() if err != nil { @@ -165,89 +242,23 @@ func runExistingInit(wpmCli command.Cli, opts *initOptions) error { readmeParser.Parse(string(readmeTxtContent)) } - // Extract package info based on type - var extractedVersion string - var mainFileHeaders any - - switch opts.packageType { - case "theme": - mainFilePath := filepath.Join(cwd, "style.css") - if _, err := os.Stat(mainFilePath); err != nil { - if os.IsNotExist(err) && opts.version == "" { - return errors.Errorf("style.css not found in %s", cwd) - } - if !os.IsNotExist(err) { - return errors.Wrapf(err, "failed to stat style.css") - } - } else { - headers, err := parser.GetThemeHeaders(mainFilePath) - if err != nil { - if opts.version == "" { - return errors.Wrapf(err, "failed to parse theme headers from style.css") - } - } else { - mainFileHeaders = headers - extractedVersion = headers.Version - } - } - - case "plugin": - dirEntries, err := os.ReadDir(cwd) - if err != nil { - return errors.Wrap(err, "failed to read current directory for plugin files") - } - - foundPath, headers, err := findMainPluginFile(cwd, dirEntries) - if err != nil { - if opts.version == "" { - return errors.Wrap(err, "failed to identify main plugin file") - } - } else { - _, _ = fmt.Fprintf(wpmCli.Out(), "main plugin file found: %s\n", foundPath) - mainFileHeaders = headers - extractedVersion = headers.Version - } - - default: - return errors.Errorf("unsupported package type for existing project init: %s", opts.packageType) + mainFileHeaders, extractedVersion, err := extractPackageHeaders(wpmCli, cwd, opts) + if err != nil { + return err } - // Build wpm.json config wpmCfg := buildWpmConfig(*opts, opts.packageType, mainFileHeaders, readmeParser.GetMetadata()) - // Set name if opts.name != "" { wpmCfg.Name = opts.name } else { wpmCfg.Name = filepath.Base(cwd) } - if opts.version != "" { - wpmCfg.Version = opts.version - if extractedVersion != "" && extractedVersion != opts.version { - wpmCli.Output().PrettyErrorln(output.Text{ - Plain: fmt.Sprintf("warn: provided version (%s) differs from version in parsed headers (%s)", opts.version, extractedVersion), - Fancy: fmt.Sprintf( - "%s provided version (%s) differs from version in parsed headers (%s)", - aec.YellowF.Apply("warn:"), - aec.LightBlueF.Apply(opts.version), aec.LightBlueF.Apply(extractedVersion), - ), - }) - } - } else { - if extractedVersion == "" { - return errors.New("unable to determine version; please specify it with --version") - } - wpmCfg.Version = extractedVersion - } - - // Normalize and validate version - v, err := version.Normalize(wpmCfg.Version) - if err != nil { - return errors.New("invalid version format: " + err.Error()) + if err := resolveConfigVersion(wpmCli, wpmCfg, opts, extractedVersion); err != nil { + return err } - wpmCfg.Version = v - if err = wpmCfg.Validate(); err != nil { + if err := wpmCfg.Validate(); err != nil { return err } @@ -260,7 +271,7 @@ func runExistingInit(wpmCli command.Cli, opts *initOptions) error { if baseFiles.readmeTxt != "" && baseFiles.readmeMd == "" { markdownContent := readmeParser.ToMarkdown() readmeMdPath := strings.TrimSuffix(baseFiles.readmeTxt, filepath.Ext(baseFiles.readmeTxt)) + ".md" - if err := os.WriteFile(readmeMdPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(readmeMdPath, []byte(markdownContent), 0o644); err != nil { _, _ = fmt.Fprintf(wpmCli.Err(), "failed to write %s: %v\n", readmeMdPath, err) } else { _, _ = fmt.Fprintf(wpmCli.Out(), "%s created from readme.txt\n", filepath.Base(readmeMdPath)) @@ -392,7 +403,7 @@ func promptForConfig(ctx context.Context, wpmCli command.Cli, config *wpmjson.Co } if err := pf.Prompt.Validate(val); err != nil { - fmt.Fprintf(wpmCli.Err(), "%s\n", err) + _, _ = fmt.Fprintf(wpmCli.Err(), "%s\n", err) continue } break @@ -449,7 +460,7 @@ func findMainPluginFile(cwd string, files []os.DirEntry) (string, parser.PluginF return "", parser.PluginFileHeaders{}, errors.New("no main plugin file with valid plugin headers found") } -func getMetaString(meta map[string]any, key string, defaultValue string) string { +func getMetaString(meta map[string]any, key, defaultValue string) string { if val, ok := meta[key]; ok { if strVal, ok := val.(string); ok && strVal != "" { return strVal @@ -527,7 +538,6 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm cfg.Tags = tags } - requires := &types.Requires{} dependencies := &types.Dependencies{} cfg.Team = getMetaStringSlice(readmeMeta, "contributors") wpRequires := getMetaString(readmeMeta, "requires", "") @@ -536,155 +546,138 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm switch h := mainFileHeaders.(type) { case parser.ThemeFileHeaders: - if cfg.License == "" { - cfg.License = h.License - } - - if cfg.Description == "" || !isMeaningfulText(cfg.Description) { - cfg.Description = h.Description - } - - if len(cfg.Team) == 0 && h.Author != "" { - cfg.Team = []string{h.Author} - } - - if len(tags) == 0 && len(h.Tags) > 0 { - cfg.Tags = h.Tags - } - - if h.ThemeURI != "" { - if err := validator.IsValidHomepage(h.ThemeURI); err == nil { - cfg.Homepage = h.ThemeURI - } - } - - if wpRequires == "" && h.RequiresWP != "" { - wpRequires = h.RequiresWP - } - - if phpRequires == "" && h.RequiresPHP != "" { - phpRequires = h.RequiresPHP - } - + applyThemeHeaders(cfg, h, tags, &wpRequires, &phpRequires) case parser.PluginFileHeaders: - if cfg.License == "" { - cfg.License = h.License - } - - if cfg.Description == "" || !isMeaningfulText(cfg.Description) { - cfg.Description = h.Description - } + applyPluginHeaders(cfg, h, tags, dependencies, &wpRequires, &phpRequires) + } - if len(cfg.Team) == 0 && h.Author != "" { - cfg.Team = []string{h.Author} - } + cfg.Tags = sanitizeStringList(cfg.Tags, 5, 2, 64) + cfg.Team = sanitizeStringList(cfg.Team, 100, 2, 100) - if len(tags) == 0 && len(h.Tags) > 0 { - cfg.Tags = h.Tags - } + // Trim description to max 512 characters + if len(cfg.Description) > 512 { + cfg.Description = trimMeaningfully(cfg.Description, 512) + } - if h.PluginURI != "" { - if err := validator.IsValidHomepage(h.PluginURI); err == nil { - cfg.Homepage = h.PluginURI - } - } + // Validate license length, and set to empty if invalid + if len(cfg.License) < 3 || len(cfg.License) > 100 { + cfg.License = "" + } - if wpRequires == "" && h.RequiresWP != "" { - wpRequires = h.RequiresWP - } + requires := buildRequires(wpRequires, phpRequires, testedUpTo) - if phpRequires == "" && h.RequiresPHP != "" { - phpRequires = h.RequiresPHP - } + if len(*dependencies) > 0 { + cfg.Dependencies = dependencies + } + if requires.PHP != "" || requires.WP != "" { + cfg.Requires = requires + } - if len(h.RequiresPlugins) > 0 { - for _, reqPlugin := range h.RequiresPlugins { - if err := validator.IsValidPackageName(reqPlugin); err != nil { - continue - } + return cfg +} - // Add "*" as version since requires plugins only specify the plugin slug, not a version. - (*dependencies)[reqPlugin] = "*" - } +// applyThemeHeaders fills config fields from a theme's style.css headers, +// only overwriting empty values and only forwarding WP/PHP requires when +// they weren't already set from readme.txt metadata. +func applyThemeHeaders(cfg *wpmjson.Config, h parser.ThemeFileHeaders, tags []string, wpRequires, phpRequires *string) { + if cfg.License == "" { + cfg.License = h.License + } + if cfg.Description == "" || !isMeaningfulText(cfg.Description) { + cfg.Description = h.Description + } + if len(cfg.Team) == 0 && h.Author != "" { + cfg.Team = []string{h.Author} + } + if len(tags) == 0 && len(h.Tags) > 0 { + cfg.Tags = h.Tags + } + if h.ThemeURI != "" { + if err := validator.IsValidHomepage(h.ThemeURI); err == nil { + cfg.Homepage = h.ThemeURI } } - - // Trim tags to max 5 - if len(cfg.Tags) > 5 { - cfg.Tags = cfg.Tags[:5] + if *wpRequires == "" && h.RequiresWP != "" { + *wpRequires = h.RequiresWP } + if *phpRequires == "" && h.RequiresPHP != "" { + *phpRequires = h.RequiresPHP + } +} - if len(cfg.Tags) > 0 { - // pop tags having minimum 2 and maximum 64 characters - validTags := []string{} - for _, tag := range cfg.Tags { - if len(tag) >= 2 && len(tag) <= 64 { - validTags = append(validTags, tag) - } +// applyPluginHeaders fills config fields from a plugin's main file headers +// and, additionally, populates the dependencies map from "Requires Plugins". +func applyPluginHeaders(cfg *wpmjson.Config, h parser.PluginFileHeaders, tags []string, dependencies *types.Dependencies, wpRequires, phpRequires *string) { + if cfg.License == "" { + cfg.License = h.License + } + if cfg.Description == "" || !isMeaningfulText(cfg.Description) { + cfg.Description = h.Description + } + if len(cfg.Team) == 0 && h.Author != "" { + cfg.Team = []string{h.Author} + } + if len(tags) == 0 && len(h.Tags) > 0 { + cfg.Tags = h.Tags + } + if h.PluginURI != "" { + if err := validator.IsValidHomepage(h.PluginURI); err == nil { + cfg.Homepage = h.PluginURI } - - slices.Sort(validTags) - - cfg.Tags = slices.Compact(validTags) } - - // Trim team to max 100 members - if len(cfg.Team) > 100 { - cfg.Team = cfg.Team[:100] + if *wpRequires == "" && h.RequiresWP != "" { + *wpRequires = h.RequiresWP } - - if len(cfg.Team) > 0 { - // pop team members having minimum 2 and maximum 100 characters - validTeam := []string{} - for _, member := range cfg.Team { - if len(member) >= 2 && len(member) <= 100 { - validTeam = append(validTeam, member) - } + if *phpRequires == "" && h.RequiresPHP != "" { + *phpRequires = h.RequiresPHP + } + for _, reqPlugin := range h.RequiresPlugins { + if err := validator.IsValidPackageName(reqPlugin); err != nil { + continue } - - slices.Sort(validTeam) - - cfg.Team = slices.Compact(validTeam) + // "Requires Plugins" header carries only slugs, so pin to "*". + (*dependencies)[reqPlugin] = "*" } +} - // Trim description to max 512 characters - if len(cfg.Description) > 512 { - cfg.Description = trimMeaningfully(cfg.Description, 512) +// sanitizeStringList trims the list to maxItems, drops entries outside +// [minLen, maxLen], sorts, and compacts duplicates. +func sanitizeStringList(items []string, maxItems, minLen, maxLen int) []string { + if len(items) > maxItems { + items = items[:maxItems] } - - // Validate license length, and set to empty if invalid - if len(cfg.License) < 3 || len(cfg.License) > 100 { - cfg.License = "" + if len(items) == 0 { + return items } + valid := []string{} + for _, it := range items { + if len(it) >= minLen && len(it) <= maxLen { + valid = append(valid, it) + } + } + slices.Sort(valid) + return slices.Compact(valid) +} +// buildRequires constructs the runtime requires from raw WP/PHP constraint +// strings plus an optional "tested up to" upper bound on the WP range. +func buildRequires(wpRequires, phpRequires, testedUpTo string) *types.Requires { + requires := &types.Requires{} if wpRequires != "" { - _, err := semver.NewConstraint(wpRequires) - if err == nil { + if _, err := semver.NewConstraint(wpRequires); err == nil { requires.WP = ">=" + wpRequires } - - _, err = semver.NewVersion(testedUpTo) - if err == nil && wpRequires != testedUpTo { + if _, err := semver.NewVersion(testedUpTo); err == nil && wpRequires != testedUpTo { requires.WP += " <=" + testedUpTo requires.WP = strings.TrimSpace(requires.WP) } } if phpRequires != "" { - _, err := semver.NewConstraint(phpRequires) - if err == nil { + if _, err := semver.NewConstraint(phpRequires); err == nil { requires.PHP = ">=" + phpRequires } } - - if len(*dependencies) > 0 { - cfg.Dependencies = dependencies - } - - if requires.PHP != "" || requires.WP != "" { - cfg.Requires = requires - } - - return cfg + return requires } func detectPackageType(cwd string) string { diff --git a/cli/command/install/install.go b/cli/command/install/install.go index b0592409..26f936e6 100644 --- a/cli/command/install/install.go +++ b/cli/command/install/install.go @@ -8,6 +8,11 @@ import ( "strings" "sync" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/version" "go.wpm.so/cli/pkg/output" @@ -15,11 +20,6 @@ import ( "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmjson/validator" - - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" - "golang.org/x/sync/errgroup" ) type installOptions struct { @@ -126,21 +126,11 @@ func runInstall(ctx context.Context, wpmCli command.Cli, opts installOptions, pa if err := addPackages(ctx, cfg, wpmCli, packages, opts); err != nil { return err } - - // If dependencies or devDependencies still have zero entries, set them to nil - if cfg.Dependencies != nil && len(*cfg.Dependencies) == 0 { - cfg.Dependencies = nil - } - if cfg.DevDependencies != nil && len(*cfg.DevDependencies) == 0 { - cfg.DevDependencies = nil - } - + pruneEmptyDeps(cfg) configModified = true } - // Bail if there is no packages to install - if (cfg.Dependencies == nil || len(*cfg.Dependencies) == 0) && - (cfg.DevDependencies == nil || len(*cfg.DevDependencies) == 0) { + if !hasInstallableDeps(cfg) { wpmCli.Out().WriteString("\nNo packages to install.\n") return nil } @@ -156,6 +146,24 @@ func runInstall(ctx context.Context, wpmCli command.Cli, opts installOptions, pa }) } +// pruneEmptyDeps sets Dependencies / DevDependencies to nil when their maps are empty, +// so they don't get serialized as empty objects in wpm.json. +func pruneEmptyDeps(cfg *wpmjson.Config) { + if cfg.Dependencies != nil && len(*cfg.Dependencies) == 0 { + cfg.Dependencies = nil + } + if cfg.DevDependencies != nil && len(*cfg.DevDependencies) == 0 { + cfg.DevDependencies = nil + } +} + +// hasInstallableDeps reports whether either dependency map has at least one entry. +func hasInstallableDeps(cfg *wpmjson.Config) bool { + hasDeps := cfg.Dependencies != nil && len(*cfg.Dependencies) > 0 + hasDevDeps := cfg.DevDependencies != nil && len(*cfg.DevDependencies) > 0 + return hasDeps || hasDevDeps +} + func addPackages(ctx context.Context, config *wpmjson.Config, wpmCli command.Cli, packages []string, opts installOptions) error { client, err := wpmCli.RegistryClient() if err != nil { @@ -199,13 +207,14 @@ func addPackages(ctx context.Context, config *wpmjson.Config, wpmCli command.Cli mu.Lock() defer mu.Unlock() - if opts.saveDev { + switch { + case opts.saveDev: (*config.DevDependencies)[name] = manifest.Version delete(*config.Dependencies, name) - } else if opts.saveProd { + case opts.saveProd: (*config.Dependencies)[name] = manifest.Version delete(*config.DevDependencies, name) - } else { + default: if _, exists := (*config.DevDependencies)[name]; exists { (*config.DevDependencies)[name] = manifest.Version } else { diff --git a/cli/command/install/run.go b/cli/command/install/run.go index e56ab994..d15b48e9 100644 --- a/cli/command/install/run.go +++ b/cli/command/install/run.go @@ -8,15 +8,15 @@ import ( "slices" "strconv" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "go.wpm.so/cli/cli/command" "go.wpm.so/cli/pkg/output" "go.wpm.so/cli/pkg/pm/installer" "go.wpm.so/cli/pkg/pm/resolution" "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmlock" - - "github.com/morikuni/aec" - "github.com/pkg/errors" ) type Trigger int @@ -48,6 +48,8 @@ func installerProgress(out *output.Output) func(action installer.Action) { case installer.ActionUpdate: actionStr = "+" // we use "+" for updates as well to indicate addition of new version color = aec.YellowF + case installer.ActionInstall: + // keep defaults } out.Prettyln(output.Text{ @@ -108,18 +110,8 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e return nil } - // -- Dry Run -- if opts.DryRun { - for _, action := range plan { - installerProgress(wpmCli.Output())(action) - } - totalPackages := len(plan) - - wpmCli.Output().Prettyln(output.Text{ - Plain: fmt.Sprintf("\n%d %s can be installed", totalPackages, command.Pluralize("package", "s", totalPackages)), - Fancy: fmt.Sprintf("\n%s %s can be installed", aec.GreenF.Apply(strconv.Itoa(totalPackages)), command.Pluralize("package", "s", totalPackages)), - }) - + printDryRunPlan(wpmCli, plan) return nil } @@ -130,7 +122,7 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e if err != nil { return errors.Wrap(err, "failed to initialize installer") } - defer inst.Close() + defer func() { _ = inst.Close() }() if err := inst.InstallAll(ctx, plan, installerProgress(wpmCli.Output())); err != nil { return errors.Wrap(err, "installation failed") @@ -140,7 +132,35 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e // @todo: dependencies lifecycle scripts - // -- Update Lockfile -- + updateLockPackages(lock, resolved) + if err := lock.Write(cwd); err != nil { + return errors.Wrap(err, "failed to save lockfile") + } + + // @todo: run root lifecycle scripts + + if opts.SaveConfig { + if err := wpmCfg.Write(cwd); err != nil { + return errors.Wrap(err, "failed to save wpm.json") + } + } + + printRunSummary(wpmCli, opts.Trigger, len(plan)) + return nil +} + +func printDryRunPlan(wpmCli command.Cli, plan []installer.Action) { + for _, action := range plan { + installerProgress(wpmCli.Output())(action) + } + totalPackages := len(plan) + wpmCli.Output().Prettyln(output.Text{ + Plain: fmt.Sprintf("\n%d %s can be installed", totalPackages, command.Pluralize("package", "s", totalPackages)), + Fancy: fmt.Sprintf("\n%s %s can be installed", aec.GreenF.Apply(strconv.Itoa(totalPackages)), command.Pluralize("package", "s", totalPackages)), + }) +} + +func updateLockPackages(lock *wpmlock.Lockfile, resolved map[string]resolution.Node) { lock.Packages = make(map[string]wpmlock.LockPackage, len(resolved)) for _, name := range slices.Sorted(maps.Keys(resolved)) { node := resolved[name] @@ -153,23 +173,11 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e Dependencies: node.Dependencies, } } +} - if err := lock.Write(cwd); err != nil { - return errors.Wrap(err, "failed to save lockfile") - } - - // @todo: run root lifecycle scripts - - // -- Save wpm.json -- - if opts.SaveConfig { - if err := wpmCfg.Write(cwd); err != nil { - return errors.Wrap(err, "failed to save wpm.json") - } - } - - // -- Print Summary -- +func printRunSummary(wpmCli command.Cli, trigger Trigger, count int) { var action string - switch opts.Trigger { + switch trigger { case TriggerInstall: action = "installed" case TriggerUpdate: @@ -178,12 +186,11 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e action = "uninstalled" } - if action != "" { - wpmCli.Output().Prettyln(output.Text{ - Plain: fmt.Sprintf("\n%d %s %s", len(plan), command.Pluralize("package", "s", len(plan)), action), - Fancy: fmt.Sprintf("\n%s %s %s", aec.GreenF.Apply(strconv.Itoa(len(plan))), command.Pluralize("package", "s", len(plan)), action), - }) + if action == "" { + return } - - return nil + wpmCli.Output().Prettyln(output.Text{ + Plain: fmt.Sprintf("\n%d %s %s", count, command.Pluralize("package", "s", count), action), + Fancy: fmt.Sprintf("\n%s %s %s", aec.GreenF.Apply(strconv.Itoa(count)), command.Pluralize("package", "s", count), action), + }) } diff --git a/cli/command/ls/ls.go b/cli/command/ls/ls.go index 5275f947..9afebce4 100644 --- a/cli/command/ls/ls.go +++ b/cli/command/ls/ls.go @@ -8,14 +8,14 @@ import ( "path/filepath" "sort" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmlock" - - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" ) type lsOptions struct { @@ -111,61 +111,15 @@ func (p *treePrinter) printLevel(deps map[string]string, colorize bool, prefix s sort.Strings(keys) for i, name := range keys { - requestedVersion := deps[name] isLast := i == len(keys)-1 - connector := "├── " if isLast { connector = "└── " } - var info string - var subDeps map[string]string - var isMissing bool - var isCycle bool - - // Check for cycles - if visited[name] { - isCycle = true - } - - if pkg, ok := p.lock.Packages[name]; ok { - info = fmt.Sprintf("%s@%s", name, pkg.Version) - if colorize { - info = fmt.Sprintf("%s%s%s", name, aec.LightBlackF.Apply("@"), aec.LightBlackF.Apply(pkg.Version)) - } - - if pkg.Version != requestedVersion && requestedVersion != "*" { - invalidMsg := fmt.Sprintf("(invalid: \"%s\")", requestedVersion) - if colorize { - info += " " + aec.RedF.Apply(invalidMsg) - } else { - info += " " + invalidMsg - } - } - - if isCycle { - cycleMsg := "(cycle)" - if colorize { - info += " " + aec.MagentaF.Apply(cycleMsg) - } else { - info += " " + cycleMsg - } - } - - if pkg.Dependencies != nil { - subDeps = *pkg.Dependencies - } - } else { - if colorize { - info = fmt.Sprintf("%s@%s %s", name, requestedVersion, aec.RedF.Apply("UNMET DEPENDENCY")) - } else { - info = fmt.Sprintf("%s@%s UNMET DEPENDENCY", name, requestedVersion) - } - isMissing = true - } - - fmt.Fprintf(p.out, "%s%s%s\n", prefix, connector, info) + isCycle := visited[name] + info, subDeps, isMissing := p.formatNode(name, deps[name], colorize, isCycle) + _, _ = fmt.Fprintf(p.out, "%s%s%s\n", prefix, connector, info) // Recurse only if: // 1. It's not a missing package @@ -186,3 +140,45 @@ func (p *treePrinter) printLevel(deps map[string]string, colorize bool, prefix s } } } + +// formatNode renders the display string for a single tree node and returns its +// sub-dependencies along with whether the node is missing from the lockfile. +func (p *treePrinter) formatNode(name, requestedVersion string, colorize, isCycle bool) (info string, subDeps map[string]string, isMissing bool) { + pkg, ok := p.lock.Packages[name] + if !ok { + if colorize { + info = fmt.Sprintf("%s@%s %s", name, requestedVersion, aec.RedF.Apply("UNMET DEPENDENCY")) + } else { + info = fmt.Sprintf("%s@%s UNMET DEPENDENCY", name, requestedVersion) + } + return info, nil, true + } + + info = fmt.Sprintf("%s@%s", name, pkg.Version) + if colorize { + info = fmt.Sprintf("%s%s%s", name, aec.LightBlackF.Apply("@"), aec.LightBlackF.Apply(pkg.Version)) + } + + if pkg.Version != requestedVersion && requestedVersion != "*" { + invalidMsg := fmt.Sprintf("(invalid: \"%s\")", requestedVersion) + if colorize { + info += " " + aec.RedF.Apply(invalidMsg) + } else { + info += " " + invalidMsg + } + } + + if isCycle { + cycleMsg := "(cycle)" + if colorize { + info += " " + aec.MagentaF.Apply(cycleMsg) + } else { + info += " " + cycleMsg + } + } + + if pkg.Dependencies != nil { + subDeps = *pkg.Dependencies + } + return info, subDeps, false +} diff --git a/cli/command/outdated/outdated.go b/cli/command/outdated/outdated.go index c9ef841b..f3fd2af6 100644 --- a/cli/command/outdated/outdated.go +++ b/cli/command/outdated/outdated.go @@ -8,18 +8,18 @@ import ( "sort" "sync" + "github.com/Masterminds/semver/v3" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/version" "go.wpm.so/cli/pkg/output" "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmlock" - - "github.com/Masterminds/semver/v3" - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" - "golang.org/x/sync/errgroup" ) func NewOutdatedCommand(wpmCli command.Cli) *cobra.Command { @@ -89,7 +89,7 @@ func runOutdated(ctx context.Context, wpmCli command.Cli) error { return nil } - results, err := findOutdatedPackages(ctx, config, wpmCli, checks) + results, err := findOutdatedPackages(ctx, wpmCli, checks) if err != nil { return err } @@ -119,7 +119,7 @@ type outdatedInfo struct { diffType string // major, minor, patch, or unknown } -func findOutdatedPackages(ctx context.Context, config *wpmjson.Config, wpmCli command.Cli, checks []depCheck) ([]outdatedInfo, error) { +func findOutdatedPackages(ctx context.Context, wpmCli command.Cli, checks []depCheck) ([]outdatedInfo, error) { client, err := wpmCli.RegistryClient() if err != nil { return nil, err @@ -218,7 +218,7 @@ func printOutdatedList(out io.Writer, colorize bool, results []outdatedInfo) { devStr = c(aec.Faint, "(dev)") } - fmt.Fprintf(out, "%s %s %s\n", nameStr, typeStr, devStr) + _, _ = fmt.Fprintf(out, "%s %s %s\n", nameStr, typeStr, devStr) var diffLabel string var severityColor aec.ANSI @@ -241,19 +241,19 @@ func printOutdatedList(out io.Writer, colorize bool, results []outdatedInfo) { treeEnd := c(aec.LightBlackF, "└──") treeBranch := c(aec.LightBlackF, "├──") - fmt.Fprintf(out, "%s current: %s\n", + _, _ = fmt.Fprintf(out, "%s current: %s\n", treeBranch, r.current, ) - fmt.Fprintf(out, "%s latest: %s %s\n", + _, _ = fmt.Fprintf(out, "%s latest: %s %s\n", treeEnd, c(severityColor, r.latest), // Colorized Version c(severityColor, diffLabel), // Colorized Label ) if i < len(results)-1 { - fmt.Fprintln(out, "") + _, _ = fmt.Fprintln(out, "") } } } diff --git a/cli/command/publish/publish.go b/cli/command/publish/publish.go index 40c6c1f2..aa116e0c 100644 --- a/cli/command/publish/publish.go +++ b/cli/command/publish/publish.go @@ -5,27 +5,29 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "hash" "io" "os" "path/filepath" "strings" "text/tabwriter" + "github.com/docker/go-units" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/command/completion" "go.wpm.so/cli/cli/version" "go.wpm.so/cli/pkg/archive" "go.wpm.so/cli/pkg/output" + "go.wpm.so/cli/pkg/pm/registry" "go.wpm.so/cli/pkg/pm/wpmignore" "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmjson/manifest" "go.wpm.so/cli/pkg/pm/wpmjson/types" - - "github.com/docker/go-units" - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" ) const ( @@ -107,11 +109,14 @@ func getReadme(dirPath string) (string, error) { if strings.EqualFold(entry.Name(), "readme.md") { fullPath := filepath.Join(dirPath, entry.Name()) + //nolint:gosec // This is a CLI tool safely reading from the local workspace f, err := os.Open(fullPath) if err != nil { return "", err } - defer f.Close() + defer func() { + _ = f.Close() + }() // Limit readme size to maxReadmeSize i.e. 50KB data, err := io.ReadAll(io.LimitReader(f, maxReadmeSize)) @@ -154,39 +159,87 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er if err != nil { return err } - - if wpmJson == nil { - return errors.New("no wpm.json found in the current directory") - } - - if err := wpmJson.Validate(); err != nil { + if err := validateWpmJson(wpmJson); err != nil { return err } - if wpmJson.Private { - return errors.New("package marked as private cannot be published") - } - - fmt.Fprintf(wpmCli.Err(), "📦 %s@%s\n\n", wpmJson.Name, wpmJson.Version) + _, _ = fmt.Fprintf(wpmCli.Err(), "📦 %s@%s\n\n", wpmJson.Name, wpmJson.Version) tempFile, err := os.CreateTemp("", "wpm-tarball-*.tar.zst") if err != nil { return errors.Wrap(err, "failed to create temporary tarball") } - defer os.Remove(tempFile.Name()) - defer tempFile.Close() + defer func() { + _ = os.Remove(tempFile.Name()) + _ = tempFile.Close() + }() tarballer, err := pack(cwd, opts, wpmCli.Output()) if err != nil { return errors.Wrap(err, "failed to pack the package into a tarball") } - defer tarballer.Close() + defer func() { _ = tarballer.Close() }() hasher := sha256.New() counter := &tarballSizeCounter{limit: maxPackedSize} + + if err = packIntoTarball(wpmCli, opts, tarballer, tempFile, hasher, counter); err != nil { + return err + } + + if counter.total == 0 { + return errors.New("tarball size is zero, cannot publish empty package") + } + + digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + printPublishSummary(wpmCli, opts, counter.total, tarballer, digest) + + if opts.dryRun { + _, _ = fmt.Fprintf(wpmCli.Err(), "dry run complete, %s@%s is ready to be published\n", wpmJson.Name, wpmJson.Version) + return nil + } + + if err := validateAuth(wpmCli); err != nil { + return err + } + + registryClient, err := wpmCli.RegistryClient() + if err != nil { + return err + } + + readme, err := getReadme(cwd) + if err != nil { + return errors.Wrap(err, "failed to read readme file") + } + + pkgManifest := buildManifest(wpmJson, opts, visibility, digest, counter.total, tarballer, readme) + if err = uploadPackage(ctx, wpmCli, registryClient, pkgManifest, tempFile); err != nil { + return err + } + + _, _ = fmt.Fprintf(wpmCli.Err(), "%s %s\n", aec.GreenF.Apply("✔"), "published "+wpmJson.Name+"@"+wpmJson.Version) + + return nil +} + +func validateWpmJson(wpmJson *wpmjson.Config) error { + if wpmJson == nil { + return errors.New("no wpm.json found in the current directory") + } + if wpmJson.Private { + return errors.New("package marked as private cannot be published") + } + if err := wpmJson.Validate(); err != nil { + return err + } + return nil +} + +func packIntoTarball(wpmCli command.Cli, opts publishOptions, tarballer *archive.Tarballer, tempFile io.Writer, hasher hash.Hash, counter *tarballSizeCounter) error { multiWriter := io.MultiWriter(tempFile, hasher, counter) - packTarball := func() error { + packFn := func() error { if _, err := io.Copy(multiWriter, tarballer.Reader()); err != nil { return errors.Wrap(err, "failed to process tarball") } @@ -194,24 +247,17 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er } if opts.verbose { - if err = packTarball(); err != nil { - return err - } - } else { - if err = wpmCli.Progress().RunWithProgress("packing tarball", packTarball, wpmCli.Err()); err != nil { + if err := packFn(); err != nil { return err } + _, _ = fmt.Fprint(wpmCli.Err(), "\n") + return nil } - if opts.verbose { - fmt.Fprint(wpmCli.Err(), "\n") - } - - // bail if tarball size is zero or greater than 128mb - if counter.total == 0 { - return errors.New("tarball size is zero, cannot publish empty package") - } + return wpmCli.Progress().RunWithProgress("packing tarball", packFn, wpmCli.Err()) +} +func printPublishSummary(wpmCli command.Cli, opts publishOptions, packedBytes int64, tarballer *archive.Tarballer, digest string) { c := func(a aec.ANSI, s string) string { if !wpmCli.Err().IsColorEnabled() { return s @@ -220,40 +266,29 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er } w := tabwriter.NewWriter(wpmCli.Err(), 0, 0, 2, ' ', 0) - digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) - packedSize := units.HumanSize(float64(counter.total)) + packedSize := units.HumanSize(float64(packedBytes)) unpackedSize := units.HumanSize(float64(tarballer.UnpackedSize())) - fmt.Fprintf(w, "├─ %s:\t%s\n", c(aec.LightBlueF, "Tag"), opts.tag) - fmt.Fprintf(w, "├─ %s:\t%s\n", c(aec.LightBlueF, "Access"), opts.access) - fmt.Fprintf(w, "├─ %s:\t%d\n", c(aec.LightBlueF, "Files"), tarballer.FileCount()) - fmt.Fprintf(w, "├─ %s:\t%s %s\n", c(aec.LightBlueF, "Size"), packedSize, c(aec.Faint, fmt.Sprintf("(%s unpacked)", unpackedSize))) - fmt.Fprintf(w, "└─ %s:\t%s\n", c(aec.LightBlueF, "Digest"), digest) - - w.Flush() - fmt.Fprint(wpmCli.Err(), "\n") + _, _ = fmt.Fprintf(w, "├─ %s:\t%s\n", c(aec.LightBlueF, "Tag"), opts.tag) + _, _ = fmt.Fprintf(w, "├─ %s:\t%s\n", c(aec.LightBlueF, "Access"), opts.access) + _, _ = fmt.Fprintf(w, "├─ %s:\t%d\n", c(aec.LightBlueF, "Files"), tarballer.FileCount()) + _, _ = fmt.Fprintf(w, "├─ %s:\t%s %s\n", c(aec.LightBlueF, "Size"), packedSize, c(aec.Faint, fmt.Sprintf("(%s unpacked)", unpackedSize))) + _, _ = fmt.Fprintf(w, "└─ %s:\t%s\n", c(aec.LightBlueF, "Digest"), digest) - if opts.dryRun { - fmt.Fprintf(wpmCli.Err(), "dry run complete, %s@%s is ready to be published\n", wpmJson.Name, wpmJson.Version) - return nil - } + _ = w.Flush() + _, _ = fmt.Fprint(wpmCli.Err(), "\n") +} +func validateAuth(wpmCli command.Cli) error { cfg := wpmCli.ConfigFile() if cfg.DefaultUser == "" || cfg.AuthToken == "" { return errors.New("user must be logged in to perform this action") } + return nil +} - registryClient, err := wpmCli.RegistryClient() - if err != nil { - return err - } - - readme, err := getReadme(cwd) - if err != nil { - return errors.Wrap(err, "failed to read readme file") - } - - manifest := &manifest.Package{ +func buildManifest(wpmJson *wpmjson.Config, opts publishOptions, visibility types.PackageVisibility, digest string, packedBytes int64, tarballer *archive.Tarballer, readme string) *manifest.Package { + return &manifest.Package{ Name: wpmJson.Name, Description: wpmJson.Description, Type: wpmJson.Type, @@ -268,7 +303,7 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er Tag: opts.tag, Dist: manifest.Dist{ Digest: "sha256:" + digest, - PackedSize: counter.total, + PackedSize: packedBytes, TotalFiles: tarballer.FileCount(), UnpackedSize: tarballer.UnpackedSize(), }, @@ -276,22 +311,17 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er Visibility: visibility, Readme: readme, } +} - if err = wpmCli.Progress().RunWithProgress( +func uploadPackage(ctx context.Context, wpmCli command.Cli, registryClient registry.Client, pkgManifest *manifest.Package, tempFile *os.File) error { + return wpmCli.Progress().RunWithProgress( "publishing package", func() error { if _, err := tempFile.Seek(0, io.SeekStart); err != nil { return errors.Wrap(err, "failed to seek to beginning of tarball") } - - return registryClient.PutPackage(ctx, manifest, tempFile) + return registryClient.PutPackage(ctx, pkgManifest, tempFile) }, wpmCli.Err(), - ); err != nil { - return err - } - - fmt.Fprintf(wpmCli.Err(), "%s %s\n", aec.GreenF.Apply("✔"), "published "+wpmJson.Name+"@"+wpmJson.Version) - - return nil + ) } diff --git a/cli/command/uninstall/uninstall.go b/cli/command/uninstall/uninstall.go index 80c486b5..b23dff5a 100644 --- a/cli/command/uninstall/uninstall.go +++ b/cli/command/uninstall/uninstall.go @@ -6,6 +6,10 @@ import ( "os" "path/filepath" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/command/completion" @@ -14,10 +18,6 @@ import ( "go.wpm.so/cli/pkg/output" "go.wpm.so/cli/pkg/pm/workspace" "go.wpm.so/cli/pkg/pm/wpmjson" - - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" ) func NewUninstallCommand(wpmCli command.Cli) *cobra.Command { @@ -71,7 +71,7 @@ func runUninstall(ctx context.Context, wpmCli command.Cli, packages []string) er } if cfg == nil { - return fmt.Errorf("no wpm.json found, so nothing to uninstall") + return errors.New("no wpm.json found, so nothing to uninstall") } changed := false @@ -92,7 +92,7 @@ func runUninstall(ctx context.Context, wpmCli command.Cli, packages []string) er if !changed { wpmCli.Out().WriteString("\n") - fmt.Fprintln(wpmCli.Out(), "No matching packages found to uninstall.") + _, _ = fmt.Fprintln(wpmCli.Out(), "No matching packages found to uninstall.") return nil } diff --git a/cli/command/utils.go b/cli/command/utils.go index c905c13a..8a1f6f4d 100644 --- a/cli/command/utils.go +++ b/cli/command/utils.go @@ -9,9 +9,9 @@ import ( "runtime" "strings" - "go.wpm.so/cli/pkg/streams" - "github.com/moby/term" + + "go.wpm.so/cli/pkg/streams" ) type cancelledErr string diff --git a/cli/command/whoami/whoami.go b/cli/command/whoami/whoami.go index a911a324..0e727c64 100644 --- a/cli/command/whoami/whoami.go +++ b/cli/command/whoami/whoami.go @@ -3,11 +3,11 @@ package whoami import ( "context" - "go.wpm.so/cli/cli" - "go.wpm.so/cli/cli/command" - "github.com/pkg/errors" "github.com/spf13/cobra" + + "go.wpm.so/cli/cli" + "go.wpm.so/cli/cli/command" ) func NewWhoamiCommand(wpmCli command.Cli) *cobra.Command { diff --git a/cli/command/why/why.go b/cli/command/why/why.go index b74427e3..99a8b45c 100644 --- a/cli/command/why/why.go +++ b/cli/command/why/why.go @@ -8,16 +8,16 @@ import ( "sort" "strings" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/command/completion" "go.wpm.so/cli/pkg/output" "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmlock" - - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/spf13/cobra" ) const ( @@ -61,8 +61,7 @@ func runWhy(wpmCli command.Cli, targetPkg string) error { return errors.New("no wpm.lock found. Run 'wpm install' first to generate a lockfile.") } - _, exists := lock.Packages[targetPkg] - if !exists { + if _, exists := lock.Packages[targetPkg]; !exists { return fmt.Errorf("package '%s' is not found in wpm.lock", targetPkg) } @@ -72,6 +71,24 @@ func runWhy(wpmCli command.Cli, targetPkg string) error { } colorize := wpmCli.Out().IsColorEnabled() + dependents := buildDependentsMap(config, lock, rootNode, colorize) + paths := findPathsToRoot(targetPkg, dependents) + + if len(paths) == 0 { + wpmCli.Output().Prettyln(output.Text{ + Plain: targetPkg + " is present in lockfile but has no apparent dependents (orphaned?).", + Fancy: aec.Bold.Apply(targetPkg) + " is present in lockfile but has no apparent dependents (orphaned?).", + }) + return nil + } + + printPaths(wpmCli, lock, paths) + return nil +} + +// buildDependentsMap returns "name -> list of packages that depend on name". Root entries +// for the project's dependencies and devDependencies use formatted display names. +func buildDependentsMap(config *wpmjson.Config, lock *wpmlock.Lockfile, rootNode string, colorize bool) map[string][]string { dependents := make(map[string][]string) rootNodeIdDeps := getRootNodeID(rootNode, depsSuffix, colorize) rootNodeIdDevDeps := getRootNodeID(rootNode, devDepsSuffix, colorize) @@ -91,22 +108,15 @@ func runWhy(wpmCli command.Cli, targetPkg string) error { if parentPkg.Dependencies == nil { continue } - for depName := range *parentPkg.Dependencies { dependents[depName] = append(dependents[depName], parentName) } } + return dependents +} - paths := findPathsToRoot(targetPkg, dependents) - - if len(paths) == 0 { - wpmCli.Output().Prettyln(output.Text{ - Plain: fmt.Sprintf("%s is present in lockfile but has no apparent dependents (orphaned?).", targetPkg), - Fancy: fmt.Sprintf("%s is present in lockfile but has no apparent dependents (orphaned?).", aec.Bold.Apply(targetPkg)), - }) - return nil - } - +// printPaths renders each dependency chain from the target package up to a root entry. +func printPaths(wpmCli command.Cli, lock *wpmlock.Lockfile, paths [][]string) { for _, path := range paths { indent := "" for i := len(path) - 1; i >= 0; i-- { @@ -115,27 +125,22 @@ func runWhy(wpmCli command.Cli, targetPkg string) error { info := "" if !stringsContainsRoot(name) { if pkg, ok := lock.Packages[name]; ok { - info = fmt.Sprintf("@%s", pkg.Version) + info = "@" + pkg.Version } } - // If it's the last item, don't print the branch line if i == len(path)-1 { wpmCli.Out().WriteString(fmt.Sprintf("%s%s\n", indent, name)) } else { wpmCli.Out().WriteString(fmt.Sprintf("%s└─ %s%s\n", indent, name, info)) } - // Increase indent for the next level if i < len(path)-1 { indent += " " } } - wpmCli.Out().WriteString("\n") } - - return nil } // findPathsToRoot performs a BFS traversal backwards to find chains to the root @@ -172,8 +177,8 @@ func findPathsToRoot(start string, dependents map[string][]string) [][]string { } // Create new path: target -> ... -> current -> parent - newPath := make([]string, len(path)) - copy(newPath, path) + newPath := make([]string, 0, len(path)+1) + newPath = append(newPath, path...) newPath = append(newPath, parent) queue = append(queue, newPath) } diff --git a/cli/debug/debug.go b/cli/debug/debug.go index fcc9160f..bc3b8f4f 100644 --- a/cli/debug/debug.go +++ b/cli/debug/debug.go @@ -9,14 +9,14 @@ import ( // Enable sets the WPM_DEBUG env var to true // and makes the logger to log at debug level. func Enable() { - os.Setenv("WPM_DEBUG", "1") + _ = os.Setenv("WPM_DEBUG", "1") logrus.SetLevel(logrus.DebugLevel) } // Disable sets the WPM_DEBUG env var to false // and makes the logger to log at info level. func Disable() { - os.Setenv("WPM_DEBUG", "") + _ = os.Setenv("WPM_DEBUG", "") logrus.SetLevel(logrus.InfoLevel) } diff --git a/cli/flags/options.go b/cli/flags/options.go index f9d987e8..f434585e 100644 --- a/cli/flags/options.go +++ b/cli/flags/options.go @@ -4,10 +4,10 @@ import ( "fmt" "os" - "go.wpm.so/cli/pkg/config" - "github.com/sirupsen/logrus" "github.com/spf13/pflag" + + "go.wpm.so/cli/pkg/config" ) // ClientOptions are the options used to configure the client cli. @@ -36,7 +36,7 @@ func (o *ClientOptions) InstallFlags(flags *pflag.FlagSet) { // SetDefaultOptions sets default values for options after flag parsing is // complete -func (o *ClientOptions) SetDefaultOptions(flags *pflag.FlagSet) {} +func (*ClientOptions) SetDefaultOptions(_ *pflag.FlagSet) {} // SetLogLevel sets the logrus logging level func SetLogLevel(logLevel string) { diff --git a/cli/required.go b/cli/required.go index 7d511032..0f962a4b 100644 --- a/cli/required.go +++ b/cli/required.go @@ -64,7 +64,7 @@ func RequiresMaxArgs(maxArgs int) cobra.PositionalArgs { } // RequiresRangeArgs returns an error if there is not at least min args and at most max args -func RequiresRangeArgs(minArgs int, maxArgs int) cobra.PositionalArgs { +func RequiresRangeArgs(minArgs, maxArgs int) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) >= minArgs && len(args) <= maxArgs { return nil diff --git a/cmd/docgen/docgen.go b/cmd/docgen/docgen.go index f8c7fe6e..b6fb032e 100644 --- a/cmd/docgen/docgen.go +++ b/cmd/docgen/docgen.go @@ -25,18 +25,18 @@ package main import ( "errors" "fmt" - "log" "os" - "go.wpm.so/cli/cli" - "go.wpm.so/cli/cli/command" - "go.wpm.so/cli/cli/command/commands" - "go.wpm.so/cli/cli/version" - clidocstool "github.com/docker/cli-docs-tool" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/cobra/doc" "github.com/spf13/pflag" + + "go.wpm.so/cli/cli" + "go.wpm.so/cli/cli/command" + "go.wpm.so/cli/cli/command/commands" + "go.wpm.so/cli/cli/version" ) const ( @@ -59,7 +59,7 @@ func main() { } func run() error { - log.SetFlags(0) + logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true}) opts, err := parseArgs(os.Args[1:]) if err != nil { @@ -71,6 +71,7 @@ func run() error { return err } + //nolint:gosec // Dir perms are intentionally permissive here. if err := os.MkdirAll(opts.target, 0o755); err != nil { return fmt.Errorf("create %s: %w", opts.target, err) } diff --git a/cmd/wpm/wpm.go b/cmd/wpm/wpm.go index 7fa9273e..e0c042e3 100644 --- a/cmd/wpm/wpm.go +++ b/cmd/wpm/wpm.go @@ -8,18 +8,18 @@ import ( "os/signal" "syscall" + "github.com/containerd/errdefs" + "github.com/morikuni/aec" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" "go.wpm.so/cli/cli/command/commands" cliflags "go.wpm.so/cli/cli/flags" "go.wpm.so/cli/cli/version" platformsignals "go.wpm.so/cli/cmd/wpm/internal/signals" - - "github.com/containerd/errdefs" - "github.com/morikuni/aec" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" ) type errCtxSignalTerminated struct { @@ -133,7 +133,7 @@ func newWpmCommand(wpmCli *command.WpmCli) *cli.TopLevelCommand { return command.ShowHelp(wpmCli.Err())(cmd, args) } - fmt.Fprintf(wpmCli.Err(), "wpm: unknown command: wpm %s\n", args[0]) + _, _ = fmt.Fprintf(wpmCli.Err(), "wpm: unknown command: wpm %s\n", args[0]) var candidates []string if args[0] == "help" { @@ -146,13 +146,13 @@ func newWpmCommand(wpmCli *command.WpmCli) *cli.TopLevelCommand { } if len(candidates) > 0 { - fmt.Fprint(wpmCli.Err(), "\nDid you mean this?\n") + _, _ = fmt.Fprint(wpmCli.Err(), "\nDid you mean this?\n") for _, c := range candidates { - fmt.Fprintf(wpmCli.Err(), "\t%s\n", c) + _, _ = fmt.Fprintf(wpmCli.Err(), "\t%s\n", c) } } - return fmt.Errorf("\nRun 'wpm --help' for more information") + return errors.New("\nRun 'wpm --help' for more information") }, Version: ver, DisableFlagsInUseLine: true, diff --git a/pkg/api/cache.go b/pkg/api/cache.go index a1347d45..b55efcf6 100644 --- a/pkg/api/cache.go +++ b/pkg/api/cache.go @@ -102,7 +102,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { } func (t *Transport) executeRequest(req *http.Request, finalPath string, force bool) (*http.Response, error) { - if err := os.MkdirAll(filepath.Dir(finalPath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(finalPath), 0o750); err != nil { return t.base().RoundTrip(req) } @@ -114,7 +114,7 @@ func (t *Transport) executeRequest(req *http.Request, finalPath string, force bo if et := h.Get(HeaderEtag); et != "" { req.Header.Set(HeaderIfNoneMatch, et) } - body.Close() + _ = body.Close() } } @@ -125,7 +125,7 @@ func (t *Transport) executeRequest(req *http.Request, finalPath string, force bo // Handle 304 Not Modified if resp.StatusCode == http.StatusNotModified { - resp.Body.Close() + _ = resp.Body.Close() if body, h, err := t.open(finalPath); err == nil { h.Set(HeaderLocalCache, CacheHit) return t.response(req, body, h), nil @@ -148,81 +148,88 @@ func (t *Transport) executeRequest(req *http.Request, finalPath string, force bo return resp, nil } -func (t *Transport) open(path string) (io.ReadCloser, http.Header, error) { - f, err := os.Open(path) +func (*Transport) open(path string) (io.ReadCloser, http.Header, error) { + f, err := os.Open(path) //nolint:gosec // path is derived from a sha256 hash of the request URL within our cache dir if err != nil { return nil, nil, err } - fail := func(e error) (io.ReadCloser, http.Header, error) { - f.Close() - return nil, nil, e + headers, bodyStart, bodyEnd, err := readCacheEnvelope(f) + if err != nil { + _ = f.Close() + return nil, nil, err } + return &safeReader{ + f: f, + SectionReader: io.NewSectionReader(f, bodyStart, bodyEnd-bodyStart), + }, headers, nil +} + +// readCacheEnvelope validates the cache file's magic/version/footer and returns +// the parsed headers plus the byte range that the response body occupies. +func readCacheEnvelope(f *os.File) (http.Header, int64, int64, error) { var magic uint32 if err := binary.Read(f, binary.BigEndian, &magic); err != nil || magic != headerMagic { - return fail(errors.New("bad magic")) + return nil, 0, 0, errors.New("bad magic") } ver := make([]byte, len(cacheVersion)) if _, err := io.ReadFull(f, ver); err != nil || string(ver) != cacheVersion { - return fail(errors.New("version mismatch")) + return nil, 0, 0, errors.New("version mismatch") } var metaLen uint32 if err := binary.Read(f, binary.BigEndian, &metaLen); err != nil { - return fail(err) + return nil, 0, 0, err } if metaLen > maxMetaLen { - return fail(fmt.Errorf("meta length %d exceeds %d limit", metaLen, maxMetaLen)) + return nil, 0, 0, fmt.Errorf("meta length %d exceeds %d limit", metaLen, maxMetaLen) } rawMeta := make([]byte, metaLen) if _, err := io.ReadFull(f, rawMeta); err != nil { - return fail(err) + return nil, 0, 0, err } var m meta if err := json.Unmarshal(rawMeta, &m); err != nil { - return fail(err) + return nil, 0, 0, err } bodyStart, err := f.Seek(0, io.SeekCurrent) if err != nil { - return fail(err) + return nil, 0, 0, err } stat, err := f.Stat() if err != nil { - return fail(err) + return nil, 0, 0, err } if stat.Size() < bodyStart+4 { - return fail(errors.New("truncated")) + return nil, 0, 0, errors.New("truncated") } if _, err := f.Seek(-4, io.SeekEnd); err != nil { - return fail(err) + return nil, 0, 0, err } var endMagic uint32 if err := binary.Read(f, binary.BigEndian, &endMagic); err != nil || endMagic != footerMagic { - return fail(errors.New("bad footer")) + return nil, 0, 0, errors.New("bad footer") } if _, err := f.Seek(bodyStart, io.SeekStart); err != nil { - return fail(err) + return nil, 0, 0, err } - return &safeReader{ - f: f, - SectionReader: io.NewSectionReader(f, bodyStart, stat.Size()-bodyStart-4), - }, m.Headers, nil + return m.Headers, bodyStart, stat.Size() - 4, nil } func (t *Transport) write(src io.ReadCloser, finalPath string, h http.Header) io.ReadCloser { tmpDir := filepath.Join(t.cacheDir, "tmp") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { + if err := os.MkdirAll(tmpDir, 0o750); err != nil { return src } @@ -231,11 +238,11 @@ func (t *Transport) write(src io.ReadCloser, finalPath string, h http.Header) io return src } - _ = os.Chmod(f.Name(), 0o644) + _ = os.Chmod(f.Name(), 0o600) if err := t.writeMeta(f, h); err != nil { - f.Close() - os.Remove(f.Name()) + _ = f.Close() + _ = os.Remove(f.Name()) return src } @@ -247,7 +254,7 @@ func (t *Transport) write(src io.ReadCloser, finalPath string, h http.Header) io } } -func (t *Transport) writeMeta(w io.Writer, h http.Header) error { +func (*Transport) writeMeta(w io.Writer, h http.Header) error { if err := binary.Write(w, binary.BigEndian, uint32(headerMagic)); err != nil { return err } @@ -271,7 +278,7 @@ func (t *Transport) writeMeta(w io.Writer, h http.Header) error { if len(b) > maxMetaLen { return fmt.Errorf("cacheable headers too large: %d bytes", len(b)) } - if err := binary.Write(w, binary.BigEndian, uint32(len(b))); err != nil { + if err := binary.Write(w, binary.BigEndian, uint32(len(b))); err != nil { //nolint:gosec // bounded above by maxMetaLen check return err } _, err = w.Write(b) @@ -292,8 +299,8 @@ func (w *writer) Read(p []byte) (int, error) { if n > 0 && !w.cacheFailed { if _, wErr := w.dst.Write(p[:n]); wErr != nil { w.cacheFailed = true - w.dst.Close() - os.Remove(w.tmp) + _ = w.dst.Close() + _ = os.Remove(w.tmp) } } if err == io.EOF { @@ -317,12 +324,12 @@ func (w *writer) Close() error { closeErr := w.dst.Close() if !success || closeErr != nil { - os.Remove(w.tmp) + _ = os.Remove(w.tmp) return srcErr } if err := renameFile(w.tmp, w.final); err != nil { - os.Remove(w.tmp) + _ = os.Remove(w.tmp) } return srcErr } @@ -349,9 +356,9 @@ func renameFile(src, dst string) error { return err } -func (t *Transport) response(req *http.Request, body io.ReadCloser, h http.Header) *http.Response { +func (*Transport) response(req *http.Request, body io.ReadCloser, h http.Header) *http.Response { return &http.Response{ - StatusCode: 200, + StatusCode: http.StatusOK, Body: body, Header: h, Request: req, diff --git a/pkg/api/client_options.go b/pkg/api/client_options.go index 666f273b..cddfb023 100644 --- a/pkg/api/client_options.go +++ b/pkg/api/client_options.go @@ -1,7 +1,7 @@ package api import ( - "fmt" + "errors" "io" "time" ) @@ -57,7 +57,7 @@ func optionsNeedResolution(opts ClientOptions) bool { func resolveOptions(opts ClientOptions) (ClientOptions, error) { if opts.Host == "" { - return ClientOptions{}, fmt.Errorf("host not found") + return ClientOptions{}, errors.New("host not found") } return opts, nil diff --git a/pkg/api/errors.go b/pkg/api/errors.go index f3b9485d..6b0d5254 100644 --- a/pkg/api/errors.go +++ b/pkg/api/errors.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "io" "net/http" "net/url" @@ -17,12 +16,11 @@ type HTTPError struct { StatusCode int } -// Allow HTTPError to satisfy error interface. func (err *HTTPError) Error() string { if err.Message == "" { - return fmt.Sprintf("wpm registry error: %s", strings.ToLower(http.StatusText(err.StatusCode))) + return "wpm registry error: " + strings.ToLower(http.StatusText(err.StatusCode)) } - return fmt.Sprintf("wpm registry error: %s", strings.ToLower(err.Message)) + return "wpm registry error: " + strings.ToLower(err.Message) } // HandleHTTPError parses a http.Response into a HTTPError. diff --git a/pkg/api/http_client.go b/pkg/api/http_client.go index a0d00253..6acf90d2 100644 --- a/pkg/api/http_client.go +++ b/pkg/api/http_client.go @@ -9,13 +9,13 @@ import ( "sync" "time" - "go.wpm.so/cli/pkg/asciisanitizer" - "github.com/henvic/httpretty" "github.com/klauspost/compress/zstd" "github.com/sirupsen/logrus" "github.com/thlib/go-timezone-local/tzlocal" "golang.org/x/text/transform" + + "go.wpm.so/cli/pkg/asciisanitizer" ) const ( @@ -145,9 +145,9 @@ func resolveHeaders(headers map[string]string) { } } -func newHeaderRoundTripper(host string, authToken string, headers map[string]string, rt http.RoundTripper) http.RoundTripper { +func newHeaderRoundTripper(host, authToken string, headers map[string]string, rt http.RoundTripper) http.RoundTripper { if _, ok := headers[HeaderAuthorization]; !ok && authToken != "" { - headers[HeaderAuthorization] = fmt.Sprintf("Bearer %s", authToken) + headers[HeaderAuthorization] = "Bearer " + authToken } if len(headers) == 0 { return headerRoundTripper{host: host, headers: nil, rt: rt} @@ -216,7 +216,7 @@ func (d decompressingRoundTripper) RoundTrip(req *http.Request) (*http.Response, if resp.Header.Get("Content-Encoding") == "zstd" { decoder := zstdDecoderPool.Get().(*zstd.Decoder) if err := decoder.Reset(resp.Body); err != nil { - resp.Body.Close() + _ = resp.Body.Close() zstdDecoderPool.Put(decoder) return nil, fmt.Errorf("failed to reset zstd reader: %w", err) } diff --git a/pkg/api/log_formatter.go b/pkg/api/log_formatter.go index e757dd81..941957ca 100644 --- a/pkg/api/log_formatter.go +++ b/pkg/api/log_formatter.go @@ -16,6 +16,6 @@ func (f *jsonFormatter) Format(w io.Writer, src []byte) error { return jsonpretty.Format(w, bytes.NewReader(src), " ", f.colorize) } -func (f *jsonFormatter) Match(t string) bool { +func (*jsonFormatter) Match(t string) bool { return jsonTypeRE.MatchString(t) } diff --git a/pkg/api/rest_client.go b/pkg/api/rest_client.go index e225167f..f65d580d 100644 --- a/pkg/api/rest_client.go +++ b/pkg/api/rest_client.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "fmt" "io" "net/http" "strings" @@ -50,7 +49,7 @@ func WithHeader(key, value string) RequestOption { } } -func (c *RESTClient) DoWithContext(ctx context.Context, method string, path string, body io.Reader, response any, opts ...RequestOption) error { +func (c *RESTClient) DoWithContext(ctx context.Context, method, path string, body io.Reader, response any, opts ...RequestOption) error { url := restURL(c.host, path) req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { @@ -65,7 +64,7 @@ func (c *RESTClient) DoWithContext(ctx context.Context, method string, path stri if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return HandleHTTPError(resp) @@ -95,7 +94,7 @@ func (c *RESTClient) DoWithContext(ctx context.Context, method string, path stri } } -func (c *RESTClient) RequestStream(ctx context.Context, method string, path string, body io.Reader, opts ...RequestOption) (io.ReadCloser, error) { +func (c *RESTClient) RequestStream(ctx context.Context, method, path string, body io.Reader, opts ...RequestOption) (io.ReadCloser, error) { url := restURL(c.host, path) req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { @@ -112,14 +111,14 @@ func (c *RESTClient) RequestStream(ctx context.Context, method string, path stri } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() return nil, HandleHTTPError(resp) } return resp.Body, nil } -func (c *RESTClient) Do(method string, path string, body io.Reader, response any, opts ...RequestOption) error { +func (c *RESTClient) Do(method, path string, body io.Reader, response any, opts ...RequestOption) error { return c.DoWithContext(context.Background(), method, path, body, response, opts...) } @@ -154,5 +153,5 @@ func restURL(hostname, pathOrURL string) string { } func restPrefix(hostname string) string { - return fmt.Sprintf("https://%s", hostname) + return "https://" + hostname } diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go index f33b915e..5dcf3bac 100644 --- a/pkg/archive/archive.go +++ b/pkg/archive/archive.go @@ -38,9 +38,7 @@ const ( maxDecompressedSize int64 = 512 * 1024 * 1024 // 512 MB ) -var ( - zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd} -) +var zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd} type TarOptions struct { IncludeFiles []string @@ -73,17 +71,17 @@ func isZstd(source []byte) bool { // IsArchivePath checks if the (possibly compressed) file at the given path // starts with a tar file header. func IsArchivePath(path string) bool { - file, err := os.Open(path) + file, err := os.Open(path) //nolint:gosec // callers pass paths they want to inspect if err != nil { return false } - defer file.Close() + defer func() { _ = file.Close() }() rdr, err := DecompressStream(file) if err != nil { return false } - defer rdr.Close() + defer func() { _ = rdr.Close() }() r := tar.NewReader(rdr) _, err = r.Next() @@ -106,11 +104,9 @@ func (r *readCloserWrapper) Close() error { return nil } -var ( - bufioReader256KPool = &sync.Pool{ - New: func() any { return bufio.NewReaderSize(nil, 256*1024) }, - } -) +var bufioReader256KPool = &sync.Pool{ + New: func() any { return bufio.NewReaderSize(nil, 256*1024) }, +} type bufferedReader struct { buf *bufio.Reader @@ -129,9 +125,9 @@ func (r *bufferedReader) Read(p []byte) (n int, err error) { } n, err = r.buf.Read(p) if err == io.EOF { - r.Close() + _ = r.Close() } - return + return n, err } func (r *bufferedReader) Peek(n int) ([]byte, error) { @@ -161,7 +157,7 @@ func DecompressStream(archive io.Reader) (io.ReadCloser, error) { // check if the stream is compressed with zstd if !isZstd(bs) { - return nil, fmt.Errorf("unsupported archive format: expected zstd compressed archive") + return nil, errors.New("unsupported archive format: expected zstd compressed archive") } zstdReader, err := zstd.NewReader(buf, zstd.WithDecoderMaxWindow(zstdMaxWindowSize)) @@ -259,7 +255,7 @@ func (ta *tarAppender) addTarFile(path, name string) error { hdr.Name += "/" } } else { - hdr.Name = filepath.ToSlash(filepath.Join("package", originalHdrName)) + hdr.Name = filepath.ToSlash(filepath.Join("package", originalHdrName)) //nolint:gosec // prefix is a constant; this builds an archive entry name, not a filesystem path } // if it's not a directory and has more than 1 link, it's hard linked @@ -281,7 +277,7 @@ func (ta *tarAppender) addTarFile(path, name string) error { } _, err = copyWithBuffer(ta.TarWriter, file) - file.Close() + _ = file.Close() if err != nil { return err } @@ -290,7 +286,7 @@ func (ta *tarAppender) addTarFile(path, name string) error { return nil } -func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, options *TarOptions) error { +func createTarFile(path string, hdr *tar.Header, reader io.Reader, options *TarOptions) error { switch hdr.Typeflag { case tar.TypeDir: // Create directory unless it exists as a directory already. @@ -309,10 +305,10 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o return err } if _, err := copyWithBuffer(file, io.LimitReader(reader, hdr.Size)); err != nil { - file.Close() + _ = file.Close() return err } - file.Close() + _ = file.Close() case tar.TypeLink, tar.TypeSymlink: if options != nil && options.Logger != nil { @@ -379,8 +375,8 @@ func NewTarballer(srcPath string, options *TarOptions, reporterFn func(fs.FileIn zstdWriter, err := zstd.NewWriter(pipeWriter, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) if err != nil { - pipeReader.Close() - pipeWriter.Close() + _ = pipeReader.Close() + _ = pipeWriter.Close() return nil, fmt.Errorf("failed to create zstd writer: %w", err) } @@ -420,6 +416,8 @@ func (t *Tarballer) Close() error { // Do performs the archiving operation in the background. The resulting archive // can be read from t.Reader(). Do should only be called once on each Tarballer // instance. +// +//nolint:gocyclo // this function is necessarily complex due to the file walking and pattern matching logic func (t *Tarballer) Do() { ta := newTarAppender(t.compressWriter) @@ -435,9 +433,9 @@ func (t *Tarballer) Do() { } if doErr != nil { - t.pipeWriter.CloseWithError(doErr) + _ = t.pipeWriter.CloseWithError(doErr) } else { - t.pipeWriter.Close() + _ = t.pipeWriter.Close() } }() @@ -578,6 +576,8 @@ func (t *Tarballer) Do() { } // Unpack unpacks the decompressedArchive to dest with options. +// +//nolint:gocyclo // this function is necessarily complex due to the file walking and pattern matching logic func Unpack(decompressedArchive io.Reader, dest string, options *TarOptions) error { tr := tar.NewReader(decompressedArchive) @@ -636,7 +636,7 @@ loop: return err } - path := filepath.Join(dest, hdr.Name) + path := filepath.Join(dest, hdr.Name) //nolint:gosec // traversal is guarded by the filepath.Rel check immediately below rel, err := filepath.Rel(dest, path) if err != nil { return err @@ -669,7 +669,7 @@ loop: } } - if err := createTarFile(path, dest, hdr, tr, options); err != nil { + if err := createTarFile(path, hdr, tr, options); err != nil { return err } @@ -681,7 +681,7 @@ loop: } for _, hdr := range dirs { - path := filepath.Join(dest, hdr.Name) + path := filepath.Join(dest, hdr.Name) //nolint:gosec // hdr.Name was already validated by the main extract loop above if err := chtimes(path, boundTime(latestTime(hdr.AccessTime, hdr.ModTime)), boundTime(hdr.ModTime)); err != nil { return err @@ -728,10 +728,10 @@ type extractionLimiter struct { func (b *extractionLimiter) Read(p []byte) (int, error) { if b.compressedTracker.bytesRead > maxCompressedSize { - return 0, fmt.Errorf("invalid archive: compressed size exceeds 128MB limit") + return 0, errors.New("invalid archive: compressed size exceeds 128MB limit") } if b.decompressedBytes >= maxDecompressedSize { - return 0, fmt.Errorf("invalid archive: decompressed size exceeds 512MB limit (potential zip bomb)") + return 0, errors.New("invalid archive: decompressed size exceeds 512MB limit (potential zip bomb)") } remaining := maxDecompressedSize - b.decompressedBytes @@ -748,8 +748,8 @@ func (b *extractionLimiter) Read(p []byte) (int, error) { if cBytes == 0 { cBytes = 1 // prevent division by zero } - if b.decompressedBytes >= int64(maxCompressionRatio)*cBytes { - return n, fmt.Errorf("invalid archive: compression ratio exceeds 99.6%% (potential zip bomb)") + if b.decompressedBytes >= maxCompressionRatio*cBytes { + return n, errors.New("invalid archive: compression ratio exceeds 99.6%% (potential zip bomb)") } } @@ -777,7 +777,7 @@ func Untar(tarArchive io.Reader, dest string, options *TarOptions) error { if err != nil { return err } - defer decompressedArchive.Close() + defer func() { _ = decompressedArchive.Close() }() detector := &extractionLimiter{ compressedTracker: compressedTracker, diff --git a/pkg/archive/copy.go b/pkg/archive/copy.go index 3e633f6d..217e5f50 100644 --- a/pkg/archive/copy.go +++ b/pkg/archive/copy.go @@ -18,7 +18,7 @@ func copyWithBuffer(dst io.Writer, src io.Reader) (written int64, err error) { buf := copyPool.Get().(*[]byte) written, err = io.CopyBuffer(dst, src, *buf) copyPool.Put(buf) - return + return written, err } var copyPool = sync.Pool{ diff --git a/pkg/archive/time_nonwindows.go b/pkg/archive/time_nonwindows.go index 1a0006fe..a52cb27c 100644 --- a/pkg/archive/time_nonwindows.go +++ b/pkg/archive/time_nonwindows.go @@ -11,6 +11,6 @@ import ( // If the modified time is prior to the Unix Epoch (unixMinTime), or after the // end of Unix Time (unixEpochTime), os.Chtimes has undefined behavior. In this // case, Chtimes defaults to Unix Epoch, just in case. -func chtimes(name string, atime time.Time, mtime time.Time) error { +func chtimes(name string, atime, mtime time.Time) error { return os.Chtimes(name, atime, mtime) } diff --git a/pkg/asciisanitizer/sanitizer.go b/pkg/asciisanitizer/sanitizer.go index 0292bec4..e8dad3a3 100644 --- a/pkg/asciisanitizer/sanitizer.go +++ b/pkg/asciisanitizer/sanitizer.go @@ -41,50 +41,20 @@ func (t *Sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err } for len(src) > 0 { - // When sanitizing JSON strings make sure that we have 6 bytes if available. - if t.JSON && len(src) < 6 && !atEOF { - err = transform.ErrShortSrc - return + r, size, decodeErr := decodeNextRune(t.JSON, src, atEOF) + if decodeErr != nil { + return nDst, nSrc, decodeErr } - r, size := utf8.DecodeRune(src) - if r == utf8.RuneError && size < 2 { - if !atEOF { - err = transform.ErrShortSrc - return - } else { - err = errors.New("invalid UTF-8 string") - return - } - } - // Replace C0 and C1 control characters. - if unicode.IsControl(r) { - if repl, found := mapControlToCaret(r); found { - err = transfer(repl, src[:size]) - if err != nil { - return - } - continue - } - } - // Replace JSON C0 and C1 control characters. - if t.JSON && len(src) >= 6 { - if repl, found := mapJSONControlToCaret(src[:6]); found { - if t.addEscape { - // Add an escape character when necessary to prevent creating - // invalid JSON with our replacements. - repl = append([]byte{'\\'}, repl...) - t.addEscape = false - } - err = transfer(repl, src[:6]) - if err != nil { - return - } - continue + + if repl, consumed, found := t.findReplacement(r, size, src); found { + if err = transfer(repl, src[:consumed]); err != nil { + return nDst, nSrc, err } + continue } - err = transfer(src[:size], src[:size]) - if err != nil { - return + + if err = transfer(src[:size], src[:size]); err != nil { + return nDst, nSrc, err } if t.JSON { if r == '\\' { @@ -94,7 +64,45 @@ func (t *Sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err } } } - return + return nDst, nSrc, err +} + +// decodeNextRune validates the JSON short-window invariant and decodes the next +// rune, mapping decode failures to either ErrShortSrc or an "invalid UTF-8" error. +func decodeNextRune(json bool, src []byte, atEOF bool) (rune, int, error) { + if json && len(src) < 6 && !atEOF { + return 0, 0, transform.ErrShortSrc + } + r, size := utf8.DecodeRune(src) + if r == utf8.RuneError && size < 2 { + if !atEOF { + return 0, 0, transform.ErrShortSrc + } + return 0, 0, errors.New("invalid UTF-8 string") + } + return r, size, nil +} + +// findReplacement looks for either a C0/C1 control rune or its JSON-encoded +// form at the start of src and returns the substitution plus the number of +// source bytes it covers. +func (t *Sanitizer) findReplacement(r rune, size int, src []byte) (repl []byte, consumed int, found bool) { + if unicode.IsControl(r) { + if rep, ok := mapControlToCaret(r); ok { + return rep, size, true + } + } + if t.JSON && len(src) >= 6 { + if rep, ok := mapJSONControlToCaret(src[:6]); ok { + if t.addEscape { + // Prepend an escape so the replacement doesn't produce invalid JSON. + rep = append([]byte{'\\'}, rep...) + t.addEscape = false + } + return rep, 6, true + } + } + return nil, 0, false } // Reset resets the state and allows the Sanitizer to be reused. @@ -104,7 +112,7 @@ func (t *Sanitizer) Reset() { // mapControlToCaret maps C0 and C1 control characters to their caret notation. func mapControlToCaret(r rune) ([]byte, bool) { - //\t (09), \n (10), \v (11), \r (13) are safe C0 characters and are not sanitized. + // \t (09), \n (10), \v (11), \r (13) are safe C0 characters and are not sanitized. m := map[rune]string{ 0: `^@`, 1: `^A`, @@ -183,7 +191,7 @@ func mapJSONControlToCaret(b []byte) ([]byte, bool) { if !bytes.HasPrefix(b, []byte(`\u00`)) { return nil, false } - //\t (\u0009), \n (\u000a), \v (\u000b), \r (\u000d) are safe C0 characters and are not sanitized. + // \t (\u0009), \n (\u000a), \v (\u000b), \r (\u000d) are safe C0 characters and are not sanitized. m := map[string]string{ `\u0000`: `^@`, `\u0001`: `^A`, diff --git a/pkg/config/config.go b/pkg/config/config.go index 3ee7f8ba..cc60a994 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,9 +10,9 @@ import ( "strings" "sync" - "go.wpm.so/cli/pkg/config/configfile" - "github.com/pkg/errors" + + "go.wpm.so/cli/pkg/config/configfile" ) const ( @@ -113,7 +113,7 @@ func load(configDir string) (*configfile.ConfigFile, error) { filename := filepath.Join(configDir, ConfigFileName) configFile := configfile.New(filename) - file, err := os.Open(filename) + file, err := os.Open(filename) //nolint:gosec // filename is built from configDir + a constant if err != nil { if os.IsNotExist(err) { // It is OK for no configuration file to be present, in which @@ -123,7 +123,7 @@ func load(configDir string) (*configfile.ConfigFile, error) { // Any other error happening when failing to read the file must be returned. return configFile, errors.Wrap(err, "loading config file") } - defer file.Close() + defer func() { _ = file.Close() }() err = configFile.LoadFromReader(file) if err != nil { err = errors.Wrapf(err, "parsing config file (%s)", filename) diff --git a/pkg/config/configfile/file.go b/pkg/config/configfile/file.go index a55d7cb2..19808073 100644 --- a/pkg/config/configfile/file.go +++ b/pkg/config/configfile/file.go @@ -124,7 +124,7 @@ func (configFile *ConfigFile) SaveToWriter(writer io.Writer) error { } } - data, err := json.MarshalIndent(configFile, "", "\t") + data, err := json.MarshalIndent(configFile, "", "\t") //nolint:gosec // serializing AuthToken is the entire purpose of writing the config file if err != nil { return err } @@ -147,7 +147,7 @@ func (configFile *ConfigFile) Save() (retErr error) { return err } defer func() { - temp.Close() + _ = temp.Close() if retErr != nil { if err := os.Remove(temp.Name()); err != nil { logrus.WithError(err).WithField("file", temp.Name()).Debug("Error cleaning up temp file") diff --git a/pkg/jsonpretty/format.go b/pkg/jsonpretty/format.go index f695c923..b0f5aec1 100644 --- a/pkg/jsonpretty/format.go +++ b/pkg/jsonpretty/format.go @@ -41,93 +41,128 @@ func Format(w io.Writer, r io.Reader, indent string, colorize bool) error { return err } - switch tt := t.(type) { - case json.Delim: - switch tt { - case '{', '[': - stack = append(stack, tt) - idx = 0 - if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil { - return err - } - if dec.More() { - if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack))); err != nil { - return err - } - } - continue - case '}', ']': - stack = stack[:len(stack)-1] - idx = 0 - if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil { - return err - } - } - default: - b, err := marshalJSON(tt) - if err != nil { - return err - } - - isKey := len(stack) > 0 && stack[len(stack)-1] == '{' && idx%2 == 0 - idx++ - - var color string - if isKey { - color = colorKey - } else if tt == nil { - color = colorNull - } else { - switch t.(type) { - case string: - color = colorString - case bool: - color = colorBool - } - } + skipTrailing, isKey, err := processToken(w, dec, t, c, indent, &stack, &idx) + if err != nil { + return err + } - if color != "" { - if _, err := fmt.Fprint(w, c(color)); err != nil { - return err - } - } - if _, err := w.Write(b); err != nil { - return err - } - if color != "" { - if _, err := fmt.Fprint(w, c(colorReset)); err != nil { - return err - } - } + if skipTrailing || isKey { + continue + } - if isKey { - if _, err := fmt.Fprint(w, c(colorDelim), ":", c(colorReset), " "); err != nil { - return err - } - continue - } + if err := processTrailing(w, dec, c, indent, stack); err != nil { + return err } + } + + return nil +} + +func processToken(w io.Writer, dec *json.Decoder, t json.Token, c func(string) string, indent string, stack *[]json.Delim, idx *int) (skipTrailing, isKey bool, err error) { + switch tt := t.(type) { + case json.Delim: + err = processDelim(w, dec, tt, c, indent, stack, idx) + return tt == '{' || tt == '[', false, err + default: + isKey, err = processValue(w, tt, t, c, *stack, idx) + return false, isKey, err + } +} +func processDelim(w io.Writer, dec *json.Decoder, tt json.Delim, c func(string) string, indent string, stack *[]json.Delim, idx *int) error { + switch tt { + case '{', '[': + *stack = append(*stack, tt) + *idx = 0 + if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil { + return err + } if dec.More() { - if _, err := fmt.Fprint(w, c(colorDelim), ",", c(colorReset), "\n", strings.Repeat(indent, len(stack))); err != nil { - return err - } - } else if len(stack) > 0 { - if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1)); err != nil { - return err - } - } else { - if _, err := fmt.Fprint(w, "\n"); err != nil { + if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(*stack))); err != nil { return err } } + case '}', ']': + *stack = (*stack)[:len(*stack)-1] + *idx = 0 + if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil { + return err + } + } + return nil +} + +func processValue(w io.Writer, tt any, t json.Token, c func(string) string, stack []json.Delim, idx *int) (isKey bool, err error) { + b, err := marshalJSON(tt) + if err != nil { + return false, err + } + + isKey = len(stack) > 0 && stack[len(stack)-1] == '{' && *idx%2 == 0 + *idx++ + + color := selectColor(isKey, tt, t) + + if color != "" { + if _, err := fmt.Fprint(w, c(color)); err != nil { + return false, err + } + } + if _, err := w.Write(b); err != nil { + return false, err + } + if color != "" { + if _, err := fmt.Fprint(w, c(colorReset)); err != nil { + return false, err + } + } + + if isKey { + if _, err := fmt.Fprint(w, c(colorDelim), ":", c(colorReset), " "); err != nil { + return false, err + } + return true, nil } + return false, nil +} + +func selectColor(isKey bool, tt any, t json.Token) string { + switch { + case isKey: + return colorKey + case tt == nil: + return colorNull + default: + switch t.(type) { + case string: + return colorString + case bool: + return colorBool + } + } + return "" +} +func processTrailing(w io.Writer, dec *json.Decoder, c func(string) string, indent string, stack []json.Delim) error { + switch { + case dec.More(): + if _, err := fmt.Fprint(w, c(colorDelim), ",", c(colorReset), "\n", strings.Repeat(indent, len(stack))); err != nil { + return err + } + case len(stack) > 0: + if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1)); err != nil { + return err + } + default: + if _, err := fmt.Fprint(w, "\n"); err != nil { + return err + } + } return nil } // marshalJSON works like json.Marshal, but with HTML-escaping disabled. -func marshalJSON(v interface{}) ([]byte, error) { +func marshalJSON(v any) ([]byte, error) { buf := bytes.Buffer{} enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) diff --git a/pkg/pm/installer/installer.go b/pkg/pm/installer/installer.go index 17637cb3..a5628527 100644 --- a/pkg/pm/installer/installer.go +++ b/pkg/pm/installer/installer.go @@ -16,14 +16,14 @@ import ( "syscall" "time" + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + "go.wpm.so/cli/pkg/archive" "go.wpm.so/cli/pkg/pm/registry" "go.wpm.so/cli/pkg/pm/signatures" "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmjson/validator" - - "github.com/pkg/errors" - "golang.org/x/sync/errgroup" ) const ( @@ -55,11 +55,13 @@ func New( concurrency = 16 } + //nolint:gosec // Dir perms are intentionally permissive here. if err := os.MkdirAll(contentDir, 0o755); err != nil { return nil, errors.Wrap(err, "failed to create content directory") } tmpDir := filepath.Join(contentDir, ".tmp") + //nolint:gosec // Dir perms are intentionally permissive here. if err := os.MkdirAll(tmpDir, 0o755); err != nil { return nil, errors.Wrap(err, "failed to create tmp directory") } @@ -173,7 +175,9 @@ func (i *Installer) installOrUpdate(ctx context.Context, action Action, targetDi if err != nil { return errors.Wrapf(err, "failed to download %s", action.Resolved) } - defer resp.Close() + defer func() { + _ = resp.Close() + }() hasher := sha256.New() stream := io.TeeReader(resp, hasher) @@ -230,6 +234,7 @@ func (i *Installer) unpackToStaging(r io.Reader) (string, string, error) { } func (i *Installer) replaceDir(ctx context.Context, sourceDir, targetDir string) error { + //nolint:gosec // Dir perms are intentionally permissive here. if err := os.MkdirAll(filepath.Dir(targetDir), 0o755); err != nil { return errors.Wrap(err, "failed to create parent directory") } @@ -278,7 +283,7 @@ func (i *Installer) rename(ctx context.Context, src, dst string) error { }, isRetriableError) } -func (i *Installer) removeAll(ctx context.Context, path string) error { +func (*Installer) removeAll(ctx context.Context, path string) error { if path == "" { return nil } @@ -368,12 +373,16 @@ func (i *Installer) getTargetDir(pkgType types.PackageType, name string) (string return "", errors.Wrapf(err, "refusing to operate on package with invalid name %q", name) } - subDir := "plugins" + var subDir string switch pkgType { case types.TypeTheme: subDir = "themes" case types.TypeMuPlugin: subDir = "mu-plugins" + case types.TypePlugin: + subDir = "plugins" + default: + return "", errors.Errorf("unknown package type %q for package %q", pkgType, name) } target := filepath.Join(i.contentDir, subDir, name) diff --git a/pkg/pm/installer/plan.go b/pkg/pm/installer/plan.go index 8733a3e4..27ccdc4f 100644 --- a/pkg/pm/installer/plan.go +++ b/pkg/pm/installer/plan.go @@ -47,28 +47,13 @@ func CalculatePlan( for name, node := range resolved { seen[name] = true - shouldInstall := true - if noDev && !prodSet[name] { - shouldInstall = false - } - - // Calculate target path to check filesystem state - subDir := "plugins" - switch node.Type { - case types.TypeTheme: - subDir = "themes" - case types.TypeMuPlugin: - subDir = "mu-plugins" - } - targetPath := filepath.Join(contentDir, subDir, name) - - exists := false - if _, err := os.Stat(targetPath); err == nil { - exists = true + subDir, ok := subDirForType(node.Type) + if !ok { + continue } + exists := pathExists(filepath.Join(contentDir, subDir, name)) - // If we shouldn't install it (dev dep in --no-dev mode) - if !shouldInstall { + if noDev && !prodSet[name] { if exists { // It exists on disk but shouldn't be there -> Remove actions = append(actions, Action{ @@ -80,44 +65,8 @@ func CalculatePlan( continue } - // Check if package exists in lockfile - if oldPkg, ok := lock.Packages[name]; ok { - // Update if version or digest has changed - if oldPkg.Version != node.Version || oldPkg.Digest != node.Digest { - actions = append(actions, Action{ - Type: ActionUpdate, - Name: name, - Version: node.Version, - Resolved: node.Resolved, - Digest: node.Digest, - PkgType: node.Type, - }) - continue - } - - if !exists { - actions = append(actions, Action{ - Type: ActionInstall, - Name: name, - Version: node.Version, - Resolved: node.Resolved, - Digest: node.Digest, - PkgType: node.Type, - }) - } - - // If it exists and matches lockfile, do nothing (NoOp) - - } else { - // New package -> Install - actions = append(actions, Action{ - Type: ActionInstall, - Name: name, - Version: node.Version, - Resolved: node.Resolved, - Digest: node.Digest, - PkgType: node.Type, - }) + if action, hasAction := resolveAction(name, node, lock, exists); hasAction { + actions = append(actions, action) } } @@ -136,6 +85,66 @@ func CalculatePlan( return actions } +// subDirForType maps a package type to its content sub-directory. Returns false for unknown types. +func subDirForType(t types.PackageType) (string, bool) { + switch t { + case types.TypeTheme: + return "themes", true + case types.TypeMuPlugin: + return "mu-plugins", true + case types.TypePlugin: + return "plugins", true + default: + return "", false + } +} + +func pathExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// resolveAction picks the Action (if any) for a resolved package by comparing it +// against lockfile state and on-disk presence. Returns (zero, false) for NoOp. +func resolveAction(name string, node resolution.Node, lock *wpmlock.Lockfile, exists bool) (Action, bool) { + oldPkg, inLock := lock.Packages[name] + if !inLock { + return Action{ + Type: ActionInstall, + Name: name, + Version: node.Version, + Resolved: node.Resolved, + Digest: node.Digest, + PkgType: node.Type, + }, true + } + + if oldPkg.Version != node.Version || oldPkg.Digest != node.Digest { + return Action{ + Type: ActionUpdate, + Name: name, + Version: node.Version, + Resolved: node.Resolved, + Digest: node.Digest, + PkgType: node.Type, + }, true + } + + if !exists { + return Action{ + Type: ActionInstall, + Name: name, + Version: node.Version, + Resolved: node.Resolved, + Digest: node.Digest, + PkgType: node.Type, + }, true + } + + // Exists on disk and matches lockfile -> NoOp + return Action{}, false +} + // getProdDependencies returns a set of all production dependencies and their transitive dependencies. func getProdDependencies(root *wpmjson.Config, resolved map[string]resolution.Node) map[string]bool { prodSet := make(map[string]bool) diff --git a/pkg/pm/registry/client.go b/pkg/pm/registry/client.go index 94c47b5f..e1d425e9 100644 --- a/pkg/pm/registry/client.go +++ b/pkg/pm/registry/client.go @@ -57,7 +57,7 @@ func New(host, authToken, userAgent, cacheDir string, colorize bool, out io.Writ // PutPackage uploads a package to the registry func (c *client) PutPackage(ctx context.Context, data *manifest.Package, tarball io.Reader) error { - manifest, err := json.Marshal(data) + manifestBytes, err := json.Marshal(data) if err != nil { return err } @@ -67,13 +67,13 @@ func (c *client) PutPackage(ctx context.Context, data *manifest.Package, tarball tarball, nil, api.WithHeader(api.HeaderContentType, contentTypeOctetStream), - api.WithHeader(wpmManifestHeader, base64.StdEncoding.EncodeToString(manifest)), + api.WithHeader(wpmManifestHeader, base64.StdEncoding.EncodeToString(manifestBytes)), ) } // GetPackageManifest retrieves a package manifest from the registry func (c *client) GetPackageManifest(ctx context.Context, packageName, versionOrTag string, force bool) (*manifest.Package, error) { - var manifest *manifest.Package + var pkg *manifest.Package if versionOrTag == "" || versionOrTag == "*" { versionOrTag = "latest" @@ -86,7 +86,7 @@ func (c *client) GetPackageManifest(ctx context.Context, packageName, versionOrT err := c.restClient.Get( "/"+packageName+"/"+versionOrTag, - &manifest, + &pkg, api.WithHeader(header, "true"), // Used by cache round tripper. api.WithHeader(api.HeaderAccept, wpmContentTypeManifestV1), ) @@ -94,7 +94,7 @@ func (c *client) GetPackageManifest(ctx context.Context, packageName, versionOrT return nil, err } - return manifest, nil + return pkg, nil } // DownloadTarball downloads a package tarball from the registry diff --git a/pkg/pm/resolution/resolver.go b/pkg/pm/resolution/resolver.go index 940d0bfe..044f1d8b 100644 --- a/pkg/pm/resolution/resolver.go +++ b/pkg/pm/resolution/resolver.go @@ -4,16 +4,17 @@ import ( "context" "fmt" "io" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" "go.wpm.so/cli/pkg/pm/registry" "go.wpm.so/cli/pkg/pm/wpmjson" "go.wpm.so/cli/pkg/pm/wpmjson/manifest" "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmlock" - - "github.com/Masterminds/semver/v3" - "github.com/pkg/errors" - "golang.org/x/sync/errgroup" ) type Node struct { @@ -60,7 +61,7 @@ type fetchResult struct { func (r *Resolver) Resolve(ctx context.Context, progress ProgressReporter, w io.Writer) (map[string]Node, error) { resolved := make(map[string]Node) - queue := make([]dependencyRequest, 0) + queue := r.seedQueue() progress.StartProgressIndicator(w) defer func() { @@ -68,109 +69,128 @@ func (r *Resolver) Resolve(ctx context.Context, progress ProgressReporter, w io. progress.StopProgressIndicator() }() - // Seed the queue with root dependencies + for len(queue) > 0 { + uniqueRequests := dedupeRequests(queue, resolved) + queue = nil // clear queue for next iteration + + results, err := r.fetchAll(ctx, uniqueRequests, progress, w) + if err != nil { + return nil, err + } + + for _, res := range results { + children, err := r.applyResult(res, resolved) + if err != nil { + return nil, err + } + queue = append(queue, children...) + } + } + + return resolved, nil +} + +func (r *Resolver) seedQueue() []dependencyRequest { + var queue []dependencyRequest if r.rootConfig.Dependencies != nil { for name, version := range *r.rootConfig.Dependencies { - queue = append(queue, dependencyRequest{ - name: name, - version: version, - requestor: "", - }) + queue = append(queue, dependencyRequest{name: name, version: version, requestor: ""}) } } if r.rootConfig.DevDependencies != nil { for name, version := range *r.rootConfig.DevDependencies { - queue = append(queue, dependencyRequest{ - name: name, - version: version, - requestor: "", - }) + queue = append(queue, dependencyRequest{name: name, version: version, requestor: ""}) } } + return queue +} - for len(queue) > 0 { - uniqueRequests := make(map[string]dependencyRequest) - for _, req := range queue { - // If already resolved with the same version, skip - if exists, ok := resolved[req.name]; ok && exists.Version == req.version { - continue - } - - uniqueRequests[req.name+"@"+req.version] = req +// dedupeRequests drops requests already satisfied at the same version +// and folds identical name@version pairs in this iteration into one entry. +func dedupeRequests(queue []dependencyRequest, resolved map[string]Node) map[string]dependencyRequest { + uniqueRequests := make(map[string]dependencyRequest) + for _, req := range queue { + if exists, ok := resolved[req.name]; ok && exists.Version == req.version { + continue } + uniqueRequests[req.name+"@"+req.version] = req + } + return uniqueRequests +} - queue = nil // Clear queue for next iteration +// fetchAll fetches metadata for every request concurrently and returns the collected results. +func (r *Resolver) fetchAll(ctx context.Context, requests map[string]dependencyRequest, progress ProgressReporter, w io.Writer) ([]fetchResult, error) { + results := make(chan fetchResult, len(requests)) + g, gtx := errgroup.WithContext(ctx) + g.SetLimit(16) + + count := 0 + for _, req := range requests { + count++ + progress.Stream(w, fmt.Sprintf(" Resolving %s@%s [%d/%d]", req.name, req.version, count, len(requests))) + + g.Go(func() error { + manifest, err := r.fetchMetadata(gtx, req.name, req.version) + results <- fetchResult{req: req, manifest: manifest, err: err} + return nil + }) + } - results := make(chan fetchResult, len(uniqueRequests)) - g, ctx := errgroup.WithContext(ctx) - g.SetLimit(16) // Limit concurrent fetches + if err := g.Wait(); err != nil { + return nil, err + } + close(results) - count := 0 - for _, req := range uniqueRequests { - count++ + collected := make([]fetchResult, 0, len(requests)) + for res := range results { + collected = append(collected, res) + } + return collected, nil +} - progress.Stream(w, fmt.Sprintf(" Resolving %s@%s [%d/%d]", req.name, req.version, count, len(uniqueRequests))) +// applyResult validates and registers a single fetch result into `resolved`, +// returning any newly discovered child dependencies to enqueue. +func (r *Resolver) applyResult(res fetchResult, resolved map[string]Node) ([]dependencyRequest, error) { + if res.err != nil { + return nil, fmt.Errorf("failed to fetch metadata for %s@%s required by %s: %w", res.req.name, res.req.version, res.req.requestor, res.err) + } - g.Go(func() error { - manifest, err := r.fetchMetadata(ctx, req.name, req.version) - results <- fetchResult{req: req, manifest: manifest, err: err} - return nil - }) + if existing, ok := resolved[res.req.name]; ok { + if existing.Version == res.req.version { + return nil, nil } - - if err := g.Wait(); err != nil { + if err := r.resolveConflict(res.req, existing); err != nil { return nil, err } - close(results) - - for res := range results { - if res.err != nil { - return nil, fmt.Errorf("failed to fetch metadata for %s@%s required by %s: %w", res.req.name, res.req.version, res.req.requestor, res.err) - } - - // --- Conflict Resolution --- - if existing, ok := resolved[res.req.name]; ok { - if existing.Version == res.req.version { - continue // Same version already resolved - } - - if err := r.resolveConflict(res.req, existing); err != nil { - return nil, err - } - - continue - } - - // -- Runtime Compatibility Check --- - if err := r.checkRuntimeCompatibility(res.manifest); err != nil { - return nil, fmt.Errorf( - "package %s@%s incompatible:\n"+ - " %w", - res.req.name, res.req.version, err, - ) - } + return nil, nil + } - // Add to resolved map - resolved[res.req.name] = Node{ - Name: res.manifest.Name, - Version: res.manifest.Version, - Type: res.manifest.Type, - Resolved: "/" + res.manifest.Name + "/" + res.manifest.Version + ".tar.zst", - Digest: res.manifest.Dist.Digest, - Bin: res.manifest.Bin, - Dependencies: res.manifest.Dependencies, - } + if err := r.checkRuntimeCompatibility(res.manifest); err != nil { + return nil, fmt.Errorf( + "package %s@%s incompatible:\n"+ + " %w", + res.req.name, res.req.version, err, + ) + } - // Enqueue child dependencies - if res.manifest.Dependencies != nil { - for name, version := range *res.manifest.Dependencies { - queue = append(queue, dependencyRequest{name, version, res.req.name}) - } - } - } + resolved[res.req.name] = Node{ + Name: res.manifest.Name, + Version: res.manifest.Version, + Type: res.manifest.Type, + Resolved: "/" + res.manifest.Name + "/" + res.manifest.Version + ".tar.zst", + Digest: res.manifest.Dist.Digest, + Bin: res.manifest.Bin, + Dependencies: res.manifest.Dependencies, } - return resolved, nil + if res.manifest.Dependencies == nil { + return nil, nil + } + children := make([]dependencyRequest, 0, len(*res.manifest.Dependencies)) + for name, version := range *res.manifest.Dependencies { + children = append(children, dependencyRequest{name, version, res.req.name}) + } + return children, nil } type ResolutionError struct { @@ -181,9 +201,13 @@ type ResolutionError struct { func (e *ResolutionError) Error() string { msg := e.Header + "\n" + var builder strings.Builder for _, d := range e.Detail { - msg += " " + d + "\n" + builder.WriteString(" ") + builder.WriteString(d) + builder.WriteString("\n") } + msg += builder.String() msg += "Action: " + e.Action return msg } @@ -226,7 +250,7 @@ func (r *Resolver) resolveConflict(req dependencyRequest, existing Node) error { return &ResolutionError{ Header: fmt.Sprintf("Version downgrade detected for package %s:", req.name), Detail: []string{ - fmt.Sprintf("currently resolved: %s", rootVersion), + "currently resolved: " + rootVersion, fmt.Sprintf("%s requires: %s", req.requestor, req.version), }, Action: fmt.Sprintf("Upgrade %s in your wpm.json to %s or higher.", req.name, req.version), @@ -241,15 +265,15 @@ func (r *Resolver) resolveConflict(req dependencyRequest, existing Node) error { return &ResolutionError{ Header: fmt.Sprintf("Dependency version conflict for package %s:", req.name), Detail: []string{ - fmt.Sprintf("currently resolved: %s", existing.Version), + "currently resolved: " + existing.Version, fmt.Sprintf("%s requires: %s", req.requestor, req.version), }, Action: fmt.Sprintf(`Add "%s": "%s" (or %s) to the root wpm.json to force a resolution.`, req.name, req.version, existing.Version), } } -func (r *Resolver) checkRuntimeCompatibility(manifest *manifest.Package) error { - if manifest == nil { +func (r *Resolver) checkRuntimeCompatibility(pkg *manifest.Package) error { + if pkg == nil { return errors.New("manifest is nil") } @@ -259,13 +283,13 @@ func (r *Resolver) checkRuntimeCompatibility(manifest *manifest.Package) error { } // If manifest has no requirements, skip - if manifest.Requires == nil { + if pkg.Requires == nil { return nil } // PHP and WordPress version constraints - requiresWP := manifest.Requires.WP - requiresPHP := manifest.Requires.PHP + requiresWP := pkg.Requires.WP + requiresPHP := pkg.Requires.PHP // PHP and WordPress runtime versions runtimeWP := r.rootConfig.Config.Runtime.WP diff --git a/pkg/pm/signatures/signatures.go b/pkg/pm/signatures/signatures.go index 96ccbed0..d0718a6b 100644 --- a/pkg/pm/signatures/signatures.go +++ b/pkg/pm/signatures/signatures.go @@ -28,7 +28,7 @@ type keyJson struct { type KeysJson []keyJson // Verify verifies a Base64 encoded ASN.1 DER signature against a message using a PEM encoded Public Key. -func Verify(keys KeysJson, keyId string, signatureBase64 string, originalMessage []byte) error { +func Verify(keys KeysJson, keyId, signatureBase64 string, originalMessage []byte) error { var rawPublicKeyBase64, keyType string for _, key := range keys { if key.KeyID == keyId { @@ -66,13 +66,13 @@ func Verify(keys KeysJson, keyId string, signatureBase64 string, originalMessage return fmt.Errorf("failed to decode base64 signature: %v", err) } - var sig sig - if _, err := asn1.Unmarshal(sigBytes, &sig); err != nil { + var s sig + if _, err := asn1.Unmarshal(sigBytes, &s); err != nil { return fmt.Errorf("failed to unmarshal ASN.1 signature: %v", err) } hash := sha256.Sum256(originalMessage) - valid := ecdsa.Verify(publicKey, hash[:], sig.R, sig.S) + valid := ecdsa.Verify(publicKey, hash[:], s.R, s.S) if !valid { return errors.New("signature verification failed: invalid signature") } diff --git a/pkg/pm/workspace/lock.go b/pkg/pm/workspace/lock.go index 5b13dd3c..d517bb23 100644 --- a/pkg/pm/workspace/lock.go +++ b/pkg/pm/workspace/lock.go @@ -20,11 +20,11 @@ type ProjectLock struct { // read-modify-write window. func AcquireLock(ctx context.Context, baseDir string, printWaitMsg func()) (*ProjectLock, error) { wpmDir := filepath.Join(baseDir, ".wpm") - if err := os.MkdirAll(wpmDir, 0o755); err != nil { + if err := os.MkdirAll(wpmDir, 0o750); err != nil { return nil, errors.Wrap(err, "failed to create workspace directory") } - _ = os.WriteFile(filepath.Join(wpmDir, ".gitignore"), []byte("*\n"), 0o644) + _ = os.WriteFile(filepath.Join(wpmDir, ".gitignore"), []byte("*\n"), 0o600) fileLock := flock.New(filepath.Join(wpmDir, "install.lock")) diff --git a/pkg/pm/wpmignore/wpmignore.go b/pkg/pm/wpmignore/wpmignore.go index 4ce48662..b523de3e 100644 --- a/pkg/pm/wpmignore/wpmignore.go +++ b/pkg/pm/wpmignore/wpmignore.go @@ -13,14 +13,14 @@ import ( func ReadWpmIgnore(path string) ([]string, error) { var excludes []string - f, err := os.Open(filepath.Join(path, ".wpmignore")) + f, err := os.Open(filepath.Join(path, ".wpmignore")) //nolint:gosec // .wpmignore is a constant relative to the caller-supplied path switch { case os.IsNotExist(err): return excludes, nil case err != nil: return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() patterns, err := ignorefile.ReadAll(f) if err != nil { diff --git a/pkg/pm/wpmjson/types/types.go b/pkg/pm/wpmjson/types/types.go index b82843d0..9500db84 100644 --- a/pkg/pm/wpmjson/types/types.go +++ b/pkg/pm/wpmjson/types/types.go @@ -77,6 +77,8 @@ type Requires struct { PHP string `json:"php,omitempty"` } -type Bin map[string]string -type Scripts map[string]string -type Dependencies map[string]string +type ( + Bin map[string]string + Scripts map[string]string + Dependencies map[string]string +) diff --git a/pkg/pm/wpmjson/validator/errors.go b/pkg/pm/wpmjson/validator/errors.go index 0d0aa4e3..f7a8c74d 100644 --- a/pkg/pm/wpmjson/validator/errors.go +++ b/pkg/pm/wpmjson/validator/errors.go @@ -26,11 +26,11 @@ func (e *ErrorList) Add(field string, err error) { } // AddMsg allows adding a string message directly. -func (e *ErrorList) AddMsg(field string, msg string) { +func (e *ErrorList) AddMsg(field, msg string) { *e = append(*e, ValidationError{Field: field, Message: msg}) } -// Merge combines another error (single or ErrorList) into this list. +// MustMerge merges another error into the list, panicking if the error is not an ErrorList. func (e *ErrorList) MustMerge(err error) { if err == nil { return diff --git a/pkg/pm/wpmjson/validator/validator.go b/pkg/pm/wpmjson/validator/validator.go index f8ef67e6..157870d5 100644 --- a/pkg/pm/wpmjson/validator/validator.go +++ b/pkg/pm/wpmjson/validator/validator.go @@ -1,6 +1,7 @@ package validator import ( + "errors" "fmt" "net/url" "path/filepath" @@ -8,25 +9,23 @@ import ( "strings" "unicode" - "go.wpm.so/cli/pkg/pm/wpmjson/types" - "github.com/Masterminds/semver/v3" -) -var ( - packageNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + "go.wpm.so/cli/pkg/pm/wpmjson/types" ) +var packageNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + // IsValidPackageName checks if the package name adheres to naming conventions. func IsValidPackageName(name string) error { if len(name) < 3 { - return fmt.Errorf("must be at least 3 characters") + return errors.New("must be at least 3 characters") } if len(name) > 164 { - return fmt.Errorf("must be at most 164 characters") + return errors.New("must be at most 164 characters") } if !packageNameRegex.MatchString(name) { - return fmt.Errorf("must consist of lowercase alphanumeric characters separated by hyphens") + return errors.New("must consist of lowercase alphanumeric characters separated by hyphens") } return nil } @@ -34,7 +33,7 @@ func IsValidPackageName(name string) error { // IsValidDistTag checks if the dist tag is valid. func IsValidDistTag(tag string) error { if len(tag) > 64 { - return fmt.Errorf("must be at most 64 characters") + return errors.New("must be at most 64 characters") } return IsValidPackageName(tag) @@ -43,7 +42,7 @@ func IsValidDistTag(tag string) error { // IsValidPackageType checks if the package type is valid. func IsValidPackageType(t types.PackageType) error { if !t.Valid() { - return fmt.Errorf("must be one of: theme, plugin, or mu-plugin") + return errors.New("must be one of: theme, plugin, or mu-plugin") } return nil } @@ -51,19 +50,19 @@ func IsValidPackageType(t types.PackageType) error { // IsValidVersion checks if the version string is a valid semantic version. func IsValidVersion(v string) error { if v == "" { - return fmt.Errorf("cannot be empty") + return errors.New("cannot be empty") } if len(v) < 5 { - return fmt.Errorf("must be at least 5 characters") + return errors.New("must be at least 5 characters") } if len(v) > 64 { - return fmt.Errorf("must be at most 64 characters") + return errors.New("must be at most 64 characters") } if strings.HasPrefix(v, "v") { - return fmt.Errorf("cannot start with 'v'") + return errors.New("cannot start with 'v'") } if _, err := semver.StrictNewVersion(v); err != nil { - return fmt.Errorf("must be a valid semantic version (X.Y.Z)") + return errors.New("must be a valid semantic version (X.Y.Z)") } return nil } @@ -71,7 +70,7 @@ func IsValidVersion(v string) error { // IsValidDescription checks if the description meets length requirements. func IsValidDescription(desc string) error { if len(desc) < 3 || len(desc) > 512 { - return fmt.Errorf("must be between 3 and 512 characters") + return errors.New("must be between 3 and 512 characters") } return IsSafeString(desc) @@ -80,7 +79,7 @@ func IsValidDescription(desc string) error { // IsValidLicense checks if the license string meets length requirements. func IsValidLicense(license string) error { if len(license) < 3 || len(license) > 100 { - return fmt.Errorf("must be between 3 and 100 characters") + return errors.New("must be between 3 and 100 characters") } return IsSafeString(license) } @@ -88,16 +87,16 @@ func IsValidLicense(license string) error { // IsValidHomepage checks if the homepage string is a valid URL. func IsValidHomepage(homepage string) error { if len(homepage) < 10 || len(homepage) > 200 { - return fmt.Errorf("must be between 10 and 200 characters") + return errors.New("must be between 10 and 200 characters") } u, err := url.Parse(homepage) if err != nil { - return fmt.Errorf("must be a valid URL") + return errors.New("must be a valid URL") } if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("URL scheme must be http or https") + return errors.New("URL scheme must be http or https") } return nil @@ -106,16 +105,16 @@ func IsValidHomepage(homepage string) error { // IsValidConstraint checks if the version constraint string is valid. func IsValidConstraint(v string) error { if v == "" { - return fmt.Errorf("constraint cannot be empty") + return errors.New("constraint cannot be empty") } if v == "*" { return nil } if strings.HasPrefix(v, "v") { - return fmt.Errorf("constraint cannot start with 'v'") + return errors.New("constraint cannot start with 'v'") } if _, err := semver.NewConstraint(v); err != nil { - return fmt.Errorf("invalid version constraint") + return errors.New("invalid version constraint") } return nil } @@ -124,13 +123,13 @@ func IsValidConstraint(v string) error { func IsSafeString(s string) error { for _, r := range s { if r == '\u2028' || r == '\u2029' || r == '\uFFFD' || r == '\uFFFC' || r == '\u3164' { - return fmt.Errorf("contains invalid unicode characters") + return errors.New("contains invalid unicode characters") } if unicode.IsControl(r) { if r == '\t' || r == '\n' || r == '\r' || r == '\u200D' || (r >= 0xE0020 && r <= 0xE007F) { continue } - return fmt.Errorf("contains invalid control characters") + return errors.New("contains invalid control characters") } } return nil @@ -238,14 +237,14 @@ func ValidateRequires(wp, php string) error { // IsValidProjectRelPath checks that a path is relative, non-empty, and stays within the project root. func IsValidProjectRelPath(p string) error { if p == "" { - return fmt.Errorf("must not be empty") + return errors.New("must not be empty") } if filepath.IsAbs(p) { - return fmt.Errorf("must be a relative path") + return errors.New("must be a relative path") } cleaned := filepath.Clean(p) if !filepath.IsLocal(cleaned) { - return fmt.Errorf("must not contain '..' or escape the project directory") + return errors.New("must not contain '..' or escape the project directory") } return nil } diff --git a/pkg/pm/wpmjson/wpmjson.go b/pkg/pm/wpmjson/wpmjson.go index 97d54c1f..148e90e6 100644 --- a/pkg/pm/wpmjson/wpmjson.go +++ b/pkg/pm/wpmjson/wpmjson.go @@ -6,11 +6,11 @@ import ( "os" "path/filepath" + "github.com/pkg/errors" + "go.wpm.so/cli/pkg/pm" "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmjson/validator" - - "github.com/pkg/errors" ) const ConfigFile = "wpm.json" @@ -157,7 +157,7 @@ func Read(cwd string) (*Config, error) { return nil, nil } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // path is cwd + ConfigFile constant if err != nil { return nil, errors.Wrap(err, "failed to read wpm.json") } @@ -183,7 +183,7 @@ func (c *Config) Write(cwd string) error { } // Write with 0644 permissions (rw-r--r--) - if err := os.WriteFile(path, data, 0644); err != nil { + if err := os.WriteFile(path, data, 0o644); err != nil { return errors.Wrap(err, "failed to write wpm.json to disk") } diff --git a/pkg/pm/wpmlock/lockfile.go b/pkg/pm/wpmlock/lockfile.go index 0b1bfa81..ef21e21d 100644 --- a/pkg/pm/wpmlock/lockfile.go +++ b/pkg/pm/wpmlock/lockfile.go @@ -5,11 +5,11 @@ import ( "os" "path/filepath" + "github.com/pkg/errors" + "go.wpm.so/cli/pkg/pm" "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmjson/validator" - - "github.com/pkg/errors" ) const ( @@ -50,7 +50,7 @@ func Read(cwd string) (*Lockfile, error) { return nil, nil } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // path is cwd + LockfileName constant if err != nil { return nil, errors.Wrap(err, "failed to read lockfile") } @@ -96,7 +96,7 @@ func (l *Lockfile) Write(cwd string) error { } // Write with 0644 permissions (rw-r--r--) - if err := os.WriteFile(path, data, 0644); err != nil { + if err := os.WriteFile(path, data, 0o644); err != nil { return errors.Wrap(err, "failed to write lockfile to disk") } diff --git a/pkg/progress/progress.go b/pkg/progress/progress.go index b5a51406..adeb619e 100644 --- a/pkg/progress/progress.go +++ b/pkg/progress/progress.go @@ -5,9 +5,9 @@ import ( "sync" "time" - "go.wpm.so/cli/pkg/unsafeconv" - "github.com/briandowns/spinner" + + "go.wpm.so/cli/pkg/unsafeconv" ) type Progress struct { diff --git a/pkg/streams/out.go b/pkg/streams/out.go index 80d35b1e..6bc90677 100644 --- a/pkg/streams/out.go +++ b/pkg/streams/out.go @@ -4,10 +4,10 @@ import ( "io" "os" - "go.wpm.so/cli/pkg/unsafeconv" - "github.com/moby/term" "github.com/sirupsen/logrus" + + "go.wpm.so/cli/pkg/unsafeconv" ) // Out is an output stream to write normal program output. It implements @@ -80,7 +80,7 @@ func (o *Out) SetRawTerminal() (err error) { // GetTtySize returns the height and width in characters of the TTY, or // zero for both if no TTY is connected. -func (o *Out) GetTtySize() (height uint, width uint) { +func (o *Out) GetTtySize() (height, width uint) { if !o.isTerminal { return 0, 0 } diff --git a/pkg/version/version.go b/pkg/version/version.go index 9e0b2975..97113f68 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -46,26 +46,7 @@ func Normalize(version string) (string, error) { return "", errors.New("version contains only 'v' prefixes") } - // Isolate the core version from prerelease/build metadata to prevent - // mangling valid semver extensions (e.g., dotted prereleases) below. - core := version - var meta string - - idxPre := strings.IndexByte(version, '-') - idxBld := strings.IndexByte(version, '+') - cutoff := -1 - - if idxPre != -1 { - cutoff = idxPre - } - if idxBld != -1 && (cutoff == -1 || idxBld < cutoff) { - cutoff = idxBld - } - - if cutoff != -1 { - core = version[:cutoff] - meta = version[cutoff:] - } + core, meta := splitVersionMeta(version) // Insert hyphen before an alphabetic qualifier with no separator. // 1.0.0beta1 -> 1.0.0-beta1, 2.1rc1 -> 2.1-rc1, 3.0a -> 3.0-a @@ -77,29 +58,7 @@ func Normalize(version string) (string, error) { core = core[:idx] } - // Collapse 4+ dotted segments into a prerelease, stripping leading zeros - // from any purely numeric segment (semver forbids them in numeric IDs). - // 1.0.0.0 -> 1.0.0-0 - // 1.0.0.01 -> 1.0.0-1 - // 1.0.0.01.02 -> 1.0.0-1.2 - // 1.2.3.4.5 -> 1.2.3-4.5 - // 1.0.0.alpha.1 -> 1.0.0-alpha.1 - // 1.0.0.0beta -> 1.0.0-0beta (mixed; left alone) - if parts := strings.Split(core, "."); len(parts) > 3 { - pre := make([]string, len(parts)-3) - for i, p := range parts[3:] { - pre[i] = stripLeadingZeros(p) - } - core = fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2]) - - // Prepend the collapsed segments to any existing metadata - if meta == "" || meta[0] == '+' { - meta = "-" + strings.Join(pre, ".") + meta - } else { - // Merge with existing prerelease by replacing the initial '-' with a '.' - meta = "-" + strings.Join(pre, ".") + "." + meta[1:] - } - } + core, meta = collapseExtraSegments(core, meta) // Coerce to semver, which will handle leading zeros and short forms. v, err := semver.NewVersion(core + meta) @@ -120,6 +79,57 @@ func Normalize(version string) (string, error) { return result, nil } +// splitVersionMeta isolates the core version from prerelease/build metadata to prevent +// mangling valid semver extensions (e.g., dotted prereleases) downstream. +func splitVersionMeta(version string) (core, meta string) { + idxPre := strings.IndexByte(version, '-') + idxBld := strings.IndexByte(version, '+') + cutoff := -1 + + if idxPre != -1 { + cutoff = idxPre + } + if idxBld != -1 && (cutoff == -1 || idxBld < cutoff) { + cutoff = idxBld + } + + if cutoff == -1 { + return version, "" + } + return version[:cutoff], version[cutoff:] +} + +// collapseExtraSegments collapses 4+ dotted segments into a prerelease, stripping leading +// zeros from any purely numeric segment (semver forbids them in numeric IDs). +// +// 1.0.0.0 -> 1.0.0-0 +// 1.0.0.01 -> 1.0.0-1 +// 1.0.0.01.02 -> 1.0.0-1.2 +// 1.2.3.4.5 -> 1.2.3-4.5 +// 1.0.0.alpha.1 -> 1.0.0-alpha.1 +// 1.0.0.0beta -> 1.0.0-0beta (mixed; left alone) +func collapseExtraSegments(core, meta string) (string, string) { + parts := strings.Split(core, ".") + if len(parts) <= 3 { + return core, meta + } + + pre := make([]string, len(parts)-3) + for i, p := range parts[3:] { + pre[i] = stripLeadingZeros(p) + } + core = fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2]) + + // Prepend the collapsed segments to any existing metadata + if meta == "" || meta[0] == '+' { + meta = "-" + strings.Join(pre, ".") + meta + } else { + // Merge with existing prerelease by replacing the initial '-' with a '.' + meta = "-" + strings.Join(pre, ".") + "." + meta[1:] + } + return core, meta +} + func stripLeadingZeros(s string) string { if !numericIdentifier.MatchString(s) { return s diff --git a/pkg/wp/parser/readme-txt.go b/pkg/wp/parser/readme-txt.go index a848dade..0de06cd8 100644 --- a/pkg/wp/parser/readme-txt.go +++ b/pkg/wp/parser/readme-txt.go @@ -24,9 +24,7 @@ const ( sectionUpgradeNotice = "upgrade_notice" ) -var ( - screenshotLineRegex = regexp.MustCompile(`^(\d+)\.\s+(.+)$`) -) +var screenshotLineRegex = regexp.MustCompile(`^(\d+)\.\s+(.+)$`) type ReadmeParser struct { Name string @@ -87,7 +85,7 @@ func (p *ReadmeParser) Parse(content string) { p.processSpecialSections() } -func (p *ReadmeParser) stripBOM(lines []string) []string { +func (*ReadmeParser) stripBOM(lines []string) []string { if len(lines) > 0 && strings.HasPrefix(lines[0], "\xEF\xBB\xBF") { lines[0] = strings.TrimPrefix(lines[0], "\xEF\xBB\xBF") } @@ -95,13 +93,13 @@ func (p *ReadmeParser) stripBOM(lines []string) []string { } // parsePluginName extracts the plugin name stripping markers. -func (p *ReadmeParser) parsePluginName(line, marker string) string { +func (*ReadmeParser) parsePluginName(line, marker string) string { name := strings.TrimPrefix(line, marker) name = strings.TrimSuffix(name, marker) return strings.TrimSpace(name) } -func (p *ReadmeParser) skipEmptyLines(lines []string, start int) int { +func (*ReadmeParser) skipEmptyLines(lines []string, start int) int { for i := start; i < len(lines); i++ { if strings.TrimSpace(lines[i]) != "" { return i @@ -122,6 +120,29 @@ func parseCommaSeparatedList(value string) []string { return result } +func (p *ReadmeParser) applyHeaderValue(canonicalKey, value string) { + switch canonicalKey { + case "contributors": + p.Contributors = parseCommaSeparatedList(value) + case "tags": + p.Tags = parseCommaSeparatedList(value) + case "requires": + p.Requires = value + case "tested": + p.Tested = value + case "requires_php": + p.RequiresPHP = value + case "stable_tag": + p.StableTag = value + case "license": + p.License = value + case "license_uri": + p.LicenseURI = value + case "donate_link": + p.DonateLink = value + } +} + func (p *ReadmeParser) parseHeaders(lines []string, start int) int { // Map of accepted header strings (lowercase) to a canonical key. validHeaders := map[string]string{ @@ -163,26 +184,7 @@ func (p *ReadmeParser) parseHeaders(lines []string, start int) int { value := strings.TrimSpace(parts[1]) if canonicalKey, ok := validHeaders[key]; ok { - switch canonicalKey { - case "contributors": - p.Contributors = parseCommaSeparatedList(value) - case "tags": - p.Tags = parseCommaSeparatedList(value) - case "requires": - p.Requires = value - case "tested": - p.Tested = value - case "requires_php": - p.RequiresPHP = value - case "stable_tag": - p.StableTag = value - case "license": - p.License = value - case "license_uri": - p.LicenseURI = value - case "donate_link": - p.DonateLink = value - } + p.applyHeaderValue(canonicalKey, value) } } else { // Malformed header (e.g., "Key:" with no value, or ": value"), end of headers. @@ -245,23 +247,22 @@ func (p *ReadmeParser) parseSections(lines []string, start int) { } currentSectionKey = strings.ToLower(strings.ReplaceAll(sectionTitle, " ", "_")) - } else { - if currentSectionKey != "" { - currentContent.WriteString(line + "\n") - } + } else if currentSectionKey != "" { + currentContent.WriteString(line) + currentContent.WriteString("\n") } } saveSection() } // getSectionContent retrieves section content, checking primary and alternative keys. -func (p *ReadmeParser) getSectionContent(primaryKey string, alternateKeys ...string) (content string, keyUsed string, found bool) { - if content, ok := p.Sections[primaryKey]; ok { - return content, primaryKey, true +func (p *ReadmeParser) getSectionContent(primaryKey string, alternateKeys ...string) (content, keyUsed string, found bool) { + if c, ok := p.Sections[primaryKey]; ok { + return c, primaryKey, true } for _, altKey := range alternateKeys { - if content, ok := p.Sections[altKey]; ok { - return content, altKey, true + if c, ok := p.Sections[altKey]; ok { + return c, altKey, true } } return "", "", false @@ -283,7 +284,7 @@ func (p *ReadmeParser) processSpecialSections() { // parseBlockStyleItems parses sections like FAQ or UpgradeNotice. // Handles Classic "= Item Title =" and Markdown "### Item Title". -func (p *ReadmeParser) parseBlockStyleItems(content string) map[string]string { +func (*ReadmeParser) parseBlockStyleItems(content string) map[string]string { itemsMap := make(map[string]string) lines := strings.Split(content, "\n") var currentItemTitle string @@ -314,7 +315,8 @@ func (p *ReadmeParser) parseBlockStyleItems(content string) map[string]string { currentItemTitle = strings.TrimSpace(currentItemTitle) } } else if currentItemTitle != "" { // Only append if we have an active item title - currentItemContent.WriteString(line + "\n") + currentItemContent.WriteString(line) + currentItemContent.WriteString("\n") } } saveItem() @@ -322,8 +324,8 @@ func (p *ReadmeParser) parseBlockStyleItems(content string) map[string]string { } func (p *ReadmeParser) parseScreenshots(content string) { - lines := strings.Split(content, "\n") - for _, line := range lines { + lines := strings.SplitSeq(content, "\n") + for line := range lines { trimmedLine := strings.TrimSpace(line) if trimmedLine == "" { continue @@ -337,7 +339,7 @@ func (p *ReadmeParser) parseScreenshots(content string) { } } -func (p *ReadmeParser) convertSubsections(content string) string { +func (*ReadmeParser) convertSubsections(content string) string { lines := strings.Split(content, "\n") var result strings.Builder for i, line := range lines { @@ -345,7 +347,8 @@ func (p *ReadmeParser) convertSubsections(content string) string { if strings.HasPrefix(trimmedLine, "=") && strings.HasSuffix(trimmedLine, "=") && !strings.HasPrefix(trimmedLine, "==") && len(trimmedLine) >= 3 { subsectionTitle := strings.TrimSpace(trimmedLine[1 : len(trimmedLine)-1]) - result.WriteString("### " + subsectionTitle) + result.WriteString("### ") + result.WriteString(subsectionTitle) } else { result.WriteString(line) } @@ -372,8 +375,11 @@ func renderFAQMarkdown(parser *ReadmeParser, _ string, md *strings.Builder) bool // Sort carefully to keep things somewhat stable sort.Strings(questions) for _, question := range questions { - md.WriteString("### " + question + "\n\n") - md.WriteString(parser.FAQ[question] + "\n\n") + md.WriteString("### ") + md.WriteString(question) + md.WriteString("\n\n") + md.WriteString(parser.FAQ[question]) + md.WriteString("\n\n") } return true } @@ -392,7 +398,9 @@ func renderScreenshotsMarkdown(parser *ReadmeParser, _ string, md *strings.Build title := parser.Screenshots[k] safeAltTitle := strings.ReplaceAll(title, "\"", """) - md.WriteString("### " + title + "\n") + md.WriteString("### ") + md.WriteString(title) + md.WriteString("\n") fmt.Fprintf(md, `%s`+"\n\n", k, k, safeAltTitle) } return true @@ -408,8 +416,11 @@ func renderUpgradeNoticeMarkdown(parser *ReadmeParser, _ string, md *strings.Bui } sort.Strings(versions) for _, version := range versions { - md.WriteString("### " + version + "\n\n") - md.WriteString(parser.UpgradeNotice[version] + "\n\n") + md.WriteString("### ") + md.WriteString(version) + md.WriteString("\n\n") + md.WriteString(parser.UpgradeNotice[version]) + md.WriteString("\n\n") } return true } @@ -441,14 +452,16 @@ func (p *ReadmeParser) ToMarkdown() string { continue } - md.WriteString(config.markdownTitle + "\n\n") + md.WriteString(config.markdownTitle) + md.WriteString("\n\n") handledByCustomRender := false if config.render != nil { handledByCustomRender = config.render(p, content, &md) } if !handledByCustomRender { - md.WriteString(p.convertSubsections(content) + "\n\n") + md.WriteString(p.convertSubsections(content)) + md.WriteString("\n\n") } } @@ -468,8 +481,11 @@ func (p *ReadmeParser) ToMarkdown() string { continue } title := titleCaser.String(strings.ReplaceAll(sectionKey, "_", " ")) - md.WriteString("## " + title + "\n\n") - md.WriteString(p.convertSubsections(content) + "\n\n") + md.WriteString("## ") + md.WriteString(title) + md.WriteString("\n\n") + md.WriteString(p.convertSubsections(content)) + md.WriteString("\n\n") } return strings.TrimSpace(md.String()) diff --git a/pkg/wp/parser/wp-file-header.go b/pkg/wp/parser/wp-file-header.go index c20d95ea..6328f1ad 100644 --- a/pkg/wp/parser/wp-file-header.go +++ b/pkg/wp/parser/wp-file-header.go @@ -68,21 +68,21 @@ var headerCleanupRe = regexp.MustCompile(`\s*(?:\*\/|\?>).*`) // getRawFileHeaders reads the first part of a file and extracts raw header values. // Returns nil map if filePath has wrong extension or does not exist. -func getRawFileHeaders(filePath string, expectedExtension string, headerSpecs map[string]string) (map[string]string, error) { +func getRawFileHeaders(filePath, expectedExtension string, headerSpecs map[string]string) map[string]string { if len(filePath) < len(expectedExtension)+1 || filePath[len(filePath)-len(expectedExtension):] != expectedExtension { - return nil, nil + return nil } if _, err := os.Stat(filePath); os.IsNotExist(err) { - return nil, nil + return nil } - file, err := os.Open(filePath) + file, err := os.Open(filePath) //nolint:gosec // filePath is supplied by the caller for header inspection if err != nil { // Mimic PHP's behavior of proceeding with empty data on most read failures. return processHeaderData("", headerSpecs) } - defer file.Close() + defer func() { _ = file.Close() }() buffer := make([]byte, maxHeaderBytes) n, readErr := file.Read(buffer) @@ -95,7 +95,7 @@ func getRawFileHeaders(filePath string, expectedExtension string, headerSpecs ma } // processHeaderData extracts and cleans header values from a given string content. -func processHeaderData(fileDataString string, headerSpecs map[string]string) (map[string]string, error) { +func processHeaderData(fileDataString string, headerSpecs map[string]string) map[string]string { fileDataString = strings.ReplaceAll(fileDataString, "\r", "\n") extractedValues := make(map[string]string) @@ -123,15 +123,12 @@ func processHeaderData(fileDataString string, headerSpecs map[string]string) (ma extractedValues[fieldName] = extractedVal } - return extractedValues, nil + return extractedValues } // GetPluginHeaders retrieves headers for a WordPress plugin file. func GetPluginHeaders(filePath string) (PluginFileHeaders, error) { - rawHeaders, err := getRawFileHeaders(filePath, phpFileExtension, pluginFileHeaders) - if err != nil { - return PluginFileHeaders{}, err - } + rawHeaders := getRawFileHeaders(filePath, phpFileExtension, pluginFileHeaders) if rawHeaders == nil { return PluginFileHeaders{}, nil } @@ -153,10 +150,7 @@ func GetPluginHeaders(filePath string) (PluginFileHeaders, error) { // GetThemeHeaders retrieves headers for a WordPress theme stylesheet. func GetThemeHeaders(filePath string) (ThemeFileHeaders, error) { - rawHeaders, err := getRawFileHeaders(filePath, cssFileExtension, themeFileHeaders) - if err != nil { - return ThemeFileHeaders{}, err - } + rawHeaders := getRawFileHeaders(filePath, cssFileExtension, themeFileHeaders) if rawHeaders == nil { return ThemeFileHeaders{}, nil }