212 lines
6.0 KiB
Go
212 lines
6.0 KiB
Go
package plugins
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
type clinePendingWrite struct {
|
|
state clineHookFile
|
|
source string
|
|
}
|
|
|
|
type clineStagedFile struct {
|
|
state clineHookFile
|
|
temporaryPath string
|
|
}
|
|
|
|
type clineRenameFunc func(source, destination string) error
|
|
|
|
func readClineHookFile(filePath string) (clineHookFile, error) {
|
|
// #nosec G304 -- filePath is resolved from Cline's native hook directory.
|
|
raw, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return clineHookFile{filePath: filePath}, nil
|
|
}
|
|
return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err)
|
|
}
|
|
source := string(raw)
|
|
metadata, err := parseClineMetadata(filePath, source)
|
|
if err != nil {
|
|
return clineHookFile{}, err
|
|
}
|
|
return clineHookFile{
|
|
exists: true,
|
|
filePath: filePath,
|
|
source: source,
|
|
metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
func requireAvailableClineHook(file clineHookFile) error {
|
|
if file.exists && file.metadata == nil {
|
|
return fmt.Errorf(
|
|
"Cline hook at %s already exists and is owned by another integration",
|
|
file.filePath,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeClineTemporary(path string) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func failClineStage(temporary *os.File, path string, cause error) error {
|
|
closeErr := temporary.Close()
|
|
removeErr := removeClineTemporary(path)
|
|
return errors.Join(cause, closeErr, removeErr)
|
|
}
|
|
|
|
func stageClineHook(write clinePendingWrite) (clineStagedFile, error) {
|
|
directory := filepath.Dir(write.state.filePath)
|
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
|
return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err)
|
|
}
|
|
temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp")
|
|
if err != nil {
|
|
return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err)
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
if err := temporary.Chmod(0700); err != nil {
|
|
return clineStagedFile{}, failClineStage(
|
|
temporary,
|
|
temporaryPath,
|
|
fmt.Errorf("set permissions for %s: %w", temporaryPath, err),
|
|
)
|
|
}
|
|
written, err := temporary.WriteString(write.source)
|
|
if err != nil {
|
|
return clineStagedFile{}, failClineStage(
|
|
temporary,
|
|
temporaryPath,
|
|
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err),
|
|
)
|
|
}
|
|
if written != len(write.source) {
|
|
return clineStagedFile{}, failClineStage(
|
|
temporary,
|
|
temporaryPath,
|
|
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite),
|
|
)
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
return clineStagedFile{}, failClineStage(
|
|
temporary,
|
|
temporaryPath,
|
|
fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err),
|
|
)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return clineStagedFile{}, errors.Join(
|
|
fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err),
|
|
removeClineTemporary(temporaryPath),
|
|
)
|
|
}
|
|
return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil
|
|
}
|
|
|
|
func rollbackClineHook(state clineHookFile) error {
|
|
if state.exists {
|
|
return writeHookFileAtomic(state.filePath, []byte(state.source), 0700)
|
|
}
|
|
return removeHookFile(state.filePath, "Cline hook rollback")
|
|
}
|
|
|
|
func writeClineHookPair(guard, audit clinePendingWrite) error {
|
|
return writeClineHookPairWithRename(guard, audit, os.Rename)
|
|
}
|
|
|
|
func writeClineHookPairWithRename(
|
|
guard, audit clinePendingWrite,
|
|
rename clineRenameFunc,
|
|
) error {
|
|
writes := []clinePendingWrite{guard, audit}
|
|
staged := make([]clineStagedFile, 0, len(writes))
|
|
for _, write := range writes {
|
|
file, err := stageClineHook(write)
|
|
if err != nil {
|
|
failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)}
|
|
for _, item := range staged {
|
|
if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil {
|
|
failures = append(failures, cleanupErr)
|
|
}
|
|
}
|
|
return errors.Join(failures...)
|
|
}
|
|
staged = append(staged, file)
|
|
}
|
|
|
|
committed := 0
|
|
for index, item := range staged {
|
|
if err := rename(item.temporaryPath, item.state.filePath); err != nil {
|
|
failures := []error{
|
|
fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err),
|
|
}
|
|
for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- {
|
|
if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil {
|
|
failures = append(failures, fmt.Errorf(
|
|
"restore Cline hook at %s: %w",
|
|
staged[rollbackIndex].state.filePath,
|
|
rollbackErr,
|
|
))
|
|
}
|
|
}
|
|
for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ {
|
|
if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil {
|
|
failures = append(failures, cleanupErr)
|
|
}
|
|
}
|
|
return errors.Join(failures...)
|
|
}
|
|
committed++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeOwnedClineHooks(files []clineHookFile, agentID string) error {
|
|
for _, file := range files {
|
|
if file.metadata == nil {
|
|
continue
|
|
}
|
|
if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) {
|
|
continue
|
|
}
|
|
if err := removeHookFile(file.filePath, "Cline hook"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
|
configPath := filepath.Join(contract.agentDirectory, "config.json")
|
|
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
|
if err != nil || !exists {
|
|
return false, err
|
|
}
|
|
agentID, ok := config["agent_id"].(string)
|
|
if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) {
|
|
return false, nil
|
|
}
|
|
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return guardExists && auditExists, nil
|
|
}
|