package projectversion import ( "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) const versionFileName = "VERSION" var ( semverRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`) packageVersionRegex = regexp.MustCompile(`(?m)^(\s*"version"\s*:\s*")([^"]+)(",?)$`) frontendVersionFiles = []string{ "frontend/.env.production", "frontend/.env.development", "frontend/.env.desktop", } ) type Version struct { Major int Minor int Patch int } func Parse(raw string) (Version, error) { trimmed := strings.TrimSpace(raw) parts := semverRegex.FindStringSubmatch(trimmed) if parts == nil { return Version{}, fmt.Errorf("版本号 %q 不符合 x.y.z 格式", raw) } major, err := strconv.Atoi(parts[1]) if err != nil { return Version{}, fmt.Errorf("解析 major 失败: %w", err) } minor, err := strconv.Atoi(parts[2]) if err != nil { return Version{}, fmt.Errorf("解析 minor 失败: %w", err) } patch, err := strconv.Atoi(parts[3]) if err != nil { return Version{}, fmt.Errorf("解析 patch 失败: %w", err) } return Version{Major: major, Minor: minor, Patch: patch}, nil } func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } func FindRepoRoot(start string) (string, error) { current := start for { workspacePath := filepath.Join(current, "go.work") if _, err := os.Stat(workspacePath); err == nil { return current, nil } parent := filepath.Dir(current) if parent == current { return "", errors.New("未找到仓库根目录 go.work") } current = parent } } func Read(root string) (Version, error) { content, err := os.ReadFile(filepath.Join(root, versionFileName)) if err != nil { return Version{}, fmt.Errorf("读取 VERSION 失败: %w", err) } return Parse(string(content)) } func ReadString(root string) (string, error) { version, err := Read(root) if err != nil { return "", err } return version.String(), nil } func Sync(root string) error { version, err := ReadString(root) if err != nil { return err } packageJSONPath := filepath.Join(root, "frontend", "package.json") packageJSONContent, err := os.ReadFile(packageJSONPath) if err != nil { return fmt.Errorf("读取 frontend/package.json 失败: %w", err) } updatedPackageJSON, err := UpdatePackageJSONVersion(string(packageJSONContent), version) if err != nil { return err } if err := os.WriteFile(packageJSONPath, []byte(updatedPackageJSON), 0o644); err != nil { return fmt.Errorf("写入 frontend/package.json 失败: %w", err) } for _, relPath := range frontendVersionFiles { fullPath := filepath.Join(root, relPath) content, err := os.ReadFile(fullPath) if err != nil { return fmt.Errorf("读取 %s 失败: %w", relPath, err) } updated := UpsertEnvVar(string(content), "VITE_APP_VERSION", version) if err := os.WriteFile(fullPath, []byte(updated), 0o644); err != nil { return fmt.Errorf("写入 %s 失败: %w", relPath, err) } } return nil } func Check(root string) error { version, err := ReadString(root) if err != nil { return err } var errs []error packageJSONPath := filepath.Join(root, "frontend", "package.json") packageJSONContent, err := os.ReadFile(packageJSONPath) if err != nil { errs = append(errs, fmt.Errorf("读取 frontend/package.json 失败: %w", err)) } else { actualVersion, readErr := ReadPackageJSONVersion(string(packageJSONContent)) if readErr != nil { errs = append(errs, readErr) } else if actualVersion != version { errs = append(errs, fmt.Errorf("frontend/package.json 版本为 %s,期望 %s", actualVersion, version)) } } for _, relPath := range frontendVersionFiles { fullPath := filepath.Join(root, relPath) content, readErr := os.ReadFile(fullPath) if readErr != nil { errs = append(errs, fmt.Errorf("读取 %s 失败: %w", relPath, readErr)) continue } actualValue, ok := ReadEnvVar(string(content), "VITE_APP_VERSION") if !ok { errs = append(errs, fmt.Errorf("%s 缺少 VITE_APP_VERSION", relPath)) continue } if actualValue != version { errs = append(errs, fmt.Errorf("%s 的 VITE_APP_VERSION 为 %s,期望 %s", relPath, actualValue, version)) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func VerifyTag(root, tag string) error { version, err := ReadString(root) if err != nil { return err } if !strings.HasPrefix(tag, "v") { return fmt.Errorf("tag %q 必须以 v 开头", tag) } if tag[1:] != version { return fmt.Errorf("tag %q 与 VERSION %q 不一致", tag, version) } return nil } func UpdatePackageJSONVersion(content, version string) (string, error) { if _, err := Parse(version); err != nil { return "", err } if !packageVersionRegex.MatchString(content) { return "", errors.New("frontend/package.json 缺少 version 字段") } updated := packageVersionRegex.ReplaceAllString(content, `${1}`+version+`${3}`) return updated, nil } func ReadPackageJSONVersion(content string) (string, error) { parts := packageVersionRegex.FindStringSubmatch(content) if parts == nil { return "", errors.New("frontend/package.json 缺少 version 字段") } if _, err := Parse(parts[2]); err != nil { return "", err } return parts[2], nil } func UpsertEnvVar(content, key, value string) string { lines := strings.Split(strings.TrimRight(content, "\n"), "\n") if len(lines) == 1 && lines[0] == "" { lines = lines[:0] } updated := false for i, line := range lines { if strings.HasPrefix(line, key+"=") { lines[i] = key + "=" + value updated = true } } if !updated { lines = append(lines, key+"="+value) } return strings.Join(lines, "\n") + "\n" } func ReadEnvVar(content, key string) (string, bool) { for _, line := range strings.Split(content, "\n") { if strings.HasPrefix(line, key+"=") { return strings.TrimPrefix(line, key+"="), true } } return "", false } func ServerAssetName(version, goos, arch string) (string, error) { if _, err := Parse(version); err != nil { return "", err } switch goos { case "linux", "windows", "darwin": default: return "", fmt.Errorf("不支持的 server 平台 %q", goos) } if arch == "" { return "", errors.New("server 资产命名缺少架构") } ext := ".tar.gz" if goos == "windows" { ext = ".zip" } return fmt.Sprintf("nex-server_%s_%s_%s%s", version, goos, arch, ext), nil } func DesktopAssetName(version, platform string) (string, error) { if _, err := Parse(version); err != nil { return "", err } switch platform { case "linux": return fmt.Sprintf("Nex_%s_linux_amd64.tar.gz", version), nil case "windows": return fmt.Sprintf("Nex_%s_windows_amd64.zip", version), nil case "macos": return fmt.Sprintf("Nex_%s_macOS_universal.zip", version), nil default: return "", fmt.Errorf("不支持的 desktop 平台 %q", platform) } } func DesktopInfoPlist(version, minMacOSVersion string) (string, error) { if _, err := Parse(version); err != nil { return "", err } if strings.TrimSpace(minMacOSVersion) == "" { return "", errors.New("min macOS version 不能为空") } content := strings.Join([]string{ ``, ``, ``, ``, ` CFBundleDevelopmentRegion`, ` zh-Hans`, ` CFBundleExecutable`, ` nex`, ` CFBundleIconFile`, ` icon`, ` CFBundleIdentifier`, ` com.lanyuanxiaoyao.nex`, ` CFBundleInfoDictionaryVersion`, ` 6.0`, ` LSApplicationCategoryType`, ` public.app-category.developer-tools`, ` CFBundleName`, ` Nex`, ` CFBundleDisplayName`, ` Nex`, ` CFBundlePackageType`, ` APPL`, ` CFBundleShortVersionString`, ` ` + version + ``, ` CFBundleVersion`, ` ` + version + ``, ` NSHumanReadableCopyright`, ` Copyright © 2026 Nex`, ` LSMinimumSystemVersion`, ` ` + minMacOSVersion + ``, ` LSUIElement`, ` `, ` NSHighResolutionCapable`, ` `, ``, ``, }, "\n") return content + "\n", nil }