a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
419 lines
13 KiB
Go
419 lines
13 KiB
Go
package agents
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// writer.go centralises every write `gortex init` performs. Going
|
|
// through one helper lets us:
|
|
//
|
|
// 1. Make writes atomic — temp file + rename. Partial failures
|
|
// can't leave a half-written MCP config that breaks the editor.
|
|
// 2. Respect ApplyOpts.DryRun uniformly. No adapter needs its own
|
|
// "would this run?" branch.
|
|
// 3. Report what happened in a structured FileAction so `--json`
|
|
// and the doctor subcommand speak the same vocabulary.
|
|
// 4. Power golden-fixture tests — the test harness points the
|
|
// "root" at a temp dir, runs Apply, and diffs the written tree
|
|
// against testdata/ golden files.
|
|
|
|
// WriteIfNotExists writes content to path when it doesn't exist.
|
|
// Used for static artifacts (slash-command markdown, Kiro steering,
|
|
// KI metadata) where merging isn't meaningful. When the file is
|
|
// already present we emit ActionSkip with Reason="exists" rather
|
|
// than silently overwriting.
|
|
//
|
|
// Under DryRun: no disk write. Returns ActionWouldCreate for a
|
|
// missing file, ActionSkip for an existing one.
|
|
//
|
|
// Directories are created as needed with 0o755.
|
|
func WriteIfNotExists(w io.Writer, path, content string, opts ApplyOpts) (FileAction, error) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
logf(w, "[gortex init] skip %s (already exists)", path)
|
|
return FileAction{Path: path, Action: ActionSkip, Reason: "exists"}, nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return FileAction{Path: path, Action: ActionSkip, Reason: err.Error()}, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
if opts.DryRun {
|
|
return FileAction{Path: path, Action: ActionWouldCreate}, nil
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return FileAction{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
|
|
}
|
|
if err := AtomicWriteFile(path, []byte(content), 0o644); err != nil {
|
|
return FileAction{}, err
|
|
}
|
|
logf(w, "[gortex init] created %s", path)
|
|
return FileAction{Path: path, Action: ActionCreate}, nil
|
|
}
|
|
|
|
// WriteOwnedFile writes content to path unconditionally, overwriting
|
|
// whatever is there. Meant for files Gortex owns end-to-end (e.g.
|
|
// generated community-routing files under .cursor/rules/,
|
|
// .continue/rules/, .clinerules/) so each `gortex init` run
|
|
// regenerates them from the current graph. Returns ActionCreate when
|
|
// the file was absent and ActionMerge when it already existed, so the
|
|
// summary line reads naturally.
|
|
//
|
|
// Under DryRun: no disk write. Returns ActionWould* mirroring the
|
|
// same existed/absent split.
|
|
func WriteOwnedFile(w io.Writer, path, content string, opts ApplyOpts) (FileAction, error) {
|
|
existing, readErr := os.ReadFile(path)
|
|
existed := readErr == nil
|
|
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
|
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
|
|
}
|
|
|
|
// Skip when the target is already byte-identical — keeps
|
|
// AssertIdempotent valid and avoids mtime bumps on no-op
|
|
// re-runs.
|
|
if existed && string(existing) == content {
|
|
return FileAction{Path: path, Action: ActionSkip, Reason: "unchanged"}, nil
|
|
}
|
|
|
|
if opts.DryRun {
|
|
action := ActionWouldCreate
|
|
if existed {
|
|
action = ActionWouldMerge
|
|
}
|
|
return FileAction{Path: path, Action: action}, nil
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return FileAction{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
|
|
}
|
|
if err := AtomicWriteFile(path, []byte(content), 0o644); err != nil {
|
|
return FileAction{}, err
|
|
}
|
|
verb := "wrote"
|
|
if existed {
|
|
verb = "regenerated"
|
|
}
|
|
logf(w, "[gortex init] %s %s", verb, path)
|
|
action := ActionCreate
|
|
if existed {
|
|
action = ActionMerge
|
|
}
|
|
return FileAction{Path: path, Action: action}, nil
|
|
}
|
|
|
|
// AtomicWriteFile writes data to path via a temp file in the same
|
|
// directory followed by a rename. Guarantees that a concurrent reader
|
|
// either sees the old file or the fully-written new file — never a
|
|
// half-written state.
|
|
//
|
|
// The temp file uses a deterministic prefix so a crash leaves
|
|
// "<name>.gortex.tmp-<pid>.<rand>" files that are easy to identify
|
|
// and clean up manually.
|
|
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", dir, err)
|
|
}
|
|
f, err := os.CreateTemp(dir, filepath.Base(path)+".gortex.tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp in %s: %w", dir, err)
|
|
}
|
|
tmpPath := f.Name()
|
|
// Best-effort cleanup on failure. We deliberately ignore the
|
|
// error: if the rename succeeds the file no longer exists, and
|
|
// if something else goes wrong the user can remove the temp by
|
|
// hand.
|
|
cleanup := func() { _ = os.Remove(tmpPath) }
|
|
|
|
if _, err := f.Write(data); err != nil {
|
|
_ = f.Close()
|
|
cleanup()
|
|
return fmt.Errorf("write %s: %w", tmpPath, err)
|
|
}
|
|
if err := f.Chmod(perm); err != nil {
|
|
_ = f.Close()
|
|
cleanup()
|
|
return fmt.Errorf("chmod %s: %w", tmpPath, err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("close %s: %w", tmpPath, err)
|
|
}
|
|
if err := renameWithRetry(tmpPath, path); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("rename %s -> %s: %w", tmpPath, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// renameWithRetry renames oldPath onto newPath, retrying briefly when the
|
|
// failure is a transient, Windows-specific sharing violation (see
|
|
// isRetryableRenameErr). On POSIX the predicate is always false, so this
|
|
// collapses to a single os.Rename with no added latency.
|
|
//
|
|
// os.Rename maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows,
|
|
// which fails with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED when
|
|
// another process still holds the destination open without
|
|
// FILE_SHARE_DELETE — an editor's language server, antivirus, a search
|
|
// indexer, or Gortex's own file watcher re-indexing the file we just
|
|
// wrote. Those holders release the handle within milliseconds, so a
|
|
// short bounded retry turns a spurious "file is being used by another
|
|
// process" error into the atomic replace the caller asked for. Worst
|
|
// case is ~225ms of backoff, imperceptible for an interactive write.
|
|
func renameWithRetry(oldPath, newPath string) error {
|
|
const attempts = 10
|
|
var err error
|
|
for attempt := range attempts {
|
|
if err = os.Rename(oldPath, newPath); err == nil {
|
|
return nil
|
|
}
|
|
if !isRetryableRenameErr(err) {
|
|
return err
|
|
}
|
|
if attempt < attempts-1 {
|
|
time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// MergeJSON reads path (if present), parses it as a JSON object,
|
|
// passes the parsed map to mutate, and writes the result back
|
|
// atomically when mutate reports a change. A nil or malformed file
|
|
// is treated as empty — a backup is written alongside the original
|
|
// before we overwrite garbage, so a user with a broken config doesn't
|
|
// lose their edits.
|
|
//
|
|
// mutate returns:
|
|
// - changed=true if the map was modified and should be written
|
|
// - changed=false if no change is needed (idempotent re-run); we
|
|
// still return a FileAction describing the skip for --json
|
|
//
|
|
// Keys is collected from the top-level map keys after mutation —
|
|
// useful for the --json report but not semantically load-bearing.
|
|
func MergeJSON(w io.Writer, path string, mutate func(root map[string]any, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
|
|
existed := false
|
|
root := make(map[string]any)
|
|
var backupPath string
|
|
|
|
var backupData []byte
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
existed = true
|
|
switch {
|
|
case len(strings.TrimSpace(string(data))) == 0:
|
|
// An empty (or whitespace-only) file is an empty object, not
|
|
// malformed — no backup, nothing to preserve.
|
|
default:
|
|
// `.jsonc` / `.json5` configs (e.g. OpenCode's
|
|
// opencode.jsonc) may carry comments and trailing commas
|
|
// that encoding/json rejects. Sanitize those before the
|
|
// parse so a commented config merges instead of being
|
|
// treated as malformed and clobbered. Comments are not
|
|
// round-tripped through the re-marshal — same policy as
|
|
// MergeTOML.
|
|
parse := data
|
|
if isJSONCPath(path) {
|
|
parse = stripJSONComments(data)
|
|
}
|
|
if err := json.Unmarshal(parse, &root); err != nil {
|
|
// Don't silently overwrite the user's file even if it's
|
|
// malformed — keep a backup for recovery. The backup is
|
|
// written only on the real write path below, so a DryRun
|
|
// never touches disk.
|
|
backupPath, backupData = path+".bak", data
|
|
root = make(map[string]any)
|
|
}
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
changed, err := mutate(root, existed)
|
|
if err != nil {
|
|
return FileAction{}, err
|
|
}
|
|
if !changed {
|
|
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
|
|
}
|
|
|
|
keys := sortedMapKeys(root)
|
|
|
|
if opts.DryRun {
|
|
action := ActionWouldCreate
|
|
if existed {
|
|
action = ActionWouldMerge
|
|
}
|
|
return FileAction{Path: path, Action: action, Keys: keys}, nil
|
|
}
|
|
|
|
out, err := json.MarshalIndent(root, "", " ")
|
|
if err != nil {
|
|
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
|
|
}
|
|
if backupPath != "" {
|
|
// Best-effort backup of the malformed original, just before we
|
|
// overwrite it (never under DryRun — that returned above).
|
|
_ = os.WriteFile(backupPath, backupData, 0o644)
|
|
}
|
|
if err := AtomicWriteFile(path, out, 0o644); err != nil {
|
|
return FileAction{}, err
|
|
}
|
|
|
|
action := ActionCreate
|
|
if existed {
|
|
action = ActionMerge
|
|
}
|
|
if backupPath != "" {
|
|
logf(w, "[gortex init] %s was malformed; backup saved to %s", path, backupPath)
|
|
}
|
|
logf(w, "[gortex init] %s %s", actionVerb(action), path)
|
|
return FileAction{Path: path, Action: action, Keys: keys}, nil
|
|
}
|
|
|
|
// isJSONCPath reports whether path uses a JSON-with-comments extension
|
|
// (`.jsonc` / `.json5`) whose contents may need sanitising before they
|
|
// can be handed to encoding/json.
|
|
func isJSONCPath(path string) bool {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".jsonc", ".json5":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// stripJSONComments rewrites JSONC / JSON5-style input into strict JSON
|
|
// that encoding/json can parse: it drops `//` line comments, `/* */`
|
|
// block comments, and trailing commas before `}` / `]`. String literals
|
|
// (and their escape sequences) are copied through untouched, so a `//`
|
|
// or comma inside a quoted value is preserved. The result is only used
|
|
// for parsing — the merged map is re-marshalled fresh, so the original
|
|
// comments and formatting are not carried over (matching MergeTOML).
|
|
func stripJSONComments(b []byte) []byte {
|
|
out := make([]byte, 0, len(b))
|
|
inString, escaped := false, false
|
|
for i := 0; i < len(b); i++ {
|
|
c := b[i]
|
|
if inString {
|
|
out = append(out, c)
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case c == '"':
|
|
inString = true
|
|
out = append(out, c)
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '/':
|
|
// Line comment: skip to (but keep) the newline.
|
|
for i+1 < len(b) && b[i+1] != '\n' {
|
|
i++
|
|
}
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
|
// Block comment: skip through the closing */.
|
|
i += 2
|
|
for i+1 < len(b) && (b[i] != '*' || b[i+1] != '/') {
|
|
i++
|
|
}
|
|
i++ // step onto '/', loop's i++ moves past it
|
|
default:
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return stripTrailingCommas(out)
|
|
}
|
|
|
|
// stripTrailingCommas removes a comma that is followed (ignoring
|
|
// whitespace) by a `}` or `]` — JSONC / JSON5 permit it, strict JSON
|
|
// does not. Commas inside string literals are left alone.
|
|
func stripTrailingCommas(b []byte) []byte {
|
|
out := make([]byte, 0, len(b))
|
|
inString, escaped := false, false
|
|
for i := range len(b) {
|
|
c := b[i]
|
|
if inString {
|
|
out = append(out, c)
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inString = true
|
|
out = append(out, c)
|
|
continue
|
|
}
|
|
if c == ',' {
|
|
j := i + 1
|
|
for j < len(b) {
|
|
switch b[j] {
|
|
case ' ', '\t', '\n', '\r':
|
|
j++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if j < len(b) && (b[j] == '}' || b[j] == ']') {
|
|
continue // drop the trailing comma
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// actionVerb renders an ActionKind for human-readable log lines.
|
|
// Kept distinct from the on-the-wire string so we can tweak messaging
|
|
// without breaking JSON consumers.
|
|
func actionVerb(a ActionKind) string {
|
|
switch a {
|
|
case ActionCreate:
|
|
return "created"
|
|
case ActionMerge:
|
|
return "merged into"
|
|
case ActionSkip:
|
|
return "skipped"
|
|
case ActionWouldCreate:
|
|
return "would create"
|
|
case ActionWouldMerge:
|
|
return "would merge into"
|
|
}
|
|
return string(a)
|
|
}
|
|
|
|
func sortedMapKeys(m map[string]any) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// logf writes a newline-terminated message when w is non-nil. Shared
|
|
// helper so adapters don't each need to guard for a nil stderr in
|
|
// tests.
|
|
func logf(w io.Writer, format string, args ...any) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, format+"\n", args...)
|
|
}
|