From d49683fd01de963036330c9524442caa8ea7420f Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:11:15 +0000 Subject: [PATCH] chore(io): migrate remaining packages to io.Local abstraction Migrate filesystem operations to use the io.Local abstraction for improved security, testability, and consistency: - pkg/cache: Replace os.ReadFile, WriteFile, Remove, RemoveAll with io.Local equivalents. io.Local.Write creates parent dirs automatically. - pkg/agentic: Migrate config.go and context.go to use io.Local for reading config files and gathering file context. - pkg/repos: Use io.Local.Read, Exists, IsDir, List for registry operations and git repo detection. - pkg/release: Use io.Local for config loading, existence checks, and artifact discovery. - pkg/devops/sources: Use io.Local.EnsureDir for CDN download. All paths are converted to absolute using filepath.Abs() before calling io.Local methods to handle relative paths correctly. Closes #104, closes #106, closes #108, closes #111 Co-Authored-By: Claude Opus 4.5 --- pkg/agentic/config.go | 37 +++++++++++++++++++++---------------- pkg/agentic/context.go | 16 ++++++++++++---- pkg/cache/cache.go | 32 ++++++++++++++++++++------------ pkg/devops/sources/cdn.go | 8 +++++--- pkg/release/config.go | 28 ++++++++++++++++++---------- pkg/release/release.go | 6 +++--- pkg/repos/registry.go | 39 +++++++++++++++++++++++---------------- 7 files changed, 102 insertions(+), 64 deletions(-) diff --git a/pkg/agentic/config.go b/pkg/agentic/config.go index 3ad088a..8620e1e 100644 --- a/pkg/agentic/config.go +++ b/pkg/agentic/config.go @@ -1,12 +1,12 @@ package agentic import ( - "bufio" "os" "path/filepath" "strings" "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -95,15 +95,19 @@ func LoadConfig(dir string) (*Config, error) { // loadEnvFile reads a .env file and extracts agentic configuration. func loadEnvFile(path string, cfg *Config) error { - file, err := os.Open(path) + absPath, err := filepath.Abs(path) if err != nil { return err } - defer file.Close() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + content, err := io.Local.Read(absPath) + if err != nil { + return err + } + + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { @@ -134,17 +138,22 @@ func loadEnvFile(path string, cfg *Config) error { } } - return scanner.Err() + return nil } // loadYAMLConfig reads configuration from a YAML file. func loadYAMLConfig(path string, cfg *Config) error { - data, err := os.ReadFile(path) + absPath, err := filepath.Abs(path) if err != nil { return err } - return yaml.Unmarshal(data, cfg) + content, err := io.Local.Read(absPath) + if err != nil { + return err + } + + return yaml.Unmarshal([]byte(content), cfg) } // applyEnvOverrides applies environment variable overrides to the config. @@ -170,19 +179,15 @@ func SaveConfig(cfg *Config) error { return errors.E("agentic.SaveConfig", "failed to get home directory", err) } - configDir := filepath.Join(homeDir, ".core") - if err := os.MkdirAll(configDir, 0755); err != nil { - return errors.E("agentic.SaveConfig", "failed to create config directory", err) - } - - configPath := filepath.Join(configDir, configFileName) + configPath := filepath.Join(homeDir, ".core", configFileName) data, err := yaml.Marshal(cfg) if err != nil { return errors.E("agentic.SaveConfig", "failed to marshal config", err) } - if err := os.WriteFile(configPath, data, 0600); err != nil { + // io.Local.Write creates parent directories automatically + if err := io.Local.Write(configPath, string(data)); err != nil { return errors.E("agentic.SaveConfig", "failed to write config file", err) } diff --git a/pkg/agentic/context.go b/pkg/agentic/context.go index a31ba63..153bee1 100644 --- a/pkg/agentic/context.go +++ b/pkg/agentic/context.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/io" ) // FileContent represents the content of a file for AI context. @@ -95,8 +96,12 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { // Read files explicitly mentioned in the task for _, relPath := range task.Files { fullPath := filepath.Join(dir, relPath) + absPath, err := filepath.Abs(fullPath) + if err != nil { + continue + } - content, err := os.ReadFile(fullPath) + content, err := io.Local.Read(absPath) if err != nil { // Skip files that don't exist continue @@ -104,7 +109,7 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { files = append(files, FileContent{ Path: relPath, - Content: string(content), + Content: content, Language: detectLanguage(relPath), }) } @@ -154,13 +159,16 @@ func findRelatedCode(task *Task, dir string) ([]FileContent, error) { } fullPath := filepath.Join(dir, line) - content, err := os.ReadFile(fullPath) + absPath, err := filepath.Abs(fullPath) + if err != nil { + continue + } + contentStr, err := io.Local.Read(absPath) if err != nil { continue } // Truncate large files - contentStr := string(content) if len(contentStr) > 5000 { contentStr = contentStr[:5000] + "\n... (truncated)" } diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index 6081fc3..f660e42 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "time" + + "github.com/host-uk/core/pkg/io" ) // DefaultTTL is the default cache expiry time. @@ -40,11 +42,19 @@ func New(baseDir string, ttl time.Duration) (*Cache, error) { ttl = DefaultTTL } - // Ensure cache directory exists - if err := os.MkdirAll(baseDir, 0755); err != nil { + // Convert to absolute path for io.Local + absBaseDir, err := filepath.Abs(baseDir) + if err != nil { return nil, err } + // Ensure cache directory exists + if err := io.Local.EnsureDir(absBaseDir); err != nil { + return nil, err + } + + baseDir = absBaseDir + return &Cache{ baseDir: baseDir, ttl: ttl, @@ -60,13 +70,14 @@ func (c *Cache) Path(key string) string { func (c *Cache) Get(key string, dest interface{}) (bool, error) { path := c.Path(key) - data, err := os.ReadFile(path) + content, err := io.Local.Read(path) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } + data := []byte(content) var entry Entry if err := json.Unmarshal(data, &entry); err != nil { @@ -91,11 +102,6 @@ func (c *Cache) Get(key string, dest interface{}) (bool, error) { func (c *Cache) Set(key string, data interface{}) error { path := c.Path(key) - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - // Marshal the data dataBytes, err := json.Marshal(data) if err != nil { @@ -113,13 +119,14 @@ func (c *Cache) Set(key string, data interface{}) error { return err } - return os.WriteFile(path, entryBytes, 0644) + // io.Local.Write creates parent directories automatically + return io.Local.Write(path, string(entryBytes)) } // Delete removes an item from the cache. func (c *Cache) Delete(key string) error { path := c.Path(key) - err := os.Remove(path) + err := io.Local.Delete(path) if os.IsNotExist(err) { return nil } @@ -128,17 +135,18 @@ func (c *Cache) Delete(key string) error { // Clear removes all cached items. func (c *Cache) Clear() error { - return os.RemoveAll(c.baseDir) + return io.Local.DeleteAll(c.baseDir) } // Age returns how old a cached item is, or -1 if not cached. func (c *Cache) Age(key string) time.Duration { path := c.Path(key) - data, err := os.ReadFile(path) + content, err := io.Local.Read(path) if err != nil { return -1 } + data := []byte(content) var entry Entry if err := json.Unmarshal(data, &entry); err != nil { diff --git a/pkg/devops/sources/cdn.go b/pkg/devops/sources/cdn.go index 4af8659..4126962 100644 --- a/pkg/devops/sources/cdn.go +++ b/pkg/devops/sources/cdn.go @@ -3,10 +3,12 @@ package sources import ( "context" "fmt" - "io" + goio "io" "net/http" "os" "path/filepath" + + "github.com/host-uk/core/pkg/io" ) // CDNSource downloads images from a CDN or S3 bucket. @@ -71,7 +73,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow } // Ensure dest directory exists - if err := os.MkdirAll(dest, 0755); err != nil { + if err := io.Local.EnsureDir(dest); err != nil { return fmt.Errorf("cdn.Download: %w", err) } @@ -99,7 +101,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow progress(downloaded, total) } } - if err == io.EOF { + if err == goio.EOF { break } if err != nil { diff --git a/pkg/release/config.go b/pkg/release/config.go index ae3d15b..24b035c 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -170,8 +171,12 @@ type ChangelogConfig struct { // Returns an error if the file exists but cannot be parsed. func LoadConfig(dir string) (*Config, error) { configPath := filepath.Join(dir, ConfigDir, ConfigFileName) + absPath, err := filepath.Abs(configPath) + if err != nil { + return nil, fmt.Errorf("release.LoadConfig: failed to resolve path: %w", err) + } - data, err := os.ReadFile(configPath) + content, err := io.Local.Read(absPath) if err != nil { if os.IsNotExist(err) { cfg := DefaultConfig() @@ -182,7 +187,7 @@ func LoadConfig(dir string) (*Config, error) { } var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { return nil, fmt.Errorf("release.LoadConfig: failed to parse config file: %w", err) } @@ -263,8 +268,12 @@ func ConfigPath(dir string) string { // ConfigExists checks if a release config file exists in the given directory. func ConfigExists(dir string) bool { - _, err := os.Stat(ConfigPath(dir)) - return err == nil + configPath := ConfigPath(dir) + absPath, err := filepath.Abs(configPath) + if err != nil { + return false + } + return io.Local.IsFile(absPath) } // GetRepository returns the repository from the config. @@ -280,11 +289,9 @@ func (c *Config) GetProjectName() string { // WriteConfig writes the config to the .core/release.yaml file. func WriteConfig(cfg *Config, dir string) error { configPath := ConfigPath(dir) - - // Ensure directory exists - configDir := filepath.Dir(configPath) - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("release.WriteConfig: failed to create directory: %w", err) + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("release.WriteConfig: failed to resolve path: %w", err) } data, err := yaml.Marshal(cfg) @@ -292,7 +299,8 @@ func WriteConfig(cfg *Config, dir string) error { return fmt.Errorf("release.WriteConfig: failed to marshal config: %w", err) } - if err := os.WriteFile(configPath, data, 0644); err != nil { + // io.Local.Write creates parent directories automatically + if err := io.Local.Write(absPath, string(data)); err != nil { return fmt.Errorf("release.WriteConfig: failed to write config file: %w", err) } diff --git a/pkg/release/release.go b/pkg/release/release.go index 699e354..f5dd53b 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -6,12 +6,12 @@ package release import ( "context" "fmt" - "os" "path/filepath" "strings" "github.com/host-uk/core/pkg/build" "github.com/host-uk/core/pkg/build/builders" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/release/publishers" ) @@ -103,13 +103,13 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { // findArtifacts discovers pre-built artifacts in the dist directory. func findArtifacts(distDir string) ([]build.Artifact, error) { - if _, err := os.Stat(distDir); os.IsNotExist(err) { + if !io.Local.IsDir(distDir) { return nil, fmt.Errorf("dist/ directory not found") } var artifacts []build.Artifact - entries, err := os.ReadDir(distDir) + entries, err := io.Local.List(distDir) if err != nil { return nil, fmt.Errorf("failed to read dist/: %w", err) } diff --git a/pkg/repos/registry.go b/pkg/repos/registry.go index a13abdb..6122fd4 100644 --- a/pkg/repos/registry.go +++ b/pkg/repos/registry.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -60,10 +61,16 @@ type Repo struct { // LoadRegistry reads and parses a repos.yaml file. func LoadRegistry(path string) (*Registry, error) { - data, err := os.ReadFile(path) + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to resolve path: %w", err) + } + + content, err := io.Local.Read(absPath) if err != nil { return nil, fmt.Errorf("failed to read registry file: %w", err) } + data := []byte(content) var reg Registry if err := yaml.Unmarshal(data, ®); err != nil { @@ -98,7 +105,7 @@ func FindRegistry() (string, error) { for { candidate := filepath.Join(dir, "repos.yaml") - if _, err := os.Stat(candidate); err == nil { + if io.Local.Exists(candidate) { return candidate, nil } @@ -121,7 +128,7 @@ func FindRegistry() (string, error) { } for _, p := range commonPaths { - if _, err := os.Stat(p); err == nil { + if io.Local.Exists(p) { return p, nil } } @@ -132,14 +139,19 @@ func FindRegistry() (string, error) { // ScanDirectory creates a Registry by scanning a directory for git repos. // This is used as a fallback when no repos.yaml is found. func ScanDirectory(dir string) (*Registry, error) { - entries, err := os.ReadDir(dir) + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve directory path: %w", err) + } + + entries, err := io.Local.List(absDir) if err != nil { return nil, fmt.Errorf("failed to read directory: %w", err) } reg := &Registry{ Version: 1, - BasePath: dir, + BasePath: absDir, Repos: make(map[string]*Repo), } @@ -149,10 +161,10 @@ func ScanDirectory(dir string) (*Registry, error) { continue } - repoPath := filepath.Join(dir, entry.Name()) + repoPath := filepath.Join(absDir, entry.Name()) gitPath := filepath.Join(repoPath, ".git") - if _, err := os.Stat(gitPath); err != nil { + if !io.Local.IsDir(gitPath) { continue // Not a git repo } @@ -176,14 +188,11 @@ func ScanDirectory(dir string) (*Registry, error) { // detectOrg tries to extract the GitHub org from a repo's origin remote. func detectOrg(repoPath string) string { // Try to read git remote - cmd := filepath.Join(repoPath, ".git", "config") - data, err := os.ReadFile(cmd) + configPath := filepath.Join(repoPath, ".git", "config") + content, err := io.Local.Read(configPath) if err != nil { return "" } - - // Simple parse for github.com URLs - content := string(data) // Look for patterns like github.com:org/repo or github.com/org/repo for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) @@ -292,15 +301,13 @@ func (r *Registry) TopologicalOrder() ([]*Repo, error) { // Exists checks if the repo directory exists on disk. func (repo *Repo) Exists() bool { - info, err := os.Stat(repo.Path) - return err == nil && info.IsDir() + return io.Local.IsDir(repo.Path) } // IsGitRepo checks if the repo directory contains a .git folder. func (repo *Repo) IsGitRepo() bool { gitPath := filepath.Join(repo.Path, ".git") - info, err := os.Stat(gitPath) - return err == nil && info.IsDir() + return io.Local.IsDir(gitPath) } // expandPath expands ~ to home directory.