301 lines
8.5 KiB
Go
301 lines
8.5 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
)
|
|
|
|
type droidDocument struct {
|
|
kind string
|
|
filePath string
|
|
exists bool
|
|
raw []byte
|
|
hooks droidHookSettings
|
|
basePath []any
|
|
hasHooksContainer bool
|
|
ownedFile bool
|
|
}
|
|
|
|
type droidSources struct {
|
|
rootPath string
|
|
primary *droidDocument
|
|
settings *droidDocument
|
|
}
|
|
|
|
type droidInstallationTargets struct {
|
|
targets map[string]*droidDocument
|
|
createdRoot *droidDocument
|
|
}
|
|
|
|
type droidRenderedDocument struct {
|
|
document *droidDocument
|
|
changed bool
|
|
next []byte
|
|
remove bool
|
|
}
|
|
|
|
func droidFactoryPaths() (string, string, string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("resolve home directory: %w", err)
|
|
}
|
|
directory := filepath.Join(home, ".factory")
|
|
return filepath.Join(directory, "hooks.json"),
|
|
filepath.Join(directory, "hooks", "hooks.json"),
|
|
filepath.Join(directory, "settings.json"), nil
|
|
}
|
|
|
|
func readDroidSources() (*droidSources, error) {
|
|
rootPath, legacyPath, settingsPath, err := droidFactoryPaths()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rootRaw, rootExists, err := readDroidOptional(rootPath, "Factory Droid hooks")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var primary *droidDocument
|
|
if rootExists {
|
|
primary, err = parseDroidDocument(true, rootPath, "hooks", rootRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
legacyRaw, legacyExists, readErr := readDroidOptional(legacyPath, "Factory Droid legacy hooks")
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if legacyExists {
|
|
primary, err = parseDroidDocument(true, legacyPath, "legacy", legacyRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
settingsRaw, settingsExists, err := readDroidOptional(settingsPath, "Factory Droid settings")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var settings *droidDocument
|
|
if settingsExists {
|
|
settings, err = parseDroidDocument(true, settingsPath, "settings", settingsRaw)
|
|
} else {
|
|
settings, err = parseDroidDocument(false, settingsPath, "settings", []byte("{}\n"))
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &droidSources{rootPath: rootPath, primary: primary, settings: settings}, nil
|
|
}
|
|
|
|
func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidDocument, error) {
|
|
label := droidDocumentLabel(kind, filePath)
|
|
root, err := decodeDroidJSONCObject(raw, label)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
document := &droidDocument{
|
|
kind: kind, filePath: filePath, exists: exists, raw: append([]byte(nil), raw...),
|
|
}
|
|
if kind == "settings" {
|
|
hooksValue, hasHooks := root["hooks"]
|
|
document.basePath = []any{"hooks"}
|
|
document.hasHooksContainer = hasHooks
|
|
if !hasHooks {
|
|
document.hooks = droidHookSettings{}
|
|
return document, nil
|
|
}
|
|
document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
|
|
return document, err
|
|
}
|
|
document.hooks, err = readDroidHookSettings(root, label)
|
|
document.hasHooksContainer = true
|
|
document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker))
|
|
return document, err
|
|
}
|
|
|
|
func droidDocumentLabel(kind, filePath string) string {
|
|
switch kind {
|
|
case "settings":
|
|
return fmt.Sprintf("Factory Droid settings at %s", filePath)
|
|
case "legacy":
|
|
return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath)
|
|
default:
|
|
return fmt.Sprintf("Factory Droid hooks at %s", filePath)
|
|
}
|
|
}
|
|
|
|
func createOwnedDroidDocument(filePath string) (*droidDocument, error) {
|
|
return parseDroidDocument(
|
|
false, filePath, "hooks", []byte(droidOwnedFileMarker+"\n{}\n"),
|
|
)
|
|
}
|
|
|
|
func selectDroidInstallationTargets(sources *droidSources) (*droidInstallationTargets, error) {
|
|
selection := &droidInstallationTargets{targets: make(map[string]*droidDocument, len(droidToolEvents))}
|
|
for _, event := range droidToolEvents {
|
|
switch {
|
|
case sources.primary != nil && hasDroidHookField(sources.primary.hooks, event):
|
|
selection.targets[event] = sources.primary
|
|
case sources.settings.hasHooksContainer && hasDroidHookField(sources.settings.hooks, event):
|
|
selection.targets[event] = sources.settings
|
|
case sources.primary != nil:
|
|
selection.targets[event] = sources.primary
|
|
case sources.settings.hasHooksContainer:
|
|
selection.targets[event] = sources.settings
|
|
default:
|
|
if selection.createdRoot == nil {
|
|
created, err := createOwnedDroidDocument(sources.rootPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
selection.createdRoot = created
|
|
}
|
|
selection.targets[event] = selection.createdRoot
|
|
}
|
|
}
|
|
return selection, nil
|
|
}
|
|
|
|
func hasDroidHookField(settings droidHookSettings, key string) bool {
|
|
_, exists := settings[key]
|
|
return exists
|
|
}
|
|
|
|
func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
|
|
result := make([]*droidDocument, 0, len(documents))
|
|
seen := map[string]bool{}
|
|
for _, document := range documents {
|
|
if document == nil {
|
|
continue
|
|
}
|
|
normalized := filepath.Clean(document.filePath)
|
|
if seen[normalized] {
|
|
continue
|
|
}
|
|
seen[normalized] = true
|
|
result = append(result, document)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func droidAdditionsFor(
|
|
document *droidDocument,
|
|
targets map[string]*droidDocument,
|
|
groups map[string]map[string]any,
|
|
) map[string]map[string]any {
|
|
additions := map[string]map[string]any{}
|
|
for _, event := range droidToolEvents {
|
|
if sameDroidPath(targets[event].filePath, document.filePath) {
|
|
additions[event] = groups[event]
|
|
}
|
|
}
|
|
return additions
|
|
}
|
|
|
|
func renderDroidDocument(
|
|
document *droidDocument,
|
|
agentID, runtimeRoot string,
|
|
additions map[string]map[string]any,
|
|
) (*droidRenderedDocument, error) {
|
|
editor, err := newDroidJSONCEditor(document.raw, droidDocumentLabel(document.kind, document.filePath))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
removals := managedDroidRemovals(document.hooks, agentID, runtimeRoot)
|
|
sort.SliceStable(removals, func(left, right int) bool {
|
|
if removals[left].event == removals[right].event {
|
|
return removals[left].groupIndex > removals[right].groupIndex
|
|
}
|
|
return removals[left].event < removals[right].event
|
|
})
|
|
for _, removal := range removals {
|
|
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
|
|
if removal.removeGroup {
|
|
if err := editor.remove(groupPath); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
|
|
for _, handlerIndex := range removal.handlerIndexes {
|
|
if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
current, err := parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if document.ownedFile {
|
|
for _, event := range droidToolEvents {
|
|
groups, exists := current.hooks[event].([]any)
|
|
if exists && len(groups) == 0 {
|
|
if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, event := range droidToolEvents {
|
|
group, exists := additions[event]
|
|
if !exists {
|
|
continue
|
|
}
|
|
current, err = parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
eventPath := appendDroidPath(document.basePath, event)
|
|
if hasDroidHookField(current.hooks, event) {
|
|
err = editor.appendArray(eventPath, group)
|
|
} else {
|
|
err = editor.addProperty(document.basePath, event, []any{group})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
next := editor.pack()
|
|
nextDocument, err := parseDroidDocument(document.exists, document.filePath, document.kind, next)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remove := len(additions) == 0 && document.ownedFile && document.kind != "settings" && len(nextDocument.hooks) == 0
|
|
return &droidRenderedDocument{
|
|
document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove,
|
|
}, nil
|
|
}
|
|
|
|
func appendDroidPath(base []any, parts ...any) []any {
|
|
path := make([]any, 0, len(base)+len(parts))
|
|
path = append(path, base...)
|
|
return append(path, parts...)
|
|
}
|
|
|
|
func displayDroidConfigPath(sources *droidSources) string {
|
|
if sources.primary != nil {
|
|
return sources.primary.filePath
|
|
}
|
|
if sources.settings.exists && sources.settings.hasHooksContainer {
|
|
return sources.settings.filePath
|
|
}
|
|
return sources.rootPath
|
|
}
|
|
|
|
func readDroidOptional(path, label string) ([]byte, bool, error) {
|
|
raw, err := os.ReadFile(path) // #nosec G304 -- paths are fixed user-level Factory configuration paths.
|
|
if err == nil {
|
|
return raw, true, nil
|
|
}
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err)
|
|
}
|