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.
- package/CHANGELOG.md +15 -0
- package/README.md +23 -13
- package/SECURITY.md +9 -8
- package/docs/adr/0024-native-policy-offline-installer-boundary.md +1 -1
- package/docs/adr/0028-self-contained-engine-update.md +66 -0
- package/docs/adr/README.md +2 -1
- package/docs/architecture.md +17 -12
- package/docs/cli.md +31 -12
- package/docs/command-map.md +6 -4
- package/docs/engine/commands.md +14 -12
- package/docs/release.md +7 -7
- package/engine/internal/hostpack/hostpack.go +7 -0
- package/engine/internal/install/install.go +131 -9
- package/engine/internal/install/install_test.go +239 -0
- package/engine/internal/release/release.go +411 -0
- package/engine/internal/release/release_test.go +167 -0
- package/engine/tests/meta_test.go +6 -6
- package/package.json +1 -1
|
@@ -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] [--
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
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))
|