package pack import ( "bufio" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" ) type PublishProviderManifestRequest struct { RepoRoot string PackID string Manifest ProviderManifest CommitMessage string } type PublishProviderManifestResult struct { RepoRoot string `json:"repo_root"` PackID string `json:"pack_id"` ProviderID string `json:"provider_id"` ProviderPath string `json:"provider_path"` PackVersionBefore string `json:"pack_version_before"` PackVersionAfter string `json:"pack_version_after"` PublishMode string `json:"publish_mode"` CommitMessage string `json:"commit_message"` CommitSHA string `json:"commit_sha"` } func PublishProviderManifest(ctx context.Context, req PublishProviderManifestRequest) (PublishProviderManifestResult, error) { repoRoot, err := resolveRepoRoot(req.RepoRoot) if err != nil { return PublishProviderManifestResult{}, err } manifest := normalizeProviderManifest(req.Manifest) packDir := filepath.Join(repoRoot, "packs", strings.TrimSpace(req.PackID)) loadedPack, err := LoadDir(packDir) if err != nil { return PublishProviderManifestResult{}, fmt.Errorf("load pack dir %q: %w", packDir, err) } if err := validateProviders(packDir, []ProviderManifest{manifest}); err != nil { return PublishProviderManifestResult{}, err } providersDir := filepath.Join(packDir, loadedPack.Manifest.ProvidersDir) if err := os.MkdirAll(providersDir, 0o755); err != nil { return PublishProviderManifestResult{}, fmt.Errorf("ensure providers dir %q: %w", providersDir, err) } providerFileName := strings.TrimSpace(manifest.ProviderID) + ".json" providerPath := filepath.Join(providersDir, providerFileName) publishMode := "created" if _, err := os.Stat(providerPath); err == nil { publishMode = "updated" } else if !os.IsNotExist(err) { return PublishProviderManifestResult{}, fmt.Errorf("stat provider file %q: %w", providerPath, err) } providerBody, err := json.MarshalIndent(manifest, "", " ") if err != nil { return PublishProviderManifestResult{}, fmt.Errorf("marshal provider manifest: %w", err) } providerBody = append(providerBody, '\n') if err := os.WriteFile(providerPath, providerBody, 0o644); err != nil { return PublishProviderManifestResult{}, fmt.Errorf("write provider manifest %q: %w", providerPath, err) } packManifest := loadedPack.Manifest previousVersion := packManifest.Version nextVersion, err := bumpPatchVersion(previousVersion) if err != nil { return PublishProviderManifestResult{}, err } packManifest.Version = nextVersion packBody, err := json.MarshalIndent(packManifest, "", " ") if err != nil { return PublishProviderManifestResult{}, fmt.Errorf("marshal pack manifest: %w", err) } packBody = append(packBody, '\n') packManifestPath := filepath.Join(packDir, "pack.json") if err := os.WriteFile(packManifestPath, packBody, 0o644); err != nil { return PublishProviderManifestResult{}, fmt.Errorf("write pack manifest %q: %w", packManifestPath, err) } if err := updateChecksumFile(packDir, packManifest.ChecksumFile, []string{ "pack.json", filepath.ToSlash(filepath.Join(packManifest.ProvidersDir, providerFileName)), }); err != nil { return PublishProviderManifestResult{}, err } if _, err := LoadDir(packDir); err != nil { return PublishProviderManifestResult{}, fmt.Errorf("re-validate published pack %q: %w", packDir, err) } commitMessage := strings.TrimSpace(req.CommitMessage) if commitMessage == "" { commitMessage = fmt.Sprintf("feat(pack): publish provider draft %s", manifest.ProviderID) } commitSHA, err := commitPackPublish(ctx, repoRoot, commitMessage, []string{ filepath.Join("packs", packManifest.PackID, "pack.json"), filepath.Join("packs", packManifest.PackID, packManifest.ChecksumFile), filepath.Join("packs", packManifest.PackID, packManifest.ProvidersDir, providerFileName), }) if err != nil { return PublishProviderManifestResult{}, err } return PublishProviderManifestResult{ RepoRoot: repoRoot, PackID: packManifest.PackID, ProviderID: manifest.ProviderID, ProviderPath: filepath.ToSlash(filepath.Join("packs", packManifest.PackID, packManifest.ProvidersDir, providerFileName)), PackVersionBefore: previousVersion, PackVersionAfter: nextVersion, PublishMode: publishMode, CommitMessage: commitMessage, CommitSHA: commitSHA, }, nil } func normalizeProviderManifest(manifest ProviderManifest) ProviderManifest { normalized := manifest normalized.ProviderID = strings.TrimSpace(normalized.ProviderID) normalized.DisplayName = strings.TrimSpace(normalized.DisplayName) normalized.BaseURL = strings.TrimSpace(normalized.BaseURL) normalized.Platform = strings.TrimSpace(normalized.Platform) normalized.AccountType = strings.TrimSpace(normalized.AccountType) normalized.SmokeTestModel = strings.TrimSpace(normalized.SmokeTestModel) if normalized.AccountType == "" { normalized.AccountType = "apikey" } normalized.DefaultModels = normalizeModels(normalized.DefaultModels, normalized.SmokeTestModel) if normalized.GroupTemplate.Name == "" { normalized.GroupTemplate.Name = normalized.DisplayName + " 默认分组" } if normalized.GroupTemplate.RateMultiplier == 0 { normalized.GroupTemplate.RateMultiplier = 1.0 } if normalized.ChannelTemplate.Name == "" { normalized.ChannelTemplate.Name = normalized.DisplayName + " 默认渠道" } if normalized.ChannelTemplate.ModelMapping == nil { normalized.ChannelTemplate.ModelMapping = make(map[string]string, len(normalized.DefaultModels)) } for _, model := range normalized.DefaultModels { if _, ok := normalized.ChannelTemplate.ModelMapping[model]; !ok { normalized.ChannelTemplate.ModelMapping[model] = model } } if normalized.PlanTemplate.Name == "" { normalized.PlanTemplate.Name = normalized.DisplayName + " 默认套餐" } if normalized.PlanTemplate.ValidityDays <= 0 { normalized.PlanTemplate.ValidityDays = 30 } if normalized.PlanTemplate.ValidityUnit == "" { normalized.PlanTemplate.ValidityUnit = "day" } if normalized.Import == (ImportOptions{}) { normalized.Import = ImportOptions{ SupportsMultiKey: true, SupportsStrict: true, SupportsPartial: true, } } return normalized } func normalizeModels(models []string, smokeTestModel string) []string { normalized := make([]string, 0, len(models)+1) seen := make(map[string]struct{}, len(models)+1) for _, model := range models { model = strings.TrimSpace(model) if model == "" { continue } if _, ok := seen[model]; ok { continue } normalized = append(normalized, model) seen[model] = struct{}{} } smokeTestModel = strings.TrimSpace(smokeTestModel) if smokeTestModel != "" { if _, ok := seen[smokeTestModel]; !ok { normalized = append(normalized, smokeTestModel) } } return normalized } func resolveRepoRoot(repoRoot string) (string, error) { repoRoot = strings.TrimSpace(repoRoot) if repoRoot == "" { return "", fmt.Errorf("pack repo root is not configured") } absoluteRepoRoot, err := filepath.Abs(repoRoot) if err != nil { return "", fmt.Errorf("resolve repo root %q: %w", repoRoot, err) } info, err := os.Stat(absoluteRepoRoot) if err != nil { return "", fmt.Errorf("stat repo root %q: %w", absoluteRepoRoot, err) } if !info.IsDir() { return "", fmt.Errorf("repo root %q is not a directory", absoluteRepoRoot) } return absoluteRepoRoot, nil } func bumpPatchVersion(version string) (string, error) { parts := strings.Split(strings.TrimSpace(version), ".") if len(parts) != 3 { return "", fmt.Errorf("pack version %q must use x.y.z format", version) } patch, err := strconv.Atoi(parts[2]) if err != nil { return "", fmt.Errorf("parse pack version %q patch: %w", version, err) } parts[2] = strconv.Itoa(patch + 1) return strings.Join(parts, "."), nil } func updateChecksumFile(packDir string, checksumFile string, relativePaths []string) error { path := filepath.Join(packDir, checksumFile) entries, err := readChecksumEntries(path) if err != nil { return err } for _, relativePath := range relativePaths { normalizedPath := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relativePath))) if normalizedPath == "." || normalizedPath == "" { continue } body, err := os.ReadFile(filepath.Join(packDir, normalizedPath)) if err != nil { return fmt.Errorf("read checksum target %q: %w", normalizedPath, err) } sum := sha256.Sum256(body) entries[normalizedPath] = hex.EncodeToString(sum[:]) } paths := make([]string, 0, len(entries)) for relativePath := range entries { paths = append(paths, relativePath) } sort.Strings(paths) lines := make([]string, 0, len(paths)) for _, relativePath := range paths { lines = append(lines, fmt.Sprintf("%s %s", entries[relativePath], relativePath)) } if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { return fmt.Errorf("write checksum file %q: %w", path, err) } return nil } func readChecksumEntries(path string) (map[string]string, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("read checksum file %q: %w", path, err) } defer file.Close() entries := map[string]string{} scanner := bufio.NewScanner(file) lineNumber := 0 for scanner.Scan() { lineNumber++ line := strings.TrimSpace(scanner.Text()) if line == "" { continue } parts := strings.Fields(line) if len(parts) != 2 { return nil, fmt.Errorf("checksum file %q line %d: invalid format", path, lineNumber) } entries[filepath.ToSlash(parts[1])] = parts[0] } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("scan checksum file %q: %w", path, err) } return entries, nil } func commitPackPublish(ctx context.Context, repoRoot string, message string, relativePaths []string) (string, error) { addArgs := append([]string{"add"}, relativePaths...) if _, err := runGit(ctx, repoRoot, addArgs...); err != nil { return "", err } if _, err := runGit(ctx, repoRoot, "commit", "-m", message); err != nil { return "", err } sha, err := runGit(ctx, repoRoot, "rev-parse", "--short", "HEAD") if err != nil { return "", err } return strings.TrimSpace(sha), nil } func runGit(ctx context.Context, repoRoot string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repoRoot}, args...)...) output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("run git %q: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) } return strings.TrimSpace(string(output)), nil }