yao/registry/manager/common/packer.go
Max dae35cfe86 Implement force push option for agent and MCP commands
- Add a `--force` flag to the `push` command in both agent and MCP modules, allowing users to overwrite existing versions by deleting them before pushing.
- Update the `PushOptions` struct to include the `Force` field, ensuring the functionality is integrated into the push logic.
- Enhance E2E tests to validate the behavior of the force push feature, ensuring that existing versions are deleted as expected when the flag is used.
- Introduce tests for packing directories to respect `.yaoignore` rules, improving file exclusion handling during the packaging process.
2026-03-03 10:31:50 +08:00

251 lines
6.1 KiB
Go

package common
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/yaoapp/gou/application/ignore"
)
// DefaultIgnorePatterns are always excluded when packing, regardless of
// whether a .yaoignore file exists. The syntax is identical to .gitignore.
var DefaultIgnorePatterns = []string{
".git/",
".gitignore",
".DS_Store",
"Thumbs.db",
"*.swp",
"*.swo",
"*.bak",
"*.tmp",
"*.log",
"__debug_bin*",
".vscode/",
".cursor/",
".idea/",
"node_modules/",
".yaoignore",
}
// PackDir creates a .yao.zip from a directory. All files under dir are stored
// under the "package/" prefix in the zip. extraFiles maps additional relative
// paths (under "package/") to their absolute source paths on disk.
// The manifest is written as "package/pkg.yao".
func PackDir(dir string, manifest *PkgManifest, extraFiles map[string]string) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// Sync Dependencies → RawDependencies before serialization
manifest.PrepareMarshal()
// Write pkg.yao manifest
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal pkg.yao: %w", err)
}
f, err := w.Create("package/pkg.yao")
if err != nil {
return nil, err
}
if _, err := f.Write(data); err != nil {
return nil, err
}
// Load ignore rules: built-in defaults first, then .yaoignore on top so
// that user negation patterns (e.g. !important.tmp) can override defaults.
gi := loadIgnoreRules(filepath.Join(dir, ".yaoignore"))
// Walk the main directory
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if info.IsDir() {
if rel != "." && gi.MatchesPath(rel+"/") {
return filepath.SkipDir
}
return nil
}
if rel == "pkg.yao" {
return nil
}
if gi.MatchesPath(rel) {
return nil
}
return addFileToZip(w, "package/"+rel, path)
}); err != nil {
return nil, fmt.Errorf("walk dir %s: %w", dir, err)
}
// Add extra files (e.g., scripts collected from project root)
for relPath, absPath := range extraFiles {
zipPath := "package/" + filepath.ToSlash(relPath)
if err := addFileToZip(w, zipPath, absPath); err != nil {
return nil, fmt.Errorf("add extra file %s: %w", relPath, err)
}
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// UnpackTo extracts the "package/" contents from a .yao.zip to destDir.
// Returns a list of extracted file paths relative to destDir.
func UnpackTo(zipData []byte, destDir string) ([]string, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var extracted []string
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
name := f.Name
if !strings.HasPrefix(name, "package/") {
continue
}
rel := strings.TrimPrefix(name, "package/")
if rel == "" || rel == "pkg.yao" {
continue
}
dest := filepath.Join(destDir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, err
}
out, err := os.Create(dest)
if err != nil {
rc.Close()
return nil, err
}
_, copyErr := io.Copy(out, rc)
rc.Close()
out.Close()
if copyErr != nil {
return nil, copyErr
}
extracted = append(extracted, filepath.ToSlash(rel))
}
return extracted, nil
}
// ReadManifest reads and parses the pkg.yao from a .yao.zip byte slice.
func ReadManifest(zipData []byte) (*PkgManifest, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
for _, f := range r.File {
if f.Name == "package/pkg.yao" {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
var m PkgManifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return nil, fmt.Errorf("decode pkg.yao: %w", err)
}
m.NormalizeDependencies()
return &m, nil
}
}
return nil, fmt.Errorf("pkg.yao not found in zip")
}
// ExtractFile reads a single file from the zip under "package/" prefix.
func ExtractFile(zipData []byte, relPath string) ([]byte, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
target := "package/" + relPath
for _, f := range r.File {
if f.Name == target {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("file %q not found in zip", relPath)
}
// ListZipFiles returns all file paths in the zip under "package/" prefix,
// excluding "package/pkg.yao".
func ListZipFiles(zipData []byte) ([]string, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var files []string
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
if !strings.HasPrefix(f.Name, "package/") {
continue
}
rel := strings.TrimPrefix(f.Name, "package/")
if rel == "" || rel == "pkg.yao" {
continue
}
files = append(files, rel)
}
return files, nil
}
// loadIgnoreRules compiles ignore patterns with defaults first, then the
// .yaoignore file contents appended so user rules (including negations) win.
func loadIgnoreRules(yaoignorePath string) *ignore.GitIgnore {
lines := make([]string, 0, len(DefaultIgnorePatterns)+16)
lines = append(lines, DefaultIgnorePatterns...)
if data, err := os.ReadFile(yaoignorePath); err == nil {
for _, l := range strings.Split(string(data), "\n") {
lines = append(lines, l)
}
}
return ignore.CompileIgnoreLines(lines...)
}
func addFileToZip(w *zip.Writer, zipPath, srcPath string) error {
f, err := w.Create(zipPath)
if err != nil {
return err
}
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(f, src)
return err
}