diff --git a/README.md b/README.md index 6a0e7eb..5d8211a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,11 @@ 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 -- pipeline d'auto-update via GitLab Releases +- pipeline d'auto-update via endpoint de release configurable + +Le package `update` ne deduit pas la forge ni l'authentification. +L'application cliente fournit l'URL de release, le header d'auth eventuel et, +si besoin, les variables d'environnement a consulter. Packages exposes : diff --git a/config/config_test.go b/config/config_test.go index a2ba592..47952c5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -38,7 +38,7 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { CurrentProfile: "prod", Profiles: map[string]testProfile{ "prod": { - BaseURL: "https://graylog.example.com", + BaseURL: "https://api.example.com", StreamID: "stream-1", }, }, @@ -72,7 +72,7 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { if cfg.CurrentProfile != "prod" { t.Fatalf("CurrentProfile = %q, want prod", cfg.CurrentProfile) } - if cfg.Profiles["prod"].BaseURL != "https://graylog.example.com" { + if cfg.Profiles["prod"].BaseURL != "https://api.example.com" { t.Fatalf("BaseURL = %q", cfg.Profiles["prod"].BaseURL) } } diff --git a/go.mod b/go.mod index d6a0f56..cb493df 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitlab.lundimatin.app/artificial-intelligence-ia/claude/mcp-framework +module gitea.lclr.dev/AI/mcp-framework go 1.25.0 diff --git a/update/update.go b/update/update.go index 3fcdf59..12593d8 100644 --- a/update/update.go +++ b/update/update.go @@ -22,17 +22,18 @@ type Options struct { LatestReleaseURL string Stdout io.Writer BinaryName string - ReleaseSource GitLabSource + ReleaseSource ReleaseSource GOOS string GOARCH string } -type GitLabSource struct { - BaseURL string - ProjectPath string - Token string - TokenHeader string - TokenEnvNames []string +type ReleaseSource struct { + Name string + BaseURL string + LatestReleaseURL string + Token string + TokenHeader string + TokenEnvNames []string } type Auth struct { @@ -70,7 +71,15 @@ func Run(ctx context.Context, opts Options) error { } source := normalizeSource(opts.ReleaseSource) - auth := ResolveGitLabAuth(source.Token, source) + auth := ResolveAuth(source.Token, source) + + releaseURL := opts.LatestReleaseURL + if strings.TrimSpace(releaseURL) == "" { + releaseURL = strings.TrimSpace(source.LatestReleaseURL) + } + if releaseURL == "" { + return errors.New("latest release URL must not be empty") + } targetPath, err := ResolveUpdateTarget(opts.ExecutablePath) if err != nil { @@ -82,11 +91,6 @@ func Run(ctx context.Context, opts Options) error { return err } - releaseURL := opts.LatestReleaseURL - if strings.TrimSpace(releaseURL) == "" { - releaseURL = LatestReleaseAPIURL(source) - } - release, err := FetchLatestRelease(ctx, opts.Client, releaseURL, auth, source) if err != nil { return err @@ -115,7 +119,7 @@ func Run(ctx context.Context, opts Options) error { return nil } -func ResolveGitLabAuth(explicitToken string, source GitLabSource) Auth { +func ResolveAuth(explicitToken string, source ReleaseSource) Auth { source = normalizeSource(source) if token := strings.TrimSpace(explicitToken); token != "" { @@ -131,15 +135,6 @@ func ResolveGitLabAuth(explicitToken string, source GitLabSource) Auth { return Auth{} } -func LatestReleaseAPIURL(source GitLabSource) string { - source = normalizeSource(source) - return fmt.Sprintf( - "%s/api/v4/projects/%s/releases/permalink/latest", - source.BaseURL, - url.PathEscape(source.ProjectPath), - ) -} - func ResolveUpdateTarget(explicitPath string) (string, error) { targetPath := strings.TrimSpace(explicitPath) if targetPath == "" { @@ -177,7 +172,7 @@ func AssetName(binaryName, goos, goarch string) (string, error) { } } -func FetchLatestRelease(ctx context.Context, client *http.Client, releaseURL string, auth Auth, source GitLabSource) (Release, error) { +func FetchLatestRelease(ctx context.Context, client *http.Client, releaseURL string, auth Auth, source ReleaseSource) (Release, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil) if err != nil { return Release{}, fmt.Errorf("build latest release request: %w", err) @@ -240,7 +235,7 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) { return "", fmt.Errorf("latest release does not contain asset %q", assetName) } -func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source GitLabSource) (string, error) { +func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) if err != nil { return "", fmt.Errorf("build artifact download request: %w", err) @@ -307,12 +302,11 @@ func ReplaceExecutable(downloadPath, targetPath string) error { return nil } -func normalizeSource(source GitLabSource) GitLabSource { - if source.TokenHeader == "" { - source.TokenHeader = "PRIVATE-TOKEN" - } +func normalizeSource(source ReleaseSource) ReleaseSource { + source.Name = strings.TrimSpace(source.Name) source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/") - source.ProjectPath = strings.TrimSpace(source.ProjectPath) + source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL) + source.TokenHeader = strings.TrimSpace(source.TokenHeader) return source } @@ -335,7 +329,7 @@ func (a Auth) apply(req *http.Request) { req.Header.Set(a.Header, a.Token) } -func (a Auth) maybeHint(statusCode int, body []byte, source GitLabSource) error { +func (a Auth) maybeHint(statusCode int, body []byte, source ReleaseSource) error { source = normalizeSource(source) if strings.TrimSpace(a.Token) != "" || len(source.TokenEnvNames) == 0 { return nil @@ -349,22 +343,34 @@ func (a Auth) maybeHint(statusCode int, body []byte, source GitLabSource) error message := strings.ToLower(strings.TrimSpace(string(body))) if !strings.Contains(message, "project not found") && + !strings.Contains(message, "not found") && !strings.Contains(message, "unauthorized") && !strings.Contains(message, "forbidden") { return nil } + target := source.BaseURL + if target == "" { + target = "release endpoint" + } + name := source.Name + if name == "" { + name = "release" + } + if len(source.TokenEnvNames) == 1 { return fmt.Errorf( - "GitLab release access requires authentication on %s; set %s and retry", - source.BaseURL, + "%s access requires authentication on %s; set %s and retry", + name, + target, source.TokenEnvNames[0], ) } return fmt.Errorf( - "GitLab release access requires authentication on %s; set %s (or %s) and retry", - source.BaseURL, + "%s access requires authentication on %s; set %s (or %s) and retry", + name, + target, source.TokenEnvNames[0], source.TokenEnvNames[1], ) diff --git a/update/update_test.go b/update/update_test.go index b49904b..047ac74 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -78,52 +78,54 @@ func TestReleaseAssetURLResolvesRelativeLinks(t *testing.T) { {Name: "graylog-mcp-linux-amd64", URL: "/downloads/graylog-mcp-linux-amd64"}, } - got, err := release.AssetURL("graylog-mcp-linux-amd64", "https://gitlab.example.com/api/v4/projects/1/releases/permalink/latest") + got, err := release.AssetURL("graylog-mcp-linux-amd64", "https://releases.example.com/latest") if err != nil { t.Fatalf("AssetURL: %v", err) } - if got != "https://gitlab.example.com/downloads/graylog-mcp-linux-amd64" { + if got != "https://releases.example.com/downloads/graylog-mcp-linux-amd64" { t.Fatalf("got %q", got) } } -func TestResolveGitLabAuthPrefersExplicitToken(t *testing.T) { - t.Setenv("GITLAB_TOKEN", "env-token") +func TestResolveAuthPrefersExplicitToken(t *testing.T) { + t.Setenv("RELEASE_TOKEN", "env-token") - auth := ResolveGitLabAuth("explicit-token", GitLabSource{ - BaseURL: "https://gitlab.example.com", - ProjectPath: "group/project", - TokenEnvNames: []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}, + auth := ResolveAuth("explicit-token", ReleaseSource{ + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenHeader: "X-Release-Token", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }) - if auth.Header != "PRIVATE-TOKEN" { - t.Fatalf("header = %q, want PRIVATE-TOKEN", auth.Header) + if auth.Header != "X-Release-Token" { + t.Fatalf("header = %q, want X-Release-Token", auth.Header) } if auth.Token != "explicit-token" { t.Fatalf("token = %q, want explicit token", auth.Token) } } -func TestResolveGitLabAuthReadsEnvironment(t *testing.T) { - t.Setenv("GITLAB_PRIVATE_TOKEN", "env-token") +func TestResolveAuthReadsEnvironment(t *testing.T) { + t.Setenv("RELEASE_PRIVATE_TOKEN", "env-token") - auth := ResolveGitLabAuth("", GitLabSource{ - BaseURL: "https://gitlab.example.com", - ProjectPath: "group/project", - TokenEnvNames: []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}, + auth := ResolveAuth("", ReleaseSource{ + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenHeader: "X-Release-Token", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }) - if auth.Header != "PRIVATE-TOKEN" { - t.Fatalf("header = %q, want PRIVATE-TOKEN", auth.Header) + if auth.Header != "X-Release-Token" { + t.Fatalf("header = %q, want X-Release-Token", auth.Header) } if auth.Token != "env-token" { t.Fatalf("token = %q, want env token", auth.Token) } } -func TestFetchLatestReleaseAddsGitLabAuthHeader(t *testing.T) { +func TestFetchLatestReleaseAddsConfiguredAuthHeader(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("PRIVATE-TOKEN"); got != "secret-token" { - t.Fatalf("PRIVATE-TOKEN = %q, want secret-token", got) + if got := r.Header.Get("X-Release-Token"); got != "secret-token" { + t.Fatalf("X-Release-Token = %q, want secret-token", got) } payload, err := json.Marshal(Release{TagName: "v1.2.3"}) @@ -141,9 +143,9 @@ func TestFetchLatestReleaseAddsGitLabAuthHeader(t *testing.T) { release, err := FetchLatestRelease( context.Background(), client, - "https://gitlab.example.com/latest", - Auth{Header: "PRIVATE-TOKEN", Token: "secret-token"}, - GitLabSource{BaseURL: "https://gitlab.example.com", ProjectPath: "group/project"}, + "https://releases.example.com/latest", + Auth{Header: "X-Release-Token", Token: "secret-token"}, + ReleaseSource{Name: "release endpoint", BaseURL: "https://releases.example.com"}, ) if err != nil { t.Fatalf("FetchLatestRelease: %v", err) @@ -153,7 +155,7 @@ func TestFetchLatestReleaseAddsGitLabAuthHeader(t *testing.T) { } } -func TestFetchLatestReleaseHintsWhenGitLabAuthIsMissing(t *testing.T) { +func TestFetchLatestReleaseHintsWhenAuthIsMissing(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ @@ -167,18 +169,18 @@ func TestFetchLatestReleaseHintsWhenGitLabAuthIsMissing(t *testing.T) { _, err := FetchLatestRelease( context.Background(), client, - "https://gitlab.example.com/latest", + "https://releases.example.com/latest", Auth{}, - GitLabSource{ - BaseURL: "https://gitlab.example.com", - ProjectPath: "group/project", - TokenEnvNames: []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}, + ReleaseSource{ + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }, ) if err == nil { t.Fatal("expected error") } - if !strings.Contains(err.Error(), "GITLAB_TOKEN") { + if !strings.Contains(err.Error(), "RELEASE_TOKEN") { t.Fatalf("error = %v, want token hint", err) } } @@ -203,10 +205,10 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { - case "https://gitlab.example.com/latest": + case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://gitlab.example.com/artifact"}, + {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) @@ -218,7 +220,7 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil - case "https://gitlab.example.com/artifact": + case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), @@ -250,13 +252,13 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { Client: client, CurrentVersion: "v1.2.2", ExecutablePath: link, - LatestReleaseURL: "https://gitlab.example.com/latest", + LatestReleaseURL: "https://releases.example.com/latest", Stdout: &stdout, BinaryName: "graylog-mcp", - ReleaseSource: GitLabSource{ - BaseURL: "https://gitlab.example.com", - ProjectPath: "group/project", - TokenEnvNames: []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}, + ReleaseSource: ReleaseSource{ + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }, }) if err != nil { @@ -290,10 +292,10 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { - case "https://gitlab.example.com/latest": + case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ - {Name: assetName, URL: "https://gitlab.example.com/artifact"}, + {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) @@ -305,7 +307,7 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil - case "https://gitlab.example.com/artifact": + case "https://releases.example.com/artifact": downloaded = true return &http.Response{ StatusCode: http.StatusOK, @@ -333,13 +335,13 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { Client: client, CurrentVersion: "v1.2.3", ExecutablePath: target, - LatestReleaseURL: "https://gitlab.example.com/latest", + LatestReleaseURL: "https://releases.example.com/latest", Stdout: &stdout, BinaryName: "graylog-mcp", - ReleaseSource: GitLabSource{ - BaseURL: "https://gitlab.example.com", - ProjectPath: "group/project", - TokenEnvNames: []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}, + ReleaseSource: ReleaseSource{ + Name: "release endpoint", + BaseURL: "https://releases.example.com", + TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }, }) if err != nil { @@ -361,3 +363,21 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { t.Fatalf("stdout = %q, want up-to-date message", stdout.String()) } } + +func TestRunRequiresLatestReleaseURL(t *testing.T) { + target := filepath.Join(t.TempDir(), "graylog-mcp") + if err := os.WriteFile(target, []byte("current-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err := Run(context.Background(), Options{ + BinaryName: "graylog-mcp", + ExecutablePath: target, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "latest release URL") { + t.Fatalf("error = %v", err) + } +}