diff --git a/README.md b/README.md index 5d8211a..8ecb33e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Bibliotheque Go pour construire des binaires MCP avec : - resolution de profils CLI - stockage JSON de configuration dans `os.UserConfigDir()` - stockage de secrets dans le wallet natif selon l'OS +- lecture d'un manifeste `mcp.toml` a la racine du projet - pipeline d'auto-update via endpoint de release configurable Le package `update` ne deduit pas la forge ni l'authentification. @@ -15,5 +16,17 @@ Packages exposes : - `cli` - `config` +- `manifest` - `secretstore` - `update` + +Exemple minimal de `mcp.toml` : + +```toml +[update] +source_name = "Gitea releases" +base_url = "https://gitea.example.com" +latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" +token_header = "Authorization" +token_env_names = ["GITEA_TOKEN"] +``` diff --git a/go.mod b/go.mod index cb493df..1a3e5c8 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/99designs/keyring v1.2.2 + github.com/BurntSushi/toml v1.6.0 golang.org/x/term v0.40.0 ) diff --git a/go.sum b/go.sum index bdfc1a5..8dc0be6 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/manifest/manifest.go b/manifest/manifest.go new file mode 100644 index 0000000..2bb8ca1 --- /dev/null +++ b/manifest/manifest.go @@ -0,0 +1,123 @@ +package manifest + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + + "gitea.lclr.dev/AI/mcp-framework/update" +) + +const DefaultFile = "mcp.toml" + +type File struct { + Update Update `toml:"update"` +} + +type Update struct { + SourceName string `toml:"source_name"` + BaseURL string `toml:"base_url"` + LatestReleaseURL string `toml:"latest_release_url"` + TokenHeader string `toml:"token_header"` + TokenEnvNames []string `toml:"token_env_names"` +} + +func Find(startDir string) (string, error) { + dir := strings.TrimSpace(startDir) + if dir == "" { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve working directory: %w", err) + } + dir = wd + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve manifest search path %q: %w", dir, err) + } + + for { + path := filepath.Join(absDir, DefaultFile) + info, err := os.Stat(path) + switch { + case err == nil: + if info.IsDir() { + return "", fmt.Errorf("manifest path %q is a directory", path) + } + return path, nil + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("stat manifest %q: %w", path, err) + } + + parent := filepath.Dir(absDir) + if parent == absDir { + return "", fmt.Errorf("find manifest %q from %q: %w", DefaultFile, dir, os.ErrNotExist) + } + absDir = parent + } +} + +func Load(path string) (File, error) { + data, err := os.ReadFile(path) + if err != nil { + return File{}, fmt.Errorf("read manifest %s: %w", path, err) + } + + var file File + if err := toml.Unmarshal(data, &file); err != nil { + return File{}, fmt.Errorf("parse manifest %s: %w", path, err) + } + + file.normalize() + return file, nil +} + +func LoadDefault(startDir string) (File, string, error) { + path, err := Find(startDir) + if err != nil { + return File{}, "", err + } + + file, err := Load(path) + if err != nil { + return File{}, "", err + } + + return file, path, nil +} + +func (f *File) normalize() { + f.Update.normalize() +} + +func (u *Update) normalize() { + u.SourceName = strings.TrimSpace(u.SourceName) + u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/") + u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) + u.TokenHeader = strings.TrimSpace(u.TokenHeader) + + envNames := u.TokenEnvNames[:0] + for _, envName := range u.TokenEnvNames { + if trimmed := strings.TrimSpace(envName); trimmed != "" { + envNames = append(envNames, trimmed) + } + } + u.TokenEnvNames = envNames +} + +func (u Update) ReleaseSource() update.ReleaseSource { + u.normalize() + + return update.ReleaseSource{ + Name: u.SourceName, + BaseURL: u.BaseURL, + LatestReleaseURL: u.LatestReleaseURL, + TokenHeader: u.TokenHeader, + TokenEnvNames: append([]string(nil), u.TokenEnvNames...), + } +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go new file mode 100644 index 0000000..cfd35ec --- /dev/null +++ b/manifest/manifest_test.go @@ -0,0 +1,116 @@ +package manifest + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestFindWalksParents(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, DefaultFile) + if err := os.WriteFile(manifestPath, []byte("[update]\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + startDir := filepath.Join(root, "cmd", "graylog") + if err := os.MkdirAll(startDir, 0o755); err != nil { + t.Fatalf("MkdirAll startDir: %v", err) + } + + got, err := Find(startDir) + if err != nil { + t.Fatalf("Find returned error: %v", err) + } + if got != manifestPath { + t.Fatalf("Find = %q, want %q", got, manifestPath) + } +} + +func TestFindReturnsNotExistWhenMissing(t *testing.T) { + _, err := Find(t.TempDir()) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("error = %v, want os.ErrNotExist", err) + } +} + +func TestLoadParsesUpdateConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[update] +source_name = " Gitea releases " +base_url = "https://gitea.example.com/" +latest_release_url = "https://gitea.example.com/api/releases/latest" +token_header = " Authorization " +token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] +` + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + file, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + source := file.Update.ReleaseSource() + if source.Name != "Gitea releases" { + t.Fatalf("source name = %q", source.Name) + } + if source.BaseURL != "https://gitea.example.com" { + t.Fatalf("base URL = %q", source.BaseURL) + } + if source.LatestReleaseURL != "https://gitea.example.com/api/releases/latest" { + t.Fatalf("latest release URL = %q", source.LatestReleaseURL) + } + if source.TokenHeader != "Authorization" { + t.Fatalf("token header = %q", source.TokenHeader) + } + if len(source.TokenEnvNames) != 2 { + t.Fatalf("token env names = %v", source.TokenEnvNames) + } +} + +func TestLoadDefaultFindsManifest(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, DefaultFile) + if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + startDir := filepath.Join(root, "cmd", "tool") + if err := os.MkdirAll(startDir, 0o755); err != nil { + t.Fatalf("MkdirAll startDir: %v", err) + } + + file, gotPath, err := LoadDefault(startDir) + if err != nil { + t.Fatalf("LoadDefault returned error: %v", err) + } + if gotPath != path { + t.Fatalf("path = %q, want %q", gotPath, path) + } + if file.Update.LatestReleaseURL != "https://example.com/latest" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} + +func TestLoadReturnsParseError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + if err := os.WriteFile(path, []byte("[update\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("expected error") + } +}