Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/gen/diff.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions cmd/gen/handlers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cmd/gen/registry.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions cmd/gen/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import (

type commandRunner func(*cobra.Command, service.Service, []string) error

// diffExitStatus signals the conventional successful comparison-with-differences
// exit code without printing an error diagnostic.
type diffExitStatus struct{}

func (diffExitStatus) Error() string { return "differences found" }
func (diffExitStatus) ExitCode() int { return 1 }

func mustOperation(methodName string) opsgen.OperationDescriptor {
operation, ok := opsgen.ByMethodName(methodName)
if !ok {
Expand All @@ -36,6 +43,10 @@ func newGeneratedCommand(s service.Service, operation opsgen.OperationDescriptor
return runner(cmd, s, args)
},
}
if operation.MethodName == "Diff" {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
}
for _, input := range operation.Inputs {
if input.CLIKind != "flag" {
continue
Expand Down
202 changes: 201 additions & 1 deletion internal/adapters/localfs/emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ package localfs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/techgodhq/creed/internal/domain"
"github.com/techgodhq/creed/internal/ports"
)

// Compile-time assertion that Emitter implements ports.TargetEmitter.
// Compile-time assertions for Emitter capabilities.
var _ ports.TargetEmitter = (*Emitter)(nil)
var _ ports.OutputInventory = (*Emitter)(nil)

// Emitter writes synced files to a local filesystem directory.
// It implements ports.TargetEmitter.
Expand All @@ -39,10 +43,160 @@ func (e *Emitter) Emit(ctx context.Context, target domain.Target, files []ports.
result := e.emitFile(f)
results = append(results, result)
}
for _, result := range results {
if result.Status == ports.EmitStatusError {
return results, nil
}
}
if err := e.writeOwnedPaths(target.Name, files); err != nil {
return results, fmt.Errorf("record managed output: %w", err)
}

return results, nil
}

// ownershipPath is deliberately kept below .creed/ so stale detection only
// considers files Creed previously emitted, never unrelated user files that
// happen to share a target output directory.
func (e *Emitter) ownershipPath(targetName string) string {
return filepath.Join(e.baseDir, ".creed", ".outputs", targetName+".json")
}

// ownershipDirectory returns the directory used for Creed's private output
// inventory. Existing components must be directories, never symlinks, so
// ownership metadata written during sync cannot escape the project.
func (e *Emitter) ownershipDirectory(create bool) (string, error) {
current := e.baseDir
for _, part := range []string{".creed", ".outputs"} {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
if !create {
return current, nil
}
if err := os.Mkdir(current, 0755); err != nil && !os.IsExist(err) {
return "", err
}
info, err = os.Lstat(current)
}
if err != nil {
return "", err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", fmt.Errorf("ownership directory %q must be a non-symlink directory", current)
}
}
return current, nil
}

func (e *Emitter) safeOwnershipPath(targetName string, create bool) (string, error) {
directory, err := e.ownershipDirectory(create)
if err != nil {
return "", err
}
return filepath.Join(directory, targetName+".json"), nil
}

func (e *Emitter) writeOwnedPaths(targetName string, files []ports.EmittedFile) error {
// Retain paths from prior successful syncs. Sync intentionally does not
// delete removed render outputs, so replacing this list would make a stale
// formerly-generated file invisible to `creed diff` after the next sync.
owned, err := e.ownedPaths(domain.Target{Name: targetName})
if err != nil {
return err
}
paths := make(map[string]struct{}, len(owned)+len(files))
for _, path := range owned {
paths[path] = struct{}{}
}
for _, file := range files {
path, err := cleanOutputPath(file.Path)
if err != nil {
return err
}
paths[path] = struct{}{}
}
ordered := make([]string, 0, len(paths))
for path := range paths {
ordered = append(ordered, path)
}
sort.Strings(ordered)
data, err := json.Marshal(ordered)
if err != nil {
return err
}
path, err := e.safeOwnershipPath(targetName, true)
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

func (e *Emitter) ownedPaths(target domain.Target) ([]string, error) {
ownershipPath, err := e.safeOwnershipPath(target.Name, false)
if err != nil {
return nil, err
}
if info, err := os.Lstat(ownershipPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("ownership manifest must not be a symlink")
} else if err != nil && !os.IsNotExist(err) {
return nil, err
}
data, err := os.ReadFile(ownershipPath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var rawPaths []string
if err := json.Unmarshal(data, &rawPaths); err != nil {
return nil, fmt.Errorf("decode ownership manifest: %w", err)
}
paths := make([]string, 0, len(rawPaths))
for _, path := range rawPaths {
clean, err := cleanOutputPath(path)
if err != nil {
return nil, fmt.Errorf("invalid ownership path %q: %w", path, err)
}
paths = append(paths, clean)
}
return paths, nil
}

func cleanOutputPath(path string) (string, error) {
if path == "" || filepath.IsAbs(path) {
return "", fmt.Errorf("must be a non-empty relative path")
}
clean := filepath.Clean(filepath.FromSlash(path))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("must not escape the project root")
}
return filepath.ToSlash(clean), nil
}

func (e *Emitter) safeOutputPath(relPath string) (string, error) {
clean, err := cleanOutputPath(relPath)
if err != nil {
return "", err
}
current := e.baseDir
for _, part := range strings.Split(filepath.FromSlash(clean), string(filepath.Separator)) {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
return current, nil
}
if err != nil {
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("output path %q traverses a symlink", relPath)
}
}
return current, nil
}

// emitFile writes a single file atomically, returning the result.
func (e *Emitter) emitFile(f ports.EmittedFile) ports.EmitResult {
fullPath := filepath.Join(e.baseDir, f.Path)
Expand Down Expand Up @@ -139,6 +293,52 @@ func (e *Emitter) Preview(_ context.Context, _ domain.Target, files []ports.Emit
return results, nil
}

// ExistingFiles returns current candidate outputs and files recorded by prior
// successful emits. It never recursively adopts arbitrary user files below a
// directory-style output descriptor.
func (e *Emitter) ExistingFiles(ctx context.Context, target domain.Target, candidates []ports.EmittedFile) ([]ports.ExistingFile, error) {
files := []ports.ExistingFile{}
paths := make(map[string]struct{})
for _, relPath := range target.EmitPaths("") {
if !strings.HasSuffix(relPath, "/") {
paths[relPath] = struct{}{}
}
}
for _, candidate := range candidates {
path, err := cleanOutputPath(candidate.Path)
if err != nil {
return nil, err
}
paths[path] = struct{}{}
}
owned, err := e.ownedPaths(target)
if err != nil {
return nil, err
}
for _, path := range owned {
paths[path] = struct{}{}
}
for relPath := range paths {
if err := ctx.Err(); err != nil {
return nil, err
}
fullPath, err := e.safeOutputPath(relPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(fullPath)
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", relPath, err)
}
files = append(files, ports.ExistingFile{Path: filepath.ToSlash(relPath), Content: data})
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return files, nil
}

// Clean removes all files and directories that the target would emit.
// It uses the target's EmitPaths to determine what to remove.
func (e *Emitter) Clean(ctx context.Context, target domain.Target) error {
Expand Down
56 changes: 56 additions & 0 deletions internal/adapters/localfs/emitter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package localfs

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -196,3 +197,58 @@ func TestCleanEmptyDir(t *testing.T) {
t.Fatalf("Clean on empty dir error: %v", err)
}
}

func TestExistingFilesRejectsEscapingOwnershipPath(t *testing.T) {
baseDir := t.TempDir()
emitter := NewEmitter(baseDir)
path := emitter.ownershipPath("claude")
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal([]string{"../../outside"})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
if _, err := emitter.ExistingFiles(context.Background(), testTarget(), nil); err == nil {
t.Fatal("ExistingFiles accepted an escaping ownership path")
}
}

func TestExistingFilesIncludesCandidateWithoutOwnershipManifest(t *testing.T) {
baseDir := t.TempDir()
emitter := NewEmitter(baseDir)
if err := os.MkdirAll(filepath.Join(baseDir, ".claude", "skills"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(baseDir, ".claude", "skills", "review.md"), []byte("# review\n"), 0644); err != nil {
t.Fatal(err)
}
files, err := emitter.ExistingFiles(context.Background(), testTarget(), []ports.EmittedFile{{Path: ".claude/skills/review.md"}})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 || files[0].Path != ".claude/skills/review.md" {
t.Fatalf("candidate inventory = %#v, want matching candidate file", files)
}
}

func TestEmitRejectsSymlinkedOwnershipDirectory(t *testing.T) {
baseDir := t.TempDir()
external := t.TempDir()
if err := os.MkdirAll(filepath.Join(baseDir, ".creed"), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(external, filepath.Join(baseDir, ".creed", ".outputs")); err != nil {
t.Fatal(err)
}
emitter := NewEmitter(baseDir)
if _, err := emitter.Emit(context.Background(), testTarget(), []ports.EmittedFile{{Path: "CLAUDE.md", Content: []byte("# Claude\n")}}); err == nil {
t.Fatal("Emit accepted a symlinked ownership directory")
}
if _, err := os.Stat(filepath.Join(external, "claude.json")); !os.IsNotExist(err) {
t.Fatalf("ownership metadata escaped project: %v", err)
}
}
Loading
Loading