diff --git a/cmd/agent/push.go b/cmd/agent/push.go index 7bd29d42..11dc2680 100644 --- a/cmd/agent/push.go +++ b/cmd/agent/push.go @@ -21,6 +21,7 @@ var PushCmd = &cobra.Command{ yaoID := args[0] version, _ := cmd.Flags().GetString("version") + force, _ := cmd.Flags().GetBool("force") client := registry.New(config.Conf.Registry, registry.WithAuth( @@ -32,6 +33,7 @@ var PushCmd = &cobra.Command{ mgr := agentmgr.New(client, config.Conf.Root, nil) if err := mgr.Push(yaoID, agentmgr.PushOptions{ Version: version, + Force: force, }); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -41,6 +43,7 @@ var PushCmd = &cobra.Command{ func init() { PushCmd.Flags().StringP("version", "v", "", L("Package version (required)")) + PushCmd.Flags().Bool("force", false, L("Overwrite existing version")) PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory")) PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file")) } diff --git a/cmd/mcp/push.go b/cmd/mcp/push.go index f264033a..ea8e4feb 100644 --- a/cmd/mcp/push.go +++ b/cmd/mcp/push.go @@ -20,6 +20,7 @@ var PushCmd = &cobra.Command{ Boot() yaoID := args[0] version, _ := cmd.Flags().GetString("version") + force, _ := cmd.Flags().GetBool("force") client := registry.New(config.Conf.Registry, registry.WithAuth( @@ -31,6 +32,7 @@ var PushCmd = &cobra.Command{ mgr := mcpmgr.New(client, config.Conf.Root, nil) if err := mgr.Push(yaoID, mcpmgr.PushOptions{ Version: version, + Force: force, }); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -40,6 +42,7 @@ var PushCmd = &cobra.Command{ func init() { PushCmd.Flags().StringP("version", "v", "", L("Package version (required)")) + PushCmd.Flags().Bool("force", false, L("Overwrite existing version")) PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory")) PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file")) } diff --git a/registry/manager/agent/agent_test.go b/registry/manager/agent/agent_test.go index 1cf32fa4..ae3bf3d0 100644 --- a/registry/manager/agent/agent_test.go +++ b/registry/manager/agent/agent_test.go @@ -73,6 +73,13 @@ func mockRegistryServer(packages map[string][]byte) *httptest.Server { return } + // Delete: DELETE /v1/{type}/{scope}/{name}/{version} + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + return + } + // Push: PUT /v1/{type}/{scope}/{name}/{version} if r.Method == http.MethodPut { w.WriteHeader(http.StatusCreated) @@ -425,6 +432,52 @@ func TestPushLocalScope(t *testing.T) { } } +func TestPushForce(t *testing.T) { + appRoot := t.TempDir() + + assistantDir := filepath.Join(appRoot, "assistants", "max", "my-agent") + os.MkdirAll(assistantDir, 0755) + os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"my-agent"}`), 0644) + + var deleteCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/yao-registry" { + json.NewEncoder(w).Encode(map[string]interface{}{ + "registry": map[string]string{"version": "1.0.0", "api": "/v1"}, + "types": []string{"assistants"}, + }) + return + } + if r.Method == http.MethodDelete { + deleteCalled = true + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + return + } + if r.Method == http.MethodPut { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "type": "assistants", "scope": "@max", + "name": "my-agent", "version": "1.0.0", "digest": "sha256-forced", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := registry.New(srv.URL, registry.WithAuth("u", "p")) + mgr := New(client, appRoot, &common.AutoConfirmPrompter{}) + + err := mgr.Push("max.my-agent", PushOptions{Version: "1.0.0", Force: true}) + if err != nil { + t.Fatalf("Force push failed: %v", err) + } + if !deleteCalled { + t.Error("expected DELETE to be called before PUT when Force=true") + } +} + func TestPushNoVersion(t *testing.T) { appRoot := t.TempDir() srv := mockRegistryServer(nil) diff --git a/registry/manager/agent/push.go b/registry/manager/agent/push.go index b63ab0f7..469510fc 100644 --- a/registry/manager/agent/push.go +++ b/registry/manager/agent/push.go @@ -11,6 +11,7 @@ import ( // PushOptions configures the Push operation. type PushOptions struct { Version string // required semver + Force bool // delete existing version before push } // Push packages and uploads an assistant to the registry. @@ -66,8 +67,14 @@ func (m *Manager) Push(yaoID string, opts PushOptions) error { return fmt.Errorf("pack: %w", err) } - // Push to registry regType := common.TypeToRegistryType(common.TypeAssistant) + + // Force: delete existing version first (ignore 404) + if opts.Force { + m.client.DeleteVersion(regType, "@"+scope, name, opts.Version) + } + + // Push to registry result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData) if err != nil { return fmt.Errorf("push: %w", err) diff --git a/registry/manager/agent_e2e_test.go b/registry/manager/agent_e2e_test.go index 9c199fce..f38a2b40 100644 --- a/registry/manager/agent_e2e_test.go +++ b/registry/manager/agent_e2e_test.go @@ -29,6 +29,13 @@ func TestE2EAgent_SingleDepLifecycle(t *testing.T) { t.Fatalf("Push MCP: %v", err) } + // Verify .yaoignore fixtures exist in source before push + srcAgent := filepath.Join(devApp, "assistants", testScope, "registry-agent") + requireFileExists(t, filepath.Join(srcAgent, ".yaoignore")) + requireFileExists(t, filepath.Join(srcAgent, "dev-notes.md")) + requireFileExists(t, filepath.Join(srcAgent, "wireframe.sketch")) + requireFileExists(t, filepath.Join(srcAgent, "debug", "trace.log")) + // Push agent agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil { @@ -56,6 +63,12 @@ func TestE2EAgent_SingleDepLifecycle(t *testing.T) { requireFileExists(t, filepath.Join(agentDir, "package.yao")) requireFileContains(t, filepath.Join(agentDir, "prompts.yml"), "registry E2E testing") + // .yaoignore: excluded files must NOT appear in the installed package + requireFileNotExists(t, filepath.Join(agentDir, ".yaoignore")) + requireFileNotExists(t, filepath.Join(agentDir, "dev-notes.md")) + requireFileNotExists(t, filepath.Join(agentDir, "wireframe.sketch")) + requireFileNotExists(t, filepath.Join(agentDir, "debug", "trace.log")) + // Lockfile: agent entry agentPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent") if agentPkg.Version != "1.0.0" { diff --git a/registry/manager/common/packer.go b/registry/manager/common/packer.go index 582420ff..37846c4c 100644 --- a/registry/manager/common/packer.go +++ b/registry/manager/common/packer.go @@ -9,8 +9,30 @@ import ( "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. @@ -35,23 +57,32 @@ func PackDir(dir string, manifest *PkgManifest, extraFiles map[string]string) ([ 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 } - if info.IsDir() { - return nil - } rel, err := filepath.Rel(dir, path) if err != nil { return err } rel = filepath.ToSlash(rel) - // Skip pkg.yao if it exists in source (we generate our own) + 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) @@ -191,6 +222,20 @@ func ListZipFiles(zipData []byte) ([]string, error) { 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 { diff --git a/registry/manager/common/packer_test.go b/registry/manager/common/packer_test.go index 94f0acd6..79229a19 100644 --- a/registry/manager/common/packer_test.go +++ b/registry/manager/common/packer_test.go @@ -133,6 +133,132 @@ func TestReadManifestMissing(t *testing.T) { } } +func TestPackDirBuiltinIgnore(t *testing.T) { + srcDir := t.TempDir() + os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{}`), 0644) + os.WriteFile(filepath.Join(srcDir, "prompts.md"), []byte("hello"), 0644) + + // Files that should be excluded by built-in defaults + os.WriteFile(filepath.Join(srcDir, ".DS_Store"), []byte{}, 0644) + os.WriteFile(filepath.Join(srcDir, "debug.swp"), []byte{}, 0644) + os.WriteFile(filepath.Join(srcDir, "notes.bak"), []byte{}, 0644) + os.MkdirAll(filepath.Join(srcDir, ".git", "objects"), 0755) + os.WriteFile(filepath.Join(srcDir, ".git", "config"), []byte{}, 0644) + os.MkdirAll(filepath.Join(srcDir, ".vscode"), 0755) + os.WriteFile(filepath.Join(srcDir, ".vscode", "settings.json"), []byte{}, 0644) + os.MkdirAll(filepath.Join(srcDir, "node_modules", "foo"), 0755) + os.WriteFile(filepath.Join(srcDir, "node_modules", "foo", "index.js"), []byte{}, 0644) + + manifest := &PkgManifest{Type: TypeAssistant, Scope: "test", Name: "ign", Version: "1.0.0"} + zipData, err := PackDir(srcDir, manifest, nil) + if err != nil { + t.Fatalf("PackDir: %v", err) + } + + files, err := ListZipFiles(zipData) + if err != nil { + t.Fatal(err) + } + + fileSet := map[string]bool{} + for _, f := range files { + fileSet[f] = true + } + + if !fileSet["package.yao"] { + t.Error("expected package.yao in zip") + } + if !fileSet["prompts.md"] { + t.Error("expected prompts.md in zip") + } + for _, excluded := range []string{".DS_Store", "debug.swp", "notes.bak", ".git/config", ".vscode/settings.json", "node_modules/foo/index.js"} { + if fileSet[excluded] { + t.Errorf("expected %s to be excluded from zip", excluded) + } + } +} + +func TestPackDirYaoignoreFile(t *testing.T) { + srcDir := t.TempDir() + os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{}`), 0644) + os.WriteFile(filepath.Join(srcDir, "keep.txt"), []byte("keep"), 0644) + os.WriteFile(filepath.Join(srcDir, "secret.key"), []byte("secret"), 0644) + os.MkdirAll(filepath.Join(srcDir, "drafts"), 0755) + os.WriteFile(filepath.Join(srcDir, "drafts", "notes.md"), []byte("draft"), 0644) + os.WriteFile(filepath.Join(srcDir, "test.log"), []byte("log"), 0644) + + // .yaoignore excludes *.key and drafts/ + os.WriteFile(filepath.Join(srcDir, ".yaoignore"), []byte("*.key\ndrafts/\n"), 0644) + + manifest := &PkgManifest{Type: TypeAssistant, Scope: "test", Name: "ign2", Version: "1.0.0"} + zipData, err := PackDir(srcDir, manifest, nil) + if err != nil { + t.Fatalf("PackDir: %v", err) + } + + files, err := ListZipFiles(zipData) + if err != nil { + t.Fatal(err) + } + + fileSet := map[string]bool{} + for _, f := range files { + fileSet[f] = true + } + + if !fileSet["package.yao"] { + t.Error("expected package.yao") + } + if !fileSet["keep.txt"] { + t.Error("expected keep.txt") + } + if fileSet["secret.key"] { + t.Error("secret.key should be excluded by .yaoignore") + } + if fileSet["drafts/notes.md"] { + t.Error("drafts/notes.md should be excluded by .yaoignore") + } + if fileSet["test.log"] { + t.Error("test.log should be excluded by built-in *.log pattern") + } + if fileSet[".yaoignore"] { + t.Error(".yaoignore itself should be excluded") + } +} + +func TestPackDirYaoignoreNegation(t *testing.T) { + srcDir := t.TempDir() + os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{}`), 0644) + os.WriteFile(filepath.Join(srcDir, "a.tmp"), []byte("tmp"), 0644) + os.WriteFile(filepath.Join(srcDir, "important.tmp"), []byte("keep"), 0644) + + // *.tmp is in defaults, but negate important.tmp + os.WriteFile(filepath.Join(srcDir, ".yaoignore"), []byte("!important.tmp\n"), 0644) + + manifest := &PkgManifest{Type: TypeAssistant, Scope: "test", Name: "neg", Version: "1.0.0"} + zipData, err := PackDir(srcDir, manifest, nil) + if err != nil { + t.Fatalf("PackDir: %v", err) + } + + files, err := ListZipFiles(zipData) + if err != nil { + t.Fatal(err) + } + + fileSet := map[string]bool{} + for _, f := range files { + fileSet[f] = true + } + + if fileSet["a.tmp"] { + t.Error("a.tmp should be excluded by built-in *.tmp") + } + if !fileSet["important.tmp"] { + t.Error("important.tmp should be included via negation in .yaoignore") + } +} + func TestExtractFile(t *testing.T) { srcDir := t.TempDir() os.WriteFile(filepath.Join(srcDir, "data.json"), []byte(`{"key":"value"}`), 0644) diff --git a/registry/manager/mcp/push.go b/registry/manager/mcp/push.go index 1628c315..abb53657 100644 --- a/registry/manager/mcp/push.go +++ b/registry/manager/mcp/push.go @@ -11,6 +11,7 @@ import ( // PushOptions configures the Push operation. type PushOptions struct { Version string + Force bool // delete existing version before push } // Push packages and uploads an MCP to the registry. @@ -72,6 +73,11 @@ func (m *Manager) Push(yaoID string, opts PushOptions) error { } regType := common.TypeToRegistryType(common.TypeMCP) + + if opts.Force { + m.client.DeleteVersion(regType, "@"+scope, name, opts.Version) + } + result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData) if err != nil { return fmt.Errorf("push: %w", err)