diff --git a/cli/command/init/init.go b/cli/command/init/init.go index 5cf0897c..63358787 100644 --- a/cli/command/init/init.go +++ b/cli/command/init/init.go @@ -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" @@ -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()) } @@ -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" diff --git a/cli/command/publish/publish.go b/cli/command/publish/publish.go index 68c329d9..ab851a3a 100644 --- a/cli/command/publish/publish.go +++ b/cli/command/publish/publish.go @@ -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") } @@ -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) + } 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") diff --git a/pkg/streams/out.go b/pkg/streams/out.go index e04fc966..ade590ec 100644 --- a/pkg/streams/out.go +++ b/pkg/streams/out.go @@ -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 } diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 00000000..9e0b2975 --- /dev/null +++ b/pkg/version/version.go @@ -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) { + 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) + } + + return result, nil +} + +func stripLeadingZeros(s string) string { + if !numericIdentifier.MatchString(s) { + return s + } + trimmed := strings.TrimLeft(s, "0") + if trimmed == "" { + return "0" + } + return trimmed +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 00000000..2babeb73 --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,161 @@ +package version + +import "testing" + +func TestNormalize(t *testing.T) { + cases := []struct { + name string + in string + want string + wantErr bool + }{ + // Strict Passthrough + {"strict standard", "1.2.3", "1.2.3", false}, + {"strict with build", "1.2.3+build.5", "1.2.3+build.5", false}, + {"strict with prerelease", "1.2.3-beta.1", "1.2.3-beta.1", false}, + {"strict with dotted prerelease", "1.2.3-rc.1", "1.2.3-rc.1", false}, + {"strict with both", "1.2.3-beta.1+build.123", "1.2.3-beta.1+build.123", false}, + + // Prefixes & Whitespace + {"trim spaces", " 1.2.3 ", "1.2.3", false}, + {"lowercase v prefix", "v1.2.3", "1.2.3", false}, + {"uppercase V prefix", "V1.2.3", "1.2.3", false}, + {"trim spaces with v", " v1.2.3 ", "1.2.3", false}, + {"v with prerelease", "v1.2.3-rc.1", "1.2.3-rc.1", false}, + {"trim tabs and newlines", "\t\n1.2.3\r\n", "1.2.3", false}, + + // Short Forms + {"single segment", "1", "1.0.0", false}, + {"two segments", "1.2", "1.2.0", false}, + {"v single segment", "v1", "1.0.0", false}, + + // Leading Zeros in Core Segments + {"leading zero major", "01.0.0", "1.0.0", false}, + {"leading zero minor", "1.02.0", "1.2.0", false}, + {"leading zero patch", "1.2.03", "1.2.3", false}, + {"leading zeros in all", "01.02.03", "1.2.3", false}, + {"leading zero with suffix", "01.0.01-beta", "1.0.1-beta", false}, + + // Alphabetic Suffixes + {"minor+a", "3.0a", "3.0.0-a", false}, + {"minor+rc", "2.1rc1", "2.1.0-rc1", false}, + {"patch+alpha", "1.0.0beta", "1.0.0-beta", false}, + {"short form alpha", "1beta", "1.0.0-beta", false}, + {"two segments alpha", "1.2beta", "1.2.0-beta", false}, + {"patch+alpha+num", "1.0.0beta1", "1.0.0-beta1", false}, + {"alpha with dots inside", "1.2beta.3", "1.2.0-beta.3", false}, // Ensures regex catches dots + {"trailing alpha with leading-zero patch", "1.0.00beta", "1.0.0-beta", false}, + + // 4+ Dotted Segments Collapsing + {"four zeros", "1.0.0.0", "1.0.0-0", false}, + {"five segments", "1.2.3.4.5", "1.2.3-4.5", false}, + {"many segments", "1.2.3.4.5.6.7.8.9", "1.2.3-4.5.6.7.8.9", false}, + {"prerelease leading zero stripped", "1.0.0.01", "1.0.0-1", false}, + {"prerelease all zeros becomes zero", "1.0.0.00", "1.0.0-0", false}, + {"four segments where 4th is alpha", "1.2.3.alpha", "1.2.3-alpha", false}, + {"4 segments where 4th is alphanumeric", "1.2.3.4beta", "1.2.3-4beta", false}, + {"prerelease alphanumeric 0beta untouched", "1.0.0.0beta", "1.0.0-0beta", false}, + {"prerelease multiple leading zeros stripped", "1.0.0.005.006", "1.0.0-5.6", false}, + {"prerelease mixed alpha+zero untouched", "1.0.0.alpha.01", "1.0.0-alpha.1", false}, + {"0beta in mid prerelease position", "1.0.0.alpha.0beta", "1.0.0-alpha.0beta", false}, + + // Core vs Metadata Split logic + {"4 segments + minimal build", "1.2.3.4+b", "1.2.3-4+b", false}, + {"hyphen in prerelease kept", "1.2.3-rc-1", "1.2.3-rc-1", false}, + {"4 segments + prerelease only", "1.2.3.4-rc", "1.2.3-4.rc", false}, + {"plus comes before hyphen", "1.0.0+build-1", "1.0.0+build-1", false}, + {"4+ segments with build only", "1.2.3.4+build", "1.2.3-4+build", false}, + {"patch+alpha with build", "1.0.0beta+build.1", "1.0.0-beta+build.1", false}, + {"5 segments + dotted prerelease", "1.2.3.4.5-rc.1", "1.2.3-4.5.rc.1", false}, + {"plus before hyphen in suffix", "1.2.3+build-rc.1", "1.2.3+build-rc.1", false}, + {"4+ segments with existing prerelease", "1.2.3.4-alpha", "1.2.3-4.alpha", false}, + {"4 segments + prerelease + build minimal", "1.2.3.4-rc+b", "1.2.3-4.rc+b", false}, + {"hyphen inside build metadata kept", "1.2.3+build-123", "1.2.3+build-123", false}, + {"v prefix with prerelease and build", "v1.2.3-rc.1+build.1", "1.2.3-rc.1+build.1", false}, + {"4+ segments with prerelease and build", "1.2.3.4-alpha+bld", "1.2.3-4.alpha+bld", false}, + + // Complex / Mixed Normalizations + {"short form alpha+build", "1.2beta+bld", "1.2.0-beta+bld", false}, + {"V with full complex form", "V1.2.3.4-rc.1", "1.2.3-4.rc.1", false}, + {"v prefix, 4 segments, alpha+build", "v01.02.03.04beta+build", "1.2.3-04beta+build", false}, + {"all the weirdness combined", " v01.02.03.04.05beta+build ", "1.2.3-4.05beta+build", false}, + + // Length Constraints + { + name: "max length exact", + in: "1.2.3-alpha.x123456789012345678901234567890123456789012345678901", + want: "1.2.3-alpha.x123456789012345678901234567890123456789012345678901", + wantErr: false, + }, + { + name: "max length exceeded", + in: "1.2.3-alpha.01234567890123456789012345678901234567890123456789012", + want: "", + wantErr: true, + }, + { + name: "reducible long string passes", + in: "v000000000000000000000000000000000000000000000000000000000001.2.3", + want: "1.2.3", + wantErr: false, + }, + { + name: "expansion causes length failure", + in: "1-alpha.0123456789012345678901234567890123456789012345678901", + want: "", + wantErr: true, + }, + { + name: "expansion stays under cap", + in: "1-alpha.x12345678901234567890123456789012345678901234567890", + want: "1.0.0-alpha.x12345678901234567890123456789012345678901234567890", + wantErr: false, + }, + + // Errors + {"only v", "v", "", true}, + {"empty string", "", "", true}, + {"only whitespace", " ", "", true}, + {"consecutive dots", "1..0", "", true}, + {"empty prerelease", "1.2.3-", "", true}, + {"empty build metadata", "1.2.3+", "", true}, + {"garbage string", "not-a-version", "", true}, + {"wildcards (unsupported)", "1.2.x", "", true}, + {"invalid internal spaces", "1.2. 3", "", true}, + {"numeric prerelease 3-digit leading zero", "1.0.0-001", "", true}, + {"dotted numeric prerelease with leading zero", "1.0.0-1.02", "", true}, + {"multiple build tags", "1.2.3+build1+build2", "", true}, // Semver only allows one '+' + { + name: "explicit invalid prerelease leading zero", + in: "1.2.3-01", + want: "", + wantErr: true, + }, + + // Non-ASCII characters + {"unicode digits rejected", "١.٢.٣", "", true}, + {"non-ASCII in prerelease", "1.2.3-α", "", true}, + {"zero-width space inside", "1.2.3\u200B", "", true}, + + // Adversarial / Edge Cases + {"only v's", "vvv", "", true}, + {"triple v", "vvv1.2.3", "1.2.3", false}, + {"v with no numerics", "vbeta", "", true}, + {"NUL byte rejected", "1.2.3\x00", "", true}, + {"mixed multiple v", "Vv1.2.3", "1.2.3", false}, + {"invalid UTF-8 rejected", "1.2.3\xff", "", true}, + {"double lowercase v", "vv1.2.3", "1.2.3", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Normalize(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("Normalize(%q) error = %v, wantErr = %v", tc.in, err, tc.wantErr) + } + if !tc.wantErr && got != tc.want { + t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +}