devrites 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,7 @@ import (
23
23
  "github.com/devrites/devrites/internal/devritespaths"
24
24
  "github.com/devrites/devrites/internal/fsutil"
25
25
  "github.com/devrites/devrites/internal/hostpack"
26
+ "github.com/devrites/devrites/internal/release"
26
27
  "github.com/devrites/devrites/internal/safepath"
27
28
  "github.com/devrites/devrites/internal/version"
28
29
  )
@@ -215,7 +216,7 @@ func usage(mode Mode) string {
215
216
  case ModeUninstall:
216
217
  return "usage: devrites-engine uninstall [--target DIR] [--dry-run] [--force] [--keep-binary]\n"
217
218
  case ModeUpdate:
218
- return "usage: devrites-engine update [--target DIR] [--source-dir DIR] [--payload-dir DIR] [--dry-run] [--force] [--check] [install flags]\n"
219
+ return "usage: devrites-engine update [--target DIR] [--dry-run] [--force] [--check] [install flags] [--source-dir DIR --payload-dir DIR]\n"
219
220
  default:
220
221
  return "usage: devrites-engine install [--target DIR] [--dry-run] [--force] [--no-codex] [--no-agents] [--no-skills] [--no-binary] [--short-aliases=all]\n"
221
222
  }
@@ -925,7 +926,11 @@ func (r *runner) pruneDropped() error {
925
926
  if err := r.recheckPath(merge.TargetRel); err != nil {
926
927
  return err
927
928
  }
928
- if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
929
+ if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
930
+ if err := stripHooksPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))); err != nil {
931
+ return fmt.Errorf("strip hooks from %s: %w", merge.TargetRel, err)
932
+ }
933
+ } else if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
929
934
  _ = r.stripClaudeSettings(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), true)
930
935
  } else {
931
936
  _ = stripMarkerPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), merge.Begin, merge.End)
@@ -1023,6 +1028,12 @@ func (r *runner) uninstall() error {
1023
1028
  if err := r.recheckPath(merge.TargetRel); err != nil {
1024
1029
  return err
1025
1030
  }
1031
+ if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
1032
+ if err := stripHooksPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))); err != nil {
1033
+ return fmt.Errorf("strip hooks from %s: %w", merge.TargetRel, err)
1034
+ }
1035
+ continue
1036
+ }
1026
1037
  if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
1027
1038
  if err := r.stripClaudeSettings(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), true); err != nil {
1028
1039
  return fmt.Errorf("strip DevRites settings from %s: %w", merge.TargetRel, err)
@@ -1092,6 +1103,26 @@ func (r *runner) uninstall() error {
1092
1103
  return nil
1093
1104
  }
1094
1105
 
1106
+ func stripHooksPath(path string) error {
1107
+ current, err := readJSON(path)
1108
+ if err != nil {
1109
+ if errors.Is(err, fs.ErrNotExist) {
1110
+ return nil
1111
+ }
1112
+ return fmt.Errorf("load hooks config: %w", err)
1113
+ }
1114
+ next := stripDevritesHooks(current)
1115
+ if len(next) == 0 {
1116
+ return os.Remove(path)
1117
+ }
1118
+ data, err := json.MarshalIndent(next, "", " ")
1119
+ if err != nil {
1120
+ return fmt.Errorf("encode hooks config: %w", err)
1121
+ }
1122
+ data = append(data, '\n')
1123
+ return fsutil.WriteFileAtomic(path, data, 0o644)
1124
+ }
1125
+
1095
1126
  func (r *runner) stripClaudeSettings(path string, preserveEmpty bool) error {
1096
1127
  current, err := readJSON(path)
1097
1128
  if err != nil {
@@ -1126,7 +1157,10 @@ func runUpdate(opts Options) error {
1126
1157
  installed = "unknown"
1127
1158
  }
1128
1159
  if opts.SourceDir == "" {
1129
- return fmt.Errorf("update requires --source-dir with the locally acquired source")
1160
+ if opts.PayloadDir != "" {
1161
+ return fmt.Errorf("local update with --payload-dir also requires --source-dir")
1162
+ }
1163
+ return runRemoteUpdate(opts, target, installed)
1130
1164
  }
1131
1165
  source, err := filepath.Abs(opts.SourceDir)
1132
1166
  if err != nil {
@@ -1137,10 +1171,12 @@ func runUpdate(opts Options) error {
1137
1171
  return fmt.Errorf("derive update version from local source %s: package.json has no version", source)
1138
1172
  }
1139
1173
  candidate = strings.TrimPrefix(candidate, "v")
1140
- fmt.Fprintln(opts.Stdout, "DevRites update")
1141
- fmt.Fprintf(opts.Stdout, " project: %s\n", target)
1142
- fmt.Fprintf(opts.Stdout, " installed: %s\n", installed)
1143
- fmt.Fprintf(opts.Stdout, " candidate: %s\n", candidate)
1174
+ if os.Getenv("DEVRITES_UPDATE_HANDOFF") != "1" {
1175
+ fmt.Fprintln(opts.Stdout, "DevRites update")
1176
+ fmt.Fprintf(opts.Stdout, " project: %s\n", target)
1177
+ fmt.Fprintf(opts.Stdout, " installed: %s\n", installed)
1178
+ fmt.Fprintf(opts.Stdout, " candidate: %s\n", candidate)
1179
+ }
1144
1180
  if opts.UpdateCheck {
1145
1181
  if installed == candidate || strings.TrimPrefix(installed, "v") == candidate {
1146
1182
  fmt.Fprintln(opts.Stdout, "up to date.")
@@ -1194,6 +1230,89 @@ func runUpdate(opts Options) error {
1194
1230
  return r.install()
1195
1231
  }
1196
1232
 
1233
+ var (
1234
+ resolveLatestRelease = release.Latest
1235
+ acquireRelease = release.Acquire
1236
+ )
1237
+
1238
+ func runRemoteUpdate(opts Options, target, installed string) error {
1239
+ repository := os.Getenv("DEVRITES_REPO")
1240
+ if repository == "" {
1241
+ repository = release.DefaultRepository
1242
+ }
1243
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
1244
+ defer cancel()
1245
+ tag, err := resolveLatestRelease(ctx, repository)
1246
+ if err != nil {
1247
+ return err
1248
+ }
1249
+ latest := strings.TrimPrefix(tag, "v")
1250
+ fmt.Fprintln(opts.Stdout, "DevRites update")
1251
+ fmt.Fprintf(opts.Stdout, " project: %s\n", target)
1252
+ fmt.Fprintf(opts.Stdout, " installed: %s\n", installed)
1253
+ fmt.Fprintf(opts.Stdout, " latest: %s\n", latest)
1254
+ if opts.UpdateCheck {
1255
+ if installed == latest || strings.TrimPrefix(installed, "v") == latest {
1256
+ fmt.Fprintln(opts.Stdout, "up to date.")
1257
+ return nil
1258
+ }
1259
+ return fmt.Errorf("update available: %s -> %s", installed, latest)
1260
+ }
1261
+ if !opts.Force && (installed == latest || strings.TrimPrefix(installed, "v") == latest) {
1262
+ fmt.Fprintln(opts.Stdout, "already up to date (use --force to reinstall).")
1263
+ return nil
1264
+ }
1265
+ candidate, cleanup, err := acquireRelease(ctx, repository, tag)
1266
+ if err != nil {
1267
+ return fmt.Errorf("acquire update %s: %w", tag, err)
1268
+ }
1269
+ defer cleanup()
1270
+ fmt.Fprintf(opts.Stdout, " bundle: %s\n", candidate.BundleURL)
1271
+ if _, err := verifyEngineBinary(candidate.EnginePath, tag, 30*time.Second); err != nil {
1272
+ return fmt.Errorf("verify update engine: %w", err)
1273
+ }
1274
+ args := []string{
1275
+ "update",
1276
+ "--target", target,
1277
+ "--source-dir", candidate.SourceDir,
1278
+ "--payload-dir", candidate.PayloadDir,
1279
+ }
1280
+ if opts.DryRun {
1281
+ args = append(args, "--dry-run")
1282
+ }
1283
+ if opts.Force {
1284
+ args = append(args, "--force")
1285
+ }
1286
+ cmd := exec.CommandContext(ctx, candidate.EnginePath, args...)
1287
+ cmd.Stdout = opts.Stdout
1288
+ cmd.Stderr = opts.Stderr
1289
+ cmd.Env = replaceEnv(os.Environ(), map[string]string{
1290
+ "DEVRITES_ENGINE_CLI": candidate.EnginePath,
1291
+ "DEVRITES_UPDATE_HANDOFF": "1",
1292
+ })
1293
+ if err := cmd.Run(); err != nil {
1294
+ return fmt.Errorf("apply update %s: %w", tag, err)
1295
+ }
1296
+ return nil
1297
+ }
1298
+
1299
+ func replaceEnv(env []string, replacements map[string]string) []string {
1300
+ out := make([]string, 0, len(env)+len(replacements))
1301
+ for _, entry := range env {
1302
+ name, _, found := strings.Cut(entry, "=")
1303
+ if found {
1304
+ if _, replace := replacements[name]; replace {
1305
+ continue
1306
+ }
1307
+ }
1308
+ out = append(out, entry)
1309
+ }
1310
+ for name, value := range replacements {
1311
+ out = append(out, name+"="+value)
1312
+ }
1313
+ return out
1314
+ }
1315
+
1197
1316
  func stripMarkerPath(path, begin, end string) error {
1198
1317
  data, err := os.ReadFile(path)
1199
1318
  if err != nil {
@@ -1310,9 +1429,12 @@ func stripDevritesHooks(config map[string]any) map[string]any {
1310
1429
  }
1311
1430
  next[k] = v
1312
1431
  }
1313
- rawHooks, ok := next["hooks"].(map[string]any)
1432
+ rawHooksValue, exists := next["hooks"]
1433
+ if !exists {
1434
+ return next
1435
+ }
1436
+ rawHooks, ok := rawHooksValue.(map[string]any)
1314
1437
  if !ok {
1315
- delete(next, "hooks")
1316
1438
  return next
1317
1439
  }
1318
1440
  hooks := map[string]any{}
@@ -2,17 +2,20 @@ package install
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "context"
5
6
  "crypto/sha256"
6
7
  "fmt"
7
8
  "os"
8
9
  "os/exec"
9
10
  "path/filepath"
10
11
  "runtime"
12
+ "slices"
11
13
  "strings"
12
14
  "testing"
13
15
  "time"
14
16
 
15
17
  "github.com/devrites/devrites/internal/hostpack"
18
+ "github.com/devrites/devrites/internal/release"
16
19
  "github.com/devrites/devrites/internal/testutil"
17
20
  )
18
21
 
@@ -483,6 +486,36 @@ func buildVersionBinary(t *testing.T, version string) string {
483
486
  return binary
484
487
  }
485
488
 
489
+ func buildUpdateHandoffBinary(t *testing.T, version string) string {
490
+ t.Helper()
491
+ dir := t.TempDir()
492
+ source := filepath.Join(dir, "main.go")
493
+ testutil.WriteFile(t, source, fmt.Sprintf(`package main
494
+ import (
495
+ "fmt"
496
+ "os"
497
+ "strings"
498
+ )
499
+ func main() {
500
+ if len(os.Args) == 2 && os.Args[1] == "version" {
501
+ fmt.Println(%q)
502
+ return
503
+ }
504
+ output := strings.Join(os.Args[1:], "\n") + "\n" +
505
+ "DEVRITES_UPDATE_HANDOFF=" + os.Getenv("DEVRITES_UPDATE_HANDOFF") + "\n" +
506
+ "DEVRITES_ENGINE_CLI=" + os.Getenv("DEVRITES_ENGINE_CLI") + "\n"
507
+ if err := os.WriteFile(os.Getenv("DEVRITES_TEST_UPDATE_HANDOFF"), []byte(output), 0600); err != nil {
508
+ panic(err)
509
+ }
510
+ }
511
+ `, version))
512
+ binary := filepath.Join(dir, engineBinaryName())
513
+ if out, err := exec.Command("go", "build", "-o", binary, source).CombinedOutput(); err != nil {
514
+ t.Fatalf("build update handoff engine: %v\n%s", err, out)
515
+ }
516
+ return binary
517
+ }
518
+
486
519
  func TestBinaryCandidatesSkipsUnsetBinDir(t *testing.T) {
487
520
  t.Setenv("DEVRITES_BIN_DIR", "")
488
521
 
@@ -530,6 +563,7 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
530
563
  opts.Stderr = &bytes.Buffer{}
531
564
  check := opts
532
565
  check.UpdateCheck = true
566
+ check.PayloadDir = ""
533
567
  if err := Apply(check); err == nil || !strings.Contains(err.Error(), "update available: 1.0.0 -> 2.0.0") {
534
568
  t.Fatalf("update check error = %v, want local candidate report", err)
535
569
  }
@@ -551,6 +585,184 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
551
585
  }
552
586
  }
553
587
 
588
+ func TestUpdateAcquiresLatestReleaseAndHandsOff(t *testing.T) {
589
+ t.Setenv("DEVRITES_NO_BINARY", "1")
590
+ target := t.TempDir()
591
+ runInstall(t, target, testPayload(t), func(o *Options) {
592
+ o.SourceDir = testSource(t, "1.0.0")
593
+ })
594
+ source := testSource(t, "2.0.0")
595
+ payload := testPayload(t)
596
+ engine := buildUpdateHandoffBinary(t, "2.0.0")
597
+ marker := filepath.Join(t.TempDir(), "handoff.txt")
598
+ t.Setenv("DEVRITES_TEST_UPDATE_HANDOFF", marker)
599
+
600
+ oldResolve, oldAcquire := resolveLatestRelease, acquireRelease
601
+ resolveLatestRelease = func(context.Context, string) (string, error) { return "v2.0.0", nil }
602
+ cleaned := false
603
+ acquireRelease = func(context.Context, string, string) (release.Candidate, func(), error) {
604
+ return release.Candidate{
605
+ SourceDir: source,
606
+ PayloadDir: payload,
607
+ EnginePath: engine,
608
+ BundleURL: "https://example.invalid/devrites-v2.0.0.tar.gz",
609
+ }, func() { cleaned = true }, nil
610
+ }
611
+ t.Cleanup(func() {
612
+ resolveLatestRelease, acquireRelease = oldResolve, oldAcquire
613
+ })
614
+
615
+ var stdout, stderr bytes.Buffer
616
+ opts := DefaultOptions(ModeUpdate)
617
+ opts.Target = target
618
+ opts.Stdout = &stdout
619
+ opts.Stderr = &stderr
620
+ if err := Apply(opts); err != nil {
621
+ t.Fatalf("remote update: %v\n%s", err, stderr.String())
622
+ }
623
+ if !cleaned {
624
+ t.Fatal("remote update did not clean acquired release")
625
+ }
626
+ handoff := testutil.ReadFile(t, marker)
627
+ for _, want := range []string{
628
+ "update\n",
629
+ "--target\n" + target + "\n",
630
+ "--source-dir\n" + source + "\n",
631
+ "--payload-dir\n" + payload + "\n",
632
+ "DEVRITES_UPDATE_HANDOFF=1\n",
633
+ "DEVRITES_ENGINE_CLI=" + engine + "\n",
634
+ } {
635
+ if !strings.Contains(handoff, want) {
636
+ t.Fatalf("handoff missing %q:\n%s", want, handoff)
637
+ }
638
+ }
639
+ if !strings.Contains(stdout.String(), "latest: 2.0.0") || !strings.Contains(stdout.String(), "bundle: https://example.invalid/") {
640
+ t.Fatalf("remote update output:\n%s", stdout.String())
641
+ }
642
+ }
643
+
644
+ func TestRemoteUpdateCheckDoesNotDownload(t *testing.T) {
645
+ target := t.TempDir()
646
+ testutil.WriteFile(t, filepath.Join(target, ManifestName), "# devrites-version: 1.0.0\n")
647
+ oldResolve, oldAcquire := resolveLatestRelease, acquireRelease
648
+ resolveLatestRelease = func(context.Context, string) (string, error) { return "v2.0.0", nil }
649
+ acquireRelease = func(context.Context, string, string) (release.Candidate, func(), error) {
650
+ t.Fatal("--check downloaded release assets")
651
+ return release.Candidate{}, func() {}, nil
652
+ }
653
+ t.Cleanup(func() {
654
+ resolveLatestRelease, acquireRelease = oldResolve, oldAcquire
655
+ })
656
+ opts := DefaultOptions(ModeUpdate)
657
+ opts.Target = target
658
+ opts.UpdateCheck = true
659
+ opts.Stdout = &bytes.Buffer{}
660
+ opts.Stderr = &bytes.Buffer{}
661
+ if err := Apply(opts); err == nil || !strings.Contains(err.Error(), "update available: 1.0.0 -> 2.0.0") {
662
+ t.Fatalf("remote update check error = %v", err)
663
+ }
664
+ }
665
+
666
+ func TestUpdateCleansLegacyCodexHooks(t *testing.T) {
667
+ t.Setenv("DEVRITES_NO_BINARY", "1")
668
+ payload := testPayload(t)
669
+ source := testSource(t, "4.0.0")
670
+
671
+ t.Run("preserves user hooks", func(t *testing.T) {
672
+ target := t.TempDir()
673
+ hooks := `{
674
+ "$comment": "DevRites hooks for Codex. Project hooks load only after trust.",
675
+ "hooks": {"Stop":[{"hooks":[
676
+ {"type":"command","command":"devrites-engine hook stop-gate --harness=codex"},
677
+ {"type":"command","command":"echo user"}
678
+ ]}]}
679
+ }` + "\n"
680
+ seedLegacyCodexHooksInstall(t, target, hooks)
681
+
682
+ runLocalUpdate(t, target, source, payload)
683
+
684
+ if exists(filepath.Join(target, ".claude", "devrites.codex-hooks-merge")) {
685
+ t.Fatal("update kept the legacy Codex hooks marker")
686
+ }
687
+ got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json"))
688
+ if strings.Contains(got, "devrites-engine hook") || strings.Contains(got, "DevRites hooks") || !strings.Contains(got, "echo user") {
689
+ t.Fatalf("legacy Codex hook cleanup mismatch:\n%s", got)
690
+ }
691
+ })
692
+
693
+ t.Run("removes empty hooks file", func(t *testing.T) {
694
+ target := t.TempDir()
695
+ hooks := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"devrites-engine hook stop-gate --harness=codex"}]}]}}` + "\n"
696
+ seedLegacyCodexHooksInstall(t, target, hooks)
697
+
698
+ runLocalUpdate(t, target, source, payload)
699
+
700
+ if exists(filepath.Join(target, ".codex", "hooks.json")) {
701
+ t.Fatal("update kept an empty legacy Codex hooks file")
702
+ }
703
+ })
704
+
705
+ t.Run("preserves unrecognized user hook shape", func(t *testing.T) {
706
+ target := t.TempDir()
707
+ seedLegacyCodexHooksInstall(t, target, `{"hooks":"user-defined"}`+"\n")
708
+
709
+ runLocalUpdate(t, target, source, payload)
710
+
711
+ got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json"))
712
+ if !strings.Contains(got, `"hooks": "user-defined"`) {
713
+ t.Fatalf("legacy cleanup removed unrecognized user hooks: %s", got)
714
+ }
715
+ })
716
+
717
+ t.Run("malformed hooks keep ownership marker", func(t *testing.T) {
718
+ target := t.TempDir()
719
+ seedLegacyCodexHooksInstall(t, target, "{\n")
720
+
721
+ err := applyUpdate(target, source, payload)
722
+ if err == nil || !strings.Contains(err.Error(), "load hooks config") {
723
+ t.Fatalf("update error = %v, want malformed legacy hooks error", err)
724
+ }
725
+ if !exists(filepath.Join(target, ".claude", "devrites.codex-hooks-merge")) {
726
+ t.Fatal("failed cleanup removed the legacy ownership marker")
727
+ }
728
+ if got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json")); got != "{\n" {
729
+ t.Fatalf("failed cleanup changed hooks: %q", got)
730
+ }
731
+ })
732
+
733
+ t.Run("uninstall preserves user hooks", func(t *testing.T) {
734
+ target := t.TempDir()
735
+ hooks := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"devrites-engine hook stop-gate"},{"type":"command","command":"echo user"}]}]}}` + "\n"
736
+ seedLegacyCodexHooksInstall(t, target, hooks)
737
+
738
+ runUninstall(t, target)
739
+
740
+ got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json"))
741
+ if strings.Contains(got, "devrites-engine hook") || !strings.Contains(got, "echo user") {
742
+ t.Fatalf("legacy Codex hook uninstall mismatch:\n%s", got)
743
+ }
744
+ })
745
+ }
746
+
747
+ func TestLegacyCodexHooksMergeIsCleanupOnly(t *testing.T) {
748
+ legacy := hostpack.LegacyCodexHooksMerge
749
+ merge, ok := hostpack.ManagedMergeForMarker(legacy.MarkerRel)
750
+ if !ok || merge != legacy {
751
+ t.Fatalf("legacy marker lookup = %#v, %t", merge, ok)
752
+ }
753
+ if slices.Contains(hostpack.RequiredPayload(true), "codex/hooks.json") {
754
+ t.Fatal("legacy Codex hooks entered the required payload")
755
+ }
756
+ r := runner{opts: DefaultOptions(ModeInstall), payloadFS: os.DirFS(testPayload(t))}
757
+ desired, err := r.desiredInstallPaths()
758
+ if err != nil {
759
+ t.Fatal(err)
760
+ }
761
+ if desired[legacy.MarkerRel] || desired[legacy.TargetRel] || slices.Contains(r.installMergeTargets(), legacy.TargetRel) {
762
+ t.Fatalf("legacy Codex hooks entered active install paths: desired=%v merge-targets=%v", desired, r.installMergeTargets())
763
+ }
764
+ }
765
+
554
766
  func TestUpdateRejectsMissingGeneratedPayload(t *testing.T) {
555
767
  t.Setenv("DEVRITES_NO_BINARY", "1")
556
768
  oldPayload := testPayload(t)
@@ -939,6 +1151,33 @@ func applyInstall(target, payload string, force, dryRun bool, out *bytes.Buffer)
939
1151
  return Apply(opts)
940
1152
  }
941
1153
 
1154
+ func applyUpdate(target, source, payload string) error {
1155
+ opts := DefaultOptions(ModeUpdate)
1156
+ opts.Target = target
1157
+ opts.SourceDir = source
1158
+ opts.PayloadDir = payload
1159
+ opts.Stdout = &bytes.Buffer{}
1160
+ opts.Stderr = &bytes.Buffer{}
1161
+ return Apply(opts)
1162
+ }
1163
+
1164
+ func runLocalUpdate(t *testing.T, target, source, payload string) {
1165
+ t.Helper()
1166
+ if err := applyUpdate(target, source, payload); err != nil {
1167
+ t.Fatal(err)
1168
+ }
1169
+ }
1170
+
1171
+ func seedLegacyCodexHooksInstall(t *testing.T, target, hooks string) {
1172
+ t.Helper()
1173
+ markerRel := ".claude/devrites.codex-hooks-merge"
1174
+ marker := []byte(".codex/hooks.json contains DevRites managed hook entries.\n")
1175
+ testutil.WriteFile(t, filepath.Join(target, ".codex", "hooks.json"), hooks)
1176
+ testutil.WriteFile(t, filepath.Join(target, filepath.FromSlash(markerRel)), string(marker))
1177
+ testutil.WriteFile(t, filepath.Join(target, ManifestName), "# DevRites install manifest - do not edit by hand.\n# devrites-version: 3.2.27\n# devrites-flags: \n")
1178
+ addManifestRecord(t, filepath.Join(target, ManifestName), markerRel, marker)
1179
+ }
1180
+
942
1181
  func addManifestRecord(t *testing.T, manifest, rel string, data []byte) {
943
1182
  t.Helper()
944
1183
  hash := fmt.Sprintf("sha256:%x", sha256.Sum256(data))