diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go new file mode 100644 index 0000000..59a1b70 --- /dev/null +++ b/internal/adapter/adapter.go @@ -0,0 +1,39 @@ +// Package adapter defines the harness adapter interface and FileSet. +package adapter + +import "github.com/lukaszraczylo/harness-sync/internal/canonical" + +type Kind int + +const ( + RenderedFile Kind = iota + SymlinkFile + SymlinkDir +) + +func (k Kind) String() string { + switch k { + case RenderedFile: + return "rendered" + case SymlinkFile: + return "symlink-file" + case SymlinkDir: + return "symlink-dir" + } + return "unknown" +} + +type Adapter interface { + Name() string + Detect() bool + Render(b *canonical.Bundle) (*FileSet, error) + Import(home string) (*ImportResult, error) +} + +type ImportResult struct { + Instructions string + Skills []canonical.Skill + Agents []canonical.Agent + MCP []canonical.MCPServer + Profiles []canonical.Profile +} diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go new file mode 100644 index 0000000..2f0d96e --- /dev/null +++ b/internal/adapter/adapter_test.go @@ -0,0 +1,25 @@ +package adapter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFileSetAddAndIterate(t *testing.T) { + fs := NewFileSet() + fs.Add(File{Dest: "/tmp/a", Kind: RenderedFile, Content: []byte("a")}) + fs.Add(File{Dest: "/tmp/b", Kind: SymlinkFile, SymlinkTarget: "/canon/b"}) + + seen := map[string]Kind{} + fs.ForEach(func(f File) { + seen[f.Dest] = f.Kind + }) + assert.Equal(t, RenderedFile, seen["/tmp/a"]) + assert.Equal(t, SymlinkFile, seen["/tmp/b"]) +} + +func TestKindString(t *testing.T) { + assert.Equal(t, "rendered", RenderedFile.String()) + assert.Equal(t, "symlink-file", SymlinkFile.String()) +} diff --git a/internal/adapter/fileset.go b/internal/adapter/fileset.go new file mode 100644 index 0000000..8bf0a64 --- /dev/null +++ b/internal/adapter/fileset.go @@ -0,0 +1,32 @@ +package adapter + +import "os" + +type File struct { + Dest string + SymlinkTarget string + Content []byte + Mode os.FileMode + Kind Kind +} + +type FileSet struct { + files []File +} + +func NewFileSet() *FileSet { return &FileSet{} } + +func (fs *FileSet) Add(f File) { + if f.Mode == 0 && f.Kind == RenderedFile { + f.Mode = 0o644 + } + fs.files = append(fs.files, f) +} + +func (fs *FileSet) ForEach(fn func(File)) { + for _, f := range fs.files { + fn(f) + } +} + +func (fs *FileSet) Len() int { return len(fs.files) } diff --git a/internal/secrets/envsub.go b/internal/secrets/envsub.go new file mode 100644 index 0000000..885d680 --- /dev/null +++ b/internal/secrets/envsub.go @@ -0,0 +1,47 @@ +// Package secrets provides ${VAR} substitution for canonical configs. +package secrets + +import ( + "fmt" + "os" + "strings" +) + +// Lookup resolves a placeholder name to a value. Returns false if missing. +type Lookup func(name string) (string, bool) + +// OSEnv resolves via os.LookupEnv. +func OSEnv(name string) (string, bool) { + return os.LookupEnv(name) +} + +// Substitute replaces ${NAME} placeholders in s using lookup. Escape with $$. +// Missing keys produce an error so secrets are never silently empty. +func Substitute(s string, lookup Lookup) (string, error) { + var b strings.Builder + i := 0 + for i < len(s) { + if i+1 < len(s) && s[i] == '$' && s[i+1] == '$' { + b.WriteByte('$') + i += 2 + continue + } + if i+1 < len(s) && s[i] == '$' && s[i+1] == '{' { + end := strings.IndexByte(s[i+2:], '}') + if end == -1 { + return "", fmt.Errorf("unterminated placeholder at offset %d", i) + } + name := s[i+2 : i+2+end] + val, ok := lookup(name) + if !ok { + return "", fmt.Errorf("missing env var %q", name) + } + b.WriteString(val) + i += 2 + end + 1 + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String(), nil +} diff --git a/internal/secrets/envsub_test.go b/internal/secrets/envsub_test.go new file mode 100644 index 0000000..3f7f1ad --- /dev/null +++ b/internal/secrets/envsub_test.go @@ -0,0 +1,32 @@ +package secrets + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubstituteSimple(t *testing.T) { + lookup := func(k string) (string, bool) { + if k == "FOO" { + return "bar", true + } + return "", false + } + out, err := Substitute("hello ${FOO}", lookup) + assert.NoError(t, err) + assert.Equal(t, "hello bar", out) +} + +func TestSubstituteMissingStrict(t *testing.T) { + lookup := func(k string) (string, bool) { return "", false } + _, err := Substitute("${MISSING}", lookup) + assert.Error(t, err) +} + +func TestSubstituteEscaped(t *testing.T) { + lookup := func(k string) (string, bool) { return "should-not-see", true } + out, err := Substitute("$${LITERAL}", lookup) + assert.NoError(t, err) + assert.Equal(t, "${LITERAL}", out) +}