Enhance command structure with MCP and Robot functionalities
- Introduce new MCP and Robot command groups in the CLI, allowing for better organization of package management commands. - Add corresponding command descriptions for MCP and Robot functionalities to improve user guidance. - Update the agent command group to include additional commands for enhanced agent management. - Modify .gitignore to exclude specific design markdown files from version control. - Add environment variable for test application path in GitHub workflows to streamline testing setup.
This commit is contained in:
parent
35ab1d1040
commit
44af6ba759
49 changed files with 6301 additions and 6 deletions
1
.github/workflows/pr-test.yml
vendored
1
.github/workflows/pr-test.yml
vendored
|
|
@ -1510,6 +1510,7 @@ jobs:
|
|||
- name: Run Registry Client Tests
|
||||
env:
|
||||
YAO_REGISTRY_URL: http://localhost:8080
|
||||
YAO_TEST_APPLICATION: ${{ github.workspace }}/../app
|
||||
run: make unit-test-registry
|
||||
|
||||
- name: Codecov Report
|
||||
|
|
|
|||
1
.github/workflows/unit-test.yml
vendored
1
.github/workflows/unit-test.yml
vendored
|
|
@ -1126,6 +1126,7 @@ jobs:
|
|||
- name: Run Registry Client Tests
|
||||
env:
|
||||
YAO_REGISTRY_URL: http://localhost:8080
|
||||
YAO_TEST_APPLICATION: ${{ github.workspace }}/../app
|
||||
run: make unit-test-registry
|
||||
|
||||
- name: Codecov Report
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -73,3 +73,4 @@ tg-session.json
|
|||
tg-login
|
||||
tg-send
|
||||
registry/data/
|
||||
registry/manager/DESIGN*.md
|
||||
|
|
|
|||
50
cmd/agent/add.go
Normal file
50
cmd/agent/add.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
)
|
||||
|
||||
var agentAddForce bool
|
||||
|
||||
// AddCmd implements "yao agent add @scope/name"
|
||||
var AddCmd = &cobra.Command{
|
||||
Use: "add [package]",
|
||||
Short: L("Install an assistant package from the registry"),
|
||||
Long: L("Install an assistant package from the registry. Example: yao agent add @yao/keeper"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
|
||||
pkgID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := agentmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Add(pkgID, agentmgr.AddOptions{
|
||||
Version: version,
|
||||
Force: agentAddForce,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
AddCmd.Flags().StringP("version", "v", "latest", L("Package version or dist-tag"))
|
||||
AddCmd.Flags().BoolVarP(&agentAddForce, "force", "", false, L("Force reinstall"))
|
||||
AddCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
AddCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
|
|
@ -35,6 +35,15 @@ var langs = map[string]string{
|
|||
"Error: agent (-n) is required when using direct message input and not in an agent directory": "错误: 使用直接消息输入且不在智能体目录时需要指定 -n 参数",
|
||||
"Hint: Make sure you're in a Yao application directory or specify --app flag": "提示: 确保在 Yao 应用目录中或使用 --app 参数指定",
|
||||
"Error: invalid timeout format": "错误: 无效的超时格式",
|
||||
// Registry commands
|
||||
"Install an assistant package from the registry": "从注册中心安装助手包",
|
||||
"Update an installed assistant package": "更新已安装的助手包",
|
||||
"Push an assistant package to the registry": "推送助手包到注册中心",
|
||||
"Fork an assistant to a local scope": "Fork 一个助手到本地范围",
|
||||
"Package version or dist-tag": "包版本或 dist-tag",
|
||||
"Force reinstall": "强制重新安装",
|
||||
"Package version (required)": "包版本 (必填)",
|
||||
"Target version or dist-tag": "目标版本或 dist-tag",
|
||||
// Extract command
|
||||
"Extract test results to individual files for review": "提取测试结果到单独的文件供审查",
|
||||
"Extract test results from output JSONL file to individual Markdown or JSON files": "从输出 JSONL 文件中提取测试结果到单独的 Markdown 或 JSON 文件",
|
||||
|
|
|
|||
48
cmd/agent/fork.go
Normal file
48
cmd/agent/fork.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
)
|
||||
|
||||
// ForkCmd implements "yao agent fork @scope/name [@target-scope]"
|
||||
var ForkCmd = &cobra.Command{
|
||||
Use: "fork [package] [target-scope]",
|
||||
Short: L("Fork an assistant to a local scope"),
|
||||
Long: L("Fork an assistant for local modification. Example: yao agent fork @yao/keeper"),
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
|
||||
pkgID := args[0]
|
||||
var targetScope string
|
||||
if len(args) > 1 {
|
||||
targetScope = args[1]
|
||||
}
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := agentmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Fork(pkgID, agentmgr.ForkOptions{
|
||||
TargetScope: targetScope,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ForkCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
ForkCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
46
cmd/agent/push.go
Normal file
46
cmd/agent/push.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
)
|
||||
|
||||
// PushCmd implements "yao agent push scope.name --version x.y.z"
|
||||
var PushCmd = &cobra.Command{
|
||||
Use: "push [yao-id]",
|
||||
Short: L("Push an assistant package to the registry"),
|
||||
Long: L("Package and push an assistant to the registry. Example: yao agent push max.keeper --version 1.0.0"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
|
||||
yaoID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := agentmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Push(yaoID, agentmgr.PushOptions{
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
PushCmd.Flags().StringP("version", "v", "", L("Package version (required)"))
|
||||
PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
46
cmd/agent/update.go
Normal file
46
cmd/agent/update.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
)
|
||||
|
||||
// UpdateCmd implements "yao agent update @scope/name"
|
||||
var UpdateCmd = &cobra.Command{
|
||||
Use: "update [package]",
|
||||
Short: L("Update an installed assistant package"),
|
||||
Long: L("Update an installed assistant to a newer version. Example: yao agent update @yao/keeper"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
|
||||
pkgID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := agentmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Update(pkgID, agentmgr.UpdateOptions{
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
UpdateCmd.Flags().StringP("version", "v", "latest", L("Target version or dist-tag"))
|
||||
UpdateCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
UpdateCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
49
cmd/mcp/add.go
Normal file
49
cmd/mcp/add.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
)
|
||||
|
||||
var mcpAddForce bool
|
||||
|
||||
// AddCmd implements "yao mcp add @scope/name"
|
||||
var AddCmd = &cobra.Command{
|
||||
Use: "add [package]",
|
||||
Short: L("Install an MCP package from the registry"),
|
||||
Long: L("Install an MCP package from the registry. Example: yao mcp add @yao/rag-tools"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
pkgID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := mcpmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Add(pkgID, mcpmgr.AddOptions{
|
||||
Version: version,
|
||||
Force: mcpAddForce,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
AddCmd.Flags().StringP("version", "v", "latest", L("Package version or dist-tag"))
|
||||
AddCmd.Flags().BoolVarP(&mcpAddForce, "force", "", false, L("Force reinstall"))
|
||||
AddCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
AddCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
47
cmd/mcp/fork.go
Normal file
47
cmd/mcp/fork.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
)
|
||||
|
||||
// ForkCmd implements "yao mcp fork @scope/name [@target-scope]"
|
||||
var ForkCmd = &cobra.Command{
|
||||
Use: "fork [package] [target-scope]",
|
||||
Short: L("Fork an MCP to a local scope"),
|
||||
Long: L("Fork an MCP for local modification. Example: yao mcp fork @yao/rag-tools"),
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
pkgID := args[0]
|
||||
var targetScope string
|
||||
if len(args) > 1 {
|
||||
targetScope = args[1]
|
||||
}
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := mcpmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Fork(pkgID, mcpmgr.ForkOptions{
|
||||
TargetScope: targetScope,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ForkCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
ForkCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
57
cmd/mcp/mcp.go
Normal file
57
cmd/mcp/mcp.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/config"
|
||||
)
|
||||
|
||||
var appPath string
|
||||
var envFile string
|
||||
|
||||
var langs = map[string]string{
|
||||
"Install an MCP package from the registry": "从注册中心安装 MCP 包",
|
||||
"Update an installed MCP package": "更新已安装的 MCP 包",
|
||||
"Push an MCP package to the registry": "推送 MCP 包到注册中心",
|
||||
"Fork an MCP to a local scope": "Fork 一个 MCP 到本地范围",
|
||||
"Package version or dist-tag": "包版本或 dist-tag",
|
||||
"Force reinstall": "强制重新安装",
|
||||
"Package version (required)": "包版本 (必填)",
|
||||
"Target version or dist-tag": "目标版本或 dist-tag",
|
||||
"Application directory": "应用目录",
|
||||
"Environment file": "环境变量文件",
|
||||
}
|
||||
|
||||
// L Language switch
|
||||
func L(words string) string {
|
||||
var lang = os.Getenv("YAO_LANG")
|
||||
if lang == "" {
|
||||
return words
|
||||
}
|
||||
if trans, has := langs[words]; has {
|
||||
return trans
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// Boot sets the configuration
|
||||
func Boot() {
|
||||
root := config.Conf.Root
|
||||
if appPath != "" {
|
||||
r, err := filepath.Abs(appPath)
|
||||
if err != nil {
|
||||
exception.New("Root error %s", 500, err.Error()).Throw()
|
||||
}
|
||||
root = r
|
||||
}
|
||||
|
||||
if envFile != "" {
|
||||
config.Conf = config.LoadFromWithRoot(envFile, root)
|
||||
} else {
|
||||
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
|
||||
}
|
||||
|
||||
config.ApplyMode()
|
||||
}
|
||||
45
cmd/mcp/push.go
Normal file
45
cmd/mcp/push.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
)
|
||||
|
||||
// PushCmd implements "yao mcp push scope.name --version x.y.z"
|
||||
var PushCmd = &cobra.Command{
|
||||
Use: "push [yao-id]",
|
||||
Short: L("Push an MCP package to the registry"),
|
||||
Long: L("Package and push an MCP to the registry. Example: yao mcp push max.rag-tools --version 1.0.0"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
yaoID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := mcpmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Push(yaoID, mcpmgr.PushOptions{
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
PushCmd.Flags().StringP("version", "v", "", L("Package version (required)"))
|
||||
PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
45
cmd/mcp/update.go
Normal file
45
cmd/mcp/update.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
)
|
||||
|
||||
// UpdateCmd implements "yao mcp update @scope/name"
|
||||
var UpdateCmd = &cobra.Command{
|
||||
Use: "update [package]",
|
||||
Short: L("Update an installed MCP package"),
|
||||
Long: L("Update an installed MCP package. Example: yao mcp update @yao/rag-tools"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
pkgID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := mcpmgr.New(client, config.Conf.Root, nil)
|
||||
if err := mgr.Update(pkgID, mcpmgr.UpdateOptions{
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
UpdateCmd.Flags().StringP("version", "v", "latest", L("Target version or dist-tag"))
|
||||
UpdateCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
UpdateCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
54
cmd/robot/add.go
Normal file
54
cmd/robot/add.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/registry"
|
||||
robotmgr "github.com/yaoapp/yao/registry/manager/robot"
|
||||
)
|
||||
|
||||
// AddCmd implements "yao robot add @scope/name --team TEAM_ID"
|
||||
var AddCmd = &cobra.Command{
|
||||
Use: "add [package]",
|
||||
Short: L("Install a robot package from the registry"),
|
||||
Long: L("Install a robot and its dependencies. Example: yao robot add @yao/keeper --team team-123"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Boot()
|
||||
pkgID := args[0]
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
teamID, _ := cmd.Flags().GetString("team")
|
||||
|
||||
client := registry.New(config.Conf.Registry,
|
||||
registry.WithAuth(
|
||||
os.Getenv("YAO_REGISTRY_USER"),
|
||||
os.Getenv("YAO_REGISTRY_PASS"),
|
||||
),
|
||||
)
|
||||
|
||||
mgr := robotmgr.New(client, config.Conf.Root, nil)
|
||||
robot, err := mgr.Add(pkgID, robotmgr.AddOptions{
|
||||
Version: version,
|
||||
TeamID: teamID,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// The actual member record creation requires database access.
|
||||
// In P0 we print the robot config for the CLI layer to handle.
|
||||
fmt.Printf("Robot ready: %s (display_name: %s)\n", pkgID, robot.DisplayName)
|
||||
fmt.Println("Note: Member record must be created via Mission Control or database.")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
AddCmd.Flags().StringP("version", "v", "latest", L("Package version or dist-tag"))
|
||||
AddCmd.Flags().StringP("team", "t", "", L("Team ID (required)"))
|
||||
AddCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
|
||||
AddCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
|
||||
}
|
||||
52
cmd/robot/robot.go
Normal file
52
cmd/robot/robot.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/config"
|
||||
)
|
||||
|
||||
var appPath string
|
||||
var envFile string
|
||||
|
||||
var langs = map[string]string{
|
||||
"Install a robot package from the registry": "从注册中心安装 Robot 包",
|
||||
"Team ID (required)": "团队 ID (必填)",
|
||||
"Package version or dist-tag": "包版本或 dist-tag",
|
||||
"Application directory": "应用目录",
|
||||
"Environment file": "环境变量文件",
|
||||
}
|
||||
|
||||
// L Language switch
|
||||
func L(words string) string {
|
||||
var lang = os.Getenv("YAO_LANG")
|
||||
if lang == "" {
|
||||
return words
|
||||
}
|
||||
if trans, has := langs[words]; has {
|
||||
return trans
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// Boot sets the configuration
|
||||
func Boot() {
|
||||
root := config.Conf.Root
|
||||
if appPath != "" {
|
||||
r, err := filepath.Abs(appPath)
|
||||
if err != nil {
|
||||
exception.New("Root error %s", 500, err.Error()).Throw()
|
||||
}
|
||||
root = r
|
||||
}
|
||||
|
||||
if envFile != "" {
|
||||
config.Conf = config.LoadFromWithRoot(envFile, root)
|
||||
} else {
|
||||
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
|
||||
}
|
||||
|
||||
config.ApplyMode()
|
||||
}
|
||||
45
cmd/root.go
45
cmd/root.go
|
|
@ -8,6 +8,8 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/cmd/agent"
|
||||
"github.com/yaoapp/yao/cmd/mcp"
|
||||
"github.com/yaoapp/yao/cmd/robot"
|
||||
"github.com/yaoapp/yao/cmd/sui"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/pack"
|
||||
|
|
@ -64,6 +66,10 @@ var langs = map[string]string{
|
|||
"🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉",
|
||||
"Print all version information": "显示详细版本信息",
|
||||
"SUI Template Engine": "SUI 模板引擎命令",
|
||||
"MCP commands": "MCP 包管理命令",
|
||||
"MCP package management commands": "MCP 包管理命令",
|
||||
"Robot commands": "Robot 包管理命令",
|
||||
"Robot package management commands": "Robot 包管理命令",
|
||||
}
|
||||
|
||||
// L Language switch
|
||||
|
|
@ -128,6 +134,30 @@ var agentCmd = &cobra.Command{
|
|||
},
|
||||
}
|
||||
|
||||
var mcpCmd = &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: L("MCP commands"),
|
||||
Long: L("MCP package management commands"),
|
||||
CompletionOptions: cobra.CompletionOptions{
|
||||
DisableDefaultCmd: true,
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
var robotCmd = &cobra.Command{
|
||||
Use: "robot",
|
||||
Short: L("Robot commands"),
|
||||
Long: L("Robot package management commands"),
|
||||
CompletionOptions: cobra.CompletionOptions{
|
||||
DisableDefaultCmd: true,
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Command initialize
|
||||
func init() {
|
||||
|
||||
|
|
@ -139,6 +169,19 @@ func init() {
|
|||
// Agent
|
||||
agentCmd.AddCommand(agent.TestCmd)
|
||||
agentCmd.AddCommand(agent.ExtractCmd)
|
||||
agentCmd.AddCommand(agent.AddCmd)
|
||||
agentCmd.AddCommand(agent.UpdateCmd)
|
||||
agentCmd.AddCommand(agent.PushCmd)
|
||||
agentCmd.AddCommand(agent.ForkCmd)
|
||||
|
||||
// MCP
|
||||
mcpCmd.AddCommand(mcp.AddCmd)
|
||||
mcpCmd.AddCommand(mcp.UpdateCmd)
|
||||
mcpCmd.AddCommand(mcp.PushCmd)
|
||||
mcpCmd.AddCommand(mcp.ForkCmd)
|
||||
|
||||
// Robot
|
||||
robotCmd.AddCommand(robot.AddCmd)
|
||||
|
||||
rootCmd.AddCommand(
|
||||
versionCmd,
|
||||
|
|
@ -154,6 +197,8 @@ func init() {
|
|||
// packCmd,
|
||||
suiCmd,
|
||||
agentCmd,
|
||||
mcpCmd,
|
||||
robotCmd,
|
||||
// upgradeCmd,
|
||||
)
|
||||
// rootCmd.SetHelpCommand(helpCmd)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type Config struct {
|
|||
Session Session `json:"session,omitempty"` // Session Config
|
||||
Runtime Runtime `json:"runtime,omitempty"` // Runtime config
|
||||
Trace Trace `json:"trace,omitempty"` // Trace config
|
||||
Registry string `json:"registry,omitempty" env:"YAO_REGISTRY" envDefault:"https://registry.yaoagents.com"` // The package registry server URL
|
||||
}
|
||||
|
||||
// Database 数据库配置
|
||||
|
|
|
|||
223
registry/manager/agent/add.go
Normal file
223
registry/manager/agent/add.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// AddOptions configures the Add operation.
|
||||
type AddOptions struct {
|
||||
Version string // version or dist-tag, default "latest"
|
||||
Force bool // force reinstall even if already installed
|
||||
}
|
||||
|
||||
// Add installs an assistant package from the registry.
|
||||
// Flow per DESIGN-AGENT.md:
|
||||
// 1. Parse @scope/name
|
||||
// 2. Check target path conflict
|
||||
// 3. Pull from registry
|
||||
// 4. Check and install dependencies (recursive)
|
||||
// 5. Unpack to assistants/{scope}/{name}/
|
||||
// 6. Compute file hashes, write registry.yao
|
||||
// 7. Hot-reload
|
||||
func (m *Manager) Add(pkgID string, opts AddOptions) error {
|
||||
if opts.Version == "" {
|
||||
opts.Version = "latest"
|
||||
}
|
||||
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if existing, ok := lf.GetPackage(pkgID); ok && !opts.Force {
|
||||
return fmt.Errorf("package %s is already installed (version %s). Use --force to reinstall", pkgID, existing.Version)
|
||||
}
|
||||
|
||||
// Check directory conflict
|
||||
destDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
|
||||
if _, err := os.Stat(destDir); err == nil {
|
||||
if _, ok := lf.GetPackage(pkgID); !ok {
|
||||
return fmt.Errorf("directory %s already exists but is not managed by registry. Please remove or relocate it first", destDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull from registry
|
||||
regType := common.TypeToRegistryType(common.TypeAssistant)
|
||||
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
manifest, err := common.ReadManifest(zipData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Install dependencies first
|
||||
if len(manifest.Dependencies) > 0 {
|
||||
if err := m.installDependencies(manifest.Dependencies, lf, pkgID, map[string]bool{pkgID: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Unpack to destination
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.UnpackTo(zipData, destDir); err != nil {
|
||||
return fmt.Errorf("unpack: %w", err)
|
||||
}
|
||||
|
||||
// Compute file hashes
|
||||
relDir := common.PackageDirRel(common.TypeAssistant, scope, name)
|
||||
files, err := common.HashDir(destDir, relDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash files: %w", err)
|
||||
}
|
||||
|
||||
// Update lockfile
|
||||
info := common.PackageInfo{
|
||||
Type: common.TypeAssistant,
|
||||
Version: manifest.Version,
|
||||
Integrity: digest,
|
||||
Dependencies: manifest.Dependencies,
|
||||
Files: files,
|
||||
}
|
||||
lf.SetPackage(pkgID, info)
|
||||
|
||||
// Update required_by on dependencies
|
||||
for depID := range manifest.Dependencies {
|
||||
lf.AddRequiredBy(depID, pkgID)
|
||||
}
|
||||
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hot-reload: in production this calls assistant.LoadPath().
|
||||
// For the manager package we keep it as a no-op since LoadPath requires
|
||||
// the full engine runtime. The CLI layer will handle hot-reload.
|
||||
fmt.Printf("✓ Installed %s@%s → %s\n", pkgID, manifest.Version, destDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// installDependencies recursively installs missing dependencies.
|
||||
func (m *Manager) installDependencies(deps map[string]string, lf *common.RegistryYao, parentID string, installing map[string]bool) error {
|
||||
missing, conflicts, _ := common.CheckDependencies(deps, lf)
|
||||
|
||||
// Handle conflicts
|
||||
for _, c := range conflicts {
|
||||
msg := fmt.Sprintf(
|
||||
"⚠ %s is currently %s (required by %s)\n %s requires %s\n",
|
||||
c.PackageID, c.InstalledVersion, parentID, parentID, c.RequiredVersion,
|
||||
)
|
||||
options := []string{
|
||||
fmt.Sprintf("Upgrade %s (may break other dependents)", c.PackageID),
|
||||
"Keep current version",
|
||||
"Abort installation",
|
||||
}
|
||||
choice := m.prompter.Choose(msg, options)
|
||||
switch choice {
|
||||
case 0:
|
||||
// Upgrade: treat as missing so it gets reinstalled
|
||||
missing = append(missing, c)
|
||||
case 1:
|
||||
// Keep current
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("installation aborted by user")
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build summary
|
||||
var summary strings.Builder
|
||||
summary.WriteString("The following dependencies need to be installed:\n")
|
||||
for _, dep := range missing {
|
||||
summary.WriteString(fmt.Sprintf(" %s %s\n", dep.PackageID, dep.RequiredVersion))
|
||||
}
|
||||
if !m.prompter.Confirm(summary.String() + "Install?") {
|
||||
return fmt.Errorf("dependency installation declined, aborting")
|
||||
}
|
||||
|
||||
for _, dep := range missing {
|
||||
if common.DetectCycle(installing, dep.PackageID) {
|
||||
continue
|
||||
}
|
||||
installing[dep.PackageID] = true
|
||||
|
||||
// Determine type from package ID by trying to pull and reading manifest
|
||||
depScope, depName, err := common.ParsePackageID(dep.PackageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try assistant type first, then mcp
|
||||
var installed bool
|
||||
for _, regType := range []string{common.TypeDirAssistants, common.TypeDirMCPs} {
|
||||
zipData, digest, err := m.client.Pull(regType, "@"+depScope, depName, "latest")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
manifest, err := common.ReadManifest(zipData)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pkgType := manifest.Type
|
||||
destDir := common.PackageDir(pkgType, depScope, depName, m.appRoot)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.UnpackTo(zipData, destDir); err != nil {
|
||||
return fmt.Errorf("unpack dependency %s: %w", dep.PackageID, err)
|
||||
}
|
||||
|
||||
relDir := common.PackageDirRel(pkgType, depScope, depName)
|
||||
files, err := common.HashDir(destDir, relDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info := common.PackageInfo{
|
||||
Type: pkgType,
|
||||
Version: manifest.Version,
|
||||
Integrity: digest,
|
||||
Dependencies: manifest.Dependencies,
|
||||
Files: files,
|
||||
}
|
||||
lf.SetPackage(dep.PackageID, info)
|
||||
|
||||
// Recursively install this dep's dependencies
|
||||
if len(manifest.Dependencies) > 0 {
|
||||
if err := m.installDependencies(manifest.Dependencies, lf, dep.PackageID, installing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ Dependency %s@%s installed\n", dep.PackageID, manifest.Version)
|
||||
installed = true
|
||||
break
|
||||
}
|
||||
|
||||
if !installed {
|
||||
return fmt.Errorf("failed to install dependency %s: not found in registry", dep.PackageID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
26
registry/manager/agent/agent.go
Normal file
26
registry/manager/agent/agent.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Package agent implements the assistant package manager for the Yao registry.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/registry"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// Manager handles assistant package operations (add, update, push, fork).
|
||||
type Manager struct {
|
||||
client *registry.Client
|
||||
appRoot string
|
||||
prompter common.Prompter
|
||||
}
|
||||
|
||||
// New creates an agent Manager.
|
||||
func New(client *registry.Client, appRoot string, prompter common.Prompter) *Manager {
|
||||
if prompter == nil {
|
||||
prompter = &common.StdinPrompter{}
|
||||
}
|
||||
return &Manager{
|
||||
client: client,
|
||||
appRoot: appRoot,
|
||||
prompter: prompter,
|
||||
}
|
||||
}
|
||||
628
registry/manager/agent/agent_test.go
Normal file
628
registry/manager/agent/agent_test.go
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/registry"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
"github.com/yaoapp/yao/registry/testdata"
|
||||
)
|
||||
|
||||
// buildTestZip builds a simple assistant .yao.zip for testing.
|
||||
func buildTestZip(scope, name, version string, deps []testdata.ManifestDep, files map[string]string) []byte {
|
||||
zip, err := testdata.BuildZip(&testdata.Manifest{
|
||||
Type: "assistant",
|
||||
Scope: scope,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Dependencies: deps,
|
||||
}, files)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
// buildMCPTestZip builds a simple MCP .yao.zip for testing.
|
||||
func buildMCPTestZip(scope, name, version string) []byte {
|
||||
zip, err := testdata.BuildZip(&testdata.Manifest{
|
||||
Type: "mcp",
|
||||
Scope: scope,
|
||||
Name: name,
|
||||
Version: version,
|
||||
}, map[string]string{
|
||||
"test.mcp.yao": `{"transport":"process"}`,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
// mockRegistryServer creates a test HTTP server that serves pre-built zip packages.
|
||||
func mockRegistryServer(packages map[string][]byte) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// well-known discovery
|
||||
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", "mcps", "robots"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Pull: GET /v1/{type}/{scope}/{name}/{version}/pull
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
|
||||
if len(parts) >= 4 {
|
||||
key := parts[0] + "/" + parts[1] + "/" + parts[2]
|
||||
if zipData, ok := packages[key]; ok {
|
||||
w.Header().Set("X-Digest", "sha256-test")
|
||||
w.Write(zipData)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Push: PUT /v1/{type}/{scope}/{name}/{version}
|
||||
if r.Method == http.MethodPut {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
|
||||
result := map[string]string{
|
||||
"type": parts[0],
|
||||
"scope": parts[1],
|
||||
"name": parts[2],
|
||||
"version": parts[3],
|
||||
"digest": "sha256-pushed",
|
||||
}
|
||||
json.NewEncoder(w).Encode(result)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAddBasic(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zip := buildTestZip("@test", "demo-agent", "1.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"demo"}`,
|
||||
"prompts.yml": "You are a demo.",
|
||||
})
|
||||
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/demo-agent": zip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Add("@test/demo-agent", AddOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Add failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify directory created
|
||||
destDir := filepath.Join(appRoot, "assistants", "test", "demo-agent")
|
||||
if _, err := os.Stat(destDir); err != nil {
|
||||
t.Fatalf("expected directory %s to exist", destDir)
|
||||
}
|
||||
|
||||
// Verify files
|
||||
if _, err := os.Stat(filepath.Join(destDir, "package.yao")); err != nil {
|
||||
t.Error("expected package.yao")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "prompts.yml")); err != nil {
|
||||
t.Error("expected prompts.yml")
|
||||
}
|
||||
|
||||
// Verify lockfile
|
||||
lf, err := common.LoadLockfile(appRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, ok := lf.GetPackage("@test/demo-agent")
|
||||
if !ok {
|
||||
t.Fatal("expected @test/demo-agent in lockfile")
|
||||
}
|
||||
if pkg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", pkg.Version)
|
||||
}
|
||||
if pkg.Type != common.TypeAssistant {
|
||||
t.Errorf("expected type assistant, got %s", pkg.Type)
|
||||
}
|
||||
if len(pkg.Files) == 0 {
|
||||
t.Error("expected non-empty files hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAlreadyInstalled(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zip := buildTestZip("@test", "dup", "1.0.0", nil, nil)
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/dup": zip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
if err := mgr.Add("@test/dup", AddOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Second add should fail
|
||||
err := mgr.Add("@test/dup", AddOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate install")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already installed") {
|
||||
t.Errorf("expected 'already installed' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDirectoryConflict(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Create conflicting directory manually (not managed)
|
||||
os.MkdirAll(filepath.Join(appRoot, "assistants", "test", "conflict"), 0755)
|
||||
|
||||
zip := buildTestZip("@test", "conflict", "1.0.0", nil, nil)
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/conflict": zip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Add("@test/conflict", AddOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not managed by registry") {
|
||||
t.Errorf("expected conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWithDependencies(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
mcpZip := buildMCPTestZip("@test", "dep-mcp", "1.0.0")
|
||||
agentZip := buildTestZip("@test", "dep-agent", "1.0.0",
|
||||
[]testdata.ManifestDep{
|
||||
{Type: "mcp", Scope: "@test", Name: "dep-mcp", Version: "^1.0.0"},
|
||||
},
|
||||
map[string]string{"package.yao": `{"name":"dep-agent"}`},
|
||||
)
|
||||
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/dep-agent": agentZip,
|
||||
"mcps/@test/dep-mcp": mcpZip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Add("@test/dep-agent", AddOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Add with deps failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify dependency was installed
|
||||
lf, _ := common.LoadLockfile(appRoot)
|
||||
if _, ok := lf.GetPackage("@test/dep-mcp"); !ok {
|
||||
t.Error("expected dependency @test/dep-mcp to be installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBasic(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zipV1 := buildTestZip("@test", "updatable", "1.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"updatable"}`,
|
||||
"prompts.yml": "Original prompt.",
|
||||
})
|
||||
zipV2 := buildTestZip("@test", "updatable", "2.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"updatable","version":"2.0.0"}`,
|
||||
"prompts.yml": "Updated prompt.",
|
||||
"new-file.md": "New in v2.",
|
||||
})
|
||||
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/updatable": zipV2,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
// First install v1 using the real zip
|
||||
srvV1 := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/updatable": zipV1,
|
||||
})
|
||||
clientV1 := registry.New(srvV1.URL)
|
||||
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
|
||||
if err := mgrV1.Add("@test/updatable", AddOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srvV1.Close()
|
||||
|
||||
// Now update to v2
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Update("@test/updatable", UpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify version updated in lockfile
|
||||
lf, _ := common.LoadLockfile(appRoot)
|
||||
pkg, _ := lf.GetPackage("@test/updatable")
|
||||
if pkg.Version != "2.0.0" {
|
||||
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
|
||||
}
|
||||
|
||||
// Verify new file exists
|
||||
newFilePath := filepath.Join(appRoot, "assistants", "test", "updatable", "new-file.md")
|
||||
if _, err := os.Stat(newFilePath); err != nil {
|
||||
t.Error("expected new-file.md to be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLocallyModified(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zipV1 := buildTestZip("@test", "modified", "1.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"modified"}`,
|
||||
"prompts.yml": "Original.",
|
||||
})
|
||||
zipV2 := buildTestZip("@test", "modified", "2.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"modified"}`,
|
||||
"prompts.yml": "Updated.",
|
||||
})
|
||||
|
||||
// Install v1
|
||||
srvV1 := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/modified": zipV1,
|
||||
})
|
||||
clientV1 := registry.New(srvV1.URL)
|
||||
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
|
||||
if err := mgrV1.Add("@test/modified", AddOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srvV1.Close()
|
||||
|
||||
// Modify prompts.yml locally
|
||||
promptsPath := filepath.Join(appRoot, "assistants", "test", "modified", "prompts.yml")
|
||||
os.WriteFile(promptsPath, []byte("My custom prompt."), 0644)
|
||||
|
||||
// Update to v2
|
||||
srvV2 := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/modified": zipV2,
|
||||
})
|
||||
defer srvV2.Close()
|
||||
clientV2 := registry.New(srvV2.URL)
|
||||
mgrV2 := New(clientV2, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgrV2.Update("@test/modified", UpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// prompts.yml should be preserved (locally modified)
|
||||
data, _ := os.ReadFile(promptsPath)
|
||||
if string(data) != "My custom prompt." {
|
||||
t.Errorf("expected locally modified prompts.yml preserved, got: %s", data)
|
||||
}
|
||||
|
||||
// New version should be saved as .new
|
||||
newPath := promptsPath + ".new"
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Error("expected prompts.yml.new to exist")
|
||||
}
|
||||
newData, _ := os.ReadFile(newPath)
|
||||
if string(newData) != "Updated." {
|
||||
t.Errorf("expected .new file to contain new version, got: %s", newData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNotInstalled(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
srv := mockRegistryServer(nil)
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Update("@test/nonexistent", UpdateOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for not-installed package")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not installed") {
|
||||
t.Errorf("expected 'not installed' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForkedPackage(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Set up a forked package in lockfile
|
||||
lf := &common.RegistryYao{
|
||||
Scope: "@local",
|
||||
Packages: map[string]common.PackageInfo{
|
||||
"@local/keeper": {
|
||||
Type: common.TypeAssistant,
|
||||
Version: "1.0.0",
|
||||
ForkedFrom: "@yao/keeper",
|
||||
Managed: common.BoolPtr(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
common.SaveLockfile(appRoot, lf)
|
||||
|
||||
srv := mockRegistryServer(nil)
|
||||
defer srv.Close()
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Update("@local/keeper", UpdateOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for forked package")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "forked") {
|
||||
t.Errorf("expected 'forked' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushBasic(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Create assistant directory
|
||||
assistantDir := filepath.Join(appRoot, "assistants", "max", "my-agent")
|
||||
os.MkdirAll(assistantDir, 0755)
|
||||
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"my-agent"}`), 0644)
|
||||
os.WriteFile(filepath.Join(assistantDir, "prompts.yml"), []byte("test prompt"), 0644)
|
||||
|
||||
srv := mockRegistryServer(nil)
|
||||
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"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushLocalScope(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
srv := mockRegistryServer(nil)
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Push("local.my-agent", PushOptions{Version: "1.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for @local push")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "@local") {
|
||||
t.Errorf("expected @local rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushNoVersion(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
srv := mockRegistryServer(nil)
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Push("max.my-agent", PushOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkFromInstalled(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Set up an installed package
|
||||
assistantDir := filepath.Join(appRoot, "assistants", "yao", "keeper")
|
||||
os.MkdirAll(assistantDir, 0755)
|
||||
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"keeper"}`), 0644)
|
||||
os.WriteFile(filepath.Join(assistantDir, "prompts.yml"), []byte("keeper prompt"), 0644)
|
||||
|
||||
lf := &common.RegistryYao{
|
||||
Scope: "@local",
|
||||
Packages: map[string]common.PackageInfo{
|
||||
"@yao/keeper": {
|
||||
Type: common.TypeAssistant,
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
common.SaveLockfile(appRoot, lf)
|
||||
|
||||
srv := mockRegistryServer(nil)
|
||||
defer srv.Close()
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Fork("@yao/keeper", ForkOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify forked directory
|
||||
forkDir := filepath.Join(appRoot, "assistants", "local", "keeper")
|
||||
if _, err := os.Stat(forkDir); err != nil {
|
||||
t.Fatal("expected forked directory")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(forkDir, "package.yao"))
|
||||
if string(data) != `{"name":"keeper"}` {
|
||||
t.Errorf("expected copied content, got: %s", data)
|
||||
}
|
||||
|
||||
// Verify lockfile
|
||||
lf, _ = common.LoadLockfile(appRoot)
|
||||
pkg, ok := lf.GetPackage("@local/keeper")
|
||||
if !ok {
|
||||
t.Fatal("expected @local/keeper in lockfile")
|
||||
}
|
||||
if pkg.ForkedFrom != "@yao/keeper" {
|
||||
t.Errorf("expected forked_from @yao/keeper, got %s", pkg.ForkedFrom)
|
||||
}
|
||||
if pkg.IsManaged() {
|
||||
t.Error("expected managed=false")
|
||||
}
|
||||
if pkg.Version != "2.0.0" {
|
||||
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkFromRegistry(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zip := buildTestZip("@test", "remote-agent", "3.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"remote-agent"}`,
|
||||
})
|
||||
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@test/remote-agent": zip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Fork("@test/remote-agent", ForkOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork from registry failed: %v", err)
|
||||
}
|
||||
|
||||
forkDir := filepath.Join(appRoot, "assistants", "local", "remote-agent")
|
||||
if _, err := os.Stat(forkDir); err != nil {
|
||||
t.Fatal("expected forked directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkTargetExists(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Create target directory
|
||||
os.MkdirAll(filepath.Join(appRoot, "assistants", "local", "existing"), 0755)
|
||||
|
||||
srv := mockRegistryServer(nil)
|
||||
defer srv.Close()
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Fork("@yao/existing", ForkOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when target exists")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkCustomScope(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zip := buildTestZip("@yao", "keeper", "1.0.0", nil, map[string]string{
|
||||
"package.yao": `{"name":"keeper"}`,
|
||||
})
|
||||
|
||||
srv := mockRegistryServer(map[string][]byte{
|
||||
"assistants/@yao/keeper": zip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Fork("@yao/keeper", ForkOptions{TargetScope: "max"})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork to custom scope failed: %v", err)
|
||||
}
|
||||
|
||||
forkDir := filepath.Join(appRoot, "assistants", "max", "keeper")
|
||||
if _, err := os.Stat(forkDir); err != nil {
|
||||
t.Fatal("expected directory in max scope")
|
||||
}
|
||||
|
||||
lf, _ := common.LoadLockfile(appRoot)
|
||||
if _, ok := lf.GetPackage("@max/keeper"); !ok {
|
||||
t.Error("expected @max/keeper in lockfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDependencies(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Create assistant with MCP dependency
|
||||
assistantDir := filepath.Join(appRoot, "assistants", "max", "test-scan")
|
||||
os.MkdirAll(assistantDir, 0755)
|
||||
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{
|
||||
"name":"test-scan",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"rag": {"server_id": "yao.rag-tools"}
|
||||
}
|
||||
}
|
||||
}`), 0644)
|
||||
|
||||
// Create scoped MCP directory so it gets picked up
|
||||
os.MkdirAll(filepath.Join(appRoot, "mcps", "yao", "rag-tools"), 0755)
|
||||
|
||||
deps, err := ScanDependencies(assistantDir, appRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := deps["@yao/rag-tools"]; !ok {
|
||||
t.Error("expected @yao/rag-tools in scanned dependencies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDependenciesSkipUnscoped(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
assistantDir := filepath.Join(appRoot, "assistants", "max", "test-local")
|
||||
os.MkdirAll(assistantDir, 0755)
|
||||
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{
|
||||
"name":"test-local",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"echo": {"server_id": "echo"}
|
||||
}
|
||||
}
|
||||
}`), 0644)
|
||||
|
||||
deps, err := ScanDependencies(assistantDir, appRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// "echo" has no dot, so IDFromYaoID should fail and it should be skipped
|
||||
if len(deps) != 0 {
|
||||
t.Errorf("expected no dependencies for unscoped MCP, got %v", deps)
|
||||
}
|
||||
}
|
||||
141
registry/manager/agent/fork.go
Normal file
141
registry/manager/agent/fork.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// ForkOptions configures the Fork operation.
|
||||
type ForkOptions struct {
|
||||
TargetScope string // target scope, defaults to lockfile's default scope (usually "local")
|
||||
}
|
||||
|
||||
// Fork copies an assistant to a new scope for local modification.
|
||||
// Flow per DESIGN.md Fork:
|
||||
// 1. If locally installed → copy directory
|
||||
// 2. If not installed → pull from registry
|
||||
// 3. Place in target scope directory
|
||||
// 4. Write registry.yao with managed:false
|
||||
// 5. Hot-reload
|
||||
func (m *Manager) Fork(pkgID string, opts ForkOptions) error {
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetScope := opts.TargetScope
|
||||
if targetScope == "" {
|
||||
targetScope = lf.DefaultScope()
|
||||
}
|
||||
|
||||
targetPkgID := common.FormatPackageID(targetScope, name)
|
||||
|
||||
// Check if target already exists
|
||||
targetDir := common.PackageDir(common.TypeAssistant, targetScope, name, m.appRoot)
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
return fmt.Errorf("target directory %s already exists", targetDir)
|
||||
}
|
||||
|
||||
sourceDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
|
||||
|
||||
if _, ok := lf.GetPackage(pkgID); ok {
|
||||
// Local copy
|
||||
if err := copyDir(sourceDir, targetDir); err != nil {
|
||||
return fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Pull from registry
|
||||
regType := common.TypeToRegistryType(common.TypeAssistant)
|
||||
zipData, _, err := m.client.Pull(regType, "@"+scope, name, "latest")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.UnpackTo(zipData, targetDir); err != nil {
|
||||
return fmt.Errorf("unpack: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute file hashes
|
||||
relDir := common.PackageDirRel(common.TypeAssistant, targetScope, name)
|
||||
files, err := common.HashDir(targetDir, relDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash files: %w", err)
|
||||
}
|
||||
|
||||
// Write lockfile entry
|
||||
info := common.PackageInfo{
|
||||
Type: common.TypeAssistant,
|
||||
Version: "0.0.0",
|
||||
ForkedFrom: pkgID,
|
||||
Managed: common.BoolPtr(false),
|
||||
Files: files,
|
||||
}
|
||||
|
||||
// Try to get version from source
|
||||
if existing, ok := lf.GetPackage(pkgID); ok {
|
||||
info.Version = existing.Version
|
||||
}
|
||||
|
||||
lf.SetPackage(targetPkgID, info)
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
yaoID := targetScope + "." + name
|
||||
fmt.Printf("✓ Forked %s → %s (ID: %s)\n", pkgID, targetDir, yaoID)
|
||||
fmt.Printf(" Internal references (mcp.servers, uses) still point to original scope.\n")
|
||||
fmt.Printf(" Edit package.yao if you need to change them.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
78
registry/manager/agent/push.go
Normal file
78
registry/manager/agent/push.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// PushOptions configures the Push operation.
|
||||
type PushOptions struct {
|
||||
Version string // required semver
|
||||
}
|
||||
|
||||
// Push packages and uploads an assistant to the registry.
|
||||
// Flow per DESIGN-AGENT.md:
|
||||
// 1. Yao ID → path
|
||||
// 2. Validate package.yao exists
|
||||
// 3. Derive scope/name from path
|
||||
// 4. Reject @local
|
||||
// 5. Pack directory (including embedded mcps/)
|
||||
// 6. Scan external dependencies
|
||||
// 7. Generate pkg.yao manifest
|
||||
// 8. Push to registry
|
||||
func (m *Manager) Push(yaoID string, opts PushOptions) error {
|
||||
if opts.Version == "" {
|
||||
return fmt.Errorf("--version is required for push")
|
||||
}
|
||||
|
||||
scope, name, err := common.IDFromYaoID(yaoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid assistant ID %q: %w", yaoID, err)
|
||||
}
|
||||
|
||||
if common.IsLocalScope(scope) {
|
||||
return fmt.Errorf("cannot push @local packages. Fork to your own scope first")
|
||||
}
|
||||
|
||||
assistantDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
|
||||
|
||||
// Validate package.yao exists
|
||||
pkgYaoPath := filepath.Join(assistantDir, "package.yao")
|
||||
if _, err := os.Stat(pkgYaoPath); err != nil {
|
||||
return fmt.Errorf("package.yao not found at %s", pkgYaoPath)
|
||||
}
|
||||
|
||||
// Scan external dependencies
|
||||
scannedDeps, err := ScanDependencies(assistantDir, m.appRoot)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠ Warning: could not scan dependencies: %v\n", err)
|
||||
scannedDeps = map[string]string{}
|
||||
}
|
||||
|
||||
manifest := &common.PkgManifest{
|
||||
Type: common.TypeAssistant,
|
||||
Scope: "@" + scope,
|
||||
Name: name,
|
||||
Version: opts.Version,
|
||||
Dependencies: scannedDeps,
|
||||
}
|
||||
|
||||
// Pack the directory
|
||||
zipData, err := common.PackDir(assistantDir, manifest, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pack: %w", err)
|
||||
}
|
||||
|
||||
// Push to registry
|
||||
regType := common.TypeToRegistryType(common.TypeAssistant)
|
||||
result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Pushed %s@%s (digest: %s)\n", common.FormatPackageID(scope, name), result.Version, result.Digest)
|
||||
return nil
|
||||
}
|
||||
89
registry/manager/agent/scan.go
Normal file
89
registry/manager/agent/scan.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
goujson "github.com/yaoapp/gou/json"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// packageYao represents the assistant's package.yao DSL (subset of fields we care about).
|
||||
type packageYao struct {
|
||||
MCP *mcpConfig `json:"mcp,omitempty"`
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
Uses json.RawMessage `json:"uses,omitempty"`
|
||||
}
|
||||
|
||||
type mcpConfig struct {
|
||||
Servers map[string]mcpServerEntry `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
type mcpServerEntry struct {
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
// ScanDependencies scans an assistant directory's package.yao for external dependencies.
|
||||
// It finds MCP dependencies from mcp.servers and returns them as "@scope/name" → "*" entries.
|
||||
// Only MCPs with a scope directory (mcps/{scope}/) are included; top-level mcps/ are skipped.
|
||||
func ScanDependencies(assistantDir, appRoot string) (map[string]string, error) {
|
||||
pkgPath := filepath.Join(assistantDir, "package.yao")
|
||||
data, err := os.ReadFile(pkgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read package.yao: %w", err)
|
||||
}
|
||||
|
||||
var pkg packageYao
|
||||
if err := goujson.ParseFile("package.yao", data, &pkg); err != nil {
|
||||
return nil, fmt.Errorf("parse package.yao: %w", err)
|
||||
}
|
||||
|
||||
deps := map[string]string{}
|
||||
|
||||
// Scan MCP servers
|
||||
if pkg.MCP != nil {
|
||||
for _, entry := range pkg.MCP.Servers {
|
||||
serverID := entry.ServerID
|
||||
if serverID == "" {
|
||||
continue
|
||||
}
|
||||
pkgID, err := resolveMCPDep(serverID, appRoot)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pkgID != "" {
|
||||
deps[pkgID] = "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deps, nil
|
||||
}
|
||||
|
||||
// resolveMCPDep resolves an MCP server_id to a package ID if it lives under a scoped directory.
|
||||
// Returns empty string for non-scoped (local) MCPs.
|
||||
func resolveMCPDep(serverID, appRoot string) (string, error) {
|
||||
// server_id like "yao.rag-tools" → scope=yao, name=rag-tools
|
||||
// Check if mcps/yao/rag-tools/ exists (has scope directory)
|
||||
scope, name, err := common.IDFromYaoID(serverID)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
mcpDir := filepath.Join(appRoot, "mcps", scope, strings.ReplaceAll(name, ".", "/"))
|
||||
if _, err := os.Stat(mcpDir); err == nil {
|
||||
return common.FormatPackageID(scope, name), nil
|
||||
}
|
||||
|
||||
// Also check for single-file MCP: mcps/{scope}/{name}.mcp.yao
|
||||
// This is less common but possible
|
||||
mcpFile := filepath.Join(appRoot, "mcps", scope, name+".mcp.yao")
|
||||
if _, err := os.Stat(mcpFile); err == nil {
|
||||
return common.FormatPackageID(scope, name), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
204
registry/manager/agent/update.go
Normal file
204
registry/manager/agent/update.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// UpdateOptions configures the Update operation.
|
||||
type UpdateOptions struct {
|
||||
Version string // target version or dist-tag, default "latest"
|
||||
}
|
||||
|
||||
// Update performs a hash-based safe update per DESIGN.md Update Strategy:
|
||||
// 1. Confirm installed and managed
|
||||
// 2. Pull new version
|
||||
// 3. Check required_by compatibility
|
||||
// 4. Per-file hash comparison: overwrite unmodified, skip modified (.new), add new, delete removed
|
||||
// 5. Update registry.yao
|
||||
// 6. Hot-reload
|
||||
func (m *Manager) Update(pkgID string, opts UpdateOptions) error {
|
||||
if opts.Version == "" {
|
||||
opts.Version = "latest"
|
||||
}
|
||||
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, ok := lf.GetPackage(pkgID)
|
||||
if !ok {
|
||||
return fmt.Errorf("package %s is not installed", pkgID)
|
||||
}
|
||||
if !existing.IsManaged() {
|
||||
return fmt.Errorf("package %s is forked (from %s) and not managed by registry", pkgID, existing.ForkedFrom)
|
||||
}
|
||||
|
||||
// Pull new version
|
||||
regType := common.TypeToRegistryType(common.TypeAssistant)
|
||||
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
|
||||
manifest, err := common.ReadManifest(zipData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check required_by compatibility
|
||||
if len(existing.RequiredBy) > 0 {
|
||||
var warnings []string
|
||||
for _, depID := range existing.RequiredBy {
|
||||
depPkg, depOK := lf.GetPackage(depID)
|
||||
if !depOK {
|
||||
continue
|
||||
}
|
||||
if constraint, has := depPkg.Dependencies[pkgID]; has {
|
||||
if !common.VersionSatisfies(manifest.Version, constraint) {
|
||||
warnings = append(warnings, fmt.Sprintf(" %s requires %s ← ⚠ incompatible", depID, constraint))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
msg := fmt.Sprintf("%s is depended on by:\n%s\nContinue update?", pkgID, strings.Join(warnings, "\n"))
|
||||
if !m.prompter.Confirm(msg) {
|
||||
return fmt.Errorf("update aborted by user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
|
||||
relDir := common.PackageDirRel(common.TypeAssistant, scope, name)
|
||||
|
||||
// Get list of new files from zip
|
||||
newFiles, err := common.ListZipFiles(zipData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a set of new file relative paths (with full relDir prefix)
|
||||
newFileSet := map[string]bool{}
|
||||
for _, f := range newFiles {
|
||||
newFileSet[relDir+"/"+f] = true
|
||||
}
|
||||
|
||||
newHashes := map[string]string{}
|
||||
|
||||
// Per-file comparison
|
||||
for _, f := range newFiles {
|
||||
fullRel := relDir + "/" + f
|
||||
localPath := filepath.Join(destDir, f)
|
||||
oldHash, wasTracked := existing.Files[fullRel]
|
||||
|
||||
// Read new file content from zip
|
||||
newContent, err := common.ExtractFile(zipData, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newHash := common.HashBytes(newContent)
|
||||
|
||||
if !wasTracked {
|
||||
// New file in new version → add
|
||||
if err := writeFile(localPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("+ %s — new file, added\n", f)
|
||||
newHashes[fullRel] = newHash
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if local file was modified
|
||||
localHash, err := common.HashFile(localPath)
|
||||
if err != nil {
|
||||
// File might have been deleted locally, just write it
|
||||
if err := writeFile(localPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ %s — restored (was missing locally)\n", f)
|
||||
newHashes[fullRel] = newHash
|
||||
continue
|
||||
}
|
||||
|
||||
if localHash == oldHash {
|
||||
// Unmodified → overwrite
|
||||
if err := writeFile(localPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ %s — unmodified, updated\n", f)
|
||||
newHashes[fullRel] = newHash
|
||||
} else {
|
||||
// Locally modified → skip, save new version as .new
|
||||
newPath := localPath + ".new"
|
||||
if err := writeFile(newPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✗ %s — locally modified, skipped (new version → %s.new)\n", f, f)
|
||||
// Update hash to new version's hash per DESIGN.md
|
||||
newHashes[fullRel] = newHash
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleted files (in old but not in new)
|
||||
for oldFile, oldHash := range existing.Files {
|
||||
if newFileSet[oldFile] {
|
||||
continue
|
||||
}
|
||||
localPath := filepath.Join(m.appRoot, filepath.FromSlash(oldFile))
|
||||
localHash, err := common.HashFile(localPath)
|
||||
if err != nil {
|
||||
// Already gone
|
||||
continue
|
||||
}
|
||||
if localHash == oldHash {
|
||||
// Unmodified → delete
|
||||
os.Remove(localPath)
|
||||
fmt.Printf("- %s — removed (deleted in new version)\n", filepath.Base(oldFile))
|
||||
} else {
|
||||
fmt.Printf("⚠ %s — locally modified, kept (deleted in new version)\n", filepath.Base(oldFile))
|
||||
// Keep it, but don't track it anymore
|
||||
}
|
||||
}
|
||||
|
||||
// Check new version dependencies
|
||||
if len(manifest.Dependencies) > 0 {
|
||||
if err := m.installDependencies(manifest.Dependencies, lf, pkgID, map[string]bool{pkgID: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update lockfile
|
||||
existing.Version = manifest.Version
|
||||
existing.Integrity = digest
|
||||
existing.Dependencies = manifest.Dependencies
|
||||
existing.Files = newHashes
|
||||
lf.SetPackage(pkgID, existing)
|
||||
|
||||
for depID := range manifest.Dependencies {
|
||||
lf.AddRequiredBy(depID, pkgID)
|
||||
}
|
||||
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Updated %s to %s\n", pkgID, manifest.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFile(path string, content []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, content, 0644)
|
||||
}
|
||||
148
registry/manager/common/deps.go
Normal file
148
registry/manager/common/deps.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DepStatus represents the status of a dependency check.
|
||||
type DepStatus int
|
||||
|
||||
const (
|
||||
DepNotInstalled DepStatus = iota // Not installed at all
|
||||
DepSatisfied // Installed and version satisfies requirement
|
||||
DepConflict // Installed but version does not satisfy requirement
|
||||
)
|
||||
|
||||
// DepCheckResult holds the result of checking a single dependency.
|
||||
type DepCheckResult struct {
|
||||
PackageID string
|
||||
RequiredVersion string
|
||||
InstalledVersion string
|
||||
Status DepStatus
|
||||
}
|
||||
|
||||
// CheckDependencies checks each dependency from the manifest against the lockfile.
|
||||
// Returns missing, conflicting, and satisfied dependencies.
|
||||
func CheckDependencies(deps map[string]string, lf *RegistryYao) (missing, conflicts, satisfied []DepCheckResult) {
|
||||
for pkgID, requiredVer := range deps {
|
||||
installed, ok := lf.GetPackage(pkgID)
|
||||
if !ok {
|
||||
missing = append(missing, DepCheckResult{
|
||||
PackageID: pkgID,
|
||||
RequiredVersion: requiredVer,
|
||||
Status: DepNotInstalled,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if VersionSatisfies(installed.Version, requiredVer) {
|
||||
satisfied = append(satisfied, DepCheckResult{
|
||||
PackageID: pkgID,
|
||||
RequiredVersion: requiredVer,
|
||||
InstalledVersion: installed.Version,
|
||||
Status: DepSatisfied,
|
||||
})
|
||||
} else {
|
||||
conflicts = append(conflicts, DepCheckResult{
|
||||
PackageID: pkgID,
|
||||
RequiredVersion: requiredVer,
|
||||
InstalledVersion: installed.Version,
|
||||
Status: DepConflict,
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DetectCycle checks if adding pkgID to the installing set would cause a cycle.
|
||||
// Returns true if a cycle is detected.
|
||||
func DetectCycle(installing map[string]bool, pkgID string) bool {
|
||||
return installing[pkgID]
|
||||
}
|
||||
|
||||
// VersionSatisfies checks if installedVer satisfies the constraint.
|
||||
// Supports:
|
||||
// - "^X.Y.Z" — same major, >= minor.patch
|
||||
// - ">=X.Y.Z" — greater or equal
|
||||
// - "X.Y.Z" — exact match
|
||||
// - "*" — any version
|
||||
func VersionSatisfies(installedVer, constraint string) bool {
|
||||
constraint = strings.TrimSpace(constraint)
|
||||
if constraint == "" || constraint == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(constraint, "^") {
|
||||
return caretSatisfies(installedVer, constraint[1:])
|
||||
}
|
||||
if strings.HasPrefix(constraint, ">=") {
|
||||
return compareVersions(installedVer, strings.TrimSpace(constraint[2:])) >= 0
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return installedVer == constraint
|
||||
}
|
||||
|
||||
// caretSatisfies implements ^X.Y.Z: same major version, >= the specified version.
|
||||
func caretSatisfies(installed, minVer string) bool {
|
||||
iMajor, iMinor, iPatch, err := parseVersion(installed)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mMajor, mMinor, mPatch, err := parseVersion(minVer)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if iMajor != mMajor {
|
||||
return false
|
||||
}
|
||||
if iMinor > mMinor {
|
||||
return true
|
||||
}
|
||||
if iMinor == mMinor {
|
||||
return iPatch >= mPatch
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compareVersions(a, b string) int {
|
||||
aMaj, aMin, aPat, err1 := parseVersion(a)
|
||||
bMaj, bMin, bPat, err2 := parseVersion(b)
|
||||
if err1 != nil || err2 != nil {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
if a > b {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
if aMaj != bMaj {
|
||||
return aMaj - bMaj
|
||||
}
|
||||
if aMin != bMin {
|
||||
return aMin - bMin
|
||||
}
|
||||
return aPat - bPat
|
||||
}
|
||||
|
||||
func parseVersion(v string) (major, minor, patch int, err error) {
|
||||
v = strings.TrimSpace(v)
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return 0, 0, 0, fmt.Errorf("invalid version %q", v)
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &major); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid major in %q", v)
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &minor); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid minor in %q", v)
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[2], "%d", &patch); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid patch in %q", v)
|
||||
}
|
||||
return major, minor, patch, nil
|
||||
}
|
||||
133
registry/manager/common/deps_test.go
Normal file
133
registry/manager/common/deps_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionSatisfies(t *testing.T) {
|
||||
tests := []struct {
|
||||
installed string
|
||||
constraint string
|
||||
want bool
|
||||
}{
|
||||
{"1.0.0", "^1.0.0", true},
|
||||
{"1.2.3", "^1.0.0", true},
|
||||
{"1.0.1", "^1.0.0", true},
|
||||
{"2.0.0", "^1.0.0", false},
|
||||
{"0.9.0", "^1.0.0", false},
|
||||
{"1.0.0", ">=1.0.0", true},
|
||||
{"2.0.0", ">=1.0.0", true},
|
||||
{"0.9.0", ">=1.0.0", false},
|
||||
{"1.0.0", "1.0.0", true},
|
||||
{"1.0.1", "1.0.0", false},
|
||||
{"1.0.0", "*", true},
|
||||
{"1.0.0", "", true},
|
||||
{"1.3.0", "^1.0.0", true},
|
||||
{"1.0.0", "^1.3.0", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := VersionSatisfies(tt.installed, tt.constraint)
|
||||
if got != tt.want {
|
||||
t.Errorf("VersionSatisfies(%q, %q) = %v, want %v", tt.installed, tt.constraint, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDependencies(t *testing.T) {
|
||||
lf := &RegistryYao{
|
||||
Packages: map[string]PackageInfo{
|
||||
"@yao/rag-tools": {Type: TypeMCP, Version: "1.3.0"},
|
||||
"@yao/title-gen": {Type: TypeAssistant, Version: "1.0.0"},
|
||||
"@yao/old-helper": {Type: TypeAssistant, Version: "0.5.0"},
|
||||
},
|
||||
}
|
||||
|
||||
deps := map[string]string{
|
||||
"@yao/rag-tools": "^1.0.0",
|
||||
"@yao/title-gen": "^2.0.0", // conflict: installed 1.0.0, needs ^2.0.0
|
||||
"@yao/old-helper": "^0.5.0",
|
||||
"@yao/new-pkg": "^1.0.0", // missing
|
||||
}
|
||||
|
||||
missing, conflicts, satisfied := CheckDependencies(deps, lf)
|
||||
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("expected 1 missing, got %d", len(missing))
|
||||
}
|
||||
if missing[0].PackageID != "@yao/new-pkg" {
|
||||
t.Errorf("expected @yao/new-pkg missing, got %s", missing[0].PackageID)
|
||||
}
|
||||
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
if conflicts[0].PackageID != "@yao/title-gen" {
|
||||
t.Errorf("expected @yao/title-gen conflict, got %s", conflicts[0].PackageID)
|
||||
}
|
||||
if conflicts[0].InstalledVersion != "1.0.0" {
|
||||
t.Errorf("expected installed 1.0.0, got %s", conflicts[0].InstalledVersion)
|
||||
}
|
||||
|
||||
if len(satisfied) != 2 {
|
||||
t.Fatalf("expected 2 satisfied, got %d", len(satisfied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDependenciesEmptyLockfile(t *testing.T) {
|
||||
lf := &RegistryYao{Packages: map[string]PackageInfo{}}
|
||||
deps := map[string]string{
|
||||
"@yao/a": "^1.0.0",
|
||||
"@yao/b": "^2.0.0",
|
||||
}
|
||||
|
||||
missing, conflicts, satisfied := CheckDependencies(deps, lf)
|
||||
if len(missing) != 2 {
|
||||
t.Errorf("expected 2 missing, got %d", len(missing))
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected 0 conflicts, got %d", len(conflicts))
|
||||
}
|
||||
if len(satisfied) != 0 {
|
||||
t.Errorf("expected 0 satisfied, got %d", len(satisfied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCycle(t *testing.T) {
|
||||
installing := map[string]bool{
|
||||
"@yao/keeper": true,
|
||||
}
|
||||
|
||||
if !DetectCycle(installing, "@yao/keeper") {
|
||||
t.Error("expected cycle detected for @yao/keeper")
|
||||
}
|
||||
if DetectCycle(installing, "@yao/other") {
|
||||
t.Error("expected no cycle for @yao/other")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
if compareVersions("1.0.0", "1.0.0") != 0 {
|
||||
t.Error("1.0.0 == 1.0.0")
|
||||
}
|
||||
if compareVersions("2.0.0", "1.0.0") <= 0 {
|
||||
t.Error("2.0.0 > 1.0.0")
|
||||
}
|
||||
if compareVersions("1.0.0", "2.0.0") >= 0 {
|
||||
t.Error("1.0.0 < 2.0.0")
|
||||
}
|
||||
if compareVersions("1.1.0", "1.0.0") <= 0 {
|
||||
t.Error("1.1.0 > 1.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersionInvalid(t *testing.T) {
|
||||
_, _, _, err := parseVersion("bad")
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
_, _, _, err = parseVersion("1.2")
|
||||
if err == nil {
|
||||
t.Error("expected error for 2-part version")
|
||||
}
|
||||
}
|
||||
60
registry/manager/common/hash.go
Normal file
60
registry/manager/common/hash.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HashFile computes the SHA-256 hash of a file and returns it as "sha256-<hex>".
|
||||
func HashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("sha256-%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// HashBytes computes the SHA-256 hash of raw bytes and returns "sha256-<hex>".
|
||||
func HashBytes(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return fmt.Sprintf("sha256-%x", h[:])
|
||||
}
|
||||
|
||||
// HashDir walks a directory and returns a map of relative paths to their SHA-256 hashes.
|
||||
// The relPrefix is prepended to each relative path (use "" for no prefix).
|
||||
func HashDir(dir, relPrefix string) (map[string]string, error) {
|
||||
result := map[string]string{}
|
||||
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)
|
||||
if relPrefix != "" {
|
||||
rel = strings.TrimRight(filepath.ToSlash(relPrefix), "/") + "/" + rel
|
||||
}
|
||||
hash, err := HashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result[rel] = hash
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
87
registry/manager/common/hash_test.go
Normal file
87
registry/manager/common/hash_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hash, err := HashFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "sha256-") {
|
||||
t.Errorf("expected sha256- prefix, got %q", hash)
|
||||
}
|
||||
if len(hash) != 7+64 { // "sha256-" + 64 hex chars
|
||||
t.Errorf("unexpected hash length: %d", len(hash))
|
||||
}
|
||||
|
||||
// Same content should produce same hash
|
||||
hash2, _ := HashFile(path)
|
||||
if hash != hash2 {
|
||||
t.Error("same file should produce same hash")
|
||||
}
|
||||
|
||||
// Non-existent file
|
||||
_, err = HashFile(filepath.Join(dir, "nope.txt"))
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashBytes(t *testing.T) {
|
||||
h := HashBytes([]byte("hello world"))
|
||||
if !strings.HasPrefix(h, "sha256-") {
|
||||
t.Errorf("expected sha256- prefix, got %q", h)
|
||||
}
|
||||
|
||||
h2 := HashBytes([]byte("hello world"))
|
||||
if h != h2 {
|
||||
t.Error("same bytes should produce same hash")
|
||||
}
|
||||
|
||||
h3 := HashBytes([]byte("different"))
|
||||
if h == h3 {
|
||||
t.Error("different bytes should produce different hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("bbb"), 0644)
|
||||
|
||||
// Without prefix
|
||||
hashes, err := HashDir(dir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hashes) != 2 {
|
||||
t.Errorf("expected 2 files, got %d", len(hashes))
|
||||
}
|
||||
if _, ok := hashes["a.txt"]; !ok {
|
||||
t.Error("expected a.txt in hashes")
|
||||
}
|
||||
if _, ok := hashes["sub/b.txt"]; !ok {
|
||||
t.Error("expected sub/b.txt in hashes")
|
||||
}
|
||||
|
||||
// With prefix
|
||||
hashes, err = HashDir(dir, "mcps/yao/rag-tools")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := hashes["mcps/yao/rag-tools/a.txt"]; !ok {
|
||||
t.Error("expected prefixed path")
|
||||
}
|
||||
}
|
||||
119
registry/manager/common/lockfile.go
Normal file
119
registry/manager/common/lockfile.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const lockfileName = "registry.yao"
|
||||
|
||||
// LoadLockfile reads registry.yao from appRoot. Returns an empty lockfile if
|
||||
// the file does not exist.
|
||||
func LoadLockfile(appRoot string) (*RegistryYao, error) {
|
||||
path := filepath.Join(appRoot, lockfileName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &RegistryYao{
|
||||
Scope: "@local",
|
||||
Packages: map[string]PackageInfo{},
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", lockfileName, err)
|
||||
}
|
||||
|
||||
var lf RegistryYao
|
||||
if err := json.Unmarshal(data, &lf); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", lockfileName, err)
|
||||
}
|
||||
if lf.Packages == nil {
|
||||
lf.Packages = map[string]PackageInfo{}
|
||||
}
|
||||
if lf.Scope == "" {
|
||||
lf.Scope = "@local"
|
||||
}
|
||||
return &lf, nil
|
||||
}
|
||||
|
||||
// SaveLockfile writes registry.yao to appRoot.
|
||||
func SaveLockfile(appRoot string, lf *RegistryYao) error {
|
||||
path := filepath.Join(appRoot, lockfileName)
|
||||
data, err := json.MarshalIndent(lf, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", lockfileName, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// GetPackage returns the package info and existence flag for a given ID.
|
||||
func (lf *RegistryYao) GetPackage(pkgID string) (PackageInfo, bool) {
|
||||
info, ok := lf.Packages[pkgID]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
// SetPackage adds or updates a package entry.
|
||||
func (lf *RegistryYao) SetPackage(pkgID string, info PackageInfo) {
|
||||
lf.Packages[pkgID] = info
|
||||
}
|
||||
|
||||
// RemovePackage removes a package entry and cleans up required_by references.
|
||||
func (lf *RegistryYao) RemovePackage(pkgID string) {
|
||||
pkg, ok := lf.Packages[pkgID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove this package from the required_by lists of its dependencies
|
||||
for depID := range pkg.Dependencies {
|
||||
if dep, exists := lf.Packages[depID]; exists {
|
||||
dep.RequiredBy = removeFromSlice(dep.RequiredBy, pkgID)
|
||||
lf.Packages[depID] = dep
|
||||
}
|
||||
}
|
||||
|
||||
delete(lf.Packages, pkgID)
|
||||
}
|
||||
|
||||
// AddRequiredBy adds a reverse dependency reference.
|
||||
func (lf *RegistryYao) AddRequiredBy(depID, requiredByID string) {
|
||||
dep, ok := lf.Packages[depID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, id := range dep.RequiredBy {
|
||||
if id == requiredByID {
|
||||
return
|
||||
}
|
||||
}
|
||||
dep.RequiredBy = append(dep.RequiredBy, requiredByID)
|
||||
lf.Packages[depID] = dep
|
||||
}
|
||||
|
||||
// DefaultScope returns the user's default scope (from the "scope" field).
|
||||
// Returns "local" (without @) for use in directory paths.
|
||||
func (lf *RegistryYao) DefaultScope() string {
|
||||
scope := lf.Scope
|
||||
if scope == "" {
|
||||
scope = "@local"
|
||||
}
|
||||
if len(scope) > 0 && scope[0] == '@' {
|
||||
return scope[1:]
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func removeFromSlice(s []string, item string) []string {
|
||||
result := make([]string, 0, len(s))
|
||||
for _, v := range s {
|
||||
if v != item {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
178
registry/manager/common/lockfile_test.go
Normal file
178
registry/manager/common/lockfile_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadLockfileNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lf, err := LoadLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lf.Scope != "@local" {
|
||||
t.Errorf("expected @local scope, got %q", lf.Scope)
|
||||
}
|
||||
if len(lf.Packages) != 0 {
|
||||
t.Errorf("expected empty packages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAndSaveLockfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
lf := &RegistryYao{
|
||||
Scope: "@local",
|
||||
Packages: map[string]PackageInfo{
|
||||
"@yao/keeper": {
|
||||
Type: TypeAssistant,
|
||||
Version: "2.0.0",
|
||||
Files: map[string]string{"package.yao": "sha256-aaa"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveLockfile(dir, lf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
data, err := os.ReadFile(filepath.Join(dir, "registry.yao"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("expected non-empty file")
|
||||
}
|
||||
|
||||
// Reload
|
||||
lf2, err := LoadLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lf2.Scope != "@local" {
|
||||
t.Errorf("scope mismatch: %q", lf2.Scope)
|
||||
}
|
||||
pkg, ok := lf2.GetPackage("@yao/keeper")
|
||||
if !ok {
|
||||
t.Fatal("expected @yao/keeper in lockfile")
|
||||
}
|
||||
if pkg.Version != "2.0.0" {
|
||||
t.Errorf("version mismatch: %q", pkg.Version)
|
||||
}
|
||||
if pkg.Files["package.yao"] != "sha256-aaa" {
|
||||
t.Error("files hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLockfileInvalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "registry.yao"), []byte("not json"), 0644)
|
||||
|
||||
_, err := LoadLockfile(dir)
|
||||
if err == nil {
|
||||
t.Error("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndGetPackage(t *testing.T) {
|
||||
lf := &RegistryYao{Packages: map[string]PackageInfo{}}
|
||||
lf.SetPackage("@yao/test", PackageInfo{Type: TypeMCP, Version: "1.0.0"})
|
||||
|
||||
pkg, ok := lf.GetPackage("@yao/test")
|
||||
if !ok {
|
||||
t.Fatal("expected package")
|
||||
}
|
||||
if pkg.Type != TypeMCP {
|
||||
t.Errorf("type mismatch: %q", pkg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePackage(t *testing.T) {
|
||||
lf := &RegistryYao{
|
||||
Packages: map[string]PackageInfo{
|
||||
"@yao/keeper": {
|
||||
Type: TypeAssistant,
|
||||
Version: "1.0.0",
|
||||
Dependencies: map[string]string{"@yao/rag-tools": "^1.0.0"},
|
||||
},
|
||||
"@yao/rag-tools": {
|
||||
Type: TypeMCP,
|
||||
Version: "1.0.0",
|
||||
RequiredBy: []string{"@yao/keeper"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lf.RemovePackage("@yao/keeper")
|
||||
|
||||
if _, ok := lf.GetPackage("@yao/keeper"); ok {
|
||||
t.Error("expected @yao/keeper removed")
|
||||
}
|
||||
|
||||
dep, ok := lf.GetPackage("@yao/rag-tools")
|
||||
if !ok {
|
||||
t.Fatal("expected @yao/rag-tools still present")
|
||||
}
|
||||
if len(dep.RequiredBy) != 0 {
|
||||
t.Errorf("expected required_by cleaned up, got %v", dep.RequiredBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRequiredBy(t *testing.T) {
|
||||
lf := &RegistryYao{
|
||||
Packages: map[string]PackageInfo{
|
||||
"@yao/rag-tools": {Type: TypeMCP, Version: "1.0.0"},
|
||||
},
|
||||
}
|
||||
|
||||
lf.AddRequiredBy("@yao/rag-tools", "@yao/keeper")
|
||||
lf.AddRequiredBy("@yao/rag-tools", "@yao/keeper") // duplicate, should not add again
|
||||
|
||||
dep, _ := lf.GetPackage("@yao/rag-tools")
|
||||
if len(dep.RequiredBy) != 1 {
|
||||
t.Errorf("expected 1 required_by entry, got %d", len(dep.RequiredBy))
|
||||
}
|
||||
if dep.RequiredBy[0] != "@yao/keeper" {
|
||||
t.Errorf("expected @yao/keeper, got %q", dep.RequiredBy[0])
|
||||
}
|
||||
|
||||
// Non-existent package should be a no-op
|
||||
lf.AddRequiredBy("@nonexistent/pkg", "@yao/keeper")
|
||||
}
|
||||
|
||||
func TestDefaultScope(t *testing.T) {
|
||||
lf := &RegistryYao{Scope: "@local"}
|
||||
if lf.DefaultScope() != "local" {
|
||||
t.Errorf("expected local, got %q", lf.DefaultScope())
|
||||
}
|
||||
|
||||
lf.Scope = "@max"
|
||||
if lf.DefaultScope() != "max" {
|
||||
t.Errorf("expected max, got %q", lf.DefaultScope())
|
||||
}
|
||||
|
||||
lf.Scope = ""
|
||||
if lf.DefaultScope() != "local" {
|
||||
t.Errorf("expected local for empty scope, got %q", lf.DefaultScope())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsManaged(t *testing.T) {
|
||||
p := PackageInfo{}
|
||||
if !p.IsManaged() {
|
||||
t.Error("nil managed should be true")
|
||||
}
|
||||
|
||||
p.Managed = BoolPtr(false)
|
||||
if p.IsManaged() {
|
||||
t.Error("false managed should be false")
|
||||
}
|
||||
|
||||
p.Managed = BoolPtr(true)
|
||||
if !p.IsManaged() {
|
||||
t.Error("true managed should be true")
|
||||
}
|
||||
}
|
||||
206
registry/manager/common/packer.go
Normal file
206
registry/manager/common/packer.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 rel == "pkg.yao" {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
158
registry/manager/common/packer_test.go
Normal file
158
registry/manager/common/packer_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPackAndUnpack(t *testing.T) {
|
||||
// Create source directory
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{"name":"test"}`), 0644)
|
||||
os.MkdirAll(filepath.Join(srcDir, "prompts"), 0755)
|
||||
os.WriteFile(filepath.Join(srcDir, "prompts", "main.md"), []byte("You are a test."), 0644)
|
||||
|
||||
manifest := &PkgManifest{
|
||||
Type: TypeAssistant,
|
||||
Scope: "test",
|
||||
Name: "demo",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
zipData, err := PackDir(srcDir, manifest, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PackDir: %v", err)
|
||||
}
|
||||
if len(zipData) == 0 {
|
||||
t.Fatal("expected non-empty zip")
|
||||
}
|
||||
|
||||
// Read manifest from zip
|
||||
m, err := ReadManifest(zipData)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest: %v", err)
|
||||
}
|
||||
if m.Type != TypeAssistant || m.Version != "1.0.0" {
|
||||
t.Errorf("unexpected manifest: %+v", m)
|
||||
}
|
||||
|
||||
// Unpack
|
||||
destDir := t.TempDir()
|
||||
files, err := UnpackTo(zipData, destDir)
|
||||
if err != nil {
|
||||
t.Fatalf("UnpackTo: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(files)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d: %v", len(files), files)
|
||||
}
|
||||
if files[0] != "package.yao" || files[1] != "prompts/main.md" {
|
||||
t.Errorf("unexpected files: %v", files)
|
||||
}
|
||||
|
||||
// Verify content
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "package.yao"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != `{"name":"test"}` {
|
||||
t.Errorf("unexpected content: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackDirWithExtraFiles(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(srcDir, "main.mcp.yao"), []byte("{}"), 0644)
|
||||
|
||||
// Create an extra file in a separate location
|
||||
extraDir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(extraDir, "scripts", "yao"), 0755)
|
||||
scriptPath := filepath.Join(extraDir, "scripts", "yao", "rag.ts")
|
||||
os.WriteFile(scriptPath, []byte("export function Search() {}"), 0644)
|
||||
|
||||
manifest := &PkgManifest{
|
||||
Type: TypeMCP,
|
||||
Scope: "yao",
|
||||
Name: "rag-tools",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
extraFiles := map[string]string{
|
||||
"scripts/yao/rag.ts": scriptPath,
|
||||
}
|
||||
|
||||
zipData, err := PackDir(srcDir, manifest, extraFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("PackDir with extras: %v", err)
|
||||
}
|
||||
|
||||
files, err := ListZipFiles(zipData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hasScript := false
|
||||
hasMCP := false
|
||||
for _, f := range files {
|
||||
if f == "scripts/yao/rag.ts" {
|
||||
hasScript = true
|
||||
}
|
||||
if f == "main.mcp.yao" {
|
||||
hasMCP = true
|
||||
}
|
||||
}
|
||||
if !hasScript {
|
||||
t.Error("expected scripts/yao/rag.ts in zip")
|
||||
}
|
||||
if !hasMCP {
|
||||
t.Error("expected main.mcp.yao in zip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadManifestMissing(t *testing.T) {
|
||||
// Create a zip without pkg.yao
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(srcDir, "test.txt"), []byte("hello"), 0644)
|
||||
|
||||
manifest := &PkgManifest{Type: "test", Version: "1.0.0"}
|
||||
zipData, err := PackDir(srcDir, manifest, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// This should succeed because PackDir always writes pkg.yao
|
||||
m, err := ReadManifest(zipData)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest should succeed: %v", err)
|
||||
}
|
||||
if m.Type != "test" {
|
||||
t.Errorf("unexpected type: %s", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFile(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(srcDir, "data.json"), []byte(`{"key":"value"}`), 0644)
|
||||
|
||||
manifest := &PkgManifest{Type: "test", Version: "1.0.0"}
|
||||
zipData, err := PackDir(srcDir, manifest, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := ExtractFile(zipData, "data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != `{"key":"value"}` {
|
||||
t.Errorf("unexpected: %s", data)
|
||||
}
|
||||
|
||||
_, err = ExtractFile(zipData, "missing.json")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
91
registry/manager/common/path.go
Normal file
91
registry/manager/common/path.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsePackageID parses "@scope/name" into (scope, name).
|
||||
// The leading "@" on scope is stripped.
|
||||
// Examples:
|
||||
//
|
||||
// "@yao/keeper" → ("yao", "keeper")
|
||||
// "@max/tools.search" → ("max", "tools.search")
|
||||
func ParsePackageID(id string) (scope, name string, err error) {
|
||||
if !strings.HasPrefix(id, "@") {
|
||||
return "", "", fmt.Errorf("invalid package ID %q: must start with @", id)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(id[1:], "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", fmt.Errorf("invalid package ID %q: expected @scope/name", id)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
// FormatPackageID formats scope and name into "@scope/name".
|
||||
func FormatPackageID(scope, name string) string {
|
||||
return "@" + scope + "/" + name
|
||||
}
|
||||
|
||||
// PackageDir returns the installation directory for a package relative to appRoot.
|
||||
// For assistants: assistants/{scope}/{name}/
|
||||
// For mcps: mcps/{scope}/{name}/
|
||||
func PackageDir(pkgType, scope, name, appRoot string) string {
|
||||
dir := TypeToDir(pkgType)
|
||||
namePath := strings.ReplaceAll(name, ".", "/")
|
||||
return filepath.Join(appRoot, dir, scope, namePath)
|
||||
}
|
||||
|
||||
// PackageDirRel returns the installation directory relative to appRoot (no leading appRoot prefix).
|
||||
func PackageDirRel(pkgType, scope, name string) string {
|
||||
dir := TypeToDir(pkgType)
|
||||
namePath := strings.ReplaceAll(name, ".", "/")
|
||||
return filepath.Join(dir, scope, namePath)
|
||||
}
|
||||
|
||||
// IDFromYaoID converts a Yao dot-separated ID to (scope, name).
|
||||
// "yao.keeper" → ("yao", "keeper")
|
||||
// "max.tools.search" → ("max", "tools.search")
|
||||
func IDFromYaoID(yaoID string) (scope, name string, err error) {
|
||||
idx := strings.Index(yaoID, ".")
|
||||
if idx <= 0 || idx >= len(yaoID)-1 {
|
||||
return "", "", fmt.Errorf("invalid Yao ID %q: expected scope.name", yaoID)
|
||||
}
|
||||
return yaoID[:idx], yaoID[idx+1:], nil
|
||||
}
|
||||
|
||||
// YaoIDFromPackageID converts "@scope/name" to "scope.name" (Yao dot-separated ID).
|
||||
func YaoIDFromPackageID(pkgID string) (string, error) {
|
||||
scope, name, err := ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scope + "." + name, nil
|
||||
}
|
||||
|
||||
// PackageIDFromYaoID converts "scope.name" to "@scope/name".
|
||||
func PackageIDFromYaoID(yaoID string) (string, error) {
|
||||
scope, name, err := IDFromYaoID(yaoID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatPackageID(scope, name), nil
|
||||
}
|
||||
|
||||
// ScopeFromPath extracts the scope from a file path relative to appRoot.
|
||||
// "assistants/yao/keeper/" → "yao"
|
||||
// "mcps/max/rag-tools/" → "max"
|
||||
func ScopeFromPath(relPath string) (string, error) {
|
||||
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("cannot extract scope from path %q", relPath)
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
// IsLocalScope returns true if the scope is "@local" or "local".
|
||||
func IsLocalScope(scope string) bool {
|
||||
return scope == "local" || scope == "@local"
|
||||
}
|
||||
163
registry/manager/common/path_test.go
Normal file
163
registry/manager/common/path_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePackageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
scope string
|
||||
name string
|
||||
expectErr bool
|
||||
}{
|
||||
{"@yao/keeper", "yao", "keeper", false},
|
||||
{"@max/tools.search", "max", "tools.search", false},
|
||||
{"@local/my-mcp", "local", "my-mcp", false},
|
||||
{"yao/keeper", "", "", true},
|
||||
{"@/keeper", "", "", true},
|
||||
{"@yao/", "", "", true},
|
||||
{"@yao", "", "", true},
|
||||
{"", "", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
scope, name, err := ParsePackageID(tt.input)
|
||||
if tt.expectErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParsePackageID(%q) expected error", tt.input)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParsePackageID(%q) unexpected error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if scope != tt.scope || name != tt.name {
|
||||
t.Errorf("ParsePackageID(%q) = (%q, %q), want (%q, %q)", tt.input, scope, name, tt.scope, tt.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPackageID(t *testing.T) {
|
||||
if got := FormatPackageID("yao", "keeper"); got != "@yao/keeper" {
|
||||
t.Errorf("FormatPackageID = %q, want @yao/keeper", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageDir(t *testing.T) {
|
||||
got := PackageDir(TypeAssistant, "yao", "keeper", "/app")
|
||||
want := "/app/assistants/yao/keeper"
|
||||
if got != want {
|
||||
t.Errorf("PackageDir = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = PackageDir(TypeMCP, "max", "rag-tools", "/app")
|
||||
want = "/app/mcps/max/rag-tools"
|
||||
if got != want {
|
||||
t.Errorf("PackageDir = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = PackageDir(TypeAssistant, "max", "tools.search", "/app")
|
||||
want = "/app/assistants/max/tools/search"
|
||||
if got != want {
|
||||
t.Errorf("PackageDir nested = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageDirRel(t *testing.T) {
|
||||
got := PackageDirRel(TypeAssistant, "yao", "keeper")
|
||||
want := "assistants/yao/keeper"
|
||||
if got != want {
|
||||
t.Errorf("PackageDirRel = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDFromYaoID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
scope string
|
||||
name string
|
||||
expectErr bool
|
||||
}{
|
||||
{"yao.keeper", "yao", "keeper", false},
|
||||
{"max.tools.search", "max", "tools.search", false},
|
||||
{"yao", "", "", true},
|
||||
{".keeper", "", "", true},
|
||||
{"yao.", "", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
scope, name, err := IDFromYaoID(tt.input)
|
||||
if tt.expectErr {
|
||||
if err == nil {
|
||||
t.Errorf("IDFromYaoID(%q) expected error", tt.input)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("IDFromYaoID(%q) unexpected error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if scope != tt.scope || name != tt.name {
|
||||
t.Errorf("IDFromYaoID(%q) = (%q, %q), want (%q, %q)", tt.input, scope, name, tt.scope, tt.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYaoIDFromPackageID(t *testing.T) {
|
||||
got, err := YaoIDFromPackageID("@yao/keeper")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "yao.keeper" {
|
||||
t.Errorf("YaoIDFromPackageID = %q, want yao.keeper", got)
|
||||
}
|
||||
|
||||
_, err = YaoIDFromPackageID("bad")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageIDFromYaoID(t *testing.T) {
|
||||
got, err := PackageIDFromYaoID("yao.keeper")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "@yao/keeper" {
|
||||
t.Errorf("PackageIDFromYaoID = %q, want @yao/keeper", got)
|
||||
}
|
||||
|
||||
_, err = PackageIDFromYaoID("bad")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeFromPath(t *testing.T) {
|
||||
got, err := ScopeFromPath("assistants/yao/keeper")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "yao" {
|
||||
t.Errorf("ScopeFromPath = %q, want yao", got)
|
||||
}
|
||||
|
||||
_, err = ScopeFromPath("single")
|
||||
if err == nil {
|
||||
t.Error("expected error for single-element path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalScope(t *testing.T) {
|
||||
if !IsLocalScope("local") {
|
||||
t.Error("expected local to be local scope")
|
||||
}
|
||||
if !IsLocalScope("@local") {
|
||||
t.Error("expected @local to be local scope")
|
||||
}
|
||||
if IsLocalScope("yao") {
|
||||
t.Error("expected yao to NOT be local scope")
|
||||
}
|
||||
}
|
||||
80
registry/manager/common/prompt.go
Normal file
80
registry/manager/common/prompt.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Prompter abstracts user interaction for testability.
|
||||
type Prompter interface {
|
||||
Confirm(message string) bool
|
||||
Choose(message string, options []string) int
|
||||
}
|
||||
|
||||
// StdinPrompter reads user input from stdin.
|
||||
type StdinPrompter struct{}
|
||||
|
||||
// Confirm asks a yes/no question. Returns true for "y" or "Y".
|
||||
func (p *StdinPrompter) Confirm(message string) bool {
|
||||
fmt.Printf("%s [Y/n] ", message)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
// Choose presents options and returns the 0-based index of the selection.
|
||||
func (p *StdinPrompter) Choose(message string, options []string) int {
|
||||
fmt.Println(message)
|
||||
for i, opt := range options {
|
||||
fmt.Printf(" [%d] %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Print("Enter choice: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(answer)
|
||||
|
||||
var choice int
|
||||
if _, err := fmt.Sscanf(answer, "%d", &choice); err != nil || choice < 1 || choice > len(options) {
|
||||
return -1
|
||||
}
|
||||
return choice - 1
|
||||
}
|
||||
|
||||
// AutoConfirmPrompter always confirms yes. Used for non-interactive mode and tests.
|
||||
type AutoConfirmPrompter struct{}
|
||||
|
||||
func (p *AutoConfirmPrompter) Confirm(message string) bool { return true }
|
||||
func (p *AutoConfirmPrompter) Choose(message string, _ []string) int { return 0 }
|
||||
|
||||
// MockPrompter records calls and returns pre-configured responses.
|
||||
type MockPrompter struct {
|
||||
ConfirmResponses []bool
|
||||
ChooseResponses []int
|
||||
ConfirmCalls []string
|
||||
ChooseCalls []string
|
||||
confirmIdx int
|
||||
chooseIdx int
|
||||
}
|
||||
|
||||
func (p *MockPrompter) Confirm(message string) bool {
|
||||
p.ConfirmCalls = append(p.ConfirmCalls, message)
|
||||
if p.confirmIdx < len(p.ConfirmResponses) {
|
||||
resp := p.ConfirmResponses[p.confirmIdx]
|
||||
p.confirmIdx++
|
||||
return resp
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *MockPrompter) Choose(message string, options []string) int {
|
||||
p.ChooseCalls = append(p.ChooseCalls, message)
|
||||
if p.chooseIdx < len(p.ChooseResponses) {
|
||||
resp := p.ChooseResponses[p.chooseIdx]
|
||||
p.chooseIdx++
|
||||
return resp
|
||||
}
|
||||
return 0
|
||||
}
|
||||
50
registry/manager/common/prompt_test.go
Normal file
50
registry/manager/common/prompt_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMockPrompter(t *testing.T) {
|
||||
m := &MockPrompter{
|
||||
ConfirmResponses: []bool{true, false},
|
||||
ChooseResponses: []int{1, 2},
|
||||
}
|
||||
|
||||
if !m.Confirm("install?") {
|
||||
t.Error("expected true")
|
||||
}
|
||||
if m.Confirm("upgrade?") {
|
||||
t.Error("expected false")
|
||||
}
|
||||
if m.Confirm("extra?") != true {
|
||||
t.Error("expected default true when responses exhausted")
|
||||
}
|
||||
|
||||
if len(m.ConfirmCalls) != 3 {
|
||||
t.Errorf("expected 3 confirm calls, got %d", len(m.ConfirmCalls))
|
||||
}
|
||||
|
||||
if m.Choose("pick", []string{"a", "b"}) != 1 {
|
||||
t.Error("expected 1")
|
||||
}
|
||||
if m.Choose("pick2", []string{"a", "b", "c"}) != 2 {
|
||||
t.Error("expected 2")
|
||||
}
|
||||
if m.Choose("pick3", nil) != 0 {
|
||||
t.Error("expected default 0 when responses exhausted")
|
||||
}
|
||||
|
||||
if len(m.ChooseCalls) != 3 {
|
||||
t.Errorf("expected 3 choose calls, got %d", len(m.ChooseCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoConfirmPrompter(t *testing.T) {
|
||||
p := &AutoConfirmPrompter{}
|
||||
if !p.Confirm("anything") {
|
||||
t.Error("expected always true")
|
||||
}
|
||||
if p.Choose("anything", []string{"a", "b"}) != 0 {
|
||||
t.Error("expected always 0")
|
||||
}
|
||||
}
|
||||
165
registry/manager/common/types.go
Normal file
165
registry/manager/common/types.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Package common provides shared types and utilities for the registry manager.
|
||||
package common
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RegistryYao represents the registry.yao lockfile that tracks installed packages.
|
||||
type RegistryYao struct {
|
||||
Scope string `json:"scope"`
|
||||
Packages map[string]PackageInfo `json:"packages"`
|
||||
}
|
||||
|
||||
// PackageInfo describes an installed package in registry.yao.
|
||||
type PackageInfo struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Integrity string `json:"integrity"`
|
||||
Dependencies map[string]string `json:"dependencies,omitempty"`
|
||||
RequiredBy []string `json:"required_by,omitempty"`
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
Managed *bool `json:"managed,omitempty"`
|
||||
ForkedFrom string `json:"forked_from,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
// IsManaged returns true if the package is managed by the registry (not forked).
|
||||
func (p *PackageInfo) IsManaged() bool {
|
||||
if p.Managed == nil {
|
||||
return true
|
||||
}
|
||||
return *p.Managed
|
||||
}
|
||||
|
||||
// PkgManifest represents the pkg.yao file inside a .yao.zip package.
|
||||
// Dependencies can come from the registry in array format [{type,scope,name,version}]
|
||||
// and are normalized to map["@scope/name"] = "version" after loading.
|
||||
type PkgManifest struct {
|
||||
Type string `json:"type"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Dependencies map[string]string `json:"-"`
|
||||
RawDependencies json.RawMessage `json:"dependencies,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Author *ManifestAuthor `json:"author,omitempty"`
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestDep represents a dependency entry in the array format from the registry.
|
||||
type ManifestDep struct {
|
||||
Type string `json:"type"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// NormalizeDependencies parses RawDependencies into the Dependencies map.
|
||||
// Supports both formats:
|
||||
// - Array: [{"type":"mcp","scope":"@test","name":"dep","version":"^1.0.0"}]
|
||||
// - Map: {"@test/dep": "^1.0.0"}
|
||||
func (m *PkgManifest) NormalizeDependencies() {
|
||||
if m.Dependencies != nil || len(m.RawDependencies) == 0 {
|
||||
return
|
||||
}
|
||||
m.Dependencies = map[string]string{}
|
||||
|
||||
// Try array format first
|
||||
var arrDeps []ManifestDep
|
||||
if err := json.Unmarshal(m.RawDependencies, &arrDeps); err == nil {
|
||||
for _, d := range arrDeps {
|
||||
scope := d.Scope
|
||||
if len(scope) > 0 && scope[0] != '@' {
|
||||
scope = "@" + scope
|
||||
}
|
||||
pkgID := scope + "/" + d.Name
|
||||
m.Dependencies[pkgID] = d.Version
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try map format
|
||||
var mapDeps map[string]string
|
||||
if err := json.Unmarshal(m.RawDependencies, &mapDeps); err == nil {
|
||||
m.Dependencies = mapDeps
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMarshal syncs Dependencies map into RawDependencies for JSON serialization.
|
||||
// Converts the internal map["@scope/name"] = "version" format to the array format
|
||||
// required by the registry server: [{"type":"...","scope":"@...","name":"...","version":"..."}].
|
||||
func (m *PkgManifest) PrepareMarshal() {
|
||||
if len(m.Dependencies) == 0 {
|
||||
return
|
||||
}
|
||||
var arr []ManifestDep
|
||||
for pkgID, ver := range m.Dependencies {
|
||||
scope, name, err := ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
depType := TypeDirMCPs
|
||||
arr = append(arr, ManifestDep{
|
||||
Type: depType,
|
||||
Scope: "@" + scope,
|
||||
Name: name,
|
||||
Version: ver,
|
||||
})
|
||||
}
|
||||
data, err := json.Marshal(arr)
|
||||
if err == nil {
|
||||
m.RawDependencies = data
|
||||
}
|
||||
}
|
||||
|
||||
// ManifestAuthor holds author information in pkg.yao.
|
||||
type ManifestAuthor struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// PackageType constants map to registry API type strings and local directory names.
|
||||
const (
|
||||
TypeAssistant = "assistant"
|
||||
TypeMCP = "mcp"
|
||||
TypeRobot = "robot"
|
||||
|
||||
TypeDirAssistants = "assistants"
|
||||
TypeDirMCPs = "mcps"
|
||||
TypeDirRobots = "robots"
|
||||
)
|
||||
|
||||
// TypeToDir maps package type to its top-level directory name.
|
||||
func TypeToDir(pkgType string) string {
|
||||
switch pkgType {
|
||||
case TypeAssistant:
|
||||
return TypeDirAssistants
|
||||
case TypeMCP:
|
||||
return TypeDirMCPs
|
||||
case TypeRobot:
|
||||
return TypeDirRobots
|
||||
default:
|
||||
return pkgType
|
||||
}
|
||||
}
|
||||
|
||||
// TypeToRegistryType maps package type to the registry API type string.
|
||||
func TypeToRegistryType(pkgType string) string {
|
||||
switch pkgType {
|
||||
case TypeAssistant:
|
||||
return TypeDirAssistants
|
||||
case TypeMCP:
|
||||
return TypeDirMCPs
|
||||
case TypeRobot:
|
||||
return TypeDirRobots
|
||||
default:
|
||||
return pkgType
|
||||
}
|
||||
}
|
||||
|
||||
// BoolPtr returns a pointer to a bool value.
|
||||
func BoolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
770
registry/manager/e2e_test.go
Normal file
770
registry/manager/e2e_test.go
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
package manager_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/registry"
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
robotmgr "github.com/yaoapp/yao/registry/manager/robot"
|
||||
)
|
||||
|
||||
// testScope is the scope aligned with registry CI credentials (yaoagents:yaoagents).
|
||||
const testScope = "yaoagents"
|
||||
|
||||
func registryURL() string {
|
||||
if u := os.Getenv("YAO_REGISTRY_URL"); u != "" {
|
||||
return u
|
||||
}
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
|
||||
func authClient() *registry.Client {
|
||||
return registry.New(registryURL(), registry.WithAuth(testScope, testScope))
|
||||
}
|
||||
|
||||
func cleanupPkg(c *registry.Client, pkgType, scope, name, version string) {
|
||||
c.DeleteVersion(pkgType, scope, name, version)
|
||||
}
|
||||
|
||||
// appRoot returns the path to yao-dev-app, which contains the real test fixtures
|
||||
// under assistants/yaoagents/, mcps/yaoagents/, scripts/yaoagents/.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. YAO_TEST_APPLICATION env var (set by CI and local env.local.sh)
|
||||
// 2. ../yao-dev-app (local development layout)
|
||||
// 3. ../app (CI layout after "Move Dependencies" step)
|
||||
func appRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
check := func(root string) bool {
|
||||
_, err := os.Stat(filepath.Join(root, "assistants", testScope, "registry-agent", "package.yao"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
if root := os.Getenv("YAO_TEST_APPLICATION"); root != "" {
|
||||
abs, _ := filepath.Abs(root)
|
||||
if check(abs) {
|
||||
return abs
|
||||
}
|
||||
t.Logf("YAO_TEST_APPLICATION=%s exists but missing registry test fixtures", root)
|
||||
}
|
||||
|
||||
// Try standard sibling layouts
|
||||
for _, rel := range []string{
|
||||
filepath.Join("..", "..", "..", "yao-dev-app"), // from registry/manager/ → yao-dev-app
|
||||
filepath.Join("..", "..", "..", "..", "app"), // CI: from yao/registry/manager/ → ../app
|
||||
filepath.Join("..", "yao-dev-app"), // from yao/ → yao-dev-app
|
||||
} {
|
||||
abs, _ := filepath.Abs(rel)
|
||||
if check(abs) {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
|
||||
t.Skip("yao-dev-app with registry test fixtures not found; set YAO_TEST_APPLICATION")
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildV2App creates a v2 variant of the test fixtures in a temp directory
|
||||
// for update testing. The content is intentionally different from v1 in yao-dev-app.
|
||||
func buildV2App(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
// Agent v2: updated description, new prompts, added tools.ts
|
||||
assistDir := filepath.Join(root, "assistants", testScope, "registry-agent")
|
||||
mustMkdir(t, assistDir)
|
||||
mustWriteFile(t, filepath.Join(assistDir, "package.yao"), `{
|
||||
"name": "Registry Test Agent v2",
|
||||
"avatar": "/api/__yao/app/icons/app.png",
|
||||
"connector": "gpt-4o",
|
||||
"description": "Enhanced v2 test assistant for registry E2E verification",
|
||||
"options": { "temperature": 0.5 },
|
||||
"public": false,
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"registry-mcp": { "server_id": "`+testScope+`.registry-mcp" }
|
||||
}
|
||||
},
|
||||
"tags": ["Test", "Registry", "V2"],
|
||||
"sort": 999,
|
||||
"readonly": true,
|
||||
"automated": false,
|
||||
"mentionable": false
|
||||
}`)
|
||||
mustWriteFile(t, filepath.Join(assistDir, "prompts.yml"),
|
||||
"system: |\n You are the v2 registry test assistant with enhanced capabilities.\n")
|
||||
mustWriteFile(t, filepath.Join(assistDir, "tools.ts"),
|
||||
`export function newV2Tool(): number { return 42; }`)
|
||||
|
||||
// MCP v2: added "suggest" tool
|
||||
mcpDir := filepath.Join(root, "mcps", testScope, "registry-mcp")
|
||||
mustMkdir(t, mcpDir)
|
||||
mustWriteFile(t, filepath.Join(mcpDir, "registry-mcp.mcp.yao"), `{
|
||||
"label": "Registry Test MCP v2",
|
||||
"description": "Enhanced v2 MCP for registry E2E testing",
|
||||
"transport": "process",
|
||||
"capabilities": {
|
||||
"tools": { "listChanged": false },
|
||||
"resources": { "subscribe": false, "listChanged": false }
|
||||
},
|
||||
"tools": {
|
||||
"ping": "scripts.`+testScope+`.registry_mcp.Ping",
|
||||
"echo": "scripts.`+testScope+`.registry_mcp.Echo",
|
||||
"suggest": "scripts.`+testScope+`.registry_mcp.Suggest"
|
||||
}
|
||||
}`)
|
||||
|
||||
// Script v2: added Suggest, changed Ping return
|
||||
scriptDir := filepath.Join(root, "scripts", testScope)
|
||||
mustMkdir(t, scriptDir)
|
||||
mustWriteFile(t, filepath.Join(scriptDir, "registry_mcp.ts"), `/**
|
||||
* Registry MCP test script v2
|
||||
*/
|
||||
|
||||
function Ping(): string {
|
||||
return "pong-v2";
|
||||
}
|
||||
|
||||
function Echo(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
function Suggest(prefix: string): string[] {
|
||||
return ["v2-suggestion1", "v2-suggestion2"];
|
||||
}
|
||||
`)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: MCP full lifecycle — Push from yao-dev-app → Add → Update → Fork
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EMCPRealLifecycle(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := appRoot(t)
|
||||
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "2.0.0")
|
||||
|
||||
// ---- Phase 1: Push MCP v1 from yao-dev-app (real developer push) ----
|
||||
|
||||
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push MCP v1 from yao-dev-app: %v", err)
|
||||
}
|
||||
|
||||
// Verify in registry
|
||||
packument, err := c.GetPackument("mcps", "@"+testScope, "registry-mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPackument after push: %v", err)
|
||||
}
|
||||
if packument.DistTags["latest"] != "1.0.0" {
|
||||
t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"])
|
||||
}
|
||||
|
||||
// ---- Phase 2: Add to a fresh app (simulates another developer installing) ----
|
||||
|
||||
installApp := t.TempDir()
|
||||
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
|
||||
|
||||
err = installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Add MCP: %v", err)
|
||||
}
|
||||
|
||||
// Verify: .mcp.yao on disk
|
||||
installedMCP := filepath.Join(installApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao")
|
||||
if _, err := os.Stat(installedMCP); err != nil {
|
||||
t.Fatal("expected registry-mcp.mcp.yao in installed dir")
|
||||
}
|
||||
mcpContent, _ := os.ReadFile(installedMCP)
|
||||
if !strings.Contains(string(mcpContent), "scripts."+testScope+".registry_mcp.Ping") {
|
||||
t.Errorf("expected process refs preserved, got: %s", mcpContent)
|
||||
}
|
||||
|
||||
// Verify: scripts extracted to project root
|
||||
installedScript := filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts")
|
||||
if _, err := os.Stat(installedScript); err != nil {
|
||||
t.Fatalf("expected scripts/%s/registry_mcp.ts extracted", testScope)
|
||||
}
|
||||
scriptContent, _ := os.ReadFile(installedScript)
|
||||
if !strings.Contains(string(scriptContent), "pong") {
|
||||
t.Errorf("expected v1 script with 'pong', got: %s", scriptContent)
|
||||
}
|
||||
|
||||
// Verify: lockfile (registry.yao)
|
||||
lf, err := common.LoadLockfile(installApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, ok := lf.GetPackage("@" + testScope + "/registry-mcp")
|
||||
if !ok {
|
||||
t.Fatal("expected package in lockfile")
|
||||
}
|
||||
if pkg.Version != "1.0.0" {
|
||||
t.Errorf("lockfile version: want 1.0.0, got %s", pkg.Version)
|
||||
}
|
||||
if pkg.Type != common.TypeMCP {
|
||||
t.Errorf("lockfile type: want mcp, got %s", pkg.Type)
|
||||
}
|
||||
if pkg.Integrity == "" {
|
||||
t.Error("expected integrity digest in lockfile")
|
||||
}
|
||||
|
||||
// lockfile.Files must track both MCP dir files and script files
|
||||
hasScript, hasMCP := false, false
|
||||
for path := range pkg.Files {
|
||||
if strings.HasPrefix(path, "scripts/") {
|
||||
hasScript = true
|
||||
}
|
||||
if strings.HasPrefix(path, "mcps/") {
|
||||
hasMCP = true
|
||||
}
|
||||
}
|
||||
if !hasScript {
|
||||
t.Error("lockfile missing script file entries")
|
||||
}
|
||||
if !hasMCP {
|
||||
t.Error("lockfile missing MCP file entries")
|
||||
}
|
||||
|
||||
// ---- Phase 3: Push v2 from temp dir, then Update ----
|
||||
|
||||
v2App := buildV2App(t)
|
||||
pushMgrV2 := mcpmgr.New(c, v2App, &common.AutoConfirmPrompter{})
|
||||
|
||||
err = pushMgrV2.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "2.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push MCP v2: %v", err)
|
||||
}
|
||||
|
||||
err = installMgr.Update("@"+testScope+"/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Update MCP to v2: %v", err)
|
||||
}
|
||||
|
||||
// lockfile version should be 2.0.0
|
||||
lf, _ = common.LoadLockfile(installApp)
|
||||
pkg, _ = lf.GetPackage("@" + testScope + "/registry-mcp")
|
||||
if pkg.Version != "2.0.0" {
|
||||
t.Errorf("expected v2.0.0 after update, got %s", pkg.Version)
|
||||
}
|
||||
|
||||
// Script should contain v2 content
|
||||
scriptContent, _ = os.ReadFile(installedScript)
|
||||
if !strings.Contains(string(scriptContent), "pong-v2") {
|
||||
t.Errorf("expected v2 script content after update, got: %s", scriptContent)
|
||||
}
|
||||
|
||||
// ---- Phase 4: Fork to @local ----
|
||||
|
||||
err = installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork MCP: %v", err)
|
||||
}
|
||||
|
||||
// Forked MCP directory
|
||||
forkedDir := filepath.Join(installApp, "mcps", "local", "registry-mcp")
|
||||
if _, err := os.Stat(forkedDir); err != nil {
|
||||
t.Fatal("expected forked MCP directory at mcps/local/registry-mcp/")
|
||||
}
|
||||
|
||||
// Process refs rewritten: scripts.yaoagents.* → scripts.local.*
|
||||
forkedMCPContent, _ := os.ReadFile(filepath.Join(forkedDir, "registry-mcp.mcp.yao"))
|
||||
if !strings.Contains(string(forkedMCPContent), "scripts.local.registry_mcp.Ping") {
|
||||
t.Errorf("expected rewritten process ref scripts.local.*, got: %s", forkedMCPContent)
|
||||
}
|
||||
if strings.Contains(string(forkedMCPContent), "scripts."+testScope+".") {
|
||||
t.Errorf("forked MCP still references original scope: %s", forkedMCPContent)
|
||||
}
|
||||
|
||||
// Forked scripts copied
|
||||
forkedScript := filepath.Join(installApp, "scripts", "local", "registry_mcp.ts")
|
||||
if _, err := os.Stat(forkedScript); err != nil {
|
||||
t.Fatal("expected scripts/local/registry_mcp.ts after fork")
|
||||
}
|
||||
|
||||
// Lockfile: forked entry is unmanaged
|
||||
lf, _ = common.LoadLockfile(installApp)
|
||||
forkedPkg, ok := lf.GetPackage("@local/registry-mcp")
|
||||
if !ok {
|
||||
t.Fatal("expected @local/registry-mcp in lockfile")
|
||||
}
|
||||
if forkedPkg.ForkedFrom != "@"+testScope+"/registry-mcp" {
|
||||
t.Errorf("expected forked_from=@%s/registry-mcp, got %s", testScope, forkedPkg.ForkedFrom)
|
||||
}
|
||||
if forkedPkg.IsManaged() {
|
||||
t.Error("forked package should not be managed")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: Agent full lifecycle — Push → Add (auto-installs MCP dep) → Update → Fork
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EAgentRealLifecycle(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := appRoot(t)
|
||||
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
|
||||
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0")
|
||||
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "2.0.0")
|
||||
|
||||
// ---- Phase 1: Push MCP dependency first (agent's package.yao references it) ----
|
||||
|
||||
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push MCP dependency: %v", err)
|
||||
}
|
||||
|
||||
// ---- Phase 2: Push assistant from yao-dev-app ----
|
||||
|
||||
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
err = agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push agent: %v", err)
|
||||
}
|
||||
|
||||
packument, err := c.GetPackument("assistants", "@"+testScope, "registry-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPackument agent: %v", err)
|
||||
}
|
||||
if packument.DistTags["latest"] != "1.0.0" {
|
||||
t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"])
|
||||
}
|
||||
|
||||
// ---- Phase 3: Add agent to fresh app (MCP dependency should auto-install) ----
|
||||
|
||||
installApp := t.TempDir()
|
||||
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
|
||||
|
||||
err = installAgent.Add("@"+testScope+"/registry-agent", agentmgr.AddOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Add agent: %v", err)
|
||||
}
|
||||
|
||||
// Agent directory on disk
|
||||
agentDir := filepath.Join(installApp, "assistants", testScope, "registry-agent")
|
||||
if _, err := os.Stat(agentDir); err != nil {
|
||||
t.Fatal("expected assistants/" + testScope + "/registry-agent/ directory")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentDir, "package.yao")); err != nil {
|
||||
t.Error("expected package.yao in installed agent")
|
||||
}
|
||||
promptsContent, _ := os.ReadFile(filepath.Join(agentDir, "prompts.yml"))
|
||||
if !strings.Contains(string(promptsContent), "registry E2E testing") {
|
||||
t.Errorf("expected original prompts content, got: %s", promptsContent)
|
||||
}
|
||||
|
||||
// Lockfile: agent entry
|
||||
lf, err := common.LoadLockfile(installApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentPkg, ok := lf.GetPackage("@" + testScope + "/registry-agent")
|
||||
if !ok {
|
||||
t.Fatal("expected agent in lockfile")
|
||||
}
|
||||
if agentPkg.Version != "1.0.0" {
|
||||
t.Errorf("want version 1.0.0, got %s", agentPkg.Version)
|
||||
}
|
||||
if agentPkg.Type != common.TypeAssistant {
|
||||
t.Errorf("want type assistant, got %s", agentPkg.Type)
|
||||
}
|
||||
if len(agentPkg.Files) == 0 {
|
||||
t.Error("expected file hashes in lockfile")
|
||||
}
|
||||
|
||||
// MCP dependency auto-installed
|
||||
depPkg, ok := lf.GetPackage("@" + testScope + "/registry-mcp")
|
||||
if !ok {
|
||||
t.Fatal("expected MCP dependency @" + testScope + "/registry-mcp auto-installed")
|
||||
}
|
||||
if depPkg.Version != "1.0.0" {
|
||||
t.Errorf("dependency version: want 1.0.0, got %s", depPkg.Version)
|
||||
}
|
||||
|
||||
// required_by set correctly
|
||||
found := false
|
||||
for _, rb := range depPkg.RequiredBy {
|
||||
if rb == "@"+testScope+"/registry-agent" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected @%s/registry-agent in dependency's required_by, got %v", testScope, depPkg.RequiredBy)
|
||||
}
|
||||
|
||||
// ---- Phase 4: Push agent v2 and Update (local modification preserved) ----
|
||||
|
||||
v2App := buildV2App(t)
|
||||
pushAgentV2 := agentmgr.New(c, v2App, &common.AutoConfirmPrompter{})
|
||||
|
||||
err = pushAgentV2.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "2.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push agent v2: %v", err)
|
||||
}
|
||||
|
||||
// Locally modify prompts.yml before update (simulates developer customization)
|
||||
customPrompt := "My custom prompt - DO NOT OVERWRITE."
|
||||
os.WriteFile(filepath.Join(agentDir, "prompts.yml"), []byte(customPrompt), 0644)
|
||||
|
||||
err = installAgent.Update("@"+testScope+"/registry-agent", agentmgr.UpdateOptions{Version: "2.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Update agent to v2: %v", err)
|
||||
}
|
||||
|
||||
// Lockfile updated to v2
|
||||
lf, _ = common.LoadLockfile(installApp)
|
||||
agentPkg, _ = lf.GetPackage("@" + testScope + "/registry-agent")
|
||||
if agentPkg.Version != "2.0.0" {
|
||||
t.Errorf("expected v2.0.0 after update, got %s", agentPkg.Version)
|
||||
}
|
||||
|
||||
// Locally modified file PRESERVED (not overwritten)
|
||||
preservedData, _ := os.ReadFile(filepath.Join(agentDir, "prompts.yml"))
|
||||
if string(preservedData) != customPrompt {
|
||||
t.Errorf("local modification should be preserved, got: %s", preservedData)
|
||||
}
|
||||
|
||||
// .new file created with upstream v2 content
|
||||
newFile := filepath.Join(agentDir, "prompts.yml.new")
|
||||
if _, err := os.Stat(newFile); err != nil {
|
||||
t.Error("expected prompts.yml.new with upstream content")
|
||||
}
|
||||
newContent, _ := os.ReadFile(newFile)
|
||||
if !strings.Contains(string(newContent), "v2 registry test assistant") {
|
||||
t.Errorf("expected v2 content in .new file, got: %s", newContent)
|
||||
}
|
||||
|
||||
// New file tools.ts added by v2
|
||||
if _, err := os.Stat(filepath.Join(agentDir, "tools.ts")); err != nil {
|
||||
t.Error("expected new file tools.ts added during update")
|
||||
}
|
||||
|
||||
// ---- Phase 5: Fork to @local ----
|
||||
|
||||
err = installAgent.Fork("@"+testScope+"/registry-agent", agentmgr.ForkOptions{TargetScope: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork agent: %v", err)
|
||||
}
|
||||
|
||||
forkDir := filepath.Join(installApp, "assistants", "local", "registry-agent")
|
||||
if _, err := os.Stat(forkDir); err != nil {
|
||||
t.Fatal("expected assistants/local/registry-agent/ after fork")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(forkDir, "package.yao")); err != nil {
|
||||
t.Error("expected package.yao in forked dir")
|
||||
}
|
||||
|
||||
lf, _ = common.LoadLockfile(installApp)
|
||||
forkedPkg, ok := lf.GetPackage("@local/registry-agent")
|
||||
if !ok {
|
||||
t.Fatal("expected @local/registry-agent in lockfile")
|
||||
}
|
||||
if forkedPkg.ForkedFrom != "@"+testScope+"/registry-agent" {
|
||||
t.Errorf("expected forked_from=@%s/registry-agent, got %s", testScope, forkedPkg.ForkedFrom)
|
||||
}
|
||||
if forkedPkg.IsManaged() {
|
||||
t.Error("forked package should not be managed")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: Push→Pull roundtrip (byte-for-byte content verification)
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EPushPullRoundtrip(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := appRoot(t)
|
||||
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
|
||||
|
||||
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
|
||||
pullApp := t.TempDir()
|
||||
pullMgr := mcpmgr.New(c, pullApp, &common.AutoConfirmPrompter{})
|
||||
err = pullMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
// Script byte-for-byte comparison
|
||||
origScript, _ := os.ReadFile(filepath.Join(devApp, "scripts", testScope, "registry_mcp.ts"))
|
||||
pulledScript, _ := os.ReadFile(filepath.Join(pullApp, "scripts", testScope, "registry_mcp.ts"))
|
||||
if string(origScript) != string(pulledScript) {
|
||||
t.Errorf("script mismatch.\nOriginal:\n%s\nPulled:\n%s", origScript, pulledScript)
|
||||
}
|
||||
|
||||
// MCP definition byte-for-byte comparison
|
||||
origMCP, _ := os.ReadFile(filepath.Join(devApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao"))
|
||||
pulledMCP, _ := os.ReadFile(filepath.Join(pullApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao"))
|
||||
if string(origMCP) != string(pulledMCP) {
|
||||
t.Errorf("MCP mismatch.\nOriginal:\n%s\nPulled:\n%s", origMCP, pulledMCP)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: Robot Add with dependency resolution
|
||||
// =============================================================================
|
||||
|
||||
func TestE2ERobotRealLifecycle(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := appRoot(t)
|
||||
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
|
||||
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0")
|
||||
defer cleanupPkg(c, "robots", "@"+testScope, "test-bot", "1.0.0")
|
||||
|
||||
// Push MCP and Agent that the robot depends on
|
||||
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
|
||||
if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
|
||||
t.Fatalf("Push MCP: %v", err)
|
||||
}
|
||||
if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil {
|
||||
t.Fatalf("Push agent: %v", err)
|
||||
}
|
||||
|
||||
// Build and push robot package (robots are DB records, so we build zip manually)
|
||||
robotJSON := map[string]interface{}{
|
||||
"display_name": "E2E Test Bot",
|
||||
"system_prompt": "You are an E2E test robot.",
|
||||
"language_model": "gpt-4o",
|
||||
"robot_config": map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"phases": map[string]string{
|
||||
"host": testScope + ".registry-agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
"mcp_servers": []string{testScope + ".registry-mcp"},
|
||||
}
|
||||
robotBytes, _ := json.Marshal(robotJSON)
|
||||
|
||||
robotZipRoot := t.TempDir()
|
||||
robotDir := filepath.Join(robotZipRoot, "package")
|
||||
mustMkdir(t, robotDir)
|
||||
mustWriteFile(t, filepath.Join(robotDir, "pkg.yao"), `{
|
||||
"type": "robot",
|
||||
"scope": "@`+testScope+`",
|
||||
"name": "test-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "E2E test robot"
|
||||
}`)
|
||||
mustWriteFile(t, filepath.Join(robotDir, "robot.json"), string(robotBytes))
|
||||
|
||||
robotZip, err := common.PackDir(robotDir, &common.PkgManifest{
|
||||
Type: common.TypeRobot,
|
||||
Scope: "@" + testScope,
|
||||
Name: "test-bot",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack robot: %v", err)
|
||||
}
|
||||
if _, err := c.Push("robots", "@"+testScope, "test-bot", "1.0.0", robotZip); err != nil {
|
||||
t.Fatalf("Push robot: %v", err)
|
||||
}
|
||||
|
||||
// ---- Add robot to fresh app (dependencies should auto-install) ----
|
||||
|
||||
installApp := t.TempDir()
|
||||
rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{})
|
||||
|
||||
robot, err := rMgr.Add("@"+testScope+"/test-bot", robotmgr.AddOptions{TeamID: "team-e2e"})
|
||||
if err != nil {
|
||||
t.Fatalf("Add robot: %v", err)
|
||||
}
|
||||
|
||||
if robot.DisplayName != "E2E Test Bot" {
|
||||
t.Errorf("want display_name 'E2E Test Bot', got %q", robot.DisplayName)
|
||||
}
|
||||
if robot.SystemPrompt != "You are an E2E test robot." {
|
||||
t.Errorf("unexpected system_prompt: %s", robot.SystemPrompt)
|
||||
}
|
||||
|
||||
lf, _ := common.LoadLockfile(installApp)
|
||||
|
||||
robotPkg, ok := lf.GetPackage("@" + testScope + "/test-bot")
|
||||
if !ok {
|
||||
t.Fatal("expected robot in lockfile")
|
||||
}
|
||||
if robotPkg.Type != common.TypeRobot {
|
||||
t.Errorf("want robot type, got %s", robotPkg.Type)
|
||||
}
|
||||
if robotPkg.TeamID != "team-e2e" {
|
||||
t.Errorf("want team_id team-e2e, got %s", robotPkg.TeamID)
|
||||
}
|
||||
|
||||
// Dependencies auto-installed
|
||||
if _, ok := lf.GetPackage("@" + testScope + "/registry-agent"); !ok {
|
||||
t.Error("expected agent dependency auto-installed")
|
||||
}
|
||||
if _, ok := lf.GetPackage("@" + testScope + "/registry-mcp"); !ok {
|
||||
t.Error("expected MCP dependency auto-installed")
|
||||
}
|
||||
|
||||
// Files on disk
|
||||
if _, err := os.Stat(filepath.Join(installApp, "assistants", testScope, "registry-agent", "package.yao")); err != nil {
|
||||
t.Error("expected agent package.yao on disk after robot add")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao")); err != nil {
|
||||
t.Error("expected MCP .mcp.yao on disk after robot add")
|
||||
}
|
||||
|
||||
// required_by
|
||||
agentPkg, _ := lf.GetPackage("@" + testScope + "/registry-agent")
|
||||
foundRB := false
|
||||
for _, rb := range agentPkg.RequiredBy {
|
||||
if rb == "@"+testScope+"/test-bot" {
|
||||
foundRB = true
|
||||
}
|
||||
}
|
||||
if !foundRB {
|
||||
t.Errorf("expected robot in agent's required_by, got %v", agentPkg.RequiredBy)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: @local push is rejected
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EPushLocalRejected(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := t.TempDir()
|
||||
|
||||
localDir := filepath.Join(devApp, "assistants", "local", "my-thing")
|
||||
mustMkdir(t, localDir)
|
||||
mustWriteFile(t, filepath.Join(localDir, "package.yao"), `{"name":"my-thing"}`)
|
||||
|
||||
mgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
err := mgr.Push("local.my-thing", agentmgr.PushOptions{Version: "1.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected push of @local to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "@local") {
|
||||
t.Errorf("expected @local rejection error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: MCP Push rejects scripts in wrong scope
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EMCPPushWrongScriptScope(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := t.TempDir()
|
||||
|
||||
mcpDir := filepath.Join(devApp, "mcps", testScope, "bad-mcp")
|
||||
mustMkdir(t, mcpDir)
|
||||
mustWriteFile(t, filepath.Join(mcpDir, "bad.mcp.yao"), `{
|
||||
"transport": "process",
|
||||
"tools": {
|
||||
"run": "scripts.other.bad.Run"
|
||||
}
|
||||
}`)
|
||||
mustWriteFile(t, filepath.Join(devApp, "scripts", "other", "bad.ts"), "export function Run() {}")
|
||||
|
||||
mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
err := mgr.Push(testScope+".bad-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected push to be rejected due to script scope mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "scope mismatch") {
|
||||
t.Errorf("expected scope mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: Update of forked package is rejected
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EUpdateForkedRejected(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := appRoot(t)
|
||||
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
|
||||
|
||||
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
|
||||
|
||||
installApp := t.TempDir()
|
||||
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
|
||||
installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
|
||||
|
||||
// Fork it
|
||||
installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"})
|
||||
|
||||
// Update forked should fail
|
||||
err := installMgr.Update("@local/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected update of forked package to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "forked") {
|
||||
t.Errorf("expected 'forked' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E: Directory conflict detection
|
||||
// =============================================================================
|
||||
|
||||
func TestE2EDirectoryConflict(t *testing.T) {
|
||||
c := authClient()
|
||||
devApp := appRoot(t)
|
||||
|
||||
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
|
||||
|
||||
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
|
||||
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
|
||||
|
||||
// Pre-create an unmanaged directory at the install path
|
||||
installApp := t.TempDir()
|
||||
conflictDir := filepath.Join(installApp, "mcps", testScope, "registry-mcp")
|
||||
mustMkdir(t, conflictDir)
|
||||
mustWriteFile(t, filepath.Join(conflictDir, "my-custom.mcp.yao"), `{"transport":"stdio"}`)
|
||||
|
||||
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
|
||||
err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected directory conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Errorf("expected 'already exists' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
151
registry/manager/mcp/add.go
Normal file
151
registry/manager/mcp/add.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// AddOptions configures the Add operation.
|
||||
type AddOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
}
|
||||
|
||||
// Add installs an MCP package from the registry.
|
||||
// Per DESIGN-MCP.md:
|
||||
// 1. Check conflict
|
||||
// 2. Pull from registry
|
||||
// 3. Check dependencies (MCPs currently have none, but structure supports it)
|
||||
// 4. Unpack .mcp.yao + mapping/ to mcps/{scope}/{name}/
|
||||
// 5. Extract scripts/ to project root scripts/{scope}/
|
||||
// 6. Check script conflicts
|
||||
// 7. Write registry.yao (files include both MCP dir and scripts)
|
||||
// 8. Hot-reload
|
||||
func (m *Manager) Add(pkgID string, opts AddOptions) error {
|
||||
if opts.Version == "" {
|
||||
opts.Version = "latest"
|
||||
}
|
||||
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing, ok := lf.GetPackage(pkgID); ok && !opts.Force {
|
||||
return fmt.Errorf("package %s is already installed (version %s). Use --force to reinstall", pkgID, existing.Version)
|
||||
}
|
||||
|
||||
destDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
|
||||
if _, err := os.Stat(destDir); err == nil {
|
||||
if _, ok := lf.GetPackage(pkgID); !ok {
|
||||
return fmt.Errorf("directory %s already exists but is not managed by registry. Please remove or relocate it first", destDir)
|
||||
}
|
||||
}
|
||||
|
||||
regType := common.TypeToRegistryType(common.TypeMCP)
|
||||
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
|
||||
manifest, err := common.ReadManifest(zipData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Unpack everything to a temp dir first, then sort into MCP dir and scripts
|
||||
tempDir, err := os.MkdirTemp("", "yao-mcp-install-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
allFiles, err := common.UnpackTo(zipData, tempDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpack: %w", err)
|
||||
}
|
||||
|
||||
fileHashes := map[string]string{}
|
||||
mcpRelDir := common.PackageDirRel(common.TypeMCP, scope, name)
|
||||
|
||||
for _, f := range allFiles {
|
||||
srcPath := filepath.Join(tempDir, f)
|
||||
|
||||
if strings.HasPrefix(f, "scripts/") {
|
||||
// Script file → project root scripts/
|
||||
destPath := filepath.Join(m.appRoot, f)
|
||||
|
||||
// Check script conflict: exists but not in registry.yao
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
if !isScriptTracked(lf, f) {
|
||||
return fmt.Errorf("script file %s already exists and is not managed by registry. Please remove or relocate it first", f)
|
||||
}
|
||||
}
|
||||
|
||||
if err := copyFileFromTo(srcPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, _ := common.HashFile(destPath)
|
||||
fileHashes[f] = hash
|
||||
} else {
|
||||
// MCP file → mcps/{scope}/{name}/
|
||||
destPath := filepath.Join(destDir, f)
|
||||
if err := copyFileFromTo(srcPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
relPath := mcpRelDir + "/" + f
|
||||
hash, _ := common.HashFile(destPath)
|
||||
fileHashes[relPath] = hash
|
||||
}
|
||||
}
|
||||
|
||||
info := common.PackageInfo{
|
||||
Type: common.TypeMCP,
|
||||
Version: manifest.Version,
|
||||
Integrity: digest,
|
||||
Dependencies: manifest.Dependencies,
|
||||
Files: fileHashes,
|
||||
}
|
||||
lf.SetPackage(pkgID, info)
|
||||
|
||||
for depID := range manifest.Dependencies {
|
||||
lf.AddRequiredBy(depID, pkgID)
|
||||
}
|
||||
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Installed %s@%s → %s\n", pkgID, manifest.Version, destDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isScriptTracked checks if a script path is tracked by any package in the lockfile.
|
||||
func isScriptTracked(lf *common.RegistryYao, scriptPath string) bool {
|
||||
for _, pkg := range lf.Packages {
|
||||
if _, ok := pkg.Files[scriptPath]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func copyFileFromTo(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0644)
|
||||
}
|
||||
209
registry/manager/mcp/fork.go
Normal file
209
registry/manager/mcp/fork.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// ForkOptions configures the Fork operation.
|
||||
type ForkOptions struct {
|
||||
TargetScope string
|
||||
}
|
||||
|
||||
// Fork copies an MCP to a new scope with process reference rewriting.
|
||||
// Per DESIGN-MCP.md Fork:
|
||||
// 1. Copy mcps/{scope}/{name}/ → mcps/{target}/{name}/
|
||||
// 2. Copy scripts precisely based on registry.yao files record
|
||||
// 3. Rewrite process references in .mcp.yao (scripts.old. → scripts.new.)
|
||||
// 4. Write registry.yao with managed:false
|
||||
// 5. Hot-reload
|
||||
func (m *Manager) Fork(pkgID string, opts ForkOptions) error {
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetScope := opts.TargetScope
|
||||
if targetScope == "" {
|
||||
targetScope = lf.DefaultScope()
|
||||
}
|
||||
|
||||
targetPkgID := common.FormatPackageID(targetScope, name)
|
||||
|
||||
// Check target directory
|
||||
targetDir := common.PackageDir(common.TypeMCP, targetScope, name, m.appRoot)
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
return fmt.Errorf("target directory %s already exists", targetDir)
|
||||
}
|
||||
|
||||
sourceDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
|
||||
var existing common.PackageInfo
|
||||
var isLocal bool
|
||||
|
||||
if pkg, ok := lf.GetPackage(pkgID); ok {
|
||||
existing = pkg
|
||||
isLocal = true
|
||||
}
|
||||
|
||||
if isLocal {
|
||||
// Copy MCP directory
|
||||
if err := copyDir(sourceDir, targetDir); err != nil {
|
||||
return fmt.Errorf("copy MCP dir: %w", err)
|
||||
}
|
||||
|
||||
// Copy scripts precisely based on registry.yao files record
|
||||
scriptFiles := ScriptPathsFromFiles(existing.Files)
|
||||
for scriptPath := range scriptFiles {
|
||||
// Rewrite script path: scripts/{oldScope}/ → scripts/{targetScope}/
|
||||
newScriptPath := rewriteScriptPath(scriptPath, scope, targetScope)
|
||||
srcAbs := filepath.Join(m.appRoot, scriptPath)
|
||||
dstAbs := filepath.Join(m.appRoot, newScriptPath)
|
||||
if err := copyFileTo(srcAbs, dstAbs); err != nil {
|
||||
return fmt.Errorf("copy script %s: %w", scriptPath, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pull from registry
|
||||
regType := common.TypeToRegistryType(common.TypeMCP)
|
||||
zipData, _, err := m.client.Pull(regType, "@"+scope, name, "latest")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
|
||||
// Unpack to temp, then sort
|
||||
tempDir, err := os.MkdirTemp("", "yao-mcp-fork-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
files, err := common.UnpackTo(zipData, tempDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
srcPath := filepath.Join(tempDir, f)
|
||||
if strings.HasPrefix(f, "scripts/") {
|
||||
newPath := rewriteScriptPath(f, scope, targetScope)
|
||||
dstPath := filepath.Join(m.appRoot, newPath)
|
||||
if err := copyFileTo(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
dstPath := filepath.Join(targetDir, f)
|
||||
if err := copyFileTo(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite process references in all .mcp.yao files in the target directory
|
||||
mcpFiles, err := FindMCPYaoFiles(targetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, mcpFile := range mcpFiles {
|
||||
data, err := os.ReadFile(mcpFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rewritten := RewriteProcessRefs(data, scope, targetScope)
|
||||
if err := os.WriteFile(mcpFile, rewritten, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Compute file hashes for the forked package
|
||||
mcpRelDir := common.PackageDirRel(common.TypeMCP, targetScope, name)
|
||||
fileHashes, err := common.HashDir(targetDir, mcpRelDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also hash the forked scripts
|
||||
scriptsDir := filepath.Join(m.appRoot, "scripts", targetScope)
|
||||
if _, err := os.Stat(scriptsDir); err == nil {
|
||||
scriptHashes, err := common.HashDir(scriptsDir, "scripts/"+targetScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range scriptHashes {
|
||||
fileHashes[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
info := common.PackageInfo{
|
||||
Type: common.TypeMCP,
|
||||
Version: "0.0.0",
|
||||
ForkedFrom: pkgID,
|
||||
Managed: common.BoolPtr(false),
|
||||
Files: fileHashes,
|
||||
}
|
||||
if isLocal {
|
||||
info.Version = existing.Version
|
||||
}
|
||||
|
||||
lf.SetPackage(targetPkgID, info)
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
yaoID := targetScope + "." + name
|
||||
fmt.Printf("✓ Forked %s → %s (ID: %s)\n", pkgID, targetDir, yaoID)
|
||||
fmt.Printf(" Process references rewritten: scripts.%s.* → scripts.%s.*\n", scope, targetScope)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteScriptPath changes "scripts/{oldScope}/..." to "scripts/{newScope}/..."
|
||||
func rewriteScriptPath(path, oldScope, newScope string) string {
|
||||
old := "scripts/" + oldScope + "/"
|
||||
replacement := "scripts/" + newScope + "/"
|
||||
return strings.Replace(path, old, replacement, 1)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFileTo(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFileTo(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
26
registry/manager/mcp/mcp.go
Normal file
26
registry/manager/mcp/mcp.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Package mcp implements the MCP package manager for the Yao registry.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/registry"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// Manager handles MCP package operations (add, update, push, fork).
|
||||
type Manager struct {
|
||||
client *registry.Client
|
||||
appRoot string
|
||||
prompter common.Prompter
|
||||
}
|
||||
|
||||
// New creates an MCP Manager.
|
||||
func New(client *registry.Client, appRoot string, prompter common.Prompter) *Manager {
|
||||
if prompter == nil {
|
||||
prompter = &common.StdinPrompter{}
|
||||
}
|
||||
return &Manager{
|
||||
client: client,
|
||||
appRoot: appRoot,
|
||||
prompter: prompter,
|
||||
}
|
||||
}
|
||||
385
registry/manager/mcp/mcp_test.go
Normal file
385
registry/manager/mcp/mcp_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/registry"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
"github.com/yaoapp/yao/registry/testdata"
|
||||
)
|
||||
|
||||
func buildMCPZip(scope, name, version string, files map[string]string) []byte {
|
||||
zip, err := testdata.BuildZip(&testdata.Manifest{
|
||||
Type: "mcp",
|
||||
Scope: scope,
|
||||
Name: name,
|
||||
Version: version,
|
||||
}, files)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
func mockServer(packages map[string][]byte) *httptest.Server {
|
||||
return 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", "mcps", "robots"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
|
||||
if len(parts) >= 4 {
|
||||
key := parts[0] + "/" + parts[1] + "/" + parts[2]
|
||||
if zipData, ok := packages[key]; ok {
|
||||
w.Header().Set("X-Digest", "sha256-test")
|
||||
w.Write(zipData)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPut {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"type": parts[0], "scope": parts[1], "name": parts[2],
|
||||
"version": parts[3], "digest": "sha256-pushed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAddMCP(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zip := buildMCPZip("@test", "echo-mcp", "1.0.0", map[string]string{
|
||||
"echo.mcp.yao": `{"transport":"process","tools":{"echo":"scripts.test.echo.Echo"}}`,
|
||||
"scripts/test/echo.ts": "export function Echo() {}",
|
||||
})
|
||||
|
||||
srv := mockServer(map[string][]byte{"mcps/@test/echo-mcp": zip})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Add("@test/echo-mcp", AddOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Add MCP failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify MCP directory
|
||||
mcpDir := filepath.Join(appRoot, "mcps", "test", "echo-mcp")
|
||||
if _, err := os.Stat(filepath.Join(mcpDir, "echo.mcp.yao")); err != nil {
|
||||
t.Error("expected echo.mcp.yao in MCP dir")
|
||||
}
|
||||
|
||||
// Verify scripts extracted to project root
|
||||
scriptPath := filepath.Join(appRoot, "scripts", "test", "echo.ts")
|
||||
if _, err := os.Stat(scriptPath); err != nil {
|
||||
t.Error("expected scripts/test/echo.ts in project root")
|
||||
}
|
||||
|
||||
// Verify lockfile
|
||||
lf, _ := common.LoadLockfile(appRoot)
|
||||
pkg, ok := lf.GetPackage("@test/echo-mcp")
|
||||
if !ok {
|
||||
t.Fatal("expected @test/echo-mcp in lockfile")
|
||||
}
|
||||
if pkg.Type != common.TypeMCP {
|
||||
t.Errorf("expected type mcp, got %s", pkg.Type)
|
||||
}
|
||||
|
||||
// Verify files include both MCP dir and scripts
|
||||
hasScript := false
|
||||
hasMCPFile := false
|
||||
for path := range pkg.Files {
|
||||
if strings.HasPrefix(path, "scripts/") {
|
||||
hasScript = true
|
||||
}
|
||||
if strings.HasPrefix(path, "mcps/") {
|
||||
hasMCPFile = true
|
||||
}
|
||||
}
|
||||
if !hasScript {
|
||||
t.Error("expected script path in files")
|
||||
}
|
||||
if !hasMCPFile {
|
||||
t.Error("expected MCP file path in files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMCP(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
zipV1 := buildMCPZip("@test", "upd-mcp", "1.0.0", map[string]string{
|
||||
"upd.mcp.yao": `{"transport":"process","tools":{"run":"scripts.test.upd.Run"}}`,
|
||||
"scripts/test/upd.ts": "export function Run() { return 'v1'; }",
|
||||
})
|
||||
zipV2 := buildMCPZip("@test", "upd-mcp", "2.0.0", map[string]string{
|
||||
"upd.mcp.yao": `{"transport":"process","tools":{"run":"scripts.test.upd.Run"}}`,
|
||||
"scripts/test/upd.ts": "export function Run() { return 'v2'; }",
|
||||
})
|
||||
|
||||
srvV1 := mockServer(map[string][]byte{"mcps/@test/upd-mcp": zipV1})
|
||||
clientV1 := registry.New(srvV1.URL)
|
||||
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
|
||||
if err := mgrV1.Add("@test/upd-mcp", AddOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srvV1.Close()
|
||||
|
||||
srvV2 := mockServer(map[string][]byte{"mcps/@test/upd-mcp": zipV2})
|
||||
defer srvV2.Close()
|
||||
clientV2 := registry.New(srvV2.URL)
|
||||
mgrV2 := New(clientV2, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgrV2.Update("@test/upd-mcp", UpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Update MCP failed: %v", err)
|
||||
}
|
||||
|
||||
lf, _ := common.LoadLockfile(appRoot)
|
||||
pkg, _ := lf.GetPackage("@test/upd-mcp")
|
||||
if pkg.Version != "2.0.0" {
|
||||
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
|
||||
}
|
||||
|
||||
// Verify updated script content
|
||||
data, _ := os.ReadFile(filepath.Join(appRoot, "scripts", "test", "upd.ts"))
|
||||
if !strings.Contains(string(data), "v2") {
|
||||
t.Errorf("expected updated script content, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushMCP(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Create MCP directory structure
|
||||
mcpDir := filepath.Join(appRoot, "mcps", "max", "search")
|
||||
os.MkdirAll(mcpDir, 0755)
|
||||
os.WriteFile(filepath.Join(mcpDir, "search.mcp.yao"), []byte(`{
|
||||
"transport": "process",
|
||||
"tools": {"search": "scripts.max.search.Search"}
|
||||
}`), 0644)
|
||||
|
||||
// Create scripts in the proper scope directory
|
||||
scriptDir := filepath.Join(appRoot, "scripts", "max")
|
||||
os.MkdirAll(scriptDir, 0755)
|
||||
os.WriteFile(filepath.Join(scriptDir, "search.ts"), []byte("export function Search() {}"), 0644)
|
||||
|
||||
srv := mockServer(nil)
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Push("max.search", PushOptions{Version: "1.0.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("Push MCP failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushMCPWrongScope(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
mcpDir := filepath.Join(appRoot, "mcps", "max", "bad-scope")
|
||||
os.MkdirAll(mcpDir, 0755)
|
||||
os.WriteFile(filepath.Join(mcpDir, "bad.mcp.yao"), []byte(`{
|
||||
"transport": "process",
|
||||
"tools": {"run": "scripts.other.bad.Run"}
|
||||
}`), 0644)
|
||||
|
||||
// Scripts in wrong scope
|
||||
os.MkdirAll(filepath.Join(appRoot, "scripts", "other"), 0755)
|
||||
os.WriteFile(filepath.Join(appRoot, "scripts", "other", "bad.ts"), []byte("nope"), 0644)
|
||||
|
||||
srv := mockServer(nil)
|
||||
defer srv.Close()
|
||||
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Push("max.bad-scope", PushOptions{Version: "1.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong script scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "scope mismatch") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkMCPLocal(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
// Create installed MCP
|
||||
mcpDir := filepath.Join(appRoot, "mcps", "yao", "rag-tools")
|
||||
os.MkdirAll(mcpDir, 0755)
|
||||
mcpContent := `{"transport":"process","tools":{"search":"scripts.yao.rag.Search"}}`
|
||||
os.WriteFile(filepath.Join(mcpDir, "rag-tools.mcp.yao"), []byte(mcpContent), 0644)
|
||||
|
||||
// Create scripts
|
||||
os.MkdirAll(filepath.Join(appRoot, "scripts", "yao"), 0755)
|
||||
os.WriteFile(filepath.Join(appRoot, "scripts", "yao", "rag.ts"), []byte("export function Search() {}"), 0644)
|
||||
|
||||
lf := &common.RegistryYao{
|
||||
Scope: "@local",
|
||||
Packages: map[string]common.PackageInfo{
|
||||
"@yao/rag-tools": {
|
||||
Type: common.TypeMCP,
|
||||
Version: "1.0.0",
|
||||
Files: map[string]string{
|
||||
"mcps/yao/rag-tools/rag-tools.mcp.yao": "sha256-aaa",
|
||||
"scripts/yao/rag.ts": "sha256-bbb",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
common.SaveLockfile(appRoot, lf)
|
||||
|
||||
srv := mockServer(nil)
|
||||
defer srv.Close()
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
err := mgr.Fork("@yao/rag-tools", ForkOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork MCP failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify forked MCP directory
|
||||
forkedDir := filepath.Join(appRoot, "mcps", "local", "rag-tools")
|
||||
if _, err := os.Stat(forkedDir); err != nil {
|
||||
t.Fatal("expected forked MCP directory")
|
||||
}
|
||||
|
||||
// Verify process references rewritten
|
||||
data, err := os.ReadFile(filepath.Join(forkedDir, "rag-tools.mcp.yao"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "scripts.local.rag.Search") {
|
||||
t.Errorf("expected rewritten process ref, got: %s", data)
|
||||
}
|
||||
if strings.Contains(string(data), "scripts.yao.rag.Search") {
|
||||
t.Error("expected old process ref to be removed")
|
||||
}
|
||||
|
||||
// Verify scripts copied to new scope
|
||||
forkedScript := filepath.Join(appRoot, "scripts", "local", "rag.ts")
|
||||
if _, err := os.Stat(forkedScript); err != nil {
|
||||
t.Error("expected forked script in scripts/local/")
|
||||
}
|
||||
|
||||
// Verify lockfile
|
||||
lf, _ = common.LoadLockfile(appRoot)
|
||||
pkg, ok := lf.GetPackage("@local/rag-tools")
|
||||
if !ok {
|
||||
t.Fatal("expected @local/rag-tools in lockfile")
|
||||
}
|
||||
if pkg.ForkedFrom != "@yao/rag-tools" {
|
||||
t.Errorf("expected forked_from @yao/rag-tools, got %s", pkg.ForkedFrom)
|
||||
}
|
||||
if pkg.IsManaged() {
|
||||
t.Error("expected managed=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptExtraction(t *testing.T) {
|
||||
refs, err := ExtractProcessRefsFromBytes([]byte(`{
|
||||
"transport": "process",
|
||||
"tools": {
|
||||
"search": "scripts.yao.rag.Search",
|
||||
"index": "scripts.yao.rag.Index",
|
||||
"status": "agents.robot.host.tools.Status"
|
||||
}
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("expected 2 process refs, got %d", len(refs))
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
if ref.Scope != "yao" {
|
||||
t.Errorf("expected scope yao, got %s", ref.Scope)
|
||||
}
|
||||
if !strings.HasPrefix(ref.ScriptPath, "scripts/yao/") {
|
||||
t.Errorf("expected scripts/yao/ prefix, got %s", ref.ScriptPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptExtractionNonProcess(t *testing.T) {
|
||||
refs, err := ExtractProcessRefsFromBytes([]byte(`{
|
||||
"transport": "stdio",
|
||||
"command": "echo"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Error("expected no refs for non-process transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteProcessRefs(t *testing.T) {
|
||||
original := []byte(`{"tools":{"search":"scripts.yao.rag.Search","index":"scripts.yao.rag.Index"}}`)
|
||||
rewritten := RewriteProcessRefs(original, "yao", "local")
|
||||
|
||||
if !strings.Contains(string(rewritten), "scripts.local.rag.Search") {
|
||||
t.Error("expected rewritten search ref")
|
||||
}
|
||||
if !strings.Contains(string(rewritten), "scripts.local.rag.Index") {
|
||||
t.Error("expected rewritten index ref")
|
||||
}
|
||||
if strings.Contains(string(rewritten), "scripts.yao.") {
|
||||
t.Error("expected no remaining yao refs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractScopeFromProcessRef(t *testing.T) {
|
||||
if s := ExtractScopeFromProcessRef("scripts.yao.rag.Search"); s != "yao" {
|
||||
t.Errorf("expected yao, got %s", s)
|
||||
}
|
||||
if s := ExtractScopeFromProcessRef("scripts.max.search.Do"); s != "max" {
|
||||
t.Errorf("expected max, got %s", s)
|
||||
}
|
||||
if s := ExtractScopeFromProcessRef("agents.robot.host"); s != "" {
|
||||
t.Errorf("expected empty for non-scripts ref, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptPathsFromFiles(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"mcps/yao/rag-tools/rag.mcp.yao": "sha256-aaa",
|
||||
"scripts/yao/rag.ts": "sha256-bbb",
|
||||
"scripts/yao/index.ts": "sha256-ccc",
|
||||
}
|
||||
|
||||
scripts := ScriptPathsFromFiles(files)
|
||||
if len(scripts) != 2 {
|
||||
t.Fatalf("expected 2 scripts, got %d", len(scripts))
|
||||
}
|
||||
if _, ok := scripts["scripts/yao/rag.ts"]; !ok {
|
||||
t.Error("expected scripts/yao/rag.ts")
|
||||
}
|
||||
}
|
||||
125
registry/manager/mcp/push.go
Normal file
125
registry/manager/mcp/push.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// PushOptions configures the Push operation.
|
||||
type PushOptions struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
// Push packages and uploads an MCP to the registry.
|
||||
// Per DESIGN-MCP.md:
|
||||
// 1. ID → path
|
||||
// 2. Validate .mcp.yao exists
|
||||
// 3. Derive scope/name, reject @local
|
||||
// 4. Validate scripts are in scripts/{scope}/
|
||||
// 5. Pack MCP dir + collect scripts from project root
|
||||
// 6. Generate pkg.yao
|
||||
// 7. Push to registry
|
||||
func (m *Manager) Push(yaoID string, opts PushOptions) error {
|
||||
if opts.Version == "" {
|
||||
return fmt.Errorf("--version is required for push")
|
||||
}
|
||||
|
||||
scope, name, err := common.IDFromYaoID(yaoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid MCP ID %q: %w", yaoID, err)
|
||||
}
|
||||
|
||||
if common.IsLocalScope(scope) {
|
||||
return fmt.Errorf("cannot push @local packages. Fork to your own scope first")
|
||||
}
|
||||
|
||||
mcpDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
|
||||
|
||||
// Find .mcp.yao files
|
||||
mcpYaoFiles, err := FindMCPYaoFiles(mcpDir)
|
||||
if err != nil || len(mcpYaoFiles) == 0 {
|
||||
return fmt.Errorf(".mcp.yao not found in %s", mcpDir)
|
||||
}
|
||||
|
||||
// Validate script scope and collect scripts
|
||||
allScripts := map[string]string{}
|
||||
for _, mcpFile := range mcpYaoFiles {
|
||||
if err := ValidateScriptScope(mcpFile, scope, m.appRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
scripts, err := CollectScripts(mcpFile, m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range scripts {
|
||||
allScripts[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
manifest := &common.PkgManifest{
|
||||
Type: common.TypeMCP,
|
||||
Scope: "@" + scope,
|
||||
Name: name,
|
||||
Version: opts.Version,
|
||||
}
|
||||
|
||||
zipData, err := common.PackDir(mcpDir, manifest, allScripts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pack: %w", err)
|
||||
}
|
||||
|
||||
regType := common.TypeToRegistryType(common.TypeMCP)
|
||||
result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Pushed %s@%s (digest: %s)\n", common.FormatPackageID(scope, name), result.Version, result.Digest)
|
||||
|
||||
// Report packed scripts
|
||||
if len(allScripts) > 0 {
|
||||
fmt.Printf(" Scripts packed:\n")
|
||||
for scriptPath := range allScripts {
|
||||
fmt.Printf(" %s\n", scriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushValidateMCPDir checks that the MCP directory structure is valid for push.
|
||||
func pushValidateMCPDir(mcpDir string) error {
|
||||
if _, err := os.Stat(mcpDir); err != nil {
|
||||
return fmt.Errorf("MCP directory %s not found", mcpDir)
|
||||
}
|
||||
|
||||
mcpFiles, err := FindMCPYaoFiles(mcpDir)
|
||||
if err != nil || len(mcpFiles) == 0 {
|
||||
return fmt.Errorf("no .mcp.yao files found in %s", mcpDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listDirFiles lists all files under a directory relative to root.
|
||||
func listDirFiles(dir string) ([]string, error) {
|
||||
var files []string
|
||||
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
|
||||
}
|
||||
files = append(files, filepath.ToSlash(rel))
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
177
registry/manager/mcp/script.go
Normal file
177
registry/manager/mcp/script.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
goujson "github.com/yaoapp/gou/json"
|
||||
)
|
||||
|
||||
// ProcessRef represents a process reference extracted from a .mcp.yao file.
|
||||
type ProcessRef struct {
|
||||
ToolName string // e.g. "search"
|
||||
ProcessPath string // e.g. "scripts.yao.rag.Search"
|
||||
ScriptPath string // resolved filesystem path: "scripts/yao/rag.ts"
|
||||
Scope string // extracted scope: "yao"
|
||||
}
|
||||
|
||||
// mcpDSL is a minimal representation of .mcp.yao for process reference extraction.
|
||||
type mcpDSL struct {
|
||||
Transport string `json:"transport"`
|
||||
Tools map[string]string `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// ExtractProcessRefs parses a .mcp.yao file and extracts all "scripts.*" process references.
|
||||
func ExtractProcessRefs(mcpYaoPath string) ([]ProcessRef, error) {
|
||||
data, err := os.ReadFile(mcpYaoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExtractProcessRefsFromBytes(data)
|
||||
}
|
||||
|
||||
// ExtractProcessRefsFromBytes extracts process refs from .mcp.yao content bytes.
|
||||
// Supports JSONC format (// and /* */ comments) used by Yao DSL files.
|
||||
func ExtractProcessRefsFromBytes(data []byte) ([]ProcessRef, error) {
|
||||
var dsl mcpDSL
|
||||
if err := goujson.ParseFile(".mcp.yao", data, &dsl); err != nil {
|
||||
return nil, fmt.Errorf("parse .mcp.yao: %w", err)
|
||||
}
|
||||
|
||||
if dsl.Transport != "process" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var refs []ProcessRef
|
||||
for toolName, processPath := range dsl.Tools {
|
||||
if !strings.HasPrefix(processPath, "scripts.") {
|
||||
continue
|
||||
}
|
||||
ref, err := parseProcessRef(toolName, processPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// parseProcessRef parses "scripts.yao.rag.Search" into a ProcessRef.
|
||||
// Convention: scripts.{scope}.{path...}.{Function}
|
||||
// Script file: scripts/{scope}/{path_joined}.ts
|
||||
func parseProcessRef(toolName, processPath string) (ProcessRef, error) {
|
||||
parts := strings.Split(processPath, ".")
|
||||
// At minimum: scripts.scope.file.Function = 4 parts
|
||||
if len(parts) < 4 {
|
||||
return ProcessRef{}, fmt.Errorf("process path %q too short", processPath)
|
||||
}
|
||||
|
||||
scope := parts[1]
|
||||
// The middle parts (between scope and function name) form the script path
|
||||
scriptParts := parts[2 : len(parts)-1]
|
||||
scriptFile := strings.Join(scriptParts, "/") + ".ts"
|
||||
scriptPath := filepath.Join("scripts", scope, scriptFile)
|
||||
|
||||
return ProcessRef{
|
||||
ToolName: toolName,
|
||||
ProcessPath: processPath,
|
||||
ScriptPath: filepath.ToSlash(scriptPath),
|
||||
Scope: scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RewriteProcessRefs rewrites all "scripts.{oldScope}." references to
|
||||
// "scripts.{newScope}." in a .mcp.yao file content.
|
||||
func RewriteProcessRefs(mcpContent []byte, oldScope, newScope string) []byte {
|
||||
old := "scripts." + oldScope + "."
|
||||
replacement := "scripts." + newScope + "."
|
||||
return []byte(strings.ReplaceAll(string(mcpContent), old, replacement))
|
||||
}
|
||||
|
||||
// ValidateScriptScope checks that all process references in a .mcp.yao point to
|
||||
// scripts in the expected scope directory. Returns an error if any violate the rule.
|
||||
func ValidateScriptScope(mcpYaoPath, expectedScope, appRoot string) error {
|
||||
refs, err := ExtractProcessRefs(mcpYaoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
if ref.Scope != expectedScope {
|
||||
return fmt.Errorf(
|
||||
"MCP script scope mismatch: %s references scripts.%s.* but expected scripts.%s.*\n"+
|
||||
" Scripts must be in scripts/%s/ to match MCP scope",
|
||||
mcpYaoPath, ref.Scope, expectedScope, expectedScope,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify script file exists
|
||||
scriptPath := filepath.Join(appRoot, ref.ScriptPath)
|
||||
if _, err := os.Stat(scriptPath); err != nil {
|
||||
return fmt.Errorf("script file %s referenced by %s not found", ref.ScriptPath, ref.ProcessPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectScripts gathers all script files referenced by a .mcp.yao file.
|
||||
// Returns a map of relative path (under package/) → absolute path on disk.
|
||||
func CollectScripts(mcpYaoPath, appRoot string) (map[string]string, error) {
|
||||
refs, err := ExtractProcessRefs(mcpYaoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scripts := map[string]string{}
|
||||
for _, ref := range refs {
|
||||
absPath := filepath.Join(appRoot, ref.ScriptPath)
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
return nil, fmt.Errorf("script %s not found: %w", ref.ScriptPath, err)
|
||||
}
|
||||
scripts[ref.ScriptPath] = absPath
|
||||
}
|
||||
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// FindMCPYaoFiles finds all .mcp.yao files in an MCP package directory.
|
||||
func FindMCPYaoFiles(mcpDir string) ([]string, error) {
|
||||
var files []string
|
||||
err := filepath.Walk(mcpDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".mcp.yao") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// ScriptPathsFromFiles extracts script-related paths from a file hash map.
|
||||
// Returns only entries starting with "scripts/".
|
||||
func ScriptPathsFromFiles(files map[string]string) map[string]string {
|
||||
result := map[string]string{}
|
||||
for path, hash := range files {
|
||||
if strings.HasPrefix(path, "scripts/") {
|
||||
result[path] = hash
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// processRefRegex matches "scripts.{scope}.{rest}" patterns.
|
||||
var processRefRegex = regexp.MustCompile(`scripts\.([a-zA-Z0-9_-]+)\.`)
|
||||
|
||||
// ExtractScopeFromProcessRef extracts the scope from a process reference like "scripts.yao.rag.Search".
|
||||
func ExtractScopeFromProcessRef(processPath string) string {
|
||||
matches := processRefRegex.FindStringSubmatch(processPath)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
200
registry/manager/mcp/update.go
Normal file
200
registry/manager/mcp/update.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
)
|
||||
|
||||
// UpdateOptions configures the Update operation.
|
||||
type UpdateOptions struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
// Update performs a hash-based safe update for an MCP package.
|
||||
// Same strategy as agent update but also handles scripts under project root.
|
||||
func (m *Manager) Update(pkgID string, opts UpdateOptions) error {
|
||||
if opts.Version == "" {
|
||||
opts.Version = "latest"
|
||||
}
|
||||
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, ok := lf.GetPackage(pkgID)
|
||||
if !ok {
|
||||
return fmt.Errorf("package %s is not installed", pkgID)
|
||||
}
|
||||
if !existing.IsManaged() {
|
||||
return fmt.Errorf("package %s is forked (from %s) and not managed by registry", pkgID, existing.ForkedFrom)
|
||||
}
|
||||
|
||||
regType := common.TypeToRegistryType(common.TypeMCP)
|
||||
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
|
||||
manifest, err := common.ReadManifest(zipData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check required_by compatibility
|
||||
if len(existing.RequiredBy) > 0 {
|
||||
var warnings []string
|
||||
for _, depID := range existing.RequiredBy {
|
||||
depPkg, depOK := lf.GetPackage(depID)
|
||||
if !depOK {
|
||||
continue
|
||||
}
|
||||
if constraint, has := depPkg.Dependencies[pkgID]; has {
|
||||
if !common.VersionSatisfies(manifest.Version, constraint) {
|
||||
warnings = append(warnings, fmt.Sprintf(" %s requires %s ← incompatible", depID, constraint))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
msg := fmt.Sprintf("%s is depended on by:\n%s\nContinue update?", pkgID, strings.Join(warnings, "\n"))
|
||||
if !m.prompter.Confirm(msg) {
|
||||
return fmt.Errorf("update aborted by user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unpack new version to temp
|
||||
tempDir, err := os.MkdirTemp("", "yao-mcp-update-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
newFileList, err := common.UnpackTo(zipData, tempDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mcpRelDir := common.PackageDirRel(common.TypeMCP, scope, name)
|
||||
destDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
|
||||
|
||||
// Build new file set with full relative paths
|
||||
newFileMap := map[string]string{} // fullRelPath → temp file path
|
||||
for _, f := range newFileList {
|
||||
if strings.HasPrefix(f, "scripts/") {
|
||||
newFileMap[f] = filepath.Join(tempDir, f)
|
||||
} else {
|
||||
fullRel := mcpRelDir + "/" + f
|
||||
newFileMap[fullRel] = filepath.Join(tempDir, f)
|
||||
}
|
||||
}
|
||||
|
||||
newHashes := map[string]string{}
|
||||
|
||||
// Process each new file
|
||||
for fullRel, tempPath := range newFileMap {
|
||||
newContent, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newHash := common.HashBytes(newContent)
|
||||
|
||||
// Determine local path
|
||||
var localPath string
|
||||
if strings.HasPrefix(fullRel, "scripts/") {
|
||||
localPath = filepath.Join(m.appRoot, fullRel)
|
||||
} else {
|
||||
relInMCP := strings.TrimPrefix(fullRel, mcpRelDir+"/")
|
||||
localPath = filepath.Join(destDir, relInMCP)
|
||||
}
|
||||
|
||||
oldHash, wasTracked := existing.Files[fullRel]
|
||||
|
||||
if !wasTracked {
|
||||
if err := writeFileTo(localPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("+ %s — new file, added\n", filepath.Base(fullRel))
|
||||
newHashes[fullRel] = newHash
|
||||
continue
|
||||
}
|
||||
|
||||
localHash, err := common.HashFile(localPath)
|
||||
if err != nil {
|
||||
if err := writeFileTo(localPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ %s — restored (was missing locally)\n", filepath.Base(fullRel))
|
||||
newHashes[fullRel] = newHash
|
||||
continue
|
||||
}
|
||||
|
||||
if localHash == oldHash {
|
||||
if err := writeFileTo(localPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ %s — unmodified, updated\n", filepath.Base(fullRel))
|
||||
newHashes[fullRel] = newHash
|
||||
} else {
|
||||
newPath := localPath + ".new"
|
||||
if err := writeFileTo(newPath, newContent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✗ %s — locally modified, skipped (new version → .new)\n", filepath.Base(fullRel))
|
||||
newHashes[fullRel] = newHash
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleted files
|
||||
for oldFile, oldHash := range existing.Files {
|
||||
if _, inNew := newFileMap[oldFile]; inNew {
|
||||
continue
|
||||
}
|
||||
var localPath string
|
||||
if strings.HasPrefix(oldFile, "scripts/") {
|
||||
localPath = filepath.Join(m.appRoot, oldFile)
|
||||
} else {
|
||||
relInMCP := strings.TrimPrefix(oldFile, mcpRelDir+"/")
|
||||
localPath = filepath.Join(destDir, relInMCP)
|
||||
}
|
||||
localHash, err := common.HashFile(localPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if localHash == oldHash {
|
||||
os.Remove(localPath)
|
||||
fmt.Printf("- %s — removed\n", filepath.Base(oldFile))
|
||||
} else {
|
||||
fmt.Printf("⚠ %s — locally modified, kept\n", filepath.Base(oldFile))
|
||||
}
|
||||
}
|
||||
|
||||
existing.Version = manifest.Version
|
||||
existing.Integrity = digest
|
||||
existing.Dependencies = manifest.Dependencies
|
||||
existing.Files = newHashes
|
||||
lf.SetPackage(pkgID, existing)
|
||||
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Updated %s to %s\n", pkgID, manifest.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFileTo(path string, content []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, content, 0644)
|
||||
}
|
||||
157
registry/manager/robot/add.go
Normal file
157
registry/manager/robot/add.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
)
|
||||
|
||||
// AddOptions configures the Add operation.
|
||||
type AddOptions struct {
|
||||
Version string
|
||||
TeamID string // required: which team to add the robot to
|
||||
}
|
||||
|
||||
// Add installs a robot package from the registry.
|
||||
// Per DESIGN-ROBOT.md:
|
||||
// 1. Pull .yao.zip
|
||||
// 2. Parse robot.json + pkg.yao
|
||||
// 3. Install dependencies (assistants + MCPs)
|
||||
// 4. Write member DB record (deferred to CLI layer which has DB access)
|
||||
// 5. Write registry.yao
|
||||
func (m *Manager) Add(pkgID string, opts AddOptions) (*RobotJSON, error) {
|
||||
if opts.Version == "" {
|
||||
opts.Version = "latest"
|
||||
}
|
||||
if opts.TeamID == "" {
|
||||
return nil, fmt.Errorf("--team is required for robot add")
|
||||
}
|
||||
|
||||
scope, name, err := common.ParsePackageID(pkgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lf, err := common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
regType := common.TypeToRegistryType(common.TypeRobot)
|
||||
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull %s: %w", pkgID, err)
|
||||
}
|
||||
|
||||
manifest, err := common.ReadManifest(zipData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Read robot.json from zip
|
||||
robotData, err := common.ExtractFile(zipData, "robot.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract robot.json: %w", err)
|
||||
}
|
||||
|
||||
var robot RobotJSON
|
||||
if err := json.Unmarshal(robotData, &robot); err != nil {
|
||||
return nil, fmt.Errorf("parse robot.json: %w", err)
|
||||
}
|
||||
|
||||
// Analyze dependencies from robot configuration
|
||||
analyzedDeps := AnalyzeDeps(&robot)
|
||||
|
||||
// Merge with pkg.yao declared dependencies
|
||||
allDeps := map[string]string{}
|
||||
for _, dep := range analyzedDeps {
|
||||
allDeps[dep.PackageID] = "*"
|
||||
}
|
||||
for depID, ver := range manifest.Dependencies {
|
||||
allDeps[depID] = ver
|
||||
}
|
||||
|
||||
// Install dependencies
|
||||
if len(allDeps) > 0 {
|
||||
missing, _, _ := common.CheckDependencies(allDeps, lf)
|
||||
if len(missing) > 0 {
|
||||
var summary string
|
||||
for _, dep := range missing {
|
||||
summary += fmt.Sprintf(" %s %s\n", dep.PackageID, dep.RequiredVersion)
|
||||
}
|
||||
if !m.prompter.Confirm(fmt.Sprintf("The following dependencies need to be installed:\n%sInstall?", summary)) {
|
||||
return nil, fmt.Errorf("dependency installation declined, aborting")
|
||||
}
|
||||
|
||||
for _, dep := range missing {
|
||||
depScope, depName, err := common.ParsePackageID(dep.PackageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload lockfile before each dep install — a previous dep may
|
||||
// have recursively installed this one already.
|
||||
freshLF, _ := common.LoadLockfile(m.appRoot)
|
||||
if _, already := freshLF.GetPackage(dep.PackageID); already {
|
||||
fmt.Printf(" ✓ Dependency %s already installed (transitive)\n", dep.PackageID)
|
||||
continue
|
||||
}
|
||||
|
||||
depType := depTypeFor(dep.PackageID, analyzedDeps)
|
||||
|
||||
switch depType {
|
||||
case "mcp":
|
||||
err = m.mcpMgr.Add(dep.PackageID, mcpmgr.AddOptions{})
|
||||
default:
|
||||
err = m.agentMgr.Add(dep.PackageID, agentmgr.AddOptions{})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to install dependency %s (%s/%s): %w", dep.PackageID, depScope, depName, err)
|
||||
}
|
||||
fmt.Printf(" ✓ Dependency %s installed\n", dep.PackageID)
|
||||
}
|
||||
|
||||
// Reload lockfile after dependency installation
|
||||
lf, err = common.LoadLockfile(m.appRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write to registry.yao (member record writing is done by CLI layer)
|
||||
info := common.PackageInfo{
|
||||
Type: common.TypeRobot,
|
||||
Version: manifest.Version,
|
||||
Integrity: digest,
|
||||
Dependencies: allDeps,
|
||||
TeamID: opts.TeamID,
|
||||
}
|
||||
lf.SetPackage(pkgID, info)
|
||||
|
||||
// Add required_by references
|
||||
for depID := range allDeps {
|
||||
lf.AddRequiredBy(depID, pkgID)
|
||||
}
|
||||
|
||||
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Robot %s@%s installed (dependencies ready, team: %s)\n", pkgID, manifest.Version, opts.TeamID)
|
||||
fmt.Printf(" The member record needs to be created in the database.\n")
|
||||
return &robot, nil
|
||||
}
|
||||
|
||||
// depTypeFor finds the type of a dependency from the analyzed deps list.
|
||||
func depTypeFor(pkgID string, analyzedDeps []RobotDep) string {
|
||||
for _, d := range analyzedDeps {
|
||||
if d.PackageID == pkgID {
|
||||
return d.Type
|
||||
}
|
||||
}
|
||||
return "assistant"
|
||||
}
|
||||
115
registry/manager/robot/deps.go
Normal file
115
registry/manager/robot/deps.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RobotJSON represents the portable fields exported from a robot member record.
|
||||
type RobotJSON struct {
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
LanguageModel string `json:"language_model,omitempty"`
|
||||
RobotConfig json.RawMessage `json:"robot_config,omitempty"`
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
MCPServers []string `json:"mcp_servers,omitempty"`
|
||||
}
|
||||
|
||||
// robotConfig is a partial parse of robot_config for dependency extraction.
|
||||
type robotConfig struct {
|
||||
Resources struct {
|
||||
Phases map[string]string `json:"phases,omitempty"`
|
||||
} `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// RobotDep represents a dependency extracted from a robot configuration.
|
||||
type RobotDep struct {
|
||||
PackageID string // "@scope/name"
|
||||
Type string // "assistant" or "mcp"
|
||||
}
|
||||
|
||||
// AnalyzeDeps extracts dependencies from a RobotJSON following DESIGN-ROBOT.md rules:
|
||||
// - phases values: "yao.robot-host" → @yao/robot-host (assistant)
|
||||
// - agents values: "yao.keeper.fetch" → @yao/keeper (first-layer assistant, take first 2 segments)
|
||||
// - mcp_servers values: "ark.image.text2img" → @ark/image.text2img (mcp)
|
||||
// - Excludes: __yao.* prefixed built-in agents
|
||||
func AnalyzeDeps(robot *RobotJSON) []RobotDep {
|
||||
seen := map[string]bool{}
|
||||
var deps []RobotDep
|
||||
|
||||
addDep := func(pkgID, depType string) {
|
||||
if seen[pkgID] {
|
||||
return
|
||||
}
|
||||
seen[pkgID] = true
|
||||
deps = append(deps, RobotDep{PackageID: pkgID, Type: depType})
|
||||
}
|
||||
|
||||
// Extract from phases (all are assistants)
|
||||
if len(robot.RobotConfig) > 0 {
|
||||
var cfg robotConfig
|
||||
if err := json.Unmarshal(robot.RobotConfig, &cfg); err == nil {
|
||||
for _, yaoID := range cfg.Resources.Phases {
|
||||
if isBuiltIn(yaoID) {
|
||||
continue
|
||||
}
|
||||
pkgID := yaoIDToPackageID(yaoID)
|
||||
if pkgID != "" {
|
||||
addDep(pkgID, "assistant")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from agents (first-layer assistant)
|
||||
for _, yaoID := range robot.Agents {
|
||||
if isBuiltIn(yaoID) {
|
||||
continue
|
||||
}
|
||||
pkgID := agentYaoIDToPackageID(yaoID)
|
||||
if pkgID != "" {
|
||||
addDep(pkgID, "assistant")
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from mcp_servers
|
||||
for _, yaoID := range robot.MCPServers {
|
||||
if isBuiltIn(yaoID) {
|
||||
continue
|
||||
}
|
||||
pkgID := yaoIDToPackageID(yaoID)
|
||||
if pkgID != "" {
|
||||
addDep(pkgID, "mcp")
|
||||
}
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
|
||||
// isBuiltIn returns true for __yao.* prefixed IDs.
|
||||
func isBuiltIn(yaoID string) bool {
|
||||
return strings.HasPrefix(yaoID, "__yao.")
|
||||
}
|
||||
|
||||
// yaoIDToPackageID converts "yao.robot-host" → "@yao/robot-host".
|
||||
// First "." separates scope from name.
|
||||
func yaoIDToPackageID(yaoID string) string {
|
||||
idx := strings.Index(yaoID, ".")
|
||||
if idx <= 0 || idx >= len(yaoID)-1 {
|
||||
return ""
|
||||
}
|
||||
scope := yaoID[:idx]
|
||||
name := yaoID[idx+1:]
|
||||
return "@" + scope + "/" + name
|
||||
}
|
||||
|
||||
// agentYaoIDToPackageID converts "yao.keeper.fetch" → "@yao/keeper".
|
||||
// Takes the first two segments only (first-layer assistant).
|
||||
func agentYaoIDToPackageID(yaoID string) string {
|
||||
parts := strings.SplitN(yaoID, ".", 3)
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + parts[0] + "/" + parts[1]
|
||||
}
|
||||
32
registry/manager/robot/robot.go
Normal file
32
registry/manager/robot/robot.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Package robot implements the Robot package manager for the Yao registry.
|
||||
package robot
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/registry"
|
||||
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
|
||||
)
|
||||
|
||||
// Manager handles robot package operations (add only for P0).
|
||||
type Manager struct {
|
||||
client *registry.Client
|
||||
appRoot string
|
||||
prompter common.Prompter
|
||||
agentMgr *agentmgr.Manager
|
||||
mcpMgr *mcpmgr.Manager
|
||||
}
|
||||
|
||||
// New creates a Robot Manager.
|
||||
func New(client *registry.Client, appRoot string, prompter common.Prompter) *Manager {
|
||||
if prompter == nil {
|
||||
prompter = &common.StdinPrompter{}
|
||||
}
|
||||
return &Manager{
|
||||
client: client,
|
||||
appRoot: appRoot,
|
||||
prompter: prompter,
|
||||
agentMgr: agentmgr.New(client, appRoot, prompter),
|
||||
mcpMgr: mcpmgr.New(client, appRoot, prompter),
|
||||
}
|
||||
}
|
||||
324
registry/manager/robot/robot_test.go
Normal file
324
registry/manager/robot/robot_test.go
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/registry"
|
||||
"github.com/yaoapp/yao/registry/manager/common"
|
||||
"github.com/yaoapp/yao/registry/testdata"
|
||||
)
|
||||
|
||||
func buildRobotZip(scope, name, version string, robotJSON *RobotJSON, deps []testdata.ManifestDep) []byte {
|
||||
robotBytes, _ := json.Marshal(robotJSON)
|
||||
zip, err := testdata.BuildZip(&testdata.Manifest{
|
||||
Type: "robot",
|
||||
Scope: scope,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Dependencies: deps,
|
||||
}, map[string]string{
|
||||
"robot.json": string(robotBytes),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
func buildAgentZip(scope, name, version string) []byte {
|
||||
zip, err := testdata.BuildZip(&testdata.Manifest{
|
||||
Type: "assistant",
|
||||
Scope: scope,
|
||||
Name: name,
|
||||
Version: version,
|
||||
}, map[string]string{
|
||||
"package.yao": `{"name":"` + name + `"}`,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
func buildMCPZip(scope, name, version string) []byte {
|
||||
zip, err := testdata.BuildZip(&testdata.Manifest{
|
||||
Type: "mcp",
|
||||
Scope: scope,
|
||||
Name: name,
|
||||
Version: version,
|
||||
}, map[string]string{
|
||||
name + ".mcp.yao": `{"transport":"stdio","command":"echo"}`,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
func mockServer(packages map[string][]byte) *httptest.Server {
|
||||
return 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", "mcps", "robots"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
|
||||
if len(parts) >= 4 {
|
||||
key := parts[0] + "/" + parts[1] + "/" + parts[2]
|
||||
if zipData, ok := packages[key]; ok {
|
||||
w.Header().Set("X-Digest", "sha256-test")
|
||||
w.Write(zipData)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAnalyzeDeps(t *testing.T) {
|
||||
robot := &RobotJSON{
|
||||
RobotConfig: json.RawMessage(`{
|
||||
"resources": {
|
||||
"phases": {
|
||||
"host": "yao.robot-host",
|
||||
"goals": "yao.robot-goals",
|
||||
"builtin": "__yao.default-host"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
Agents: []string{"yao.keeper.fetch", "__yao.system"},
|
||||
MCPServers: []string{"ark.image.text2img"},
|
||||
}
|
||||
|
||||
deps := AnalyzeDeps(robot)
|
||||
|
||||
depMap := map[string]string{}
|
||||
for _, d := range deps {
|
||||
depMap[d.PackageID] = d.Type
|
||||
}
|
||||
|
||||
// phases
|
||||
if depMap["@yao/robot-host"] != "assistant" {
|
||||
t.Error("expected @yao/robot-host as assistant")
|
||||
}
|
||||
if depMap["@yao/robot-goals"] != "assistant" {
|
||||
t.Error("expected @yao/robot-goals as assistant")
|
||||
}
|
||||
|
||||
// agents (first-layer)
|
||||
if depMap["@yao/keeper"] != "assistant" {
|
||||
t.Error("expected @yao/keeper as assistant")
|
||||
}
|
||||
|
||||
// mcp_servers
|
||||
if depMap["@ark/image.text2img"] != "mcp" {
|
||||
t.Error("expected @ark/image.text2img as mcp")
|
||||
}
|
||||
|
||||
// __yao.* should be excluded
|
||||
if _, ok := depMap["@__yao/default-host"]; ok {
|
||||
t.Error("expected __yao.default-host to be excluded")
|
||||
}
|
||||
if _, ok := depMap["@__yao/system"]; ok {
|
||||
t.Error("expected __yao.system to be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDepsEmpty(t *testing.T) {
|
||||
robot := &RobotJSON{}
|
||||
deps := AnalyzeDeps(robot)
|
||||
if len(deps) != 0 {
|
||||
t.Errorf("expected 0 deps, got %d", len(deps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDepsDedupe(t *testing.T) {
|
||||
robot := &RobotJSON{
|
||||
RobotConfig: json.RawMessage(`{
|
||||
"resources": {
|
||||
"phases": {
|
||||
"host": "yao.robot-host"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
Agents: []string{"yao.robot-host.run"},
|
||||
}
|
||||
|
||||
deps := AnalyzeDeps(robot)
|
||||
count := 0
|
||||
for _, d := range deps {
|
||||
if d.PackageID == "@yao/robot-host" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected @yao/robot-host once, got %d times", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRobotNoTeam(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
srv := mockServer(nil)
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
_, err := mgr.Add("@test/my-robot", AddOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing team")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--team") {
|
||||
t.Errorf("expected team error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRobotWithDeps(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
agentZip := buildAgentZip("@test", "robot-host", "1.0.0")
|
||||
mcpZip := buildMCPZip("@test", "image-gen", "1.0.0")
|
||||
|
||||
robotJSON := &RobotJSON{
|
||||
DisplayName: "Test Robot",
|
||||
RobotConfig: json.RawMessage(`{
|
||||
"resources": {
|
||||
"phases": {
|
||||
"host": "test.robot-host"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
MCPServers: []string{"test.image-gen"},
|
||||
}
|
||||
robotZip := buildRobotZip("@test", "my-robot", "1.0.0", robotJSON, nil)
|
||||
|
||||
srv := mockServer(map[string][]byte{
|
||||
"robots/@test/my-robot": robotZip,
|
||||
"assistants/@test/robot-host": agentZip,
|
||||
"mcps/@test/image-gen": mcpZip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
robot, err := mgr.Add("@test/my-robot", AddOptions{TeamID: "team-123"})
|
||||
if err != nil {
|
||||
t.Fatalf("Add robot failed: %v", err)
|
||||
}
|
||||
|
||||
if robot.DisplayName != "Test Robot" {
|
||||
t.Errorf("expected display_name 'Test Robot', got %q", robot.DisplayName)
|
||||
}
|
||||
|
||||
// Verify lockfile
|
||||
lf, _ := common.LoadLockfile(appRoot)
|
||||
pkg, ok := lf.GetPackage("@test/my-robot")
|
||||
if !ok {
|
||||
t.Fatal("expected @test/my-robot in lockfile")
|
||||
}
|
||||
if pkg.Type != common.TypeRobot {
|
||||
t.Errorf("expected type robot, got %s", pkg.Type)
|
||||
}
|
||||
if pkg.TeamID != "team-123" {
|
||||
t.Errorf("expected team_id team-123, got %s", pkg.TeamID)
|
||||
}
|
||||
|
||||
// Verify dependencies were installed
|
||||
if _, ok := lf.GetPackage("@test/robot-host"); !ok {
|
||||
t.Error("expected @test/robot-host dependency installed")
|
||||
}
|
||||
if _, ok := lf.GetPackage("@test/image-gen"); !ok {
|
||||
t.Error("expected @test/image-gen dependency installed")
|
||||
}
|
||||
|
||||
// Verify assistant directory was created
|
||||
agentDir := filepath.Join(appRoot, "assistants", "test", "robot-host")
|
||||
if _, err := os.Stat(agentDir); err != nil {
|
||||
t.Error("expected assistant directory created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRobotNoDeps(t *testing.T) {
|
||||
appRoot := t.TempDir()
|
||||
|
||||
robotJSON := &RobotJSON{
|
||||
DisplayName: "Simple Robot",
|
||||
}
|
||||
robotZip := buildRobotZip("@test", "simple-bot", "1.0.0", robotJSON, nil)
|
||||
|
||||
srv := mockServer(map[string][]byte{
|
||||
"robots/@test/simple-bot": robotZip,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := registry.New(srv.URL)
|
||||
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
|
||||
|
||||
robot, err := mgr.Add("@test/simple-bot", AddOptions{TeamID: "team-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Add simple robot failed: %v", err)
|
||||
}
|
||||
if robot.DisplayName != "Simple Robot" {
|
||||
t.Errorf("expected 'Simple Robot', got %q", robot.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYaoIDToPackageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"yao.robot-host", "@yao/robot-host"},
|
||||
{"ark.image.text2img", "@ark/image.text2img"},
|
||||
{"bad", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := yaoIDToPackageID(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("yaoIDToPackageID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentYaoIDToPackageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"yao.keeper.fetch", "@yao/keeper"},
|
||||
{"yao.keeper", "@yao/keeper"},
|
||||
{"bad", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := agentYaoIDToPackageID(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("agentYaoIDToPackageID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltIn(t *testing.T) {
|
||||
if !isBuiltIn("__yao.default-host") {
|
||||
t.Error("expected __yao.default-host to be built-in")
|
||||
}
|
||||
if isBuiltIn("yao.robot-host") {
|
||||
t.Error("expected yao.robot-host NOT to be built-in")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue