From dae35cfe8672bcddc804eb8386339791a9325e7c Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Mar 2026 10:31:50 +0800 Subject: [PATCH 1/2] 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. --- cmd/agent/push.go | 3 + cmd/mcp/push.go | 3 + registry/manager/agent/agent_test.go | 53 +++++++++++ registry/manager/agent/push.go | 9 +- registry/manager/agent_e2e_test.go | 13 +++ registry/manager/common/packer.go | 53 ++++++++++- registry/manager/common/packer_test.go | 126 +++++++++++++++++++++++++ registry/manager/mcp/push.go | 6 ++ 8 files changed, 261 insertions(+), 5 deletions(-) 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) From dea34d8086fb638b940d0aedb3d0bfae2b22fb79 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Mar 2026 11:02:56 +0800 Subject: [PATCH 2/2] Enhance GetConnector method to include fallback to default connector - Introduce a fallback mechanism in the GetConnector method to use a default connector if the specified connector is not found. - Add logging to notify when falling back to the default connector, improving debugging and traceability. - Update comments for clarity on connector selection priority, ensuring better understanding of the logic flow. --- agent/assistant/agent.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index 416e818b..fca12ab8 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -2,6 +2,7 @@ package assistant import ( "fmt" + "log" "time" jsoniter "github.com/json-iterator/go" @@ -567,29 +568,34 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa return finalResponse, nil } -// GetConnector get the connector object, capabilities, and error with priority: opts.Connector > ast.Connector +// GetConnector get the connector object, capabilities, and error with priority: +// opts.Connector > ast.Connector > defaultConnector (fallback) // Note: opts.Connector may be set by Create hook's applyOptionsAdjustments // Returns: (connector, capabilities, error) func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) { - // Determine connector ID with priority: opts.Connector > ast.Connector connectorID := ast.Connector if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" { connectorID = opts[0].Connector } - // If empty, return error + if connectorID == "" { + connectorID = defaultConnector + } + if connectorID == "" { return nil, nil, fmt.Errorf("connector not specified") } - // Load gou connector conn, err := connector.Select(connectorID) + if err != nil && connectorID != defaultConnector && defaultConnector != "" { + log.Printf("[Assistant] connector %q not found, falling back to default %q", connectorID, defaultConnector) + conn, err = connector.Select(defaultConnector) + } if err != nil { return nil, nil, err } capabilities := llm.GetCapabilitiesFromConn(conn) - return conn, capabilities, nil }