Merge IO batch
This commit is contained in:
commit
aadd286ee7
36 changed files with 1202 additions and 294 deletions
BIN
core-test
Executable file
BIN
core-test
Executable file
Binary file not shown.
2
go.mod
2
go.mod
|
|
@ -5,7 +5,6 @@ go 1.25.5
|
||||||
require (
|
require (
|
||||||
github.com/Snider/Borg v0.1.0
|
github.com/Snider/Borg v0.1.0
|
||||||
github.com/getkin/kin-openapi v0.133.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/debme v1.2.1
|
||||||
github.com/leaanthony/gosod v1.0.4
|
github.com/leaanthony/gosod v1.0.4
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
|
|
@ -58,6 +57,7 @@ require (
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // 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/wI2L/jsondiff v0.7.0 // indirect
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -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/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 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/host-uk/core/pkg/errors"
|
"github.com/host-uk/core/pkg/errors"
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -76,8 +77,8 @@ func runApply() error {
|
||||||
|
|
||||||
// Validate script exists
|
// Validate script exists
|
||||||
if applyScript != "" {
|
if applyScript != "" {
|
||||||
if _, err := os.Stat(applyScript); err != nil {
|
if !io.Local.IsFile(applyScript) {
|
||||||
return errors.E("dev.apply", "script not found: "+applyScript, err)
|
return errors.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Commit command flags
|
// Commit command flags
|
||||||
|
|
@ -139,8 +140,8 @@ func runCommit(registryPath string, all bool) error {
|
||||||
// isGitRepo checks if a directory is a git repository.
|
// isGitRepo checks if a directory is a git repository.
|
||||||
func isGitRepo(path string) bool {
|
func isGitRepo(path string) bool {
|
||||||
gitDir := path + "/.git"
|
gitDir := path + "/.git"
|
||||||
info, err := os.Stat(gitDir)
|
_, err := coreio.Local.List(gitDir)
|
||||||
return err == nil && info.IsDir()
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCommitSingleRepo handles commit for a single repo (current directory).
|
// runCommitSingleRepo handles commit for a single repo (current directory).
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -19,6 +18,7 @@ import (
|
||||||
"github.com/host-uk/core/pkg/errors"
|
"github.com/host-uk/core/pkg/errors"
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -63,7 +63,24 @@ func runFileSync(source string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate source exists
|
// 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 {
|
if err != nil {
|
||||||
return errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
|
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
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
|
||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
|
|
@ -287,47 +306,14 @@ func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, e
|
||||||
return string(output), nil
|
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
|
// copyDir recursively copies a directory
|
||||||
func copyDir(src, dst string) error {
|
func copyDir(src, dst string) error {
|
||||||
srcInfo, err := os.Stat(src)
|
entries, err := coreio.Local.List(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
if err := coreio.Local.EnsureDir(dst); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,7 +326,7 @@ func copyDir(src, dst string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := copyFile(srcPath, dstPath); err != nil {
|
if err := coreio.Copy(coreio.Local, srcPath, coreio.Local, dstPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,40 @@ package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli" // Added
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"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/cases"
|
||||||
"golang.org/x/text/language"
|
"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.
|
// addSyncCommand adds the 'sync' command to the given parent command.
|
||||||
func addSyncCommand(parent *cli.Command) {
|
func addSyncCommand(parent *cli.Command) {
|
||||||
syncCmd := &cli.Command{
|
syncCmd := &cli.Command{
|
||||||
|
|
@ -40,7 +61,7 @@ type symbolInfo struct {
|
||||||
|
|
||||||
func runSync() error {
|
func runSync() error {
|
||||||
pkgDir := "pkg"
|
pkgDir := "pkg"
|
||||||
internalDirs, err := os.ReadDir(pkgDir)
|
internalDirs, err := coreio.Local.List(pkgDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.Wrap(err, "failed to read pkg directory")
|
return cli.Wrap(err, "failed to read pkg directory")
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +76,7 @@ func runSync() error {
|
||||||
publicDir := serviceName
|
publicDir := serviceName
|
||||||
publicFile := filepath.Join(publicDir, serviceName+".go")
|
publicFile := filepath.Join(publicDir, serviceName+".go")
|
||||||
|
|
||||||
if _, err := os.Stat(internalFile); os.IsNotExist(err) {
|
if !coreio.Local.IsFile(internalFile) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +94,16 @@ func runSync() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExportedSymbols(path string) ([]symbolInfo, 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()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +163,7 @@ type {{.InterfaceName}} = core.{{.InterfaceName}}
|
||||||
`
|
`
|
||||||
|
|
||||||
func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,5 +190,5 @@ func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(path, buf.Bytes(), 0644)
|
return coreio.Local.Write(path, buf.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Workflow command flags
|
// Workflow command flags
|
||||||
|
|
@ -156,7 +156,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read template content
|
// Read template content
|
||||||
templateContent, err := os.ReadFile(templatePath)
|
templateContent, err := io.Local.Read(templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error"))
|
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)
|
destPath := filepath.Join(destDir, workflowFile)
|
||||||
|
|
||||||
// Check if workflow already exists and is identical
|
// Check if workflow already exists and is identical
|
||||||
if existingContent, err := os.ReadFile(destPath); err == nil {
|
if existingContent, err := io.Local.Read(destPath); err == nil {
|
||||||
if string(existingContent) == string(templateContent) {
|
if existingContent == templateContent {
|
||||||
cli.Print(" %s %s %s\n",
|
cli.Print(" %s %s %s\n",
|
||||||
dimStyle.Render("-"),
|
dimStyle.Render("-"),
|
||||||
repoNameStyle.Render(repo.Name),
|
repoNameStyle.Render(repo.Name),
|
||||||
|
|
@ -210,7 +210,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create .github/workflows directory if needed
|
// 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",
|
cli.Print(" %s %s %s\n",
|
||||||
errorStyle.Render(cli.Glyph(":cross:")),
|
errorStyle.Render(cli.Glyph(":cross:")),
|
||||||
repoNameStyle.Render(repo.Name),
|
repoNameStyle.Render(repo.Name),
|
||||||
|
|
@ -220,7 +220,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write workflow file
|
// 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",
|
cli.Print(" %s %s %s\n",
|
||||||
errorStyle.Render(cli.Glyph(":cross:")),
|
errorStyle.Render(cli.Glyph(":cross:")),
|
||||||
repoNameStyle.Render(repo.Name),
|
repoNameStyle.Render(repo.Name),
|
||||||
|
|
@ -264,7 +264,7 @@ func findWorkflows(dir string) []string {
|
||||||
workflowsDir = dir
|
workflowsDir = dir
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(workflowsDir)
|
entries, err := io.Local.List(workflowsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +298,7 @@ func findTemplateWorkflow(registryDir, workflowFile string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if io.Local.IsFile(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -93,28 +94,29 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
|
||||||
|
|
||||||
// Check for README.md
|
// Check for README.md
|
||||||
readme := filepath.Join(repo.Path, "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.Readme = readme
|
||||||
info.HasDocs = true
|
info.HasDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for CLAUDE.md
|
// Check for CLAUDE.md
|
||||||
claudeMd := filepath.Join(repo.Path, "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.ClaudeMd = claudeMd
|
||||||
info.HasDocs = true
|
info.HasDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for CHANGELOG.md
|
// Check for CHANGELOG.md
|
||||||
changelog := filepath.Join(repo.Path, "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.Changelog = changelog
|
||||||
info.HasDocs = true
|
info.HasDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively scan docs/ directory for .md files
|
// Recursively scan docs/ directory for .md files
|
||||||
docsDir := filepath.Join(repo.Path, "docs")
|
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 {
|
filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -137,11 +139,3 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
data, err := os.ReadFile(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(dst, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flag variables for sync command
|
// Flag variables for sync command
|
||||||
|
|
@ -127,9 +127,9 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
|
||||||
repoOutDir := filepath.Join(outputDir, outName)
|
repoOutDir := filepath.Join(outputDir, outName)
|
||||||
|
|
||||||
// Clear existing directory
|
// 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)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -139,8 +139,10 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
|
||||||
for _, f := range info.DocsFiles {
|
for _, f := range info.DocsFiles {
|
||||||
src := filepath.Join(docsDir, f)
|
src := filepath.Join(docsDir, f)
|
||||||
dst := filepath.Join(repoOutDir, f)
|
dst := filepath.Join(repoOutDir, f)
|
||||||
os.MkdirAll(filepath.Dir(dst), 0755)
|
// Ensure parent dir
|
||||||
if err := copyFile(src, dst); err != nil {
|
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)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
internal/cmd/help/cmd.go
Normal file
66
internal/cmd/help/cmd.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
@ -73,12 +74,12 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
|
||||||
|
|
||||||
repoPath := filepath.Join(targetDir, repoName)
|
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}))
|
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
|
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)
|
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,18 +124,17 @@ func addToRegistryFile(org, repoName string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644)
|
content, err := coreio.Local.Read(regPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
repoType := detectRepoType(repoName)
|
repoType := detectRepoType(repoName)
|
||||||
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
|
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
|
||||||
repoName, repoType)
|
repoName, repoType)
|
||||||
|
|
||||||
_, err = f.WriteString(entry)
|
content += entry
|
||||||
return err
|
return coreio.Local.Write(regPath, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectRepoType(name string) string {
|
func detectRepoType(name string) string {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ package pkgcmd
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
@ -58,7 +58,7 @@ func runPkgList() error {
|
||||||
for _, r := range allRepos {
|
for _, r := range allRepos {
|
||||||
repoPath := filepath.Join(basePath, r.Name)
|
repoPath := filepath.Join(basePath, r.Name)
|
||||||
exists := false
|
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
|
exists = true
|
||||||
installed++
|
installed++
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -147,7 +147,7 @@ func runPkgUpdate(packages []string, all bool) error {
|
||||||
for _, name := range toUpdate {
|
for _, name := range toUpdate {
|
||||||
repoPath := filepath.Join(basePath, name)
|
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"))
|
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
|
|
@ -219,7 +219,7 @@ func runPkgOutdated() error {
|
||||||
for _, r := range reg.List() {
|
for _, r := range reg.List() {
|
||||||
repoPath := filepath.Join(basePath, r.Name)
|
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++
|
notInstalled++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ package sdk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// commonSpecPaths are checked in order when no spec is configured.
|
// commonSpecPaths are checked in order when no spec is configured.
|
||||||
|
|
@ -25,7 +26,7 @@ func (s *SDK) DetectSpec() (string, error) {
|
||||||
// 1. Check configured path
|
// 1. Check configured path
|
||||||
if s.config.Spec != "" {
|
if s.config.Spec != "" {
|
||||||
specPath := filepath.Join(s.projectDir, 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 specPath, nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("sdk.DetectSpec: configured spec not found: %s", s.config.Spec)
|
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
|
// 2. Check common paths
|
||||||
for _, p := range commonSpecPaths {
|
for _, p := range commonSpecPaths {
|
||||||
specPath := filepath.Join(s.projectDir, p)
|
specPath := filepath.Join(s.projectDir, p)
|
||||||
if _, err := os.Stat(specPath); err == nil {
|
if coreio.Local.IsFile(specPath) {
|
||||||
return specPath, nil
|
return specPath, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,12 +52,12 @@ func (s *SDK) DetectSpec() (string, error) {
|
||||||
// detectScramble checks for Laravel Scramble and exports the spec.
|
// detectScramble checks for Laravel Scramble and exports the spec.
|
||||||
func (s *SDK) detectScramble() (string, error) {
|
func (s *SDK) detectScramble() (string, error) {
|
||||||
composerPath := filepath.Join(s.projectDir, "composer.json")
|
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")
|
return "", fmt.Errorf("no composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for scramble in composer.json
|
// Check for scramble in composer.json
|
||||||
data, err := os.ReadFile(composerPath)
|
data, err := coreio.Local.Read(composerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -71,8 +72,7 @@ func (s *SDK) detectScramble() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// containsScramble checks if composer.json includes scramble.
|
// containsScramble checks if composer.json includes scramble.
|
||||||
func containsScramble(data []byte) bool {
|
func containsScramble(content string) bool {
|
||||||
content := string(data)
|
|
||||||
return strings.Contains(content, "dedoc/scramble") ||
|
return strings.Contains(content, "dedoc/scramble") ||
|
||||||
strings.Contains(content, "\"scramble\"")
|
strings.Contains(content, "\"scramble\"")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func TestContainsScramble(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
assert.Equal(t, tt.expected, containsScramble([]byte(tt.data)))
|
assert.Equal(t, tt.expected, containsScramble(tt.data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GoGenerator generates Go SDKs from OpenAPI specs.
|
// GoGenerator generates Go SDKs from OpenAPI specs.
|
||||||
|
|
@ -34,7 +36,7 @@ func (g *GoGenerator) Install() string {
|
||||||
|
|
||||||
// Generate creates SDK from OpenAPI spec.
|
// Generate creates SDK from OpenAPI spec.
|
||||||
func (g *GoGenerator) Generate(ctx context.Context, opts Options) error {
|
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)
|
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)
|
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 {
|
func (g *GoGenerator) generateDocker(ctx context.Context, opts Options) error {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PHPGenerator generates PHP SDKs from OpenAPI specs.
|
// 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")
|
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)
|
return fmt.Errorf("php.Generate: failed to create output dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PythonGenerator generates Python SDKs from OpenAPI specs.
|
// PythonGenerator generates Python SDKs from OpenAPI specs.
|
||||||
|
|
@ -34,7 +36,7 @@ func (g *PythonGenerator) Install() string {
|
||||||
|
|
||||||
// Generate creates SDK from OpenAPI spec.
|
// Generate creates SDK from OpenAPI spec.
|
||||||
func (g *PythonGenerator) Generate(ctx context.Context, opts Options) error {
|
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)
|
return fmt.Errorf("python.Generate: failed to create output dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TypeScriptGenerator generates TypeScript SDKs from OpenAPI specs.
|
// TypeScriptGenerator generates TypeScript SDKs from OpenAPI specs.
|
||||||
|
|
@ -38,7 +40,7 @@ func (g *TypeScriptGenerator) Install() string {
|
||||||
|
|
||||||
// Generate creates SDK from OpenAPI spec.
|
// Generate creates SDK from OpenAPI spec.
|
||||||
func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error {
|
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)
|
return fmt.Errorf("typescript.Generate: failed to create output dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"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)
|
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName)
|
||||||
|
|
||||||
if !dryRun {
|
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)
|
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
|
// Clone core-devops first
|
||||||
devopsPath := filepath.Join(targetDir, devopsRepo)
|
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)
|
fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo)
|
||||||
|
|
||||||
if !dryRun {
|
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.
|
// isGitRepoRoot returns true if the directory is a git repository root.
|
||||||
func isGitRepoRoot(path string) bool {
|
func isGitRepoRoot(path string) bool {
|
||||||
_, err := os.Stat(filepath.Join(path, ".git"))
|
_, err := coreio.Local.List(filepath.Join(path, ".git"))
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDirEmpty returns true if the directory is empty or contains only hidden files.
|
// isDirEmpty returns true if the directory is empty or contains only hidden files.
|
||||||
func isDirEmpty(path string) (bool, error) {
|
func isDirEmpty(path string) (bool, error) {
|
||||||
entries, err := os.ReadDir(path)
|
entries, err := coreio.Local.List(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -51,9 +52,9 @@ func LoadCIConfig() *CIConfig {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
configPath := filepath.Join(dir, ".core", "ci.yaml")
|
configPath := filepath.Join(dir, ".core", "ci.yaml")
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := coreio.Local.Read(configPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if err := yaml.Unmarshal(data, cfg); err == nil {
|
if err := yaml.Unmarshal([]byte(data), cfg); err == nil {
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -80,7 +81,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
|
|
||||||
// Ensure base path exists
|
// Ensure base path exists
|
||||||
if !dryRun {
|
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)
|
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
|
// Check if already exists
|
||||||
repoPath := filepath.Join(basePath, repo.Name)
|
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++
|
exists++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +147,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
repoPath := filepath.Join(basePath, repo.Name)
|
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++
|
exists++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runRepoSetup sets up the current repository with .core/ configuration.
|
// runRepoSetup sets up the current repository with .core/ configuration.
|
||||||
|
|
@ -27,7 +27,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
|
||||||
// Create .core directory
|
// Create .core directory
|
||||||
coreDir := filepath.Join(repoPath, ".core")
|
coreDir := filepath.Join(repoPath, ".core")
|
||||||
if !dryRun {
|
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)
|
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 {
|
for filename, content := range configs {
|
||||||
configPath := filepath.Join(coreDir, filename)
|
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)
|
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)
|
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.
|
// detectProjectType identifies the project type from files present.
|
||||||
func detectProjectType(path string) string {
|
func detectProjectType(path string) string {
|
||||||
// Check in priority order
|
// 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"
|
return "wails"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
if coreio.Local.IsFile(filepath.Join(path, "go.mod")) {
|
||||||
return "go"
|
return "go"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil {
|
if coreio.Local.IsFile(filepath.Join(path, "composer.json")) {
|
||||||
return "php"
|
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 "node"
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -64,13 +65,13 @@ type SecurityConfig struct {
|
||||||
|
|
||||||
// LoadGitHubConfig reads and parses a GitHub configuration file.
|
// LoadGitHubConfig reads and parses a GitHub configuration file.
|
||||||
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := coreio.Local.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand environment variables before parsing
|
// Expand environment variables before parsing
|
||||||
expanded := expandEnvVars(string(data))
|
expanded := expandEnvVars(data)
|
||||||
|
|
||||||
var config GitHubConfig
|
var config GitHubConfig
|
||||||
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
|
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
|
||||||
|
|
@ -127,7 +128,7 @@ func expandEnvVars(input string) string {
|
||||||
// 3. github.yaml (relative to registry)
|
// 3. github.yaml (relative to registry)
|
||||||
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
||||||
if specifiedPath != "" {
|
if specifiedPath != "" {
|
||||||
if _, err := os.Stat(specifiedPath); err == nil {
|
if coreio.Local.IsFile(specifiedPath) {
|
||||||
return specifiedPath, nil
|
return specifiedPath, nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("config file not found: %s", specifiedPath)
|
return "", fmt.Errorf("config file not found: %s", specifiedPath)
|
||||||
|
|
@ -140,7 +141,7 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range candidates {
|
for _, path := range candidates {
|
||||||
if _, err := os.Stat(path); err == nil {
|
if coreio.Local.IsFile(path) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import (
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PkgVersion is set via ldflags
|
||||||
|
var PkgVersion = "dev"
|
||||||
|
|
||||||
// Version holds the current version of the application.
|
// Version holds the current version of the application.
|
||||||
// It is set at build time via ldflags or fallback to the version in package.json.
|
// It is set at build time via ldflags or fallback to the version in package.json.
|
||||||
var Version = PkgVersion
|
var Version = PkgVersion
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
coreio "github.com/host-uk/core/pkg/io"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -28,9 +29,14 @@ func DefaultConfig() *WorkspaceConfig {
|
||||||
// Returns nil if no config file exists (caller should check for nil).
|
// Returns nil if no config file exists (caller should check for nil).
|
||||||
func LoadConfig(dir string) (*WorkspaceConfig, error) {
|
func LoadConfig(dir string) (*WorkspaceConfig, error) {
|
||||||
path := filepath.Join(dir, ".core", "workspace.yaml")
|
path := filepath.Join(dir, ".core", "workspace.yaml")
|
||||||
data, err := os.ReadFile(path)
|
data, err := coreio.Local.Read(path)
|
||||||
if err != nil {
|
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
|
// Try parent directory
|
||||||
parent := filepath.Dir(dir)
|
parent := filepath.Dir(dir)
|
||||||
if parent != dir {
|
if parent != dir {
|
||||||
|
|
@ -43,7 +49,7 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
config := DefaultConfig()
|
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)
|
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.
|
// SaveConfig saves the configuration to the given directory's .core/workspace.yaml.
|
||||||
func SaveConfig(dir string, config *WorkspaceConfig) error {
|
func SaveConfig(dir string, config *WorkspaceConfig) error {
|
||||||
coreDir := filepath.Join(dir, ".core")
|
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)
|
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)
|
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)
|
return fmt.Errorf("failed to write workspace config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +88,7 @@ func FindWorkspaceRoot() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
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
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,6 @@ import (
|
||||||
_ "github.com/host-uk/core/internal/cmd/updater"
|
_ "github.com/host-uk/core/internal/cmd/updater"
|
||||||
_ "github.com/host-uk/core/internal/cmd/vm"
|
_ "github.com/host-uk/core/internal/cmd/vm"
|
||||||
_ "github.com/host-uk/core/internal/cmd/workspace"
|
_ "github.com/host-uk/core/internal/cmd/workspace"
|
||||||
|
_ "github.com/host-uk/core/internal/cmd/help"
|
||||||
_ "github.com/host-uk/core/pkg/build/buildcmd"
|
_ "github.com/host-uk/core/pkg/build/buildcmd"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1
issues.json
Normal file
1
issues.json
Normal file
File diff suppressed because one or more lines are too long
87
pkg/help/catalog.go
Normal file
87
pkg/help/catalog.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,127 @@ func TestMockMedium_FileSet_Good(t *testing.T) {
|
||||||
assert.Equal(t, "content", m.Files["test.txt"])
|
assert.Equal(t, "content", m.Files["test.txt"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Delete_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Files["test.txt"] = "content"
|
||||||
|
|
||||||
|
err := m.Delete("test.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, m.IsFile("test.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Delete_Bad_NotFound(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
err := m.Delete("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Delete_Bad_DirNotEmpty(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Dirs["mydir"] = true
|
||||||
|
m.Files["mydir/file.txt"] = "content"
|
||||||
|
|
||||||
|
err := m.Delete("mydir")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_DeleteAll_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Dirs["mydir"] = true
|
||||||
|
m.Dirs["mydir/subdir"] = true
|
||||||
|
m.Files["mydir/file.txt"] = "content"
|
||||||
|
m.Files["mydir/subdir/nested.txt"] = "nested"
|
||||||
|
|
||||||
|
err := m.DeleteAll("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, m.Dirs)
|
||||||
|
assert.Empty(t, m.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Rename_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Files["old.txt"] = "content"
|
||||||
|
|
||||||
|
err := m.Rename("old.txt", "new.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, m.IsFile("old.txt"))
|
||||||
|
assert.True(t, m.IsFile("new.txt"))
|
||||||
|
assert.Equal(t, "content", m.Files["new.txt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Rename_Good_Dir(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Dirs["olddir"] = true
|
||||||
|
m.Files["olddir/file.txt"] = "content"
|
||||||
|
|
||||||
|
err := m.Rename("olddir", "newdir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, m.Dirs["olddir"])
|
||||||
|
assert.True(t, m.Dirs["newdir"])
|
||||||
|
assert.Equal(t, "content", m.Files["newdir/file.txt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_List_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Dirs["mydir"] = true
|
||||||
|
m.Files["mydir/file1.txt"] = "content1"
|
||||||
|
m.Files["mydir/file2.txt"] = "content2"
|
||||||
|
m.Dirs["mydir/subdir"] = true
|
||||||
|
|
||||||
|
entries, err := m.List("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, entries, 3)
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
names[e.Name()] = true
|
||||||
|
}
|
||||||
|
assert.True(t, names["file1.txt"])
|
||||||
|
assert.True(t, names["file2.txt"])
|
||||||
|
assert.True(t, names["subdir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Stat_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Files["test.txt"] = "hello world"
|
||||||
|
|
||||||
|
info, err := m.Stat("test.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "test.txt", info.Name())
|
||||||
|
assert.Equal(t, int64(11), info.Size())
|
||||||
|
assert.False(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Stat_Good_Dir(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Dirs["mydir"] = true
|
||||||
|
|
||||||
|
info, err := m.Stat("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "mydir", info.Name())
|
||||||
|
assert.True(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_Exists_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Files["file.txt"] = "content"
|
||||||
|
m.Dirs["mydir"] = true
|
||||||
|
|
||||||
|
assert.True(t, m.Exists("file.txt"))
|
||||||
|
assert.True(t, m.Exists("mydir"))
|
||||||
|
assert.False(t, m.Exists("nonexistent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockMedium_IsDir_Good(t *testing.T) {
|
||||||
|
m := NewMockMedium()
|
||||||
|
m.Files["file.txt"] = "content"
|
||||||
|
m.Dirs["mydir"] = true
|
||||||
|
|
||||||
|
assert.False(t, m.IsDir("file.txt"))
|
||||||
|
assert.True(t, m.IsDir("mydir"))
|
||||||
|
assert.False(t, m.IsDir("nonexistent"))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Wrapper Function Tests ---
|
// --- Wrapper Function Tests ---
|
||||||
|
|
||||||
func TestRead_Good(t *testing.T) {
|
func TestRead_Good(t *testing.T) {
|
||||||
|
|
|
||||||
305
pkg/io/io.go
305
pkg/io/io.go
|
|
@ -1,7 +1,11 @@
|
||||||
package io
|
package io
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
coreerr "github.com/host-uk/core/pkg/framework/core"
|
coreerr "github.com/host-uk/core/pkg/framework/core"
|
||||||
"github.com/host-uk/core/pkg/io/local"
|
"github.com/host-uk/core/pkg/io/local"
|
||||||
|
|
@ -28,8 +32,58 @@ type Medium interface {
|
||||||
|
|
||||||
// FileSet is a convenience function that writes a file to the medium.
|
// FileSet is a convenience function that writes a file to the medium.
|
||||||
FileSet(path, content string) error
|
FileSet(path, content string) error
|
||||||
|
|
||||||
|
// Delete removes a file or empty directory.
|
||||||
|
Delete(path string) error
|
||||||
|
|
||||||
|
// DeleteAll removes a file or directory and all its contents recursively.
|
||||||
|
DeleteAll(path string) error
|
||||||
|
|
||||||
|
// Rename moves a file or directory from oldPath to newPath.
|
||||||
|
Rename(oldPath, newPath string) error
|
||||||
|
|
||||||
|
// List returns the directory entries for the given path.
|
||||||
|
List(path string) ([]fs.DirEntry, error)
|
||||||
|
|
||||||
|
// Stat returns file information for the given path.
|
||||||
|
Stat(path string) (fs.FileInfo, error)
|
||||||
|
|
||||||
|
// Exists checks if a path exists (file or directory).
|
||||||
|
Exists(path string) bool
|
||||||
|
|
||||||
|
// IsDir checks if a path exists and is a directory.
|
||||||
|
IsDir(path string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileInfo provides a simple implementation of fs.FileInfo for mock testing.
|
||||||
|
type FileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode fs.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi FileInfo) Name() string { return fi.name }
|
||||||
|
func (fi FileInfo) Size() int64 { return fi.size }
|
||||||
|
func (fi FileInfo) Mode() fs.FileMode { return fi.mode }
|
||||||
|
func (fi FileInfo) ModTime() time.Time { return fi.modTime }
|
||||||
|
func (fi FileInfo) IsDir() bool { return fi.isDir }
|
||||||
|
func (fi FileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
|
// DirEntry provides a simple implementation of fs.DirEntry for mock testing.
|
||||||
|
type DirEntry struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
mode fs.FileMode
|
||||||
|
info fs.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (de DirEntry) Name() string { return de.name }
|
||||||
|
func (de DirEntry) IsDir() bool { return de.isDir }
|
||||||
|
func (de DirEntry) Type() fs.FileMode { return de.mode.Type() }
|
||||||
|
func (de DirEntry) Info() (fs.FileInfo, error) { return de.info, nil }
|
||||||
|
|
||||||
// Local is a pre-initialized medium for the local filesystem.
|
// Local is a pre-initialized medium for the local filesystem.
|
||||||
// It uses "/" as root, providing unsandboxed access to the filesystem.
|
// It uses "/" as root, providing unsandboxed access to the filesystem.
|
||||||
// For sandboxed access, use NewSandboxed with a specific root path.
|
// For sandboxed access, use NewSandboxed with a specific root path.
|
||||||
|
|
@ -136,3 +190,254 @@ func (m *MockMedium) FileGet(path string) (string, error) {
|
||||||
func (m *MockMedium) FileSet(path, content string) error {
|
func (m *MockMedium) FileSet(path, content string) error {
|
||||||
return m.Write(path, content)
|
return m.Write(path, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes a file or empty directory from the mock filesystem.
|
||||||
|
func (m *MockMedium) Delete(path string) error {
|
||||||
|
if _, ok := m.Files[path]; ok {
|
||||||
|
delete(m.Files, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := m.Dirs[path]; ok {
|
||||||
|
// Check if directory is empty (no files or subdirs with this prefix)
|
||||||
|
prefix := path
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
for f := range m.Files {
|
||||||
|
if strings.HasPrefix(f, prefix) {
|
||||||
|
return coreerr.E("io.MockMedium.Delete", "directory not empty: "+path, os.ErrExist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for d := range m.Dirs {
|
||||||
|
if d != path && strings.HasPrefix(d, prefix) {
|
||||||
|
return coreerr.E("io.MockMedium.Delete", "directory not empty: "+path, os.ErrExist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m.Dirs, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return coreerr.E("io.MockMedium.Delete", "path not found: "+path, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAll removes a file or directory and all contents from the mock filesystem.
|
||||||
|
func (m *MockMedium) DeleteAll(path string) error {
|
||||||
|
found := false
|
||||||
|
if _, ok := m.Files[path]; ok {
|
||||||
|
delete(m.Files, path)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if _, ok := m.Dirs[path]; ok {
|
||||||
|
delete(m.Dirs, path)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all entries under this path
|
||||||
|
prefix := path
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
for f := range m.Files {
|
||||||
|
if strings.HasPrefix(f, prefix) {
|
||||||
|
delete(m.Files, f)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for d := range m.Dirs {
|
||||||
|
if strings.HasPrefix(d, prefix) {
|
||||||
|
delete(m.Dirs, d)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return coreerr.E("io.MockMedium.DeleteAll", "path not found: "+path, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename moves a file or directory 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)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := m.Dirs[oldPath]; ok {
|
||||||
|
// Move directory and all contents
|
||||||
|
m.Dirs[newPath] = true
|
||||||
|
delete(m.Dirs, oldPath)
|
||||||
|
|
||||||
|
oldPrefix := oldPath
|
||||||
|
if !strings.HasSuffix(oldPrefix, "/") {
|
||||||
|
oldPrefix += "/"
|
||||||
|
}
|
||||||
|
newPrefix := newPath
|
||||||
|
if !strings.HasSuffix(newPrefix, "/") {
|
||||||
|
newPrefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move files under this directory
|
||||||
|
for f, content := range m.Files {
|
||||||
|
if strings.HasPrefix(f, oldPrefix) {
|
||||||
|
newF := newPrefix + strings.TrimPrefix(f, oldPrefix)
|
||||||
|
m.Files[newF] = content
|
||||||
|
delete(m.Files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Move subdirectories
|
||||||
|
for d := range m.Dirs {
|
||||||
|
if strings.HasPrefix(d, oldPrefix) {
|
||||||
|
newD := newPrefix + strings.TrimPrefix(d, oldPrefix)
|
||||||
|
m.Dirs[newD] = true
|
||||||
|
delete(m.Dirs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return coreerr.E("io.MockMedium.Rename", "path not found: "+oldPath, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns directory entries for the mock filesystem.
|
||||||
|
func (m *MockMedium) List(path string) ([]fs.DirEntry, error) {
|
||||||
|
if _, ok := m.Dirs[path]; !ok {
|
||||||
|
// Check if it's the root or has children
|
||||||
|
hasChildren := false
|
||||||
|
prefix := path
|
||||||
|
if path != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
for f := range m.Files {
|
||||||
|
if strings.HasPrefix(f, prefix) {
|
||||||
|
hasChildren = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasChildren {
|
||||||
|
for d := range m.Dirs {
|
||||||
|
if strings.HasPrefix(d, prefix) {
|
||||||
|
hasChildren = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasChildren && path != "" {
|
||||||
|
return nil, coreerr.E("io.MockMedium.List", "directory not found: "+path, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := path
|
||||||
|
if path != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var entries []fs.DirEntry
|
||||||
|
|
||||||
|
// Find immediate children (files)
|
||||||
|
for f, content := range m.Files {
|
||||||
|
if !strings.HasPrefix(f, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(f, prefix)
|
||||||
|
if rest == "" || strings.Contains(rest, "/") {
|
||||||
|
// Skip if it's not an immediate child
|
||||||
|
if idx := strings.Index(rest, "/"); idx != -1 {
|
||||||
|
// This is a subdirectory
|
||||||
|
dirName := rest[:idx]
|
||||||
|
if !seen[dirName] {
|
||||||
|
seen[dirName] = true
|
||||||
|
entries = append(entries, DirEntry{
|
||||||
|
name: dirName,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
info: FileInfo{
|
||||||
|
name: dirName,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seen[rest] {
|
||||||
|
seen[rest] = true
|
||||||
|
entries = append(entries, DirEntry{
|
||||||
|
name: rest,
|
||||||
|
isDir: false,
|
||||||
|
mode: 0644,
|
||||||
|
info: FileInfo{
|
||||||
|
name: rest,
|
||||||
|
size: int64(len(content)),
|
||||||
|
mode: 0644,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find immediate subdirectories
|
||||||
|
for d := range m.Dirs {
|
||||||
|
if !strings.HasPrefix(d, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(d, prefix)
|
||||||
|
if rest == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Get only immediate child
|
||||||
|
if idx := strings.Index(rest, "/"); idx != -1 {
|
||||||
|
rest = rest[:idx]
|
||||||
|
}
|
||||||
|
if !seen[rest] {
|
||||||
|
seen[rest] = true
|
||||||
|
entries = append(entries, DirEntry{
|
||||||
|
name: rest,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
info: FileInfo{
|
||||||
|
name: rest,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file information for the mock filesystem.
|
||||||
|
func (m *MockMedium) Stat(path string) (fs.FileInfo, error) {
|
||||||
|
if content, ok := m.Files[path]; ok {
|
||||||
|
return FileInfo{
|
||||||
|
name: filepath.Base(path),
|
||||||
|
size: int64(len(content)),
|
||||||
|
mode: 0644,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if _, ok := m.Dirs[path]; ok {
|
||||||
|
return FileInfo{
|
||||||
|
name: filepath.Base(path),
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, coreerr.E("io.MockMedium.Stat", "path not found: "+path, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a path exists in the mock filesystem.
|
||||||
|
func (m *MockMedium) Exists(path string) bool {
|
||||||
|
if _, ok := m.Files[path]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := m.Dirs[path]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir checks if a path is a directory in the mock filesystem.
|
||||||
|
func (m *MockMedium) IsDir(path string) bool {
|
||||||
|
_, ok := m.Dirs[path]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package local
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -42,12 +43,20 @@ func (m *Medium) path(relativePath string) (string, error) {
|
||||||
return "", errors.New("path traversal attempt detected")
|
return "", errors.New("path traversal attempt detected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject absolute paths - they bypass the sandbox
|
// When root is "/" (full filesystem access), allow absolute paths
|
||||||
if filepath.IsAbs(cleanPath) {
|
isRootFS := m.root == "/" || m.root == string(filepath.Separator)
|
||||||
|
|
||||||
|
// Reject absolute paths unless we're the root filesystem
|
||||||
|
if filepath.IsAbs(cleanPath) && !isRootFS {
|
||||||
return "", errors.New("path traversal attempt detected")
|
return "", errors.New("path traversal attempt detected")
|
||||||
}
|
}
|
||||||
|
|
||||||
fullPath := filepath.Join(m.root, cleanPath)
|
var fullPath string
|
||||||
|
if filepath.IsAbs(cleanPath) {
|
||||||
|
fullPath = cleanPath
|
||||||
|
} else {
|
||||||
|
fullPath = filepath.Join(m.root, cleanPath)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the resulting path is still within root (boundary-aware check)
|
// Verify the resulting path is still within root (boundary-aware check)
|
||||||
// Must use separator to prevent /tmp/root matching /tmp/root2
|
// Must use separator to prevent /tmp/root matching /tmp/root2
|
||||||
|
|
@ -167,3 +176,75 @@ func (m *Medium) FileGet(relativePath string) (string, error) {
|
||||||
func (m *Medium) FileSet(relativePath, content string) error {
|
func (m *Medium) FileSet(relativePath, content string) error {
|
||||||
return m.Write(relativePath, content)
|
return m.Write(relativePath, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes a file or empty directory.
|
||||||
|
func (m *Medium) Delete(relativePath string) error {
|
||||||
|
fullPath, err := m.path(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAll removes a file or directory and all its contents recursively.
|
||||||
|
func (m *Medium) DeleteAll(relativePath string) error {
|
||||||
|
fullPath, err := m.path(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename moves a file or directory from oldPath to newPath.
|
||||||
|
func (m *Medium) Rename(oldPath, newPath string) error {
|
||||||
|
fullOldPath, err := m.path(oldPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fullNewPath, err := m.path(newPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(fullOldPath, fullNewPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the directory entries for the given path.
|
||||||
|
func (m *Medium) List(relativePath string) ([]fs.DirEntry, error) {
|
||||||
|
fullPath, err := m.path(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.ReadDir(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file information for the given path.
|
||||||
|
func (m *Medium) Stat(relativePath string) (fs.FileInfo, error) {
|
||||||
|
fullPath, err := m.path(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.Stat(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a path exists (file or directory).
|
||||||
|
func (m *Medium) Exists(relativePath string) bool {
|
||||||
|
fullPath, err := m.path(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = os.Stat(fullPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir checks if a path exists and is a directory.
|
||||||
|
func (m *Medium) IsDir(relativePath string) bool {
|
||||||
|
fullPath, err := m.path(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.IsDir()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,3 +199,198 @@ func TestFileGetFileSet_Good(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "path traversal attempt detected")
|
assert.Contains(t, err.Error(), "path traversal attempt detected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDelete_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_delete_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create and delete a file
|
||||||
|
err = medium.Write("file.txt", "content")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, medium.IsFile("file.txt"))
|
||||||
|
|
||||||
|
err = medium.Delete("file.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, medium.IsFile("file.txt"))
|
||||||
|
|
||||||
|
// Create and delete an empty directory
|
||||||
|
err = medium.EnsureDir("emptydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = medium.Delete("emptydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, medium.IsDir("emptydir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Bad_NotEmpty(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_delete_notempty_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a directory with a file
|
||||||
|
err = medium.Write("mydir/file.txt", "content")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to delete non-empty directory
|
||||||
|
err = medium.Delete("mydir")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_deleteall_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create nested structure
|
||||||
|
err = medium.Write("mydir/file1.txt", "content1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = medium.Write("mydir/subdir/file2.txt", "content2")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Delete all
|
||||||
|
err = medium.DeleteAll("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, medium.Exists("mydir"))
|
||||||
|
assert.False(t, medium.Exists("mydir/file1.txt"))
|
||||||
|
assert.False(t, medium.Exists("mydir/subdir/file2.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_rename_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Rename a file
|
||||||
|
err = medium.Write("old.txt", "content")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = medium.Rename("old.txt", "new.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, medium.IsFile("old.txt"))
|
||||||
|
assert.True(t, medium.IsFile("new.txt"))
|
||||||
|
|
||||||
|
content, err := medium.Read("new.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Bad_Traversal(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_rename_traversal_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = medium.Write("file.txt", "content")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = medium.Rename("file.txt", "../escaped.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "path traversal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_list_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create some files and directories
|
||||||
|
err = medium.Write("file1.txt", "content1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = medium.Write("file2.txt", "content2")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = medium.EnsureDir("subdir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// List root
|
||||||
|
entries, err := medium.List(".")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, entries, 3)
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
names[e.Name()] = true
|
||||||
|
}
|
||||||
|
assert.True(t, names["file1.txt"])
|
||||||
|
assert.True(t, names["file2.txt"])
|
||||||
|
assert.True(t, names["subdir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_stat_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Stat a file
|
||||||
|
err = medium.Write("file.txt", "hello world")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
info, err := medium.Stat("file.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "file.txt", info.Name())
|
||||||
|
assert.Equal(t, int64(11), info.Size())
|
||||||
|
assert.False(t, info.IsDir())
|
||||||
|
|
||||||
|
// Stat a directory
|
||||||
|
err = medium.EnsureDir("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
info, err = medium.Stat("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "mydir", info.Name())
|
||||||
|
assert.True(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_exists_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, medium.Exists("nonexistent"))
|
||||||
|
|
||||||
|
err = medium.Write("file.txt", "content")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, medium.Exists("file.txt"))
|
||||||
|
|
||||||
|
err = medium.EnsureDir("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, medium.Exists("mydir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDir_Good(t *testing.T) {
|
||||||
|
testRoot, err := os.MkdirTemp("", "local_isdir_test")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(testRoot)
|
||||||
|
|
||||||
|
medium, err := New(testRoot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = medium.Write("file.txt", "content")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, medium.IsDir("file.txt"))
|
||||||
|
|
||||||
|
err = medium.EnsureDir("mydir")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, medium.IsDir("mydir"))
|
||||||
|
|
||||||
|
assert.False(t, medium.IsDir("nonexistent"))
|
||||||
|
}
|
||||||
|
|
|
||||||
156
pkg/mcp/mcp.go
156
pkg/mcp/mcp.go
|
|
@ -82,61 +82,61 @@ func New(opts ...Option) (*Service, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.registerTools()
|
s.registerTools(s.server)
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerTools adds file operation tools to the MCP server.
|
// registerTools adds file operation tools to the MCP server.
|
||||||
func (s *Service) registerTools() {
|
func (s *Service) registerTools(server *mcp.Server) {
|
||||||
// File operations
|
// File operations
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "file_read",
|
Name: "file_read",
|
||||||
Description: "Read the contents of a file",
|
Description: "Read the contents of a file",
|
||||||
}, s.readFile)
|
}, s.readFile)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "file_write",
|
Name: "file_write",
|
||||||
Description: "Write content to a file",
|
Description: "Write content to a file",
|
||||||
}, s.writeFile)
|
}, s.writeFile)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "file_delete",
|
Name: "file_delete",
|
||||||
Description: "Delete a file or empty directory",
|
Description: "Delete a file or empty directory",
|
||||||
}, s.deleteFile)
|
}, s.deleteFile)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "file_rename",
|
Name: "file_rename",
|
||||||
Description: "Rename or move a file",
|
Description: "Rename or move a file",
|
||||||
}, s.renameFile)
|
}, s.renameFile)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "file_exists",
|
Name: "file_exists",
|
||||||
Description: "Check if a file or directory exists",
|
Description: "Check if a file or directory exists",
|
||||||
}, s.fileExists)
|
}, s.fileExists)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "file_edit",
|
Name: "file_edit",
|
||||||
Description: "Edit a file by replacing old_string with new_string. Use replace_all=true to replace all occurrences.",
|
Description: "Edit a file by replacing old_string with new_string. Use replace_all=true to replace all occurrences.",
|
||||||
}, s.editDiff)
|
}, s.editDiff)
|
||||||
|
|
||||||
// Directory operations
|
// Directory operations
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "dir_list",
|
Name: "dir_list",
|
||||||
Description: "List contents of a directory",
|
Description: "List contents of a directory",
|
||||||
}, s.listDirectory)
|
}, s.listDirectory)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "dir_create",
|
Name: "dir_create",
|
||||||
Description: "Create a new directory",
|
Description: "Create a new directory",
|
||||||
}, s.createDirectory)
|
}, s.createDirectory)
|
||||||
|
|
||||||
// Language detection
|
// Language detection
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "lang_detect",
|
Name: "lang_detect",
|
||||||
Description: "Detect the programming language of a file",
|
Description: "Detect the programming language of a file",
|
||||||
}, s.detectLanguage)
|
}, s.detectLanguage)
|
||||||
|
|
||||||
mcp.AddTool(s.server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "lang_list",
|
Name: "lang_list",
|
||||||
Description: "Get list of supported programming languages",
|
Description: "Get list of supported programming languages",
|
||||||
}, s.getSupportedLanguages)
|
}, 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) {
|
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
|
entries, err := s.medium.List(input.Path)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err)
|
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()
|
size = info.Size()
|
||||||
}
|
}
|
||||||
result = append(result, DirectoryEntry{
|
result = append(result, DirectoryEntry{
|
||||||
Name: e.Name(),
|
Name: e.Name(),
|
||||||
Path: filepath.Join(input.Path, 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(),
|
IsDir: e.IsDir(),
|
||||||
Size: size,
|
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) {
|
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
|
if err := s.medium.Delete(input.Path); err != nil {
|
||||||
path, err := s.resolvePath(input.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, DeleteFileOutput{}, err
|
|
||||||
}
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
|
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
|
||||||
}
|
}
|
||||||
return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil
|
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) {
|
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
|
if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil {
|
||||||
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 {
|
|
||||||
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
|
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
|
||||||
}
|
}
|
||||||
return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil
|
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 {
|
if exists {
|
||||||
return nil, FileExistsOutput{Exists: true, IsDir: false, Path: input.Path}, nil
|
return nil, FileExistsOutput{Exists: true, IsDir: false, Path: input.Path}, nil
|
||||||
}
|
}
|
||||||
// Check if it's a directory
|
// Check if it's a directory by attempting to list it
|
||||||
path, err := s.resolvePath(input.Path)
|
// List might fail if it's a file too (but we checked IsFile) or if doesn't exist.
|
||||||
if err != nil {
|
_, err := s.medium.List(input.Path)
|
||||||
return nil, FileExistsOutput{}, err
|
isDir := err == nil
|
||||||
}
|
|
||||||
info, err := os.Stat(path)
|
// If List failed, it might mean it doesn't exist OR it's a special file or permissions.
|
||||||
if os.IsNotExist(err) {
|
// Assuming if List works, it's a directory.
|
||||||
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
|
|
||||||
}
|
// Refinement: If it doesn't exist, List returns error.
|
||||||
if err != nil {
|
|
||||||
return nil, FileExistsOutput{}, fmt.Errorf("failed to check file: %w", err)
|
return nil, FileExistsOutput{Exists: isDir, IsDir: isDir, Path: input.Path}, nil
|
||||||
}
|
|
||||||
return nil, FileExistsOutput{Exists: true, IsDir: info.IsDir(), Path: input.Path}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) detectLanguage(ctx context.Context, req *mcp.CallToolRequest, input DetectLanguageInput) (*mcp.CallToolResult, DetectLanguageOutput, error) {
|
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
|
}, 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.
|
// detectLanguageFromPath maps file extensions to language IDs.
|
||||||
func detectLanguageFromPath(path string) string {
|
func detectLanguageFromPath(path string) string {
|
||||||
ext := filepath.Ext(path)
|
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 {
|
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{})
|
return s.server.Run(ctx, &mcp.StdioTransport{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,46 +129,7 @@ func TestMedium_Good_IsFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolvePath_Good(t *testing.T) {
|
func TestSandboxing_Bad_Traversal(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()
|
tmpDir := t.TempDir()
|
||||||
s, err := New(WithWorkspaceRoot(tmpDir))
|
s, err := New(WithWorkspaceRoot(tmpDir))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -176,19 +137,25 @@ func TestResolvePath_Bad_Traversal(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path traversal should fail
|
// Path traversal should fail
|
||||||
_, err = s.resolvePath("../secret.txt")
|
_, err = s.medium.Read("../secret.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected error for path traversal")
|
t.Error("Expected error for path traversal")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Absolute path outside workspace should fail
|
// Absolute path outside workspace should fail
|
||||||
_, err = s.resolvePath("/etc/passwd")
|
// Note: local.Medium rejects all absolute paths if they are not inside root.
|
||||||
|
// But Read takes relative path usually. If absolute, it cleans it.
|
||||||
|
// If we pass "/etc/passwd", local.Medium path clean might reject it or treat it relative?
|
||||||
|
// local.Medium.path() implementation:
|
||||||
|
// if filepath.IsAbs(cleanPath) { return "", errors.New("path traversal attempt detected") }
|
||||||
|
// So yes, it rejects absolute paths passed to Read.
|
||||||
|
_, err = s.medium.Read("/etc/passwd")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected error for absolute path outside workspace")
|
t.Error("Expected error for absolute path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
|
func TestSandboxing_Bad_SymlinkTraversal(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
outsideDir := t.TempDir()
|
outsideDir := t.TempDir()
|
||||||
|
|
||||||
|
|
@ -210,7 +177,7 @@ func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Symlink traversal should be blocked
|
// Symlink traversal should be blocked
|
||||||
_, err = s.resolvePath("evil-link")
|
_, err = s.medium.Read("evil-link")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected error for symlink pointing outside workspace")
|
t.Error("Expected error for symlink pointing outside workspace")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
pkg/mcp/transport_tcp.go
Normal file
131
pkg/mcp/transport_tcp.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue