From 17971bb737bce4bd3167692544fbfb61130b6054 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:19:42 +0000 Subject: [PATCH 1/3] Initial plan From c8d8d94b2287b2de6d7f31a7e42b46c4b76beacd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:31:50 +0000 Subject: [PATCH 2/3] feat: add CLI self-update command, update check, and wfctl mcp command Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/main.go | 10 ++ cmd/wfctl/mcp.go | 49 +++++++ cmd/wfctl/mcp_test.go | 21 +++ cmd/wfctl/update.go | 296 +++++++++++++++++++++++++++++++++++++++ cmd/wfctl/update_test.go | 178 +++++++++++++++++++++++ docs/mcp.md | 77 +++++++--- 6 files changed, 615 insertions(+), 16 deletions(-) create mode 100644 cmd/wfctl/mcp.go create mode 100644 cmd/wfctl/mcp_test.go create mode 100644 cmd/wfctl/update.go create mode 100644 cmd/wfctl/update_test.go diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 5ecaeed5..9027ad00 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -29,6 +29,8 @@ var commands = map[string]func([]string) error{ "generate": runGenerate, "git": runGit, "registry": runRegistry, + "update": runUpdate, + "mcp": runMCP, } func usage() { @@ -59,6 +61,8 @@ Commands: generate Code generation (github-actions: generate CI/CD workflows from config) git Git integration (connect: link to GitHub repo, push: commit and push) registry Registry management (list, add, remove plugin registry sources) + update Update wfctl to the latest version (use --check to only check) + mcp Start the MCP server over stdio for AI assistant integration Run 'wfctl -h' for command-specific help. `, version) @@ -87,6 +91,12 @@ func main() { os.Exit(1) } + // Check for updates in the background for commands that are not long-running + // (mcp and run keep the process alive, so we skip the notice there). + if cmd != "mcp" && cmd != "run" { + checkForUpdateNotice() + } + if err := fn(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) //nolint:gosec // G705: CLI error output os.Exit(1) diff --git a/cmd/wfctl/mcp.go b/cmd/wfctl/mcp.go new file mode 100644 index 00000000..1377ecfd --- /dev/null +++ b/cmd/wfctl/mcp.go @@ -0,0 +1,49 @@ +package main + +import ( + "flag" + "fmt" + + workflowmcp "github.com/GoCodeAlone/workflow/mcp" +) + +// runMCP starts the workflow MCP (Model Context Protocol) server over stdio. +// This exposes workflow engine tools and resources to AI assistants. +func runMCP(args []string) error { + fs := flag.NewFlagSet("mcp", flag.ContinueOnError) + pluginDir := fs.String("plugin-dir", "data/plugins", "Plugin data directory") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl mcp [options] + +Start the workflow MCP (Model Context Protocol) server over stdio. +This exposes workflow engine tools and resources to AI assistants such as +Claude Desktop, VS Code with GitHub Copilot, and Cursor. + +The server provides tools for listing module types, validating configs, +generating schemas, and inspecting workflow YAML configurations. + +Options: +`) + fs.PrintDefaults() + fmt.Fprintf(fs.Output(), ` +Example Claude Desktop configuration (~/.config/claude/claude_desktop_config.json): + + { + "mcpServers": { + "workflow": { + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] + } + } + } + +See docs/mcp.md for full setup instructions. +`) + } + if err := fs.Parse(args); err != nil { + return err + } + + srv := workflowmcp.NewServer(*pluginDir) + return srv.ServeStdio() +} diff --git a/cmd/wfctl/mcp_test.go b/cmd/wfctl/mcp_test.go new file mode 100644 index 00000000..bd08563b --- /dev/null +++ b/cmd/wfctl/mcp_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" +) + +func TestRunMCP_Usage(t *testing.T) { + // Passing -h should return an error from flag parsing (ExitOnError calls os.Exit, + // but we use ContinueOnError in runMCP so it returns an error instead). + err := runMCP([]string{"-h"}) + if err == nil { + t.Fatal("expected error from -h flag") + } +} + +func TestRunMCP_UnknownFlag(t *testing.T) { + err := runMCP([]string{"--unknown-flag"}) + if err == nil { + t.Fatal("expected error for unknown flag") + } +} diff --git a/cmd/wfctl/update.go b/cmd/wfctl/update.go new file mode 100644 index 00000000..7dcad1c4 --- /dev/null +++ b/cmd/wfctl/update.go @@ -0,0 +1,296 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + githubReleasesURL = "https://api.github.com/repos/GoCodeAlone/workflow/releases/latest" + envNoUpdateCheck = "WFCTL_NO_UPDATE_CHECK" +) + +// githubReleasesURLOverride allows tests to substitute a fake server URL. +var githubReleasesURLOverride string + +// githubRelease is the minimal GitHub releases API response we need. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` + HTMLURL string `json:"html_url"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// runUpdate handles the "wfctl update" command. +func runUpdate(args []string) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + checkOnly := fs.Bool("check", false, "Only check for updates without installing") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl update [options] + +Download and install the latest version of wfctl, replacing the current binary. + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + if version == "dev" && !*checkOnly { + fmt.Fprintln(os.Stderr, "warning: running a dev build; update will install the latest release") + } + + fmt.Fprintln(os.Stderr, "Checking for updates...") + rel, err := fetchLatestRelease() + if err != nil { + return fmt.Errorf("check for updates: %w", err) + } + + latest := strings.TrimPrefix(rel.TagName, "v") + current := strings.TrimPrefix(version, "v") + + if *checkOnly { + if current == "dev" || latest == current { + fmt.Printf("wfctl is up to date (version %s)\n", version) + } else { + fmt.Printf("Update available: %s → %s\n", version, rel.TagName) + fmt.Printf("Run 'wfctl update' to install the latest version.\n") + fmt.Printf("Release notes: %s\n", rel.HTMLURL) + } + return nil + } + + if latest == current && current != "dev" { + fmt.Printf("wfctl %s is already the latest version.\n", version) + return nil + } + + asset, err := findReleaseAsset(rel.Assets) + if err != nil { + return fmt.Errorf("no binary found for %s/%s in release %s: %w\nVisit %s to download manually", + runtime.GOOS, runtime.GOARCH, rel.TagName, err, rel.HTMLURL) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", asset.Name) + data, err := downloadURL(asset.BrowserDownloadURL) + if err != nil { + return fmt.Errorf("download: %w", err) + } + + // If it's an archive, extract it. + var binaryData []byte + if strings.HasSuffix(asset.Name, ".tar.gz") { + binaryData, err = extractBinaryFromTarGz(data, "wfctl") + if err != nil { + return fmt.Errorf("extract binary from archive: %w", err) + } + } else { + binaryData = data + } + + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("find current executable: %w", err) + } + // Resolve symlinks so we replace the real binary. + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("resolve executable path: %w", err) + } + + if err := replaceBinary(execPath, binaryData); err != nil { + return fmt.Errorf("replace binary: %w", err) + } + + fmt.Printf("wfctl updated to %s\n", rel.TagName) + return nil +} + +// checkForUpdateNotice prints an update notice to stderr if a newer version is +// available. The check is performed in a background goroutine and the result is +// printed before the main command returns. It skips gracefully on any error or +// when WFCTL_NO_UPDATE_CHECK is set. +func checkForUpdateNotice() { + if os.Getenv(envNoUpdateCheck) != "" { + return + } + if version == "dev" { + return + } + + type result struct { + rel *githubRelease + err error + } + ch := make(chan result, 1) + go func() { + r, e := fetchLatestRelease() + ch <- result{r, e} + }() + + // Wait up to 2 seconds so we never meaningfully delay command execution. + select { + case res := <-ch: + if res.err != nil || res.rel == nil { + return + } + latest := strings.TrimPrefix(res.rel.TagName, "v") + current := strings.TrimPrefix(version, "v") + if latest != "" && latest != current { + fmt.Fprintf(os.Stderr, "\n⚡ wfctl %s is available (you have %s). Run 'wfctl update' to upgrade.\n\n", res.rel.TagName, version) + } + case <-time.After(2 * time.Second): + // Timed out – proceed silently. + } +} + +// fetchLatestRelease queries the GitHub releases API for the latest release. +func fetchLatestRelease() (*githubRelease, error) { + url := githubReleasesURL + if githubReleasesURLOverride != "" { + url = githubReleasesURLOverride + } + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // no context needed for a quick check + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "wfctl/"+version) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + var rel githubRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + if rel.TagName == "" { + return nil, fmt.Errorf("no releases found") + } + return &rel, nil +} + +// findReleaseAsset locates the wfctl binary asset for the current OS and arch. +// It tries several naming conventions used by GoReleaser. +func findReleaseAsset(assets []githubAsset) (*githubAsset, error) { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Candidate names in preference order. + candidates := []string{ + fmt.Sprintf("wfctl-%s-%s", goos, goarch), + fmt.Sprintf("wfctl-%s-%s.tar.gz", goos, goarch), + fmt.Sprintf("wfctl_%s_%s", goos, goarch), + fmt.Sprintf("wfctl_%s_%s.tar.gz", goos, goarch), + } + if goos == "windows" { + candidates = append( + []string{ + fmt.Sprintf("wfctl-%s-%s.exe", goos, goarch), + fmt.Sprintf("wfctl_%s_%s.exe", goos, goarch), + }, + candidates..., + ) + } + + for _, name := range candidates { + for i := range assets { + if strings.EqualFold(assets[i].Name, name) { + return &assets[i], nil + } + } + } + return nil, fmt.Errorf("no matching asset for %s/%s", goos, goarch) +} + +// replaceBinary writes newData to execPath atomically by writing to a temp file +// first and then renaming it over the original. +func replaceBinary(execPath string, newData []byte) error { + dir := filepath.Dir(execPath) + tmp, err := os.CreateTemp(dir, ".wfctl-update-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { + _ = os.Remove(tmpName) // clean up if rename failed + }() + + if _, err := tmp.Write(newData); err != nil { + tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Chmod(0755); err != nil { //nolint:gosec // G302: executable needs 0755 + tmp.Close() + return fmt.Errorf("chmod temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpName, execPath); err != nil { //nolint:gosec // G703: execPath comes from os.Executable()+EvalSymlinks, tmpName from os.CreateTemp in the same dir + return fmt.Errorf("replace binary: %w", err) + } + return nil +} + +// extractBinaryFromTarGz extracts a named binary from a .tar.gz archive. +// It searches for the first entry whose base name matches binaryName (case-insensitive, +// with or without a .exe extension on Windows). +func extractBinaryFromTarGz(data []byte, binaryName string) ([]byte, error) { + tmpDir, err := os.MkdirTemp("", "wfctl-update-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + // Re-use extractTarGz from plugin_install.go to avoid duplicating decompression logic. + if err := extractTarGz(data, tmpDir); err != nil { + return nil, err + } + + // Walk the extracted directory looking for the binary. + var found string + if walkErr := filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + base := strings.ToLower(info.Name()) + target := strings.ToLower(binaryName) + if base == target || base == target+".exe" { + found = path + } + return nil + }); walkErr != nil { + return nil, walkErr + } + + if found == "" { + return nil, fmt.Errorf("binary %q not found in archive", binaryName) + } + + return os.ReadFile(found) //nolint:gosec // G304: path is within our own temp dir +} + + diff --git a/cmd/wfctl/update_test.go b/cmd/wfctl/update_test.go new file mode 100644 index 00000000..33ed03d0 --- /dev/null +++ b/cmd/wfctl/update_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestFindReleaseAsset_Found(t *testing.T) { + goos := runtime.GOOS + goarch := runtime.GOARCH + assets := []githubAsset{ + {Name: "wfctl-" + goos + "-" + goarch, BrowserDownloadURL: "http://example.com/wfctl"}, + {Name: "wfctl-plan9-mips", BrowserDownloadURL: "http://example.com/other"}, + } + + asset, err := findReleaseAsset(assets) + if err != nil { + t.Fatalf("findReleaseAsset: %v", err) + } + if asset == nil { + t.Fatal("expected asset, got nil") + } + if asset.Name != "wfctl-"+goos+"-"+goarch { + t.Errorf("unexpected asset name: %s", asset.Name) + } +} + +func TestFindReleaseAsset_NotFound(t *testing.T) { + assets := []githubAsset{ + {Name: "wfctl-plan9-mips", BrowserDownloadURL: "http://example.com/x"}, + } + _, err := findReleaseAsset(assets) + if err == nil { + t.Fatal("expected error for unsupported platform") + } +} + +func TestReplaceBinary(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "wfctl-test") + if err := os.WriteFile(target, []byte("old"), 0755); err != nil { //nolint:gosec + t.Fatal(err) + } + if err := replaceBinary(target, []byte("new")); err != nil { + t.Fatalf("replaceBinary: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Errorf("expected 'new', got %q", got) + } +} + +func TestRunUpdate_CheckOnly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v9.9.9", + HTMLURL: "https://github.com/GoCodeAlone/workflow/releases/tag/v9.9.9", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + // Should not error even when no asset matches (--check only reports version). + err := runUpdate([]string{"--check"}) + if err != nil { + t.Fatalf("runUpdate --check: %v", err) + } +} + +func TestRunUpdate_AlreadyLatest(t *testing.T) { + origVersion := version + version = "1.2.3" + defer func() { version = origVersion }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v1.2.3", + HTMLURL: "https://example.com", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + if err := runUpdate([]string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunUpdate_GitHubAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + err := runUpdate([]string{}) + if err == nil { + t.Fatal("expected error on API failure") + } +} + +func TestCheckForUpdateNotice_SkipsDevBuild(t *testing.T) { + origVersion := version + version = "dev" + defer func() { version = origVersion }() + // Should not panic or make any network requests. + checkForUpdateNotice() +} + +func TestCheckForUpdateNotice_RespectsEnvVar(t *testing.T) { + t.Setenv(envNoUpdateCheck, "1") + // Should return immediately without any network call. + checkForUpdateNotice() +} + +func TestFetchLatestRelease_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v1.0.0", + HTMLURL: "https://github.com/GoCodeAlone/workflow/releases/tag/v1.0.0", + Assets: []githubAsset{ + {Name: "wfctl-linux-amd64", BrowserDownloadURL: "http://example.com/wfctl"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + rel, err := fetchLatestRelease() + if err != nil { + t.Fatalf("fetchLatestRelease: %v", err) + } + if rel.TagName != "v1.0.0" { + t.Errorf("expected v1.0.0, got %s", rel.TagName) + } + if len(rel.Assets) != 1 { + t.Errorf("expected 1 asset, got %d", len(rel.Assets)) + } +} + +func TestFetchLatestRelease_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + _, err := fetchLatestRelease() + if err == nil { + t.Fatal("expected error for non-200 response") + } +} diff --git a/docs/mcp.md b/docs/mcp.md index ce371a2c..038c24a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,6 +2,8 @@ The workflow engine includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes engine functionality to AI assistants and tools. +> **Note**: The MCP server is now integrated into the `wfctl` CLI as the `mcp` subcommand. The standalone `workflow-mcp-server` binary is still available for backward compatibility but the recommended approach is to use `wfctl mcp`. This ensures the MCP server version always matches the CLI and engine version, and benefits from automatic updates via `wfctl update`. + ## Features The MCP server provides: @@ -28,20 +30,33 @@ The MCP server provides: | `workflow://docs/yaml-syntax` | YAML configuration syntax guide | | `workflow://docs/module-reference` | Dynamic module type reference | -## Building +## Installation + +Install `wfctl` (the CLI includes the MCP server): ```bash -# Build the MCP server binary -make build-mcp +# Install via Go +go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest -# Or directly with Go -go build -o workflow-mcp-server ./cmd/workflow-mcp-server +# Or download a pre-built binary from GitHub releases +# https://github.com/GoCodeAlone/workflow/releases/latest -# Or install globally with Go -go install github.com/GoCodeAlone/workflow/cmd/workflow-mcp-server@latest +# Keep wfctl up to date (and thus the MCP server too) +wfctl update ``` -## Installation +### Building from Source + +```bash +# Build wfctl (includes the mcp command) +go build -o wfctl ./cmd/wfctl + +# Build the standalone MCP server binary (legacy) +make build-mcp +# Or: go build -o workflow-mcp-server ./cmd/workflow-mcp-server +``` + +## Configuring AI Clients ### Claude Desktop @@ -50,6 +65,18 @@ Add the following to your Claude Desktop configuration file: **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +```json +{ + "mcpServers": { + "workflow": { + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] + } + } +} +``` + +**Legacy (standalone binary)**: ```json { "mcpServers": { @@ -69,8 +96,8 @@ Add to your VS Code `settings.json`: { "github.copilot.chat.mcpServers": { "workflow": { - "command": "/path/to/workflow-mcp-server", - "args": ["-plugin-dir", "/path/to/data/plugins"] + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] } } } @@ -84,8 +111,8 @@ Add to your Cursor MCP configuration (`.cursor/mcp.json`): { "mcpServers": { "workflow": { - "command": "/path/to/workflow-mcp-server", - "args": ["-plugin-dir", "/path/to/data/plugins"] + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] } } } @@ -96,7 +123,7 @@ Add to your Cursor MCP configuration (`.cursor/mcp.json`): The server communicates over **stdio** using JSON-RPC 2.0. Any MCP-compatible client can connect: ```bash -./workflow-mcp-server -plugin-dir ./data/plugins +wfctl mcp -plugin-dir ./data/plugins ``` ## Usage Examples @@ -131,13 +158,26 @@ Inspect this config and show me the dependency graph... ## Command Line Options ``` -Usage: workflow-mcp-server [options] +Usage: wfctl mcp [options] Options: -plugin-dir string Plugin data directory (default "data/plugins") - -version Show version and exit ``` +## Keeping the MCP Server Up to Date + +Because the MCP server is now part of `wfctl`, you can use the built-in update command to keep everything in sync: + +```bash +# Check for updates +wfctl update --check + +# Install the latest version (replaces the wfctl binary in-place) +wfctl update +``` + +Set `WFCTL_NO_UPDATE_CHECK=1` to suppress automatic update notices. + ## Dynamic Updates The MCP server dynamically reflects the current state of the engine: @@ -153,15 +193,20 @@ This means the MCP server automatically picks up new module types and plugins wi ```bash go test -v ./mcp/ +go test -v -run TestRunMCP ./cmd/wfctl/ ``` ## Architecture ``` -cmd/workflow-mcp-server/main.go → Entry point (stdio transport) +cmd/wfctl/main.go → CLI entry point; registers "mcp" command +cmd/wfctl/mcp.go → "wfctl mcp" command handler (delegates to mcp package) mcp/server.go → MCP server setup, tool handlers, resource handlers mcp/docs.go → Embedded documentation content mcp/server_test.go → Unit tests + +cmd/workflow-mcp-server/main.go → Standalone binary entry point (legacy) ``` The server uses the [mcp-go](https://github.com/mark3labs/mcp-go) library for MCP protocol implementation over stdio transport. + From 9bd93226b549bad59177c559f82252b2a5757acb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:02:30 +0000 Subject: [PATCH 3/3] fix: address review comments on self-update and MCP commands Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/main.go | 18 ++++- cmd/wfctl/mcp.go | 4 + cmd/wfctl/update.go | 156 +++++++++++++++++++++++++++++---------- cmd/wfctl/update_test.go | 141 ++++++++++++++++++++++++++++++++++- 4 files changed, 274 insertions(+), 45 deletions(-) diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 9027ad00..57666032 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "time" ) var version = "dev" @@ -91,14 +92,25 @@ func main() { os.Exit(1) } - // Check for updates in the background for commands that are not long-running - // (mcp and run keep the process alive, so we skip the notice there). + // Start the update check in the background before running the command so + // that it runs concurrently. For long-running commands (mcp, run) we skip + // it entirely. After the command finishes we wait briefly for the result. + var updateNoticeDone <-chan struct{} if cmd != "mcp" && cmd != "run" { - checkForUpdateNotice() + updateNoticeDone = checkForUpdateNotice() } if err := fn(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) //nolint:gosec // G705: CLI error output os.Exit(1) } + + // Wait briefly for the update notice after the command completes. + // A 1-second ceiling ensures we never meaningfully delay the shell prompt. + if updateNoticeDone != nil { + select { + case <-updateNoticeDone: + case <-time.After(time.Second): + } + } } diff --git a/cmd/wfctl/mcp.go b/cmd/wfctl/mcp.go index 1377ecfd..66143357 100644 --- a/cmd/wfctl/mcp.go +++ b/cmd/wfctl/mcp.go @@ -44,6 +44,10 @@ See docs/mcp.md for full setup instructions. return err } + // Propagate the CLI version so the MCP handshake and version output + // reflect the release version set at build time. + workflowmcp.Version = version + srv := workflowmcp.NewServer(*pluginDir) return srv.ServeStdio() } diff --git a/cmd/wfctl/update.go b/cmd/wfctl/update.go index 7dcad1c4..6c37040b 100644 --- a/cmd/wfctl/update.go +++ b/cmd/wfctl/update.go @@ -1,9 +1,12 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "flag" "fmt" + "io" "net/http" "os" "path/filepath" @@ -15,6 +18,7 @@ import ( const ( githubReleasesURL = "https://api.github.com/repos/GoCodeAlone/workflow/releases/latest" envNoUpdateCheck = "WFCTL_NO_UPDATE_CHECK" + downloadTimeout = 10 * time.Minute // generous timeout for large binary downloads ) // githubReleasesURLOverride allows tests to substitute a fake server URL. @@ -22,9 +26,9 @@ var githubReleasesURLOverride string // githubRelease is the minimal GitHub releases API response we need. type githubRelease struct { - TagName string `json:"tag_name"` - Assets []githubAsset `json:"assets"` - HTMLURL string `json:"html_url"` + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` + HTMLURL string `json:"html_url"` } type githubAsset struct { @@ -80,16 +84,25 @@ Options: asset, err := findReleaseAsset(rel.Assets) if err != nil { - return fmt.Errorf("no binary found for %s/%s in release %s: %w\nVisit %s to download manually", - runtime.GOOS, runtime.GOARCH, rel.TagName, err, rel.HTMLURL) + fmt.Fprintf(os.Stderr, "hint: visit %s to download manually\n", rel.HTMLURL) + return fmt.Errorf("no binary found for %s/%s in release %s: %w", runtime.GOOS, runtime.GOARCH, rel.TagName, err) } fmt.Fprintf(os.Stderr, "Downloading %s...\n", asset.Name) - data, err := downloadURL(asset.BrowserDownloadURL) + data, err := downloadWithTimeout(asset.BrowserDownloadURL, downloadTimeout) if err != nil { return fmt.Errorf("download: %w", err) } + // Verify integrity using the release's checksums.txt if available. + if checksumAsset := findChecksumAsset(rel.Assets); checksumAsset != nil { + fmt.Fprintln(os.Stderr, "Verifying checksum...") + if err := verifyAssetChecksum(checksumAsset, asset.Name, data); err != nil { + return fmt.Errorf("integrity check failed: %w", err) + } + fmt.Fprintln(os.Stderr, "Checksum verified.") + } + // If it's an archive, extract it. var binaryData []byte if strings.HasSuffix(asset.Name, ".tar.gz") { @@ -119,42 +132,30 @@ Options: return nil } -// checkForUpdateNotice prints an update notice to stderr if a newer version is -// available. The check is performed in a background goroutine and the result is -// printed before the main command returns. It skips gracefully on any error or -// when WFCTL_NO_UPDATE_CHECK is set. -func checkForUpdateNotice() { - if os.Getenv(envNoUpdateCheck) != "" { - return - } - if version == "dev" { - return - } - - type result struct { - rel *githubRelease - err error +// checkForUpdateNotice starts a background goroutine that checks GitHub for a +// newer version and prints a notice to stderr if one is available. +// It returns a channel that is closed when the check completes (or is skipped). +// Callers should wait on the channel after their main work is done to allow +// the notice to be printed without delaying command execution. +func checkForUpdateNotice() <-chan struct{} { + done := make(chan struct{}) + if os.Getenv(envNoUpdateCheck) != "" || version == "dev" { + close(done) + return done } - ch := make(chan result, 1) go func() { - r, e := fetchLatestRelease() - ch <- result{r, e} - }() - - // Wait up to 2 seconds so we never meaningfully delay command execution. - select { - case res := <-ch: - if res.err != nil || res.rel == nil { + defer close(done) + rel, err := fetchLatestRelease() + if err != nil || rel == nil { return } - latest := strings.TrimPrefix(res.rel.TagName, "v") + latest := strings.TrimPrefix(rel.TagName, "v") current := strings.TrimPrefix(version, "v") if latest != "" && latest != current { - fmt.Fprintf(os.Stderr, "\n⚡ wfctl %s is available (you have %s). Run 'wfctl update' to upgrade.\n\n", res.rel.TagName, version) + fmt.Fprintf(os.Stderr, "\n⚡ wfctl %s is available (you have %s). Run 'wfctl update' to upgrade.\n\n", rel.TagName, version) } - case <-time.After(2 * time.Second): - // Timed out – proceed silently. - } + }() + return done } // fetchLatestRelease queries the GitHub releases API for the latest release. @@ -225,9 +226,90 @@ func findReleaseAsset(assets []githubAsset) (*githubAsset, error) { return nil, fmt.Errorf("no matching asset for %s/%s", goos, goarch) } +// findChecksumAsset looks for a checksums.txt asset in the release. +func findChecksumAsset(assets []githubAsset) *githubAsset { + for i := range assets { + if strings.EqualFold(assets[i].Name, "checksums.txt") { + return &assets[i] + } + } + return nil +} + +// verifyAssetChecksum downloads checksums.txt and verifies the SHA256 of data +// matches the entry for assetName. The checksums file uses the format produced +// by sha256sum: " " per line. +func verifyAssetChecksum(checksumAsset *githubAsset, assetName string, data []byte) error { + checksumData, err := downloadWithTimeout(checksumAsset.BrowserDownloadURL, 30*time.Second) + if err != nil { + return fmt.Errorf("download checksums.txt: %w", err) + } + + for _, line := range strings.Split(string(checksumData), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + if strings.EqualFold(parts[1], assetName) { + h := sha256.Sum256(data) + got := hex.EncodeToString(h[:]) + if !strings.EqualFold(got, parts[0]) { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, got, parts[0]) + } + return nil + } + } + return fmt.Errorf("checksum for %q not found in checksums.txt", assetName) +} + +// downloadWithTimeout fetches a URL using an HTTP client with the given timeout. +func downloadWithTimeout(url string, timeout time.Duration) ([]byte, error) { + client := &http.Client{Timeout: timeout} + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // timeout is set on the client + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "wfctl/"+version) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + return io.ReadAll(resp.Body) +} + // replaceBinary writes newData to execPath atomically by writing to a temp file // first and then renaming it over the original. +// On Windows, replacing a running executable via rename is not supported; the +// new binary is written alongside the current one and the user is instructed to +// complete the swap manually. func replaceBinary(execPath string, newData []byte) error { + // Preserve the existing file's permissions; fall back to 0755 if stat fails. + mode := os.FileMode(0755) //nolint:gosec // G302: executable needs at least 0755 + if fi, err := os.Stat(execPath); err == nil { + mode = fi.Mode().Perm() + } + + if runtime.GOOS == "windows" { + // Windows does not allow replacing a running .exe via rename. + // Write the new binary with a .new.exe suffix and instruct the user. + newPath := strings.TrimSuffix(execPath, ".exe") + ".new.exe" + if err := os.WriteFile(newPath, newData, mode); err != nil { + return fmt.Errorf("write new binary: %w", err) + } + fmt.Fprintf(os.Stderr, + "New binary written to %s\nTo complete the update, replace %s with %s (e.g., after closing this terminal).\n", + newPath, execPath, newPath) + return nil + } + dir := filepath.Dir(execPath) tmp, err := os.CreateTemp(dir, ".wfctl-update-*") if err != nil { @@ -242,7 +324,7 @@ func replaceBinary(execPath string, newData []byte) error { tmp.Close() return fmt.Errorf("write temp file: %w", err) } - if err := tmp.Chmod(0755); err != nil { //nolint:gosec // G302: executable needs 0755 + if err := tmp.Chmod(mode); err != nil { tmp.Close() return fmt.Errorf("chmod temp file: %w", err) } @@ -292,5 +374,3 @@ func extractBinaryFromTarGz(data []byte, binaryName string) ([]byte, error) { return os.ReadFile(found) //nolint:gosec // G304: path is within our own temp dir } - - diff --git a/cmd/wfctl/update_test.go b/cmd/wfctl/update_test.go index 33ed03d0..e96a86be 100644 --- a/cmd/wfctl/update_test.go +++ b/cmd/wfctl/update_test.go @@ -1,13 +1,17 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "testing" + "time" ) func TestFindReleaseAsset_Found(t *testing.T) { @@ -123,14 +127,24 @@ func TestCheckForUpdateNotice_SkipsDevBuild(t *testing.T) { origVersion := version version = "dev" defer func() { version = origVersion }() - // Should not panic or make any network requests. - checkForUpdateNotice() + // Should close the done channel immediately without making any network requests. + done := checkForUpdateNotice() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("expected done channel to be closed immediately for dev build") + } } func TestCheckForUpdateNotice_RespectsEnvVar(t *testing.T) { t.Setenv(envNoUpdateCheck, "1") - // Should return immediately without any network call. - checkForUpdateNotice() + // Should close the done channel immediately without any network call. + done := checkForUpdateNotice() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("expected done channel to be closed immediately when update check disabled") + } } func TestFetchLatestRelease_Success(t *testing.T) { @@ -176,3 +190,122 @@ func TestFetchLatestRelease_ServerError(t *testing.T) { t.Fatal("expected error for non-200 response") } } + +func TestFindChecksumAsset(t *testing.T) { + assets := []githubAsset{ + {Name: "wfctl-linux-amd64", BrowserDownloadURL: "http://example.com/bin"}, + {Name: "checksums.txt", BrowserDownloadURL: "http://example.com/checksums.txt"}, + } + got := findChecksumAsset(assets) + if got == nil { + t.Fatal("expected to find checksums.txt asset") + } + if got.Name != "checksums.txt" { + t.Errorf("unexpected name: %s", got.Name) + } +} + +func TestFindChecksumAsset_NotFound(t *testing.T) { + assets := []githubAsset{ + {Name: "wfctl-linux-amd64", BrowserDownloadURL: "http://example.com/bin"}, + } + if got := findChecksumAsset(assets); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestVerifyAssetChecksum_Valid(t *testing.T) { + data := []byte("fake binary content") + h := sha256.Sum256(data) + hash := hex.EncodeToString(h[:]) + checksumsContent := fmt.Sprintf("%s wfctl-linux-amd64\n", hash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(checksumsContent)) + })) + defer srv.Close() + + checksumAsset := &githubAsset{Name: "checksums.txt", BrowserDownloadURL: srv.URL} + if err := verifyAssetChecksum(checksumAsset, "wfctl-linux-amd64", data); err != nil { + t.Fatalf("verifyAssetChecksum: %v", err) + } +} + +func TestVerifyAssetChecksum_Mismatch(t *testing.T) { + data := []byte("fake binary content") + checksumsContent := "deadbeef wfctl-linux-amd64\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(checksumsContent)) + })) + defer srv.Close() + + checksumAsset := &githubAsset{Name: "checksums.txt", BrowserDownloadURL: srv.URL} + err := verifyAssetChecksum(checksumAsset, "wfctl-linux-amd64", data) + if err == nil { + t.Fatal("expected checksum mismatch error") + } +} + +func TestVerifyAssetChecksum_Missing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("abc123 other-asset\n")) + })) + defer srv.Close() + + checksumAsset := &githubAsset{Name: "checksums.txt", BrowserDownloadURL: srv.URL} + err := verifyAssetChecksum(checksumAsset, "wfctl-linux-amd64", []byte("data")) + if err == nil { + t.Fatal("expected error when asset not in checksums.txt") + } +} + +func TestDownloadWithTimeout_Success(t *testing.T) { + body := []byte("hello world") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + got, err := downloadWithTimeout(srv.URL, 5*time.Second) + if err != nil { + t.Fatalf("downloadWithTimeout: %v", err) + } + if string(got) != string(body) { + t.Errorf("expected %q, got %q", body, got) + } +} + +func TestDownloadWithTimeout_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "gone", http.StatusGone) + })) + defer srv.Close() + + _, err := downloadWithTimeout(srv.URL, 5*time.Second) + if err == nil { + t.Fatal("expected error for non-200 response") + } +} + +func TestReplaceBinary_PreservesMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits not meaningful on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "wfctl-test") + // Write with a distinct mode. + if err := os.WriteFile(target, []byte("old"), 0750); err != nil { //nolint:gosec + t.Fatal(err) + } + if err := replaceBinary(target, []byte("new")); err != nil { + t.Fatalf("replaceBinary: %v", err) + } + fi, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0750 { + t.Errorf("expected mode 0750, got %o", fi.Mode().Perm()) + } +}