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
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)
}

View file

@ -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)"
}

32
pkg/cache/cache.go vendored
View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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, &reg); 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.