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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-02 05:11:15 +00:00
parent e81804640a
commit d49683fd01
7 changed files with 102 additions and 64 deletions

View file

@ -1,12 +1,12 @@
package agentic package agentic
import ( import (
"bufio"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -95,15 +95,19 @@ func LoadConfig(dir string) (*Config, error) {
// loadEnvFile reads a .env file and extracts agentic configuration. // loadEnvFile reads a .env file and extracts agentic configuration.
func loadEnvFile(path string, cfg *Config) error { func loadEnvFile(path string, cfg *Config) error {
file, err := os.Open(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return err return err
} }
defer file.Close()
scanner := bufio.NewScanner(file) content, err := io.Local.Read(absPath)
for scanner.Scan() { if err != nil {
line := strings.TrimSpace(scanner.Text()) return err
}
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and comments // Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") { 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. // loadYAMLConfig reads configuration from a YAML file.
func loadYAMLConfig(path string, cfg *Config) error { func loadYAMLConfig(path string, cfg *Config) error {
data, err := os.ReadFile(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return err 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. // 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) return errors.E("agentic.SaveConfig", "failed to get home directory", err)
} }
configDir := filepath.Join(homeDir, ".core") configPath := filepath.Join(homeDir, ".core", configFileName)
if err := os.MkdirAll(configDir, 0755); err != nil {
return errors.E("agentic.SaveConfig", "failed to create config directory", err)
}
configPath := filepath.Join(configDir, configFileName)
data, err := yaml.Marshal(cfg) data, err := yaml.Marshal(cfg)
if err != nil { if err != nil {
return errors.E("agentic.SaveConfig", "failed to marshal config", err) 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) return errors.E("agentic.SaveConfig", "failed to write config file", err)
} }

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/io"
) )
// FileContent represents the content of a file for AI context. // 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 // Read files explicitly mentioned in the task
for _, relPath := range task.Files { for _, relPath := range task.Files {
fullPath := filepath.Join(dir, relPath) 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 { if err != nil {
// Skip files that don't exist // Skip files that don't exist
continue continue
@ -104,7 +109,7 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) {
files = append(files, FileContent{ files = append(files, FileContent{
Path: relPath, Path: relPath,
Content: string(content), Content: content,
Language: detectLanguage(relPath), Language: detectLanguage(relPath),
}) })
} }
@ -154,13 +159,16 @@ func findRelatedCode(task *Task, dir string) ([]FileContent, error) {
} }
fullPath := filepath.Join(dir, line) 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 { if err != nil {
continue continue
} }
// Truncate large files // Truncate large files
contentStr := string(content)
if len(contentStr) > 5000 { if len(contentStr) > 5000 {
contentStr = contentStr[:5000] + "\n... (truncated)" contentStr = contentStr[:5000] + "\n... (truncated)"
} }

32
pkg/cache/cache.go vendored
View file

@ -6,6 +6,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/host-uk/core/pkg/io"
) )
// DefaultTTL is the default cache expiry time. // DefaultTTL is the default cache expiry time.
@ -40,11 +42,19 @@ func New(baseDir string, ttl time.Duration) (*Cache, error) {
ttl = DefaultTTL ttl = DefaultTTL
} }
// Ensure cache directory exists // Convert to absolute path for io.Local
if err := os.MkdirAll(baseDir, 0755); err != nil { absBaseDir, err := filepath.Abs(baseDir)
if err != nil {
return nil, err return nil, err
} }
// Ensure cache directory exists
if err := io.Local.EnsureDir(absBaseDir); err != nil {
return nil, err
}
baseDir = absBaseDir
return &Cache{ return &Cache{
baseDir: baseDir, baseDir: baseDir,
ttl: ttl, ttl: ttl,
@ -60,13 +70,14 @@ func (c *Cache) Path(key string) string {
func (c *Cache) Get(key string, dest interface{}) (bool, error) { func (c *Cache) Get(key string, dest interface{}) (bool, error) {
path := c.Path(key) path := c.Path(key)
data, err := os.ReadFile(path) content, err := io.Local.Read(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return false, nil return false, nil
} }
return false, err return false, err
} }
data := []byte(content)
var entry Entry var entry Entry
if err := json.Unmarshal(data, &entry); err != nil { 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 { func (c *Cache) Set(key string, data interface{}) error {
path := c.Path(key) path := c.Path(key)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
// Marshal the data // Marshal the data
dataBytes, err := json.Marshal(data) dataBytes, err := json.Marshal(data)
if err != nil { if err != nil {
@ -113,13 +119,14 @@ func (c *Cache) Set(key string, data interface{}) error {
return err 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. // Delete removes an item from the cache.
func (c *Cache) Delete(key string) error { func (c *Cache) Delete(key string) error {
path := c.Path(key) path := c.Path(key)
err := os.Remove(path) err := io.Local.Delete(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil return nil
} }
@ -128,17 +135,18 @@ func (c *Cache) Delete(key string) error {
// Clear removes all cached items. // Clear removes all cached items.
func (c *Cache) Clear() error { 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. // Age returns how old a cached item is, or -1 if not cached.
func (c *Cache) Age(key string) time.Duration { func (c *Cache) Age(key string) time.Duration {
path := c.Path(key) path := c.Path(key)
data, err := os.ReadFile(path) content, err := io.Local.Read(path)
if err != nil { if err != nil {
return -1 return -1
} }
data := []byte(content)
var entry Entry var entry Entry
if err := json.Unmarshal(data, &entry); err != nil { if err := json.Unmarshal(data, &entry); err != nil {

View file

@ -3,10 +3,12 @@ package sources
import ( import (
"context" "context"
"fmt" "fmt"
"io" goio "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/host-uk/core/pkg/io"
) )
// CDNSource downloads images from a CDN or S3 bucket. // 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 // 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) 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) progress(downloaded, total)
} }
} }
if err == io.EOF { if err == goio.EOF {
break break
} }
if err != nil { if err != nil {

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -170,8 +171,12 @@ type ChangelogConfig struct {
// Returns an error if the file exists but cannot be parsed. // Returns an error if the file exists but cannot be parsed.
func LoadConfig(dir string) (*Config, error) { func LoadConfig(dir string) (*Config, error) {
configPath := filepath.Join(dir, ConfigDir, ConfigFileName) 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 err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -182,7 +187,7 @@ func LoadConfig(dir string) (*Config, error) {
} }
var cfg Config 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) 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. // ConfigExists checks if a release config file exists in the given directory.
func ConfigExists(dir string) bool { func ConfigExists(dir string) bool {
_, err := os.Stat(ConfigPath(dir)) configPath := ConfigPath(dir)
return err == nil absPath, err := filepath.Abs(configPath)
if err != nil {
return false
}
return io.Local.IsFile(absPath)
} }
// GetRepository returns the repository from the config. // 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. // WriteConfig writes the config to the .core/release.yaml file.
func WriteConfig(cfg *Config, dir string) error { func WriteConfig(cfg *Config, dir string) error {
configPath := ConfigPath(dir) configPath := ConfigPath(dir)
absPath, err := filepath.Abs(configPath)
// Ensure directory exists if err != nil {
configDir := filepath.Dir(configPath) return fmt.Errorf("release.WriteConfig: failed to resolve path: %w", err)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("release.WriteConfig: failed to create directory: %w", err)
} }
data, err := yaml.Marshal(cfg) 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) 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) return fmt.Errorf("release.WriteConfig: failed to write config file: %w", err)
} }

View file

@ -6,12 +6,12 @@ package release
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/host-uk/core/pkg/build" "github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/build/builders" "github.com/host-uk/core/pkg/build/builders"
"github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/release/publishers" "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. // findArtifacts discovers pre-built artifacts in the dist directory.
func findArtifacts(distDir string) ([]build.Artifact, error) { 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") return nil, fmt.Errorf("dist/ directory not found")
} }
var artifacts []build.Artifact var artifacts []build.Artifact
entries, err := os.ReadDir(distDir) entries, err := io.Local.List(distDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read dist/: %w", err) return nil, fmt.Errorf("failed to read dist/: %w", err)
} }

View file

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -60,10 +61,16 @@ type Repo struct {
// LoadRegistry reads and parses a repos.yaml file. // LoadRegistry reads and parses a repos.yaml file.
func LoadRegistry(path string) (*Registry, error) { 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 { if err != nil {
return nil, fmt.Errorf("failed to read registry file: %w", err) return nil, fmt.Errorf("failed to read registry file: %w", err)
} }
data := []byte(content)
var reg Registry var reg Registry
if err := yaml.Unmarshal(data, &reg); err != nil { if err := yaml.Unmarshal(data, &reg); err != nil {
@ -98,7 +105,7 @@ func FindRegistry() (string, error) {
for { for {
candidate := filepath.Join(dir, "repos.yaml") candidate := filepath.Join(dir, "repos.yaml")
if _, err := os.Stat(candidate); err == nil { if io.Local.Exists(candidate) {
return candidate, nil return candidate, nil
} }
@ -121,7 +128,7 @@ func FindRegistry() (string, error) {
} }
for _, p := range commonPaths { for _, p := range commonPaths {
if _, err := os.Stat(p); err == nil { if io.Local.Exists(p) {
return p, nil return p, nil
} }
} }
@ -132,14 +139,19 @@ func FindRegistry() (string, error) {
// ScanDirectory creates a Registry by scanning a directory for git repos. // ScanDirectory creates a Registry by scanning a directory for git repos.
// This is used as a fallback when no repos.yaml is found. // This is used as a fallback when no repos.yaml is found.
func ScanDirectory(dir string) (*Registry, error) { 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 { if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err) return nil, fmt.Errorf("failed to read directory: %w", err)
} }
reg := &Registry{ reg := &Registry{
Version: 1, Version: 1,
BasePath: dir, BasePath: absDir,
Repos: make(map[string]*Repo), Repos: make(map[string]*Repo),
} }
@ -149,10 +161,10 @@ func ScanDirectory(dir string) (*Registry, error) {
continue continue
} }
repoPath := filepath.Join(dir, entry.Name()) repoPath := filepath.Join(absDir, entry.Name())
gitPath := filepath.Join(repoPath, ".git") gitPath := filepath.Join(repoPath, ".git")
if _, err := os.Stat(gitPath); err != nil { if !io.Local.IsDir(gitPath) {
continue // Not a git repo 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. // detectOrg tries to extract the GitHub org from a repo's origin remote.
func detectOrg(repoPath string) string { func detectOrg(repoPath string) string {
// Try to read git remote // Try to read git remote
cmd := filepath.Join(repoPath, ".git", "config") configPath := filepath.Join(repoPath, ".git", "config")
data, err := os.ReadFile(cmd) content, err := io.Local.Read(configPath)
if err != nil { if err != nil {
return "" return ""
} }
// Simple parse for github.com URLs
content := string(data)
// Look for patterns like github.com:org/repo or github.com/org/repo // Look for patterns like github.com:org/repo or github.com/org/repo
for _, line := range strings.Split(content, "\n") { for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@ -292,15 +301,13 @@ func (r *Registry) TopologicalOrder() ([]*Repo, error) {
// Exists checks if the repo directory exists on disk. // Exists checks if the repo directory exists on disk.
func (repo *Repo) Exists() bool { func (repo *Repo) Exists() bool {
info, err := os.Stat(repo.Path) return io.Local.IsDir(repo.Path)
return err == nil && info.IsDir()
} }
// IsGitRepo checks if the repo directory contains a .git folder. // IsGitRepo checks if the repo directory contains a .git folder.
func (repo *Repo) IsGitRepo() bool { func (repo *Repo) IsGitRepo() bool {
gitPath := filepath.Join(repo.Path, ".git") gitPath := filepath.Join(repo.Path, ".git")
info, err := os.Stat(gitPath) return io.Local.IsDir(gitPath)
return err == nil && info.IsDir()
} }
// expandPath expands ~ to home directory. // expandPath expands ~ to home directory.