mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* docs(design): add Lime TUI prototype and rebuild spec
* tui: remove the zeroline skin system
One design remains in the tree ahead of the Lime rebuild: the zeroline
package, its TUI view/skin plumbing (Options.Skin/ThemeVariant/ThemeDark,
model skin branches, theme picker), and the zeroline CLI subcommand are
gone. /theme keeps its shell-only message.
* tui: replace the cyan palette with the Lime token table
theme.go is rebuilt around the Lime design tokens (docs/design/
zero_tui_lime.html): near-black panels, one lime accent, solid tint
stand-ins for the prototype's rgba overlays. Existing render sites map
onto the new role styles (ink/line/userPrompt/toolName/toolArg) without
layout changes; no hex exists outside theme.go.
* tui: build the Lime chat-surface components
Title bar with brand badge + context window, empty state replacing the
deleted splash (block-art 0, tagline, 1-3 starter suggestions), stream
blocks (user prompt gutter, muted interim with cursor, final-answer
accent rail, done line, bordered notes), tool cards with diff/read/
bash/grep bodies and MiniDot run spinner, borderless composer with
run-state hints, and the grouped status line. Final answers are marked
at append time; resolved tool calls collapse into their result cards.
* tui: fix Stage 2 review findings
Adversarial review of the chat-surface commit confirmed 15 issues, all
fixed here:
- wrapPlainText sliced wide runes by rune count: CJK/emoji prose could
panic the TUI or emit lines ~2x the measure; split at width instead
- a cancelled run's streamingText leaked into the next turn's interim
block; cancelRun now clears it
- orphaned tool-call cards (cancelled/errored turns) re-animated with
the spinner on any later run; the glyph is now scoped to the call's
own runID
- starter-suggestion digit capture was active while the chips were off
screen (/clear mid-run); the guard now mirrors the render condition
- prompt submits while a deferred Ctrl+C quit is draining could start a
run the quit would orphan; submission is blocked while exiting
- the running placeholder advertised ctrl+c (which quits) as the
interrupt key; it now says esc
- read-card parser expected 'N: text' but read_file emits 'N | text',
so the gutter and L-range never rendered on real output
- toolCard borders were one column short of the body rows
- diff tint bands now span the full row; NEW FILE renders green on
addBg; preamble/no-newline lines are unnumbered meta and no longer
shift hunk numbering
- card bodies and system notes paint the panel surface; the head gains
the separate faintest arg column (grep target + pattern)
- title-bar overflow now truncates the minimal candidate instead of
rendering the deleted design's 'ZERO … READY' line
- untracked the stray snake-game.html (left on disk, ignored)
* tui: restyle the interactive surfaces for Lime
Permission prompts render as the amber permission card (PERMISSION
badge, risk readout, key chips); resolved prompts collapse to one line
(allowed once/always/denied · tool) and unprompted allows fold into the
tool card's [auto] tag. Ask-user and spec-review prompts share the card
language with line borders. Autocomplete and picker overlays move onto
the panel/selection tints with the accent ❯ marker; model-picker rows
gain the provider dot and ctx · KEY_ENV metadata from the registry and
provider catalog. /resume renders its list as stacked session cards
(id + age, title, meta). Key handling is untouched everywhere.
* tui: adaptive width tiers, doc updates, final polish
Width tiers re-evaluate from the live width: ≥100 renders the full
spec; 80–99 drops the tool-arg column, header ctx, and the status
'interactive' group; 58–79 drops the diff/read gutters, bares the
badge, and keeps provider+tokens+mode; <58 renders a single-segment
header, rail-less cards, and a mode-only status line. Table-driven
tests cover {58,70,80,100,120} plus a frame-wide invariant that no
emitted line exceeds the terminal at any tier. Card bodies expand tabs
so width math holds. Dead splash-chip machinery (startupCommandNames,
startupOrder) is gone; README's TUI section now describes Lime.
* tui: fix final-review findings across Stages 3-4
A second adversarial review confirmed 14 issues, all addressed:
- permission collapse and tool-card pairing are now run-scoped (rcKey):
providers with repeating synthetic ToolCallIDs (Gemini gemini_tool_N)
could attribute a decision or result to a different run's call
- rehydrated always-grants no longer mislabel as 'allowed once'
(GrantMatched fallback)
- the width invariant now actually holds everywhere: empty-state
tagline/hint, ask-user rows, permission detail blobs, pending image
chips, and the sessions trailing hint are wrapped or fitted; the
invariant test renders the empty state and all of those rows at
widths 24-120
- every card (permission, ask-user, spec-review, sessions, notes)
drops side borders on tiny terminals, not just tool cards
- /resume card fields are sanitized against separator bytes from
user-controlled titles
- the picker's selected row paints its gap so the selBg band is solid
- dead splash helpers (normalizedStartupWidth, countLines, nonEmpty,
unreachable rowWelcome branch) and stale /theme comments are gone
- README no longer claims transcript scrolling keys or Tab-accept
behavior that never existed; chatWidth documents its 24-col floor
Tag tui-lime-s4 moves to this commit.
* audit: deep-audit report, TUI scrollback re-architecture, and cross-module fixes
TUI: settled-row flush frontier prints history to native terminal
scrollback (tea.Println, ordered + ack-serialized); View() renders only
the live tail. Full diffs (400-line flush cap) and OSC 8 file:// links
land in history; streamed interim text, cancellation markers, ask_user
exchanges, and composer input history are preserved; run-scoped dedup
keys fix Gemini's repeating tool-call ids; /exit, Ctrl+C-after-Esc, and
cross-session late flushes no longer orphan or contaminate checkpoints.
Cross-module: zero search panic + offset mapping, usage report
robustness, 529 retry, SSE keep-alive watchdog, registry corrections,
update --check on dev builds, MCP ping + mcp list --json secret
redaction, sandbox.network config knob, doctor apiKey presence, atomic
config writes, C1-safe notifications, rune-safe truncation everywhere,
gofmt + vet CI gates, dead glamour dependency dropped.
Full audit report: docs/audit/2026-06-10-deep-audit.md (175 findings).
Co-Authored-By: Claude Fable 5 <[email protected]>
* tui: hide malformed ask_user tool errors
* fix tui malformed tool retries
* fix max-turn finalization
* upgrade tui agent guardrails
* fix coderabbit guardrail findings
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
453 lines
13 KiB
Go
453 lines
13 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultRepository = "Gitlawb/zero"
|
|
DefaultTimeout = 5 * time.Second
|
|
)
|
|
|
|
type Release struct {
|
|
TagName string `json:"tag_name"`
|
|
HTMLURL string `json:"html_url"`
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
|
|
type Asset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url,omitempty"`
|
|
}
|
|
|
|
type Result struct {
|
|
CurrentVersion string `json:"currentVersion"`
|
|
LatestVersion string `json:"latestVersion"`
|
|
ReleaseURL string `json:"releaseUrl"`
|
|
TagName string `json:"tagName"`
|
|
ReleaseAsset AssetCheck `json:"releaseAsset"`
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
}
|
|
|
|
type AssetCheck struct {
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
ArchiveName string `json:"archiveName"`
|
|
ArchiveURL string `json:"archiveUrl,omitempty"`
|
|
ChecksumName string `json:"checksumName"`
|
|
ChecksumURL string `json:"checksumUrl,omitempty"`
|
|
ArchiveFound bool `json:"archiveFound"`
|
|
ChecksumFound bool `json:"checksumFound"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
// Target identifies a supported release archive target.
|
|
type Target struct {
|
|
Name string `json:"name"`
|
|
GOOS string `json:"goos"`
|
|
GOARCH string `json:"goarch"`
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
}
|
|
|
|
// Options configures a release update check.
|
|
type Options struct {
|
|
CurrentVersion string
|
|
// Endpoint accepts a full release API URL, an owner/repo slug, or a data:
|
|
// endpoint for deterministic tests.
|
|
Endpoint string
|
|
Repository string
|
|
Timeout time.Duration
|
|
GOOS string
|
|
GOARCH string
|
|
// Fetch overrides the release fetcher for tests and alternate transports.
|
|
Fetch func(context.Context, string) (Release, error)
|
|
}
|
|
|
|
type semverParts [3]int
|
|
|
|
var (
|
|
versionPattern = regexp.MustCompile(`^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-+].*)?$`)
|
|
repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
|
)
|
|
|
|
// Endpoint returns the GitHub latest-release API endpoint for a repository.
|
|
func Endpoint(repository string) string {
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository)
|
|
}
|
|
|
|
// ResolveEndpoint resolves a URL or owner/repo slug into a release API endpoint.
|
|
func ResolveEndpoint(endpointOrRepository string, repository string) (string, error) {
|
|
return resolveEndpoint(endpointOrRepository, repository)
|
|
}
|
|
|
|
// NormalizeVersionTag returns a comparable x.y.z version from a release tag.
|
|
func NormalizeVersionTag(version string) (string, error) {
|
|
return normalizeVersionTag(version)
|
|
}
|
|
|
|
// CompareSemver compares two semver-ish release tags.
|
|
func CompareSemver(left string, right string) (int, error) {
|
|
leftParts, err := parseSemver(left)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rightParts, err := parseSemver(right)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return compareSemverParts(leftParts, rightParts), nil
|
|
}
|
|
|
|
// ResolveTarget maps a release target name like windows-x64 to Go build
|
|
// coordinates and release asset naming fields.
|
|
func ResolveTarget(target string) (Target, error) {
|
|
value := strings.TrimSpace(strings.ToLower(target))
|
|
switch value {
|
|
case "linux-x64":
|
|
return Target{Name: value, GOOS: "linux", GOARCH: "amd64", Platform: "linux", Arch: "x64"}, nil
|
|
case "linux-arm64":
|
|
return Target{Name: value, GOOS: "linux", GOARCH: "arm64", Platform: "linux", Arch: "arm64"}, nil
|
|
case "macos-x64":
|
|
return Target{Name: value, GOOS: "darwin", GOARCH: "amd64", Platform: "macos", Arch: "x64"}, nil
|
|
case "macos-arm64":
|
|
return Target{Name: value, GOOS: "darwin", GOARCH: "arm64", Platform: "macos", Arch: "arm64"}, nil
|
|
case "windows-x64":
|
|
return Target{Name: value, GOOS: "windows", GOARCH: "amd64", Platform: "windows", Arch: "x64"}, nil
|
|
case "windows-arm64":
|
|
return Target{Name: value, GOOS: "windows", GOARCH: "arm64", Platform: "windows", Arch: "arm64"}, nil
|
|
default:
|
|
return Target{}, fmt.Errorf("unsupported update target %q: expected one of linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64, windows-arm64", target)
|
|
}
|
|
}
|
|
|
|
func Check(ctx context.Context, options Options) (Result, error) {
|
|
rawVersion := strings.TrimSpace(firstNonEmpty(options.CurrentVersion, "0.0.0"))
|
|
currentVersion, err := normalizeVersionTag(rawVersion)
|
|
if err != nil {
|
|
// Source/dev builds carry a non-semver version ("dev"); the check is
|
|
// still useful there — compare as 0.0.0 so the latest release is always
|
|
// reported as available instead of failing before the network call.
|
|
currentVersion = "0.0.0"
|
|
}
|
|
repository := strings.TrimSpace(firstNonEmpty(options.Repository, DefaultRepository))
|
|
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv("ZERO_UPDATE_RELEASE_URL")), repository)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
timeout := options.Timeout
|
|
if timeout < 0 {
|
|
return Result{}, fmt.Errorf("timeout must be non-negative")
|
|
}
|
|
if timeout == 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
if timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
}
|
|
fetch := options.Fetch
|
|
if fetch == nil {
|
|
fetch = fetchRelease
|
|
}
|
|
release, err := fetch(ctx, endpoint)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if strings.TrimSpace(release.TagName) == "" {
|
|
return Result{}, fmt.Errorf("github release response did not include a tag_name")
|
|
}
|
|
latestVersion, err := normalizeVersionTag(release.TagName)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
releaseURL := strings.TrimSpace(release.HTMLURL)
|
|
if releaseURL == "" {
|
|
releaseURL = fmt.Sprintf("https://github.com/%s/releases/tag/%s", repository, release.TagName)
|
|
}
|
|
assetCheck, err := verifyReleaseAssets(release, latestVersion, options)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
latestParts, err := parseSemverNormalized(latestVersion)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
currentParts, err := parseSemverNormalized(currentVersion)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{
|
|
CurrentVersion: currentVersion,
|
|
LatestVersion: latestVersion,
|
|
ReleaseURL: releaseURL,
|
|
TagName: release.TagName,
|
|
ReleaseAsset: assetCheck,
|
|
UpdateAvailable: compareSemverParts(latestParts, currentParts) > 0,
|
|
}, nil
|
|
}
|
|
|
|
func Format(result Result) string {
|
|
if result.UpdateAvailable {
|
|
lines := []string{
|
|
fmt.Sprintf("[zero] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion),
|
|
"Release: " + result.ReleaseURL,
|
|
}
|
|
lines = appendAssetLines(lines, result.ReleaseAsset)
|
|
if target := releaseAssetTarget(result.ReleaseAsset); target != "" {
|
|
lines = append(lines, "Download the verified "+target+" release asset, then replace the current zero binary.")
|
|
} else {
|
|
lines = append(lines, "Download the verified release asset, then replace the current zero binary.")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
lines := []string{
|
|
fmt.Sprintf("[zero] up to date (%s)", result.CurrentVersion),
|
|
"Latest release: " + result.ReleaseURL,
|
|
}
|
|
lines = appendAssetLines(lines, result.ReleaseAsset)
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func appendAssetLines(lines []string, asset AssetCheck) []string {
|
|
if asset.ArchiveName == "" {
|
|
return lines
|
|
}
|
|
if target := releaseAssetTarget(asset); target != "" {
|
|
lines = append(lines, "Release target: "+target)
|
|
}
|
|
lines = append(lines, "Release asset: "+asset.ArchiveName)
|
|
if asset.ChecksumName != "" {
|
|
lines = append(lines, "Checksum asset: "+asset.ChecksumName)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func releaseAssetTarget(asset AssetCheck) string {
|
|
if asset.Platform == "" || asset.Arch == "" {
|
|
return ""
|
|
}
|
|
return asset.Platform + "-" + asset.Arch
|
|
}
|
|
|
|
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
|
|
if strings.HasPrefix(endpoint, "data:") {
|
|
return fetchDataRelease(endpoint)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("User-Agent", "zero/update")
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
defer func() {
|
|
if closeErr := response.Body.Close(); closeErr != nil && err == nil {
|
|
err = fmt.Errorf("close update response: %w", closeErr)
|
|
}
|
|
}()
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
|
return Release{}, fmt.Errorf("github release check failed (%s)", response.Status)
|
|
}
|
|
if err := json.NewDecoder(response.Body).Decode(&release); err != nil {
|
|
return Release{}, err
|
|
}
|
|
return release, nil
|
|
}
|
|
|
|
func fetchDataRelease(endpoint string) (Release, error) {
|
|
comma := strings.Index(endpoint, ",")
|
|
if comma == -1 {
|
|
return Release{}, fmt.Errorf("invalid data update endpoint")
|
|
}
|
|
payload, err := url.QueryUnescape(endpoint[comma+1:])
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
var release Release
|
|
if err := json.Unmarshal([]byte(payload), &release); err != nil {
|
|
return Release{}, err
|
|
}
|
|
return release, nil
|
|
}
|
|
|
|
func resolveEndpoint(endpointOrRepository string, repository string) (string, error) {
|
|
value := strings.TrimSpace(endpointOrRepository)
|
|
if value == "" {
|
|
return Endpoint(repository), nil
|
|
}
|
|
if repositoryPattern.MatchString(value) {
|
|
return Endpoint(value), nil
|
|
}
|
|
parsed, err := url.ParseRequestURI(value)
|
|
if err != nil || parsed.Scheme == "" {
|
|
return "", fmt.Errorf("invalid update endpoint %q: use a full URL or an owner/repo slug like %s", value, repository)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func verifyReleaseAssets(release Release, version string, options Options) (AssetCheck, error) {
|
|
assetCheck, err := expectedAssetCheck(version, options.GOOS, options.GOARCH)
|
|
if err != nil {
|
|
return AssetCheck{}, err
|
|
}
|
|
for _, asset := range release.Assets {
|
|
name := strings.TrimSpace(asset.Name)
|
|
switch name {
|
|
case assetCheck.ArchiveName:
|
|
assetCheck.ArchiveFound = true
|
|
assetCheck.ArchiveURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
|
case assetCheck.ChecksumName:
|
|
assetCheck.ChecksumFound = true
|
|
assetCheck.ChecksumURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
|
}
|
|
}
|
|
assetCheck.Verified = assetCheck.ArchiveFound && assetCheck.ChecksumFound
|
|
if assetCheck.Verified {
|
|
return assetCheck, nil
|
|
}
|
|
missing := []string{}
|
|
if !assetCheck.ArchiveFound {
|
|
missing = append(missing, assetCheck.ArchiveName)
|
|
}
|
|
if !assetCheck.ChecksumFound {
|
|
missing = append(missing, assetCheck.ChecksumName)
|
|
}
|
|
return AssetCheck{}, fmt.Errorf("release metadata missing expected asset(s) for %s-%s: %s", assetCheck.Platform, assetCheck.Arch, strings.Join(missing, ", "))
|
|
}
|
|
|
|
func expectedAssetCheck(version string, goos string, goarch string) (AssetCheck, error) {
|
|
platform, err := releasePlatform(firstNonEmpty(goos, runtime.GOOS))
|
|
if err != nil {
|
|
return AssetCheck{}, err
|
|
}
|
|
arch, err := releaseArch(firstNonEmpty(goarch, runtime.GOARCH))
|
|
if err != nil {
|
|
return AssetCheck{}, err
|
|
}
|
|
extension := "tar.gz"
|
|
if platform == "windows" {
|
|
extension = "zip"
|
|
}
|
|
archiveName := fmt.Sprintf("zero-v%s-%s-%s.%s", version, platform, arch, extension)
|
|
return AssetCheck{
|
|
Platform: platform,
|
|
Arch: arch,
|
|
ArchiveName: archiveName,
|
|
ChecksumName: archiveName + ".sha256",
|
|
}, nil
|
|
}
|
|
|
|
func releasePlatform(goos string) (string, error) {
|
|
switch strings.TrimSpace(goos) {
|
|
case "linux":
|
|
return "linux", nil
|
|
case "darwin":
|
|
return "macos", nil
|
|
case "windows":
|
|
return "windows", nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported release platform: %s", goos)
|
|
}
|
|
}
|
|
|
|
func releaseArch(goarch string) (string, error) {
|
|
switch strings.TrimSpace(goarch) {
|
|
case "amd64":
|
|
return "x64", nil
|
|
case "arm64":
|
|
return "arm64", nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported release architecture: %s", goarch)
|
|
}
|
|
}
|
|
|
|
func normalizeVersionTag(version string) (string, error) {
|
|
match := versionPattern.FindStringSubmatch(strings.TrimSpace(version))
|
|
if match == nil {
|
|
return "", fmt.Errorf("invalid semantic version: %s", version)
|
|
}
|
|
major, err := parseVersionComponent(version, match[1])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
minor, err := parseVersionComponent(version, match[2])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
patch, err := parseVersionComponent(version, match[3])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
|
|
}
|
|
|
|
func parseSemver(version string) (semverParts, error) {
|
|
normalized, err := NormalizeVersionTag(version)
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
return parseSemverNormalized(normalized)
|
|
}
|
|
|
|
func parseSemverNormalized(version string) (semverParts, error) {
|
|
parts := strings.Split(version, ".")
|
|
if len(parts) != 3 {
|
|
return semverParts{}, fmt.Errorf("invalid semantic version: %s", version)
|
|
}
|
|
major, err := parseVersionComponent(version, parts[0])
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
minor, err := parseVersionComponent(version, parts[1])
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
patch, err := parseVersionComponent(version, parts[2])
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
return semverParts{major, minor, patch}, nil
|
|
}
|
|
|
|
func compareSemverParts(left semverParts, right semverParts) int {
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return left[index] - right[index]
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseVersionComponent(version string, component string) (int, error) {
|
|
parsed, err := strconv.ParseInt(component, 10, 31)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid semantic version: %s", version)
|
|
}
|
|
return int(parsed), nil
|
|
}
|