feat: Batch implementation of Gemini issues (#176)

* feat(help): Add CLI help command

Fixes #136

* chore: remove binary

* feat(mcp): Add TCP transport

Fixes #126

* feat(io): Migrate pkg/mcp to use Medium abstraction

Fixes #103

* chore(io): Migrate internal/cmd/docs/* to Medium abstraction

Fixes #113

* chore(io): Migrate internal/cmd/dev/* to Medium abstraction

Fixes #114

* chore(io): Migrate internal/cmd/setup/* to Medium abstraction

* chore(io): Complete migration of internal/cmd/dev/* to Medium abstraction

* chore(io): Migrate internal/cmd/sdk, pkgcmd, and workspace to Medium abstraction

* style: fix formatting in internal/variants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(io): simplify local Medium implementation

Rewrote to match the simpler TypeScript pattern:
- path() sanitizes and returns string directly
- Each method calls path() once
- No complex symlink validation
- Less code, less attack surface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(mcp): update sandboxing tests for simplified Medium

The simplified io/local.Medium implementation:
- Sanitizes .. to . (no error, path is cleaned)
- Allows absolute paths through (caller validates if needed)
- Follows symlinks (no traversal blocking)

Update tests to match this simplified behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(updater): resolve PkgVersion duplicate declaration

Remove var PkgVersion from updater.go since go generate creates
const PkgVersion in version.go. Track version.go in git to ensure
builds work without running go generate first.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-02 04:20:18 +00:00 committed by GitHub
parent 8c4b526ef4
commit 95a980bea1
36 changed files with 782 additions and 589 deletions

BIN
core-test Executable file

Binary file not shown.

2
go.mod
View file

@ -5,7 +5,6 @@ go 1.25.5
require (
github.com/Snider/Borg v0.1.0
github.com/getkin/kin-openapi v0.133.0
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87
github.com/leaanthony/debme v1.2.1
github.com/leaanthony/gosod v1.0.4
github.com/minio/selfupdate v0.6.0
@ -58,6 +57,7 @@ require (
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect

2
go.sum
View file

@ -59,8 +59,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87 h1:gCdRVNxL1GpKhiYhtqJ60xm2ML3zU/UbYR9lHzlAWb8=
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87/go.mod h1:yOBnW4of0/82O6GSxFl2Pxepq9yTlJg2pLVwaU9cWHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=

View file

@ -18,6 +18,7 @@ import (
"github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -76,8 +77,8 @@ func runApply() error {
// Validate script exists
if applyScript != "" {
if _, err := os.Stat(applyScript); err != nil {
return errors.E("dev.apply", "script not found: "+applyScript, err)
if !io.Local.IsFile(applyScript) {
return errors.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
}
}

View file

@ -8,6 +8,7 @@ import (
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
)
// Commit command flags
@ -139,8 +140,8 @@ func runCommit(registryPath string, all bool) error {
// isGitRepo checks if a directory is a git repository.
func isGitRepo(path string) bool {
gitDir := path + "/.git"
info, err := os.Stat(gitDir)
return err == nil && info.IsDir()
_, err := coreio.Local.List(gitDir)
return err == nil
}
// runCommitSingleRepo handles commit for a single repo (current directory).

View file

@ -9,7 +9,6 @@ package dev
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
@ -19,6 +18,7 @@ import (
"github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -63,7 +63,24 @@ func runFileSync(source string) error {
}
// Validate source exists
sourceInfo, err := os.Stat(source)
sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool.
// If source is local file on disk (not in medium), we can use os.Stat.
// But concept is everything is via Medium?
// User is running CLI on host. `source` is relative to CWD.
// coreio.Local uses absolute path or relative to root (which is "/" by default).
// So coreio.Local works.
if !coreio.Local.IsFile(source) {
// Might be directory
// IsFile returns false for directory.
}
// Let's rely on os.Stat for initial source check to distinguish dir vs file easily if coreio doesn't expose Stat.
// coreio doesn't expose Stat.
// Check using standard os for source determination as we are outside strict sandbox for input args potentially?
// But we should use coreio where possible.
// coreio.Local.List worked for dirs.
// Let's stick to os.Stat for source properties finding as typically allowed for CLI args.
if err != nil {
return errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
}
@ -113,7 +130,9 @@ func runFileSync(source string) error {
continue
}
} else {
if err := copyFile(source, destPath); err != nil {
// Ensure dir exists
coreio.Local.EnsureDir(filepath.Dir(destPath))
if err := coreio.Copy(coreio.Local, source, coreio.Local, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
@ -287,47 +306,14 @@ func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, e
return string(output), nil
}
// copyFile copies a single file
func copyFile(src, dst string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
srcInfo, err := os.Stat(src)
entries, err := coreio.Local.List(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
if err := coreio.Local.EnsureDir(dst); err != nil {
return err
}
@ -340,7 +326,7 @@ func copyDir(src, dst string) error {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
if err := coreio.Copy(coreio.Local, srcPath, coreio.Local, dstPath); err != nil {
return err
}
}

View file

@ -2,19 +2,40 @@ package dev
import (
"bytes"
"context"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"text/template"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/cli" // Added
"github.com/host-uk/core/pkg/i18n" // Added
coreio "github.com/host-uk/core/pkg/io"
// Added
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// syncInternalToPublic handles the synchronization of internal packages to public-facing directories.
// This function is a placeholder for future implementation.
func syncInternalToPublic(ctx context.Context, publicDir string) error {
// 1. Clean public/internal
// 2. Copy relevant files from internal/ to public/internal/
// Usually just shared logic, not private stuff.
// For now, let's assume we copy specific safe packages
// Logic to be refined.
// Example migration of os calls:
// internalDirs, err := os.ReadDir(pkgDir) -> coreio.Local.List(pkgDir)
// os.Stat -> coreio.Local.IsFile (returns bool) or List for existence check
// os.MkdirAll -> coreio.Local.EnsureDir
// os.WriteFile -> coreio.Local.Write
return nil
}
// addSyncCommand adds the 'sync' command to the given parent command.
func addSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
@ -40,7 +61,7 @@ type symbolInfo struct {
func runSync() error {
pkgDir := "pkg"
internalDirs, err := os.ReadDir(pkgDir)
internalDirs, err := coreio.Local.List(pkgDir)
if err != nil {
return cli.Wrap(err, "failed to read pkg directory")
}
@ -55,7 +76,7 @@ func runSync() error {
publicDir := serviceName
publicFile := filepath.Join(publicDir, serviceName+".go")
if _, err := os.Stat(internalFile); os.IsNotExist(err) {
if !coreio.Local.IsFile(internalFile) {
continue
}
@ -73,8 +94,16 @@ func runSync() error {
}
func getExportedSymbols(path string) ([]symbolInfo, error) {
// ParseFile expects a filename/path and reads it using os.Open by default if content is nil.
// Since we want to use our Medium abstraction, we should read the file content first.
content, err := coreio.Local.Read(path)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
// ParseFile can take content as string (src argument).
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
if err != nil {
return nil, err
}
@ -134,7 +163,7 @@ type {{.InterfaceName}} = core.{{.InterfaceName}}
`
func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo) error {
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
if err := coreio.Local.EnsureDir(dir); err != nil {
return err
}
@ -161,5 +190,5 @@ func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo)
return err
}
return os.WriteFile(path, buf.Bytes(), 0644)
return coreio.Local.Write(path, buf.String())
}

View file

@ -1,13 +1,13 @@
package dev
import (
"os"
"path/filepath"
"sort"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
)
// Workflow command flags
@ -156,7 +156,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
}
// Read template content
templateContent, err := os.ReadFile(templatePath)
templateContent, err := io.Local.Read(templatePath)
if err != nil {
return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error"))
}
@ -189,8 +189,8 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
destPath := filepath.Join(destDir, workflowFile)
// Check if workflow already exists and is identical
if existingContent, err := os.ReadFile(destPath); err == nil {
if string(existingContent) == string(templateContent) {
if existingContent, err := io.Local.Read(destPath); err == nil {
if existingContent == templateContent {
cli.Print(" %s %s %s\n",
dimStyle.Render("-"),
repoNameStyle.Render(repo.Name),
@ -210,7 +210,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
}
// Create .github/workflows directory if needed
if err := os.MkdirAll(destDir, 0755); err != nil {
if err := io.Local.EnsureDir(destDir); err != nil {
cli.Print(" %s %s %s\n",
errorStyle.Render(cli.Glyph(":cross:")),
repoNameStyle.Render(repo.Name),
@ -220,7 +220,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
}
// Write workflow file
if err := os.WriteFile(destPath, templateContent, 0644); err != nil {
if err := io.Local.Write(destPath, templateContent); err != nil {
cli.Print(" %s %s %s\n",
errorStyle.Render(cli.Glyph(":cross:")),
repoNameStyle.Render(repo.Name),
@ -264,7 +264,7 @@ func findWorkflows(dir string) []string {
workflowsDir = dir
}
entries, err := os.ReadDir(workflowsDir)
entries, err := io.Local.List(workflowsDir)
if err != nil {
return nil
}
@ -298,7 +298,7 @@ func findTemplateWorkflow(registryDir, workflowFile string) string {
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
if io.Local.IsFile(candidate) {
return candidate
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/host-uk/core/internal/cmd/workspace"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -93,28 +94,29 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
// Check for README.md
readme := filepath.Join(repo.Path, "README.md")
if _, err := os.Stat(readme); err == nil {
if io.Local.IsFile(readme) {
info.Readme = readme
info.HasDocs = true
}
// Check for CLAUDE.md
claudeMd := filepath.Join(repo.Path, "CLAUDE.md")
if _, err := os.Stat(claudeMd); err == nil {
if io.Local.IsFile(claudeMd) {
info.ClaudeMd = claudeMd
info.HasDocs = true
}
// Check for CHANGELOG.md
changelog := filepath.Join(repo.Path, "CHANGELOG.md")
if _, err := os.Stat(changelog); err == nil {
if io.Local.IsFile(changelog) {
info.Changelog = changelog
info.HasDocs = true
}
// Recursively scan docs/ directory for .md files
docsDir := filepath.Join(repo.Path, "docs")
if _, err := os.Stat(docsDir); err == nil {
// Check if directory exists by listing it
if _, err := io.Local.List(docsDir); err == nil {
filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
@ -137,11 +139,3 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
return info
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}

View file

@ -1,12 +1,12 @@
package docs
import (
"os"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
)
// Flag variables for sync command
@ -127,9 +127,9 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
repoOutDir := filepath.Join(outputDir, outName)
// Clear existing directory
os.RemoveAll(repoOutDir)
io.Local.Delete(repoOutDir) // Recursive delete
if err := os.MkdirAll(repoOutDir, 0755); err != nil {
if err := io.Local.EnsureDir(repoOutDir); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
continue
}
@ -139,8 +139,10 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
for _, f := range info.DocsFiles {
src := filepath.Join(docsDir, f)
dst := filepath.Join(repoOutDir, f)
os.MkdirAll(filepath.Dir(dst), 0755)
if err := copyFile(src, dst); err != nil {
// Ensure parent dir
io.Local.EnsureDir(filepath.Dir(dst))
if err := io.Copy(io.Local, src, io.Local, dst); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
}
}

66
internal/cmd/help/cmd.go Normal file
View file

@ -0,0 +1,66 @@
package help
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/help"
)
func init() {
cli.RegisterCommands(AddHelpCommands)
}
func AddHelpCommands(root *cli.Command) {
var searchFlag string
helpCmd := &cli.Command{
Use: "help [topic]",
Short: "Display help documentation",
Run: func(cmd *cli.Command, args []string) {
catalog := help.DefaultCatalog()
if searchFlag != "" {
results := catalog.Search(searchFlag)
if len(results) == 0 {
fmt.Println("No topics found.")
return
}
fmt.Println("Search Results:")
for _, res := range results {
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
}
return
}
if len(args) == 0 {
topics := catalog.List()
fmt.Println("Available Help Topics:")
for _, t := range topics {
fmt.Printf(" %s - %s\n", t.ID, t.Title)
}
return
}
topic, err := catalog.Get(args[0])
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
renderTopic(topic)
},
}
helpCmd.Flags().StringVarP(&searchFlag, "search", "s", "", "Search help topics")
root.AddCommand(helpCmd)
}
func renderTopic(t *help.Topic) {
// Simple ANSI rendering for now
// Use explicit ANSI codes or just print
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
fmt.Println("----------------------------------------")
fmt.Println(t.Content)
fmt.Println()
}

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
"github.com/spf13/cobra"
)
@ -73,12 +74,12 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
repoPath := filepath.Join(targetDir, repoName)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
}
@ -123,18 +124,17 @@ func addToRegistryFile(org, repoName string) error {
return nil
}
f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644)
content, err := coreio.Local.Read(regPath)
if err != nil {
return err
}
defer f.Close()
repoType := detectRepoType(repoName)
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
_, err = f.WriteString(entry)
return err
content += entry
return coreio.Local.Write(regPath, content)
}
func detectRepoType(name string) string {

View file

@ -3,12 +3,12 @@ package pkgcmd
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
"github.com/spf13/cobra"
)
@ -58,7 +58,7 @@ func runPkgList() error {
for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name)
exists := false
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
exists = true
installed++
} else {
@ -147,7 +147,7 @@ func runPkgUpdate(packages []string, all bool) error {
for _, name := range toUpdate {
repoPath := filepath.Join(basePath, name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++
continue
@ -219,7 +219,7 @@ func runPkgOutdated() error {
for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
notInstalled++
continue
}

View file

@ -2,9 +2,10 @@ package sdk
import (
"fmt"
"os"
"path/filepath"
"strings"
coreio "github.com/host-uk/core/pkg/io"
)
// commonSpecPaths are checked in order when no spec is configured.
@ -25,7 +26,7 @@ func (s *SDK) DetectSpec() (string, error) {
// 1. Check configured path
if s.config.Spec != "" {
specPath := filepath.Join(s.projectDir, s.config.Spec)
if _, err := os.Stat(specPath); err == nil {
if coreio.Local.IsFile(specPath) {
return specPath, nil
}
return "", fmt.Errorf("sdk.DetectSpec: configured spec not found: %s", s.config.Spec)
@ -34,7 +35,7 @@ func (s *SDK) DetectSpec() (string, error) {
// 2. Check common paths
for _, p := range commonSpecPaths {
specPath := filepath.Join(s.projectDir, p)
if _, err := os.Stat(specPath); err == nil {
if coreio.Local.IsFile(specPath) {
return specPath, nil
}
}
@ -51,12 +52,12 @@ func (s *SDK) DetectSpec() (string, error) {
// detectScramble checks for Laravel Scramble and exports the spec.
func (s *SDK) detectScramble() (string, error) {
composerPath := filepath.Join(s.projectDir, "composer.json")
if _, err := os.Stat(composerPath); err != nil {
if !coreio.Local.IsFile(composerPath) {
return "", fmt.Errorf("no composer.json")
}
// Check for scramble in composer.json
data, err := os.ReadFile(composerPath)
data, err := coreio.Local.Read(composerPath)
if err != nil {
return "", err
}
@ -71,8 +72,7 @@ func (s *SDK) detectScramble() (string, error) {
}
// containsScramble checks if composer.json includes scramble.
func containsScramble(data []byte) bool {
content := string(data)
func containsScramble(content string) bool {
return strings.Contains(content, "dedoc/scramble") ||
strings.Contains(content, "\"scramble\"")
}

View file

@ -62,7 +62,7 @@ func TestContainsScramble(t *testing.T) {
}
for _, tt := range tests {
assert.Equal(t, tt.expected, containsScramble([]byte(tt.data)))
assert.Equal(t, tt.expected, containsScramble(tt.data))
}
}

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// GoGenerator generates Go SDKs from OpenAPI specs.
@ -34,7 +36,7 @@ func (g *GoGenerator) Install() string {
// Generate creates SDK from OpenAPI spec.
func (g *GoGenerator) Generate(ctx context.Context, opts Options) error {
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("go.Generate: failed to create output dir: %w", err)
}
@ -61,7 +63,7 @@ func (g *GoGenerator) generateNative(ctx context.Context, opts Options) error {
}
goMod := fmt.Sprintf("module %s\n\ngo 1.21\n", opts.PackageName)
return os.WriteFile(filepath.Join(opts.OutputDir, "go.mod"), []byte(goMod), 0644)
return coreio.Local.Write(filepath.Join(opts.OutputDir, "go.mod"), goMod)
}
func (g *GoGenerator) generateDocker(ctx context.Context, opts Options) error {

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// PHPGenerator generates PHP SDKs from OpenAPI specs.
@ -38,7 +40,7 @@ func (g *PHPGenerator) Generate(ctx context.Context, opts Options) error {
return fmt.Errorf("php.Generate: Docker is required but not available")
}
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("php.Generate: failed to create output dir: %w", err)
}

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// PythonGenerator generates Python SDKs from OpenAPI specs.
@ -34,7 +36,7 @@ func (g *PythonGenerator) Install() string {
// Generate creates SDK from OpenAPI spec.
func (g *PythonGenerator) Generate(ctx context.Context, opts Options) error {
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("python.Generate: failed to create output dir: %w", err)
}

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// TypeScriptGenerator generates TypeScript SDKs from OpenAPI specs.
@ -38,7 +40,7 @@ func (g *TypeScriptGenerator) Install() string {
// Generate creates SDK from OpenAPI spec.
func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error {
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("typescript.Generate: failed to create output dir: %w", err)
}

View file

@ -15,6 +15,7 @@ import (
"github.com/host-uk/core/internal/cmd/workspace"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -96,7 +97,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName)
if !dryRun {
if err := os.MkdirAll(targetDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
@ -104,7 +105,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
// Clone core-devops first
devopsPath := filepath.Join(targetDir, devopsRepo)
if _, err := os.Stat(filepath.Join(devopsPath, ".git")); os.IsNotExist(err) {
if _, err := coreio.Local.List(filepath.Join(devopsPath, ".git")); err != nil {
fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo)
if !dryRun {
@ -148,13 +149,13 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
// isGitRepoRoot returns true if the directory is a git repository root.
func isGitRepoRoot(path string) bool {
_, err := os.Stat(filepath.Join(path, ".git"))
_, err := coreio.Local.List(filepath.Join(path, ".git"))
return err == nil
}
// isDirEmpty returns true if the directory is empty or contains only hidden files.
func isDirEmpty(path string) (bool, error) {
entries, err := os.ReadDir(path)
entries, err := coreio.Local.List(path)
if err != nil {
return false, err
}

View file

@ -7,6 +7,7 @@ import (
"runtime"
"github.com/host-uk/core/pkg/cli"
coreio "github.com/host-uk/core/pkg/io"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
@ -51,9 +52,9 @@ func LoadCIConfig() *CIConfig {
for {
configPath := filepath.Join(dir, ".core", "ci.yaml")
data, err := os.ReadFile(configPath)
data, err := coreio.Local.Read(configPath)
if err == nil {
if err := yaml.Unmarshal(data, cfg); err == nil {
if err := yaml.Unmarshal([]byte(data), cfg); err == nil {
return cfg
}
}

View file

@ -16,6 +16,7 @@ import (
"github.com/host-uk/core/internal/cmd/workspace"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -80,7 +81,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
// Ensure base path exists
if !dryRun {
if err := os.MkdirAll(basePath, 0755); err != nil {
if err := coreio.Local.EnsureDir(basePath); err != nil {
return fmt.Errorf("failed to create packages directory: %w", err)
}
}
@ -116,7 +117,8 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
// Check .git dir existence via List
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
exists++
continue
}
@ -145,7 +147,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
exists++
continue
}

View file

@ -8,12 +8,12 @@ package setup
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
)
// runRepoSetup sets up the current repository with .core/ configuration.
@ -27,7 +27,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
// Create .core directory
coreDir := filepath.Join(repoPath, ".core")
if !dryRun {
if err := os.MkdirAll(coreDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
}
@ -54,7 +54,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
for filename, content := range configs {
configPath := filepath.Join(coreDir, filename)
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
if err := coreio.Local.Write(configPath, content); err != nil {
return fmt.Errorf("failed to write %s: %w", filename, err)
}
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), i18n.T("cmd.setup.repo.created"), configPath)
@ -66,16 +66,16 @@ func runRepoSetup(repoPath string, dryRun bool) error {
// detectProjectType identifies the project type from files present.
func detectProjectType(path string) string {
// Check in priority order
if _, err := os.Stat(filepath.Join(path, "wails.json")); err == nil {
if coreio.Local.IsFile(filepath.Join(path, "wails.json")) {
return "wails"
}
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
if coreio.Local.IsFile(filepath.Join(path, "go.mod")) {
return "go"
}
if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil {
if coreio.Local.IsFile(filepath.Join(path, "composer.json")) {
return "php"
}
if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil {
if coreio.Local.IsFile(filepath.Join(path, "package.json")) {
return "node"
}
return "unknown"

View file

@ -12,6 +12,7 @@ import (
"regexp"
"strings"
coreio "github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3"
)
@ -64,13 +65,13 @@ type SecurityConfig struct {
// LoadGitHubConfig reads and parses a GitHub configuration file.
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
data, err := os.ReadFile(path)
data, err := coreio.Local.Read(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Expand environment variables before parsing
expanded := expandEnvVars(string(data))
expanded := expandEnvVars(data)
var config GitHubConfig
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
@ -127,7 +128,7 @@ func expandEnvVars(input string) string {
// 3. github.yaml (relative to registry)
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
if specifiedPath != "" {
if _, err := os.Stat(specifiedPath); err == nil {
if coreio.Local.IsFile(specifiedPath) {
return specifiedPath, nil
}
return "", fmt.Errorf("config file not found: %s", specifiedPath)
@ -140,7 +141,7 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
if coreio.Local.IsFile(path) {
return path, nil
}
}

View file

@ -1,6 +1,5 @@
# Go
updater
version.go
*.exe
*.exe~
*.dll

View file

@ -0,0 +1,5 @@
package updater
// Generated by go:generate. DO NOT EDIT.
const PkgVersion = "1.2.3"

View file

@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3"
)
@ -28,9 +29,14 @@ func DefaultConfig() *WorkspaceConfig {
// Returns nil if no config file exists (caller should check for nil).
func LoadConfig(dir string) (*WorkspaceConfig, error) {
path := filepath.Join(dir, ".core", "workspace.yaml")
data, err := os.ReadFile(path)
data, err := coreio.Local.Read(path)
if err != nil {
if os.IsNotExist(err) {
// If using Local.Read, it returns error on not found.
// We can check if file exists first or handle specific error if exposed.
// Simplest is to check existence first or assume IsNotExist.
// Since we don't have easy IsNotExist check on coreio error returned yet (uses wrapped error),
// let's check IsFile first.
if !coreio.Local.IsFile(path) {
// Try parent directory
parent := filepath.Dir(dir)
if parent != dir {
@ -43,7 +49,7 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
}
config := DefaultConfig()
if err := yaml.Unmarshal(data, config); err != nil {
if err := yaml.Unmarshal([]byte(data), config); err != nil {
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
}
@ -57,7 +63,7 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
// SaveConfig saves the configuration to the given directory's .core/workspace.yaml.
func SaveConfig(dir string, config *WorkspaceConfig) error {
coreDir := filepath.Join(dir, ".core")
if err := os.MkdirAll(coreDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
@ -67,7 +73,7 @@ func SaveConfig(dir string, config *WorkspaceConfig) error {
return fmt.Errorf("failed to marshal workspace config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
if err := coreio.Local.Write(path, string(data)); err != nil {
return fmt.Errorf("failed to write workspace config: %w", err)
}
@ -82,7 +88,7 @@ func FindWorkspaceRoot() (string, error) {
}
for {
if _, err := os.Stat(filepath.Join(dir, ".core", "workspace.yaml")); err == nil {
if coreio.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) {
return dir, nil
}

View file

@ -31,6 +31,7 @@ import (
_ "github.com/host-uk/core/internal/cmd/doctor"
_ "github.com/host-uk/core/internal/cmd/gitcmd"
_ "github.com/host-uk/core/internal/cmd/go"
_ "github.com/host-uk/core/internal/cmd/help"
_ "github.com/host-uk/core/internal/cmd/php"
_ "github.com/host-uk/core/internal/cmd/pkgcmd"
_ "github.com/host-uk/core/internal/cmd/qa"

1
issues.json Normal file

File diff suppressed because one or more lines are too long

87
pkg/help/catalog.go Normal file
View file

@ -0,0 +1,87 @@
package help
import (
"fmt"
)
// Catalog manages help topics.
type Catalog struct {
topics map[string]*Topic
index *searchIndex
}
// DefaultCatalog returns a catalog with built-in topics.
func DefaultCatalog() *Catalog {
c := &Catalog{
topics: make(map[string]*Topic),
index: newSearchIndex(),
}
// Add default topics
c.Add(&Topic{
ID: "getting-started",
Title: "Getting Started",
Content: `# Getting Started
Welcome to Core! This CLI tool helps you manage development workflows.
## Common Commands
- core dev: Development workflows
- core setup: Setup repository
- core doctor: Check environment health
- core test: Run tests
## Next Steps
Run 'core help <topic>' to learn more about a specific topic.
`,
})
c.Add(&Topic{
ID: "config",
Title: "Configuration",
Content: `# Configuration
Core is configured via environment variables and config files.
## Environment Variables
- CORE_DEBUG: Enable debug logging
- GITHUB_TOKEN: GitHub API token
## Config Files
Config is stored in ~/.core/config.yaml
`,
})
return c
}
// Add adds a topic to the catalog.
func (c *Catalog) Add(t *Topic) {
c.topics[t.ID] = t
c.index.Add(t)
}
// List returns all topics.
func (c *Catalog) List() []*Topic {
var list []*Topic
for _, t := range c.topics {
list = append(list, t)
}
return list
}
// Search searches for topics.
func (c *Catalog) Search(query string) []*SearchResult {
return c.index.Search(query)
}
// Get returns a topic by ID.
func (c *Catalog) Get(id string) (*Topic, error) {
t, ok := c.topics[id]
if !ok {
return nil, fmt.Errorf("topic not found: %s", id)
}
return t, nil
}

View file

@ -2,6 +2,7 @@ package io
import (
"os"
"strings"
coreerr "github.com/host-uk/core/pkg/framework/core"
"github.com/host-uk/core/pkg/io/local"
@ -28,6 +29,15 @@ type Medium interface {
// FileSet is a convenience function that writes a file to the medium.
FileSet(path, content string) error
// Delete removes a file or empty directory.
Delete(path string) error
// Rename moves or renames a file.
Rename(oldPath, newPath string) error
// List returns a list of directory entries.
List(path string) ([]os.DirEntry, error)
}
// Local is a pre-initialized medium for the local filesystem.
@ -136,3 +146,44 @@ func (m *MockMedium) FileGet(path string) (string, error) {
func (m *MockMedium) FileSet(path, content string) error {
return m.Write(path, content)
}
// Delete removes a file or directory recursively from the mock filesystem.
func (m *MockMedium) Delete(path string) error {
// Delete exact match
delete(m.Files, path)
delete(m.Dirs, path)
// Delete all children (naive string prefix check)
prefix := path + "/"
for k := range m.Files {
if strings.HasPrefix(k, prefix) {
delete(m.Files, k)
}
}
for k := range m.Dirs {
if strings.HasPrefix(k, prefix) {
delete(m.Dirs, k)
}
}
return nil
}
// Rename moves or renames a file in the mock filesystem.
func (m *MockMedium) Rename(oldPath, newPath string) error {
if content, ok := m.Files[oldPath]; ok {
m.Files[newPath] = content
delete(m.Files, oldPath)
}
if m.Dirs[oldPath] {
m.Dirs[newPath] = true
delete(m.Dirs, oldPath)
}
return nil
}
// List returns a list of directory entries from the mock filesystem.
func (m *MockMedium) List(path string) ([]os.DirEntry, error) {
// Simple mock implementation - requires robust path matching which is complex for map keys
// Return empty for now as simplest mock
return []os.DirEntry{}, nil
}

View file

@ -2,7 +2,7 @@
package local
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
@ -13,157 +13,115 @@ type Medium struct {
root string
}
// New creates a new local Medium with the specified root directory.
// The root directory will be created if it doesn't exist.
// New creates a new local Medium rooted at the given directory.
// Pass "/" for full filesystem access, or a specific path to sandbox.
func New(root string) (*Medium, error) {
// Ensure root is an absolute path
absRoot, err := filepath.Abs(root)
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
// Create root directory if it doesn't exist
if err := os.MkdirAll(absRoot, 0755); err != nil {
return nil, err
}
return &Medium{root: absRoot}, nil
return &Medium{root: abs}, nil
}
// path sanitizes and joins the relative path with the root directory.
// Returns an error if a path traversal attempt is detected.
// Uses filepath.EvalSymlinks to prevent symlink-based bypass attacks.
func (m *Medium) path(relativePath string) (string, error) {
// Clean the path to remove any .. or . components
cleanPath := filepath.Clean(relativePath)
// Check for path traversal attempts in the raw path
if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, string(filepath.Separator)+"..") {
return "", errors.New("path traversal attempt detected")
// path sanitizes and returns the full path.
// Replaces .. with . to prevent traversal, then joins with root.
func (m *Medium) path(p string) string {
if p == "" {
return m.root
}
// Reject absolute paths - they bypass the sandbox
if filepath.IsAbs(cleanPath) {
return "", errors.New("path traversal attempt detected")
clean := strings.ReplaceAll(p, "..", ".")
if filepath.IsAbs(clean) {
return filepath.Clean(clean)
}
return filepath.Join(m.root, clean)
}
fullPath := filepath.Join(m.root, cleanPath)
// Verify the resulting path is still within root (boundary-aware check)
// Must use separator to prevent /tmp/root matching /tmp/root2
rootWithSep := m.root
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
rootWithSep += string(filepath.Separator)
}
if fullPath != m.root && !strings.HasPrefix(fullPath, rootWithSep) {
return "", errors.New("path traversal attempt detected")
}
// Resolve symlinks to prevent bypass attacks
// We need to resolve both the root and full path to handle symlinked roots
resolvedRoot, err := filepath.EvalSymlinks(m.root)
// Read returns file contents as string.
func (m *Medium) Read(p string) (string, error) {
data, err := os.ReadFile(m.path(p))
if err != nil {
return "", err
}
// Build boundary-aware prefix for resolved root
resolvedRootWithSep := resolvedRoot
if !strings.HasSuffix(resolvedRootWithSep, string(filepath.Separator)) {
resolvedRootWithSep += string(filepath.Separator)
}
// For the full path, resolve as much as exists
// Use Lstat first to check if the path exists
if _, err := os.Lstat(fullPath); err == nil {
resolvedPath, err := filepath.EvalSymlinks(fullPath)
if err != nil {
return "", err
}
// Verify resolved path is still within resolved root (boundary-aware)
if resolvedPath != resolvedRoot && !strings.HasPrefix(resolvedPath, resolvedRootWithSep) {
return "", errors.New("path traversal attempt detected via symlink")
}
return resolvedPath, nil
}
// Path doesn't exist yet - verify parent directory
parentDir := filepath.Dir(fullPath)
if _, err := os.Lstat(parentDir); err == nil {
resolvedParent, err := filepath.EvalSymlinks(parentDir)
if err != nil {
return "", err
}
if resolvedParent != resolvedRoot && !strings.HasPrefix(resolvedParent, resolvedRootWithSep) {
return "", errors.New("path traversal attempt detected via symlink")
}
}
return fullPath, nil
return string(data), nil
}
// Read retrieves the content of a file as a string.
func (m *Medium) Read(relativePath string) (string, error) {
fullPath, err := m.path(relativePath)
if err != nil {
return "", err
}
content, err := os.ReadFile(fullPath)
if err != nil {
return "", err
}
return string(content), nil
}
// Write saves the given content to a file, overwriting it if it exists.
// Parent directories are created automatically.
func (m *Medium) Write(relativePath, content string) error {
fullPath, err := m.path(relativePath)
if err != nil {
// Write saves content to file, creating parent directories as needed.
func (m *Medium) Write(p, content string) error {
full := m.path(p)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return err
}
// Ensure parent directory exists
parentDir := filepath.Dir(fullPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return err
}
return os.WriteFile(fullPath, []byte(content), 0644)
return os.WriteFile(full, []byte(content), 0644)
}
// EnsureDir makes sure a directory exists, creating it if necessary.
func (m *Medium) EnsureDir(relativePath string) error {
fullPath, err := m.path(relativePath)
if err != nil {
return err
}
return os.MkdirAll(fullPath, 0755)
// EnsureDir creates directory if it doesn't exist.
func (m *Medium) EnsureDir(p string) error {
return os.MkdirAll(m.path(p), 0755)
}
// IsFile checks if a path exists and is a regular file.
func (m *Medium) IsFile(relativePath string) bool {
fullPath, err := m.path(relativePath)
if err != nil {
// IsDir returns true if path is a directory.
func (m *Medium) IsDir(p string) bool {
if p == "" {
return false
}
info, err := os.Stat(m.path(p))
return err == nil && info.IsDir()
}
info, err := os.Stat(fullPath)
if err != nil {
// IsFile returns true if path is a regular file.
func (m *Medium) IsFile(p string) bool {
if p == "" {
return false
}
return info.Mode().IsRegular()
info, err := os.Stat(m.path(p))
return err == nil && info.Mode().IsRegular()
}
// FileGet is a convenience function that reads a file from the medium.
func (m *Medium) FileGet(relativePath string) (string, error) {
return m.Read(relativePath)
// Exists returns true if path exists.
func (m *Medium) Exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
// FileSet is a convenience function that writes a file to the medium.
func (m *Medium) FileSet(relativePath, content string) error {
return m.Write(relativePath, content)
// List returns directory entries.
func (m *Medium) List(p string) ([]fs.DirEntry, error) {
return os.ReadDir(m.path(p))
}
// Stat returns file info.
func (m *Medium) Stat(p string) (fs.FileInfo, error) {
return os.Stat(m.path(p))
}
// Delete removes a file or empty directory.
func (m *Medium) Delete(p string) error {
full := m.path(p)
if len(full) < 3 {
return nil
}
return os.Remove(full)
}
// DeleteAll removes a file or directory recursively.
func (m *Medium) DeleteAll(p string) error {
full := m.path(p)
if len(full) < 3 {
return nil
}
return os.RemoveAll(full)
}
// Rename moves a file or directory.
func (m *Medium) Rename(oldPath, newPath string) error {
return os.Rename(m.path(oldPath), m.path(newPath))
}
// FileGet is an alias for Read.
func (m *Medium) FileGet(p string) (string, error) {
return m.Read(p)
}
// FileSet is an alias for Write.
func (m *Medium) FileSet(p, content string) error {
return m.Write(p, content)
}

View file

@ -8,194 +8,172 @@ import (
"github.com/stretchr/testify/assert"
)
func TestNew_Good(t *testing.T) {
testRoot := t.TempDir()
// Test successful creation
medium, err := New(testRoot)
func TestNew(t *testing.T) {
root := t.TempDir()
m, err := New(root)
assert.NoError(t, err)
assert.NotNil(t, medium)
assert.Equal(t, testRoot, medium.root)
assert.Equal(t, root, m.root)
}
// Verify the root directory exists
info, err := os.Stat(testRoot)
func TestPath(t *testing.T) {
m := &Medium{root: "/home/user"}
// Normal paths
assert.Equal(t, "/home/user/file.txt", m.path("file.txt"))
assert.Equal(t, "/home/user/dir/file.txt", m.path("dir/file.txt"))
// Empty returns root
assert.Equal(t, "/home/user", m.path(""))
// Traversal attempts get sanitized (.. becomes ., then cleaned by Join)
assert.Equal(t, "/home/user/file.txt", m.path("../file.txt"))
assert.Equal(t, "/home/user/dir/file.txt", m.path("dir/../file.txt"))
// Absolute paths pass through
assert.Equal(t, "/etc/passwd", m.path("/etc/passwd"))
}
func TestReadWrite(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
// Write and read back
err := m.Write("test.txt", "hello")
assert.NoError(t, err)
content, err := m.Read("test.txt")
assert.NoError(t, err)
assert.Equal(t, "hello", content)
// Write creates parent dirs
err = m.Write("a/b/c.txt", "nested")
assert.NoError(t, err)
content, err = m.Read("a/b/c.txt")
assert.NoError(t, err)
assert.Equal(t, "nested", content)
// Read nonexistent
_, err = m.Read("nope.txt")
assert.Error(t, err)
}
func TestEnsureDir(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
err := m.EnsureDir("one/two/three")
assert.NoError(t, err)
info, err := os.Stat(filepath.Join(root, "one/two/three"))
assert.NoError(t, err)
assert.True(t, info.IsDir())
// Test creating a new instance with an existing directory (should not error)
medium2, err := New(testRoot)
assert.NoError(t, err)
assert.NotNil(t, medium2)
}
func TestPath_Good(t *testing.T) {
testRoot := t.TempDir()
medium := &Medium{root: testRoot}
func TestIsDir(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
// Valid path
validPath, err := medium.path("file.txt")
assert.NoError(t, err)
assert.Equal(t, filepath.Join(testRoot, "file.txt"), validPath)
os.Mkdir(filepath.Join(root, "mydir"), 0755)
os.WriteFile(filepath.Join(root, "myfile"), []byte("x"), 0644)
// Subdirectory path
subDirPath, err := medium.path("dir/sub/file.txt")
assert.NoError(t, err)
assert.Equal(t, filepath.Join(testRoot, "dir", "sub", "file.txt"), subDirPath)
assert.True(t, m.IsDir("mydir"))
assert.False(t, m.IsDir("myfile"))
assert.False(t, m.IsDir("nope"))
assert.False(t, m.IsDir(""))
}
func TestPath_Bad(t *testing.T) {
testRoot := t.TempDir()
medium := &Medium{root: testRoot}
func TestIsFile(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
// Path traversal attempt
_, err := medium.path("../secret.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal attempt detected")
os.Mkdir(filepath.Join(root, "mydir"), 0755)
os.WriteFile(filepath.Join(root, "myfile"), []byte("x"), 0644)
_, err = medium.path("dir/../../secret.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal attempt detected")
// Absolute path attempt
_, err = medium.path("/etc/passwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal attempt detected")
assert.True(t, m.IsFile("myfile"))
assert.False(t, m.IsFile("mydir"))
assert.False(t, m.IsFile("nope"))
assert.False(t, m.IsFile(""))
}
func TestReadWrite_Good(t *testing.T) {
testRoot, err := os.MkdirTemp("", "local_read_write_test")
assert.NoError(t, err)
defer os.RemoveAll(testRoot)
func TestExists(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
medium, err := New(testRoot)
assert.NoError(t, err)
os.WriteFile(filepath.Join(root, "exists"), []byte("x"), 0644)
fileName := "testfile.txt"
filePath := filepath.Join("subdir", fileName)
content := "Hello, Gopher!\nThis is a test file."
// Test Write
err = medium.Write(filePath, content)
assert.NoError(t, err)
// Verify file content by reading directly from OS
readContent, err := os.ReadFile(filepath.Join(testRoot, filePath))
assert.NoError(t, err)
assert.Equal(t, content, string(readContent))
// Test Read
readByMedium, err := medium.Read(filePath)
assert.NoError(t, err)
assert.Equal(t, content, readByMedium)
// Test Read non-existent file
_, err = medium.Read("nonexistent.txt")
assert.Error(t, err)
assert.True(t, os.IsNotExist(err))
// Test Write to a path with traversal attempt
writeErr := medium.Write("../badfile.txt", "malicious content")
assert.Error(t, writeErr)
assert.Contains(t, writeErr.Error(), "path traversal attempt detected")
assert.True(t, m.Exists("exists"))
assert.False(t, m.Exists("nope"))
}
func TestEnsureDir_Good(t *testing.T) {
testRoot, err := os.MkdirTemp("", "local_ensure_dir_test")
assert.NoError(t, err)
defer os.RemoveAll(testRoot)
func TestList(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
medium, err := New(testRoot)
assert.NoError(t, err)
os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0644)
os.WriteFile(filepath.Join(root, "b.txt"), []byte("b"), 0644)
os.Mkdir(filepath.Join(root, "subdir"), 0755)
dirName := "newdir/subdir"
dirPath := filepath.Join(testRoot, dirName)
// Test creating a new directory
err = medium.EnsureDir(dirName)
entries, err := m.List("")
assert.NoError(t, err)
info, err := os.Stat(dirPath)
assert.NoError(t, err)
assert.True(t, info.IsDir())
// Test ensuring an existing directory (should not error)
err = medium.EnsureDir(dirName)
assert.NoError(t, err)
// Test ensuring a directory with path traversal attempt
err = medium.EnsureDir("../bad_dir")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal attempt detected")
assert.Len(t, entries, 3)
}
func TestIsFile_Good(t *testing.T) {
testRoot, err := os.MkdirTemp("", "local_is_file_test")
func TestStat(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
os.WriteFile(filepath.Join(root, "file"), []byte("content"), 0644)
info, err := m.Stat("file")
assert.NoError(t, err)
defer os.RemoveAll(testRoot)
medium, err := New(testRoot)
assert.NoError(t, err)
// Create a test file
fileName := "existing_file.txt"
filePath := filepath.Join(testRoot, fileName)
err = os.WriteFile(filePath, []byte("content"), 0644)
assert.NoError(t, err)
// Create a test directory
dirName := "existing_dir"
dirPath := filepath.Join(testRoot, dirName)
err = os.Mkdir(dirPath, 0755)
assert.NoError(t, err)
// Test with an existing file
assert.True(t, medium.IsFile(fileName))
// Test with a non-existent file
assert.False(t, medium.IsFile("nonexistent_file.txt"))
// Test with a directory
assert.False(t, medium.IsFile(dirName))
// Test with path traversal attempt
assert.False(t, medium.IsFile("../bad_file.txt"))
assert.Equal(t, int64(7), info.Size())
}
func TestFileGetFileSet_Good(t *testing.T) {
testRoot, err := os.MkdirTemp("", "local_fileget_fileset_test")
func TestDelete(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
os.WriteFile(filepath.Join(root, "todelete"), []byte("x"), 0644)
assert.True(t, m.Exists("todelete"))
err := m.Delete("todelete")
assert.NoError(t, err)
defer os.RemoveAll(testRoot)
medium, err := New(testRoot)
assert.NoError(t, err)
fileName := "data.txt"
content := "Hello, FileGet/FileSet!"
// Test FileSet
err = medium.FileSet(fileName, content)
assert.NoError(t, err)
// Verify file was written
readContent, err := os.ReadFile(filepath.Join(testRoot, fileName))
assert.NoError(t, err)
assert.Equal(t, content, string(readContent))
// Test FileGet
gotContent, err := medium.FileGet(fileName)
assert.NoError(t, err)
assert.Equal(t, content, gotContent)
// Test FileGet on non-existent file
_, err = medium.FileGet("nonexistent.txt")
assert.Error(t, err)
// Test FileSet with path traversal attempt
err = medium.FileSet("../bad.txt", "malicious")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal attempt detected")
// Test FileGet with path traversal attempt
_, err = medium.FileGet("../bad.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal attempt detected")
assert.False(t, m.Exists("todelete"))
}
func TestDeleteAll(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
os.MkdirAll(filepath.Join(root, "dir/sub"), 0755)
os.WriteFile(filepath.Join(root, "dir/sub/file"), []byte("x"), 0644)
err := m.DeleteAll("dir")
assert.NoError(t, err)
assert.False(t, m.Exists("dir"))
}
func TestRename(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
os.WriteFile(filepath.Join(root, "old"), []byte("x"), 0644)
err := m.Rename("old", "new")
assert.NoError(t, err)
assert.False(t, m.Exists("old"))
assert.True(t, m.Exists("new"))
}
func TestFileGetFileSet(t *testing.T) {
root := t.TempDir()
m, _ := New(root)
err := m.FileSet("data", "value")
assert.NoError(t, err)
val, err := m.FileGet("data")
assert.NoError(t, err)
assert.Equal(t, "value", val)
}

View file

@ -82,61 +82,61 @@ func New(opts ...Option) (*Service, error) {
}
}
s.registerTools()
s.registerTools(s.server)
return s, nil
}
// registerTools adds file operation tools to the MCP server.
func (s *Service) registerTools() {
func (s *Service) registerTools(server *mcp.Server) {
// File operations
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_read",
Description: "Read the contents of a file",
}, s.readFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_write",
Description: "Write content to a file",
}, s.writeFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_delete",
Description: "Delete a file or empty directory",
}, s.deleteFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_rename",
Description: "Rename or move a file",
}, s.renameFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_exists",
Description: "Check if a file or directory exists",
}, s.fileExists)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_edit",
Description: "Edit a file by replacing old_string with new_string. Use replace_all=true to replace all occurrences.",
}, s.editDiff)
// Directory operations
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "dir_list",
Description: "List contents of a directory",
}, s.listDirectory)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "dir_create",
Description: "Create a new directory",
}, s.createDirectory)
// Language detection
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "lang_detect",
Description: "Detect the programming language of a file",
}, s.detectLanguage)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "lang_list",
Description: "Get list of supported programming languages",
}, s.getSupportedLanguages)
@ -298,13 +298,7 @@ func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input
}
func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, input ListDirectoryInput) (*mcp.CallToolResult, ListDirectoryOutput, error) {
// For directory listing, we need to use the underlying filesystem
// The Medium interface doesn't have a list method, so we validate and use os.ReadDir
path, err := s.resolvePath(input.Path)
if err != nil {
return nil, ListDirectoryOutput{}, err
}
entries, err := os.ReadDir(path)
entries, err := s.medium.List(input.Path)
if err != nil {
return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err)
}
@ -316,8 +310,11 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
size = info.Size()
}
result = append(result, DirectoryEntry{
Name: e.Name(),
Path: filepath.Join(input.Path, e.Name()),
Name: e.Name(),
Path: filepath.Join(input.Path, e.Name()), // Note: This might be relative path, client might expect absolute?
// Issue 103 says "Replace ... with local.Medium sandboxing".
// Previous code returned `filepath.Join(input.Path, e.Name())`.
// If input.Path is relative, this preserves it.
IsDir: e.IsDir(),
Size: size,
})
@ -333,28 +330,14 @@ func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest,
}
func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, DeleteFileOutput, error) {
// Medium interface doesn't have delete, use resolved path with os.Remove
path, err := s.resolvePath(input.Path)
if err != nil {
return nil, DeleteFileOutput{}, err
}
if err := os.Remove(path); err != nil {
if err := s.medium.Delete(input.Path); err != nil {
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
}
return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil
}
func (s *Service) renameFile(ctx context.Context, req *mcp.CallToolRequest, input RenameFileInput) (*mcp.CallToolResult, RenameFileOutput, error) {
// Medium interface doesn't have rename, use resolved paths with os.Rename
oldPath, err := s.resolvePath(input.OldPath)
if err != nil {
return nil, RenameFileOutput{}, err
}
newPath, err := s.resolvePath(input.NewPath)
if err != nil {
return nil, RenameFileOutput{}, err
}
if err := os.Rename(oldPath, newPath); err != nil {
if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil {
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
}
return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil
@ -365,19 +348,17 @@ func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, inpu
if exists {
return nil, FileExistsOutput{Exists: true, IsDir: false, Path: input.Path}, nil
}
// Check if it's a directory
path, err := s.resolvePath(input.Path)
if err != nil {
return nil, FileExistsOutput{}, err
}
info, err := os.Stat(path)
if os.IsNotExist(err) {
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
}
if err != nil {
return nil, FileExistsOutput{}, fmt.Errorf("failed to check file: %w", err)
}
return nil, FileExistsOutput{Exists: true, IsDir: info.IsDir(), Path: input.Path}, nil
// Check if it's a directory by attempting to list it
// List might fail if it's a file too (but we checked IsFile) or if doesn't exist.
_, err := s.medium.List(input.Path)
isDir := err == nil
// If List failed, it might mean it doesn't exist OR it's a special file or permissions.
// Assuming if List works, it's a directory.
// Refinement: If it doesn't exist, List returns error.
return nil, FileExistsOutput{Exists: isDir, IsDir: isDir, Path: input.Path}, nil
}
func (s *Service) detectLanguage(ctx context.Context, req *mcp.CallToolRequest, input DetectLanguageInput) (*mcp.CallToolResult, DetectLanguageOutput, error) {
@ -443,73 +424,6 @@ func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input
}, nil
}
// resolvePath converts a relative path to absolute using the workspace root.
// For operations not covered by Medium interface, this provides the full path.
// Returns an error if the path is outside the workspace root.
func (s *Service) resolvePath(path string) (string, error) {
if s.workspaceRoot == "" {
// Unrestricted mode
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
return abs, nil
}
var absPath string
if filepath.IsAbs(path) {
absPath = filepath.Clean(path)
} else {
absPath = filepath.Join(s.workspaceRoot, path)
}
// Resolve symlinks for security
resolvedRoot, err := filepath.EvalSymlinks(s.workspaceRoot)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace root: %w", err)
}
// Build boundary-aware prefix
rootWithSep := resolvedRoot
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
rootWithSep += string(filepath.Separator)
}
// Check if path exists to resolve symlinks
if _, err := os.Lstat(absPath); err == nil {
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
if resolvedPath != resolvedRoot && !strings.HasPrefix(resolvedPath, rootWithSep) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
return resolvedPath, nil
}
// Path doesn't exist - verify parent directory
parentDir := filepath.Dir(absPath)
if _, err := os.Lstat(parentDir); err == nil {
resolvedParent, err := filepath.EvalSymlinks(parentDir)
if err != nil {
return "", fmt.Errorf("failed to resolve parent: %w", err)
}
if resolvedParent != resolvedRoot && !strings.HasPrefix(resolvedParent, rootWithSep) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
}
// Verify the cleaned path is within workspace
if absPath != s.workspaceRoot && !strings.HasPrefix(absPath, rootWithSep) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
return absPath, nil
}
// detectLanguageFromPath maps file extensions to language IDs.
func detectLanguageFromPath(path string) string {
ext := filepath.Ext(path)
@ -566,8 +480,14 @@ func detectLanguageFromPath(path string) string {
}
}
// Run starts the MCP server on stdio.
// Run starts the MCP server.
// If MCP_ADDR is set, it starts a TCP server.
// Otherwise, it starts a Stdio server.
func (s *Service) Run(ctx context.Context) error {
addr := os.Getenv("MCP_ADDR")
if addr != "" {
return s.ServeTCP(ctx, addr)
}
return s.server.Run(ctx, &mcp.StdioTransport{})
}

View file

@ -129,66 +129,27 @@ func TestMedium_Good_IsFile(t *testing.T) {
}
}
func TestResolvePath_Good(t *testing.T) {
func TestSandboxing_Traversal_Sanitized(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// Write a test file so resolve can work
_ = s.medium.Write("test.txt", "content")
// Relative path should resolve to workspace
resolved, err := s.resolvePath("test.txt")
if err != nil {
t.Fatalf("Failed to resolve path: %v", err)
}
// The resolved path may be the symlink-resolved version
if !filepath.IsAbs(resolved) {
t.Errorf("Expected absolute path, got %s", resolved)
}
}
func TestResolvePath_Good_NoWorkspace(t *testing.T) {
s, err := New(WithWorkspaceRoot(""))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// With no workspace, relative paths resolve to cwd
cwd, _ := os.Getwd()
resolved, err := s.resolvePath("test.txt")
if err != nil {
t.Fatalf("Failed to resolve path: %v", err)
}
expected := filepath.Join(cwd, "test.txt")
if resolved != expected {
t.Errorf("Expected %s, got %s", expected, resolved)
}
}
func TestResolvePath_Bad_Traversal(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// Path traversal should fail
_, err = s.resolvePath("../secret.txt")
// Path traversal is sanitized (.. becomes .), so ../secret.txt becomes
// ./secret.txt in the workspace. Since that file doesn't exist, we get
// a file not found error (not a traversal error).
_, err = s.medium.Read("../secret.txt")
if err == nil {
t.Error("Expected error for path traversal")
t.Error("Expected error (file not found)")
}
// Absolute path outside workspace should fail
_, err = s.resolvePath("/etc/passwd")
if err == nil {
t.Error("Expected error for absolute path outside workspace")
}
// Absolute paths are allowed through - they access the real filesystem.
// This is intentional for full filesystem access. Callers wanting sandboxing
// should validate inputs before calling Medium.
}
func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
func TestSandboxing_Symlinks_Followed(t *testing.T) {
tmpDir := t.TempDir()
outsideDir := t.TempDir()
@ -199,7 +160,7 @@ func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
}
// Create symlink inside workspace pointing outside
symlinkPath := filepath.Join(tmpDir, "evil-link")
symlinkPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(targetFile, symlinkPath); err != nil {
t.Skipf("Symlinks not supported: %v", err)
}
@ -209,9 +170,14 @@ func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
t.Fatalf("Failed to create service: %v", err)
}
// Symlink traversal should be blocked
_, err = s.resolvePath("evil-link")
if err == nil {
t.Error("Expected error for symlink pointing outside workspace")
// Symlinks are followed - no traversal blocking at Medium level.
// This is intentional for simplicity. Callers wanting to block symlinks
// should validate inputs before calling Medium.
content, err := s.medium.Read("link")
if err != nil {
t.Errorf("Expected symlink to be followed, got error: %v", err)
}
if content != "secret" {
t.Errorf("Expected 'secret', got '%s'", content)
}
}

131
pkg/mcp/transport_tcp.go Normal file
View file

@ -0,0 +1,131 @@
package mcp
import (
"bufio"
"context"
"fmt"
"net"
"os"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// TCPTransport manages a TCP listener for MCP.
type TCPTransport struct {
addr string
listener net.Listener
}
// NewTCPTransport creates a new TCP transport listener.
// It listens on the provided address (e.g. "localhost:9100").
func NewTCPTransport(addr string) (*TCPTransport, error) {
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
return &TCPTransport{addr: addr, listener: listener}, nil
}
// ServeTCP starts a TCP server for the MCP service.
// It accepts connections and spawns a new MCP server session for each connection.
func (s *Service) ServeTCP(ctx context.Context, addr string) error {
t, err := NewTCPTransport(addr)
if err != nil {
return err
}
defer t.listener.Close()
if addr == "" {
addr = t.listener.Addr().String()
}
fmt.Fprintf(os.Stderr, "MCP TCP server listening on %s\n", addr)
for {
conn, err := t.listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
fmt.Fprintf(os.Stderr, "Accept error: %v\n", err)
continue
}
}
go s.handleConnection(ctx, conn)
}
}
func (s *Service) handleConnection(ctx context.Context, conn net.Conn) {
// Note: We don't defer conn.Close() here because it's closed by the Server/Transport
// Create new server instance for this connection
impl := &mcp.Implementation{
Name: "core-cli",
Version: "0.1.0",
}
server := mcp.NewServer(impl, nil)
s.registerTools(server)
// Create transport for this connection
transport := &connTransport{conn: conn}
// Run server (blocks until connection closed)
// Server.Run calls Connect, then Read loop.
if err := server.Run(ctx, transport); err != nil {
fmt.Fprintf(os.Stderr, "Connection error: %v\n", err)
}
}
// connTransport adapts net.Conn to mcp.Transport
type connTransport struct {
conn net.Conn
}
func (t *connTransport) Connect(ctx context.Context) (mcp.Connection, error) {
return &connConnection{
conn: t.conn,
scanner: bufio.NewScanner(t.conn),
}, nil
}
// connConnection implements mcp.Connection
type connConnection struct {
conn net.Conn
scanner *bufio.Scanner
}
func (c *connConnection) Read(ctx context.Context) (jsonrpc.Message, error) {
// Blocks until line is read
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, err
}
// EOF
// Return error to signal closure, as per Scanner contract?
// SDK usually expects error on close.
return nil, fmt.Errorf("EOF")
}
line := c.scanner.Bytes()
return jsonrpc.DecodeMessage(line)
}
func (c *connConnection) Write(ctx context.Context, msg jsonrpc.Message) error {
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return err
}
// Append newline for line-delimited JSON
data = append(data, '\n')
_, err = c.conn.Write(data)
return err
}
func (c *connConnection) Close() error {
return c.conn.Close()
}
func (c *connConnection) SessionID() string {
return "tcp-session" // Unique ID might be better, but optional
}