Skip to content
77 changes: 2 additions & 75 deletions cli/command/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"wpm/pkg/pm/wpmjson"
"wpm/pkg/pm/wpmjson/types"
"wpm/pkg/pm/wpmjson/validator"
"wpm/pkg/version"
"wpm/pkg/wp/parser"

"github.com/Masterminds/semver/v3"
Expand Down Expand Up @@ -237,7 +238,7 @@ func runExistingInit(wpmCli command.Cli, opts *initOptions) error {
}

// Normalize and validate version
v, err := normalizeVersion(wpmCfg.Version)
v, err := version.Normalize(wpmCfg.Version)
if err != nil {
return errors.New("invalid version format: " + err.Error())
}
Expand Down Expand Up @@ -682,80 +683,6 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm
return cfg
}

func normalizeVersion(version string) (string, error) {
if version == "" {
return "", errors.New("version cannot be empty")
}

v, err := semver.NewVersion(version)
if err == nil {
return v.String(), nil
}

// Attempt to normalize the version format to be compatible with semver.
// If version has more than 2 dots, we replace the last dot with a hyphen
// Example:
// 1.0.0.0 -> 1.0.0-0
// 1.0.0.alpha.1+build -> 1.0.0-alpha.1+build
parts := strings.Split(version, ".")
if len(parts) > 3 {
major := parts[0]
minor := parts[1]
patch := parts[2]
prerelease := strings.Join(parts[3:], ".")

version = fmt.Sprintf("%s.%s.%s-%s", major, minor, patch, prerelease)
}

// If version part start with 0, we remove it
// Example:
// 01.0.0 -> 1.0.0
// 1.01.0 -> 1.1.0
// 1.0.01 -> 1.0.1
// 1.0.01-beta -> 1.0.1-beta
// Split version into parts
parts = strings.Split(version, ".")
for i, part := range parts {
// Check if part starts with '0' and has more characters
if len(part) > 1 && part[0] == '0' {
// Split part into numeric and non-numeric (e.g., "01-beta" -> "01" and "-beta")
numericPart := part
nonNumericPart := ""
if hyphenIndex := strings.Index(part, "-"); hyphenIndex != -1 {
numericPart = part[:hyphenIndex]
nonNumericPart = part[hyphenIndex:]
}

// Check if numeric part is all digits and starts with '0'
isNumeric := true
for _, r := range numericPart {
if !unicode.IsDigit(r) {
isNumeric = false
break
}
}

if isNumeric && len(numericPart) > 1 && numericPart[0] == '0' {
// Remove leading zeros from numeric part
trimmed := strings.TrimLeft(numericPart, "0")
if trimmed == "" {
trimmed = "0"
}
// Reconstruct the part
parts[i] = trimmed + nonNumericPart
}
}
}
version = strings.Join(parts, ".")

v, err = semver.NewVersion(version)
if err != nil {
return "", err
}

return v.String(), nil
}

func detectPackageType(cwd string) string {
if _, err := os.Stat(filepath.Join(cwd, "style.css")); err == nil {
return "theme"
Expand Down
21 changes: 12 additions & 9 deletions cli/command/publish/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,6 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er
}
}

digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil))

if opts.verbose {
fmt.Fprint(wpmCli.Err(), "\n")
}
Expand All @@ -210,18 +208,23 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er
return errors.New("tarball size is zero, cannot publish empty package")
}

dim := aec.Faint.Apply
blue := aec.LightBlueF.Apply
c := func(a aec.ANSI, s string) string {
if !wpmCli.Err().IsColorEnabled() {
return s
}
return a.Apply(s)
}
Comment thread
thelovekesh marked this conversation as resolved.
w := tabwriter.NewWriter(wpmCli.Err(), 0, 0, 2, ' ', 0)

digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
packedSize := units.HumanSize(float64(counter.total))
unpackedSize := units.HumanSize(float64(tarballer.UnpackedSize()))

fmt.Fprintf(w, "├─ %s:\t%s\n", blue("Tag"), opts.tag)
fmt.Fprintf(w, "├─ %s:\t%s\n", blue("Access"), opts.access)
fmt.Fprintf(w, "├─ %s:\t%d\n", blue("Files"), tarballer.FileCount())
fmt.Fprintf(w, "├─ %s:\t%s %s\n", blue("Size"), packedSize, dim(fmt.Sprintf("(%s unpacked)", unpackedSize)))
fmt.Fprintf(w, "└─ %s:\t%s\n", blue("Digest"), digest)
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")
Expand Down
7 changes: 5 additions & 2 deletions pkg/streams/out.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ func (o *Out) IsColorEnabled() bool {
return false
}

force := os.Getenv("CLICOLOR_FORCE")
if force != "" && force != "0" {
if force := os.Getenv("FORCE_COLOR"); force != "" && force != "0" {
return true
}

if force := os.Getenv("CLICOLOR_FORCE"); force != "" && force != "0" {
return true
}

Expand Down
132 changes: 132 additions & 0 deletions pkg/version/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package version

import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/Masterminds/semver/v3"
)

const maxVersionLength = 64

var (
numericIdentifier = regexp.MustCompile(`^\d+$`)
trailingAlphaSuffix = regexp.MustCompile(`^(\d+(?:\.\d+){0,2})([A-Za-z][A-Za-z0-9.]*)$`)
)

// Normalize converts a version string into strict semver format (X.Y.Z[-prerelease][+build]).
//
// It handles common PHP/WordPress patterns:
// - leading 'v' or 'V' prefix (v1.2.3 -> 1.2.3)
// - leading zeros in core segments (01.0.0 -> 1.0.0)
// - leading zeros in numeric prerelease segments (1.0.0.01 -> 1.0.0-1)
// - short forms (1, 1.2 -> 1.0.0, 1.2.0)
// - alphabetic suffixes without separator (1.0.0beta -> 1.0.0-beta)
// - 4+ dotted segments collapsed into prerelease (1.0.0.0 -> 1.0.0-0)
//
// The output is guaranteed to satisfy semver.StrictNewVersion and to be no
// longer than 64 characters. Inputs that cannot be normalized return an error.
func Normalize(version string) (string, error) {
Comment thread
thelovekesh marked this conversation as resolved.
version = strings.TrimSpace(version)
if version == "" {
return "", errors.New("version cannot be empty")
}

// Fast path for already-normalized versions.
if v, err := semver.StrictNewVersion(version); err == nil {
if len(version) <= maxVersionLength {
return v.String(), nil
}
}

version = strings.TrimLeft(version, "vV")
if version == "" {
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:]
}

// 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
core = trailingAlphaSuffix.ReplaceAllString(core, "$1-$2")

// If the regex introduced a hyphen, move that new suffix into meta.
if idx := strings.IndexByte(core, '-'); idx != -1 {
meta = core[idx:] + meta
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:]
}
}

// Coerce to semver, which will handle leading zeros and short forms.
v, err := semver.NewVersion(core + meta)
if err != nil {
return "", fmt.Errorf("cannot normalize %q to semver: %w", version, err)
}

result := v.String()

if len(result) > maxVersionLength {
return "", fmt.Errorf("normalized result %q has length %d, exceeds maximum of %d",
result, len(result), maxVersionLength)
}
if _, err := semver.StrictNewVersion(result); err != nil {
return "", fmt.Errorf("normalized result %q is not strict semver: %w", result, err)
}
Comment thread
thelovekesh marked this conversation as resolved.

return result, nil
}
Comment thread
thelovekesh marked this conversation as resolved.

func stripLeadingZeros(s string) string {
if !numericIdentifier.MatchString(s) {
return s
}
trimmed := strings.TrimLeft(s, "0")
if trimmed == "" {
return "0"
}
return trimmed
}
Loading