devrites 3.0.6 → 3.0.7

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.0.7](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.6...v3.0.7) (2026-07-22)
6
+
7
+ ### Fixed
8
+
9
+ * **installer:** harden binary acquisition ([7534198](https://github.com/ViktorsBaikers/DevRites/commit/7534198dd3f6a29f065fe715e763e02bdc8a71c0))
10
+
5
11
  ## [3.0.6](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.5...v3.0.6) (2026-07-22)
6
12
 
7
13
  ### Fixed
package/README.md CHANGED
@@ -21,7 +21,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
21
21
  Unattended runs may create local WIP checkpoint commits along the way, but only
22
22
  Ship collapses and pushes them.
23
23
 
24
- **Status:** [`v3.0.6`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.6): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
24
+ **Status:** [`v3.0.7`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.7): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
25
25
 
26
26
  ## Quick start
27
27
 
@@ -297,7 +297,9 @@ configured engine or a local `engine/devrites-engine`, can download a
297
297
  checksummed release binary, can build a temporary engine from the local Go
298
298
  source, and finally falls back to an existing `devrites-engine` on `PATH`. Use `--no-binary` or
299
299
  `DEVRITES_NO_BINARY=1` if you do not want the installer to keep the shared
300
- binary outside the project.
300
+ binary outside the project. `devrites-engine update` also prefers the
301
+ checksummed platform release binary and only builds from source as a fallback,
302
+ without reading Git metadata from the target project.
301
303
 
302
304
  Workflow state is read through the engine rather than shell injection. The
303
305
  installer never writes `defaultMode: bypassPermissions`. Skills use networked
@@ -16,6 +16,7 @@ import (
16
16
  "os/exec"
17
17
  "path/filepath"
18
18
  "reflect"
19
+ "runtime"
19
20
  "slices"
20
21
  "sort"
21
22
  "strings"
@@ -30,6 +31,7 @@ import (
30
31
 
31
32
  const ManifestName = devritespaths.ManifestName
32
33
  const defaultRepo = "ViktorsBaikers/DevRites"
34
+ const githubBaseURL = "https://github.com"
33
35
 
34
36
  type Mode string
35
37
 
@@ -69,15 +71,17 @@ type stats struct {
69
71
  }
70
72
 
71
73
  type runner struct {
72
- opts Options
73
- target string
74
- payload string
75
- payloadFS fs.FS
76
- source string
77
- manifest []string
78
- records map[string]string
79
- prev map[string]bool
80
- stats stats
74
+ opts Options
75
+ target string
76
+ payload string
77
+ payloadFS fs.FS
78
+ source string
79
+ manifest []string
80
+ records map[string]string
81
+ prev map[string]bool
82
+ stats stats
83
+ releaseBinaryTag string
84
+ preparedBinary string
81
85
  }
82
86
 
83
87
  func DefaultOptions(mode Mode) Options {
@@ -849,7 +853,20 @@ func runUpdate(opts Options) error {
849
853
  if opts.DryRun {
850
854
  next.DryRun = true
851
855
  }
852
- return Apply(next)
856
+ r, err := newRunner(next)
857
+ if err != nil {
858
+ return fmt.Errorf("prepare installer: %w", err)
859
+ }
860
+ r.releaseBinaryTag = latestTag
861
+ if next.WithBinary && os.Getenv("DEVRITES_NO_BINARY") != "1" && !next.DryRun {
862
+ staged, cleanup, err := r.acquireBinary(latestTag, githubBaseURL)
863
+ if err != nil {
864
+ return fmt.Errorf("prepare engine binary: %w", err)
865
+ }
866
+ defer cleanup()
867
+ r.preparedBinary = staged
868
+ }
869
+ return r.install()
853
870
  }
854
871
 
855
872
  func resolveUpdateTag(opts Options) (string, error) {
@@ -1297,24 +1314,24 @@ func (r *runner) installBinary() error {
1297
1314
  return nil
1298
1315
  }
1299
1316
  }
1300
- staged, cleanup, err := r.acquireBinary(tag)
1301
- if err != nil {
1302
- fmt.Fprintf(r.opts.Stderr, "warning: engine binary: %v - hooks fail open until you install it.\n", err)
1303
- return nil
1317
+ staged, cleanup := r.preparedBinary, func() {}
1318
+ if staged == "" {
1319
+ var err error
1320
+ staged, cleanup, err = r.acquireBinary(tag, githubBaseURL)
1321
+ if err != nil {
1322
+ return r.binaryInstallFailure(err)
1323
+ }
1304
1324
  }
1305
1325
  defer cleanup()
1306
1326
  if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
1307
- fmt.Fprintf(r.opts.Stderr, "warning: engine binary: %v - hooks fail open until you install it.\n", err)
1308
- return nil
1327
+ return r.binaryInstallFailure(err)
1309
1328
  }
1310
1329
  data, err := os.ReadFile(staged)
1311
1330
  if err != nil {
1312
- fmt.Fprintf(r.opts.Stderr, "warning: engine binary: %v - hooks fail open until you install it.\n", err)
1313
- return nil
1331
+ return r.binaryInstallFailure(err)
1314
1332
  }
1315
1333
  if err := fsutil.WriteFileAtomic(dest, data, 0o755); err != nil {
1316
- fmt.Fprintf(r.opts.Stderr, "warning: engine binary: %v - hooks fail open until you install it.\n", err)
1317
- return nil
1334
+ return r.binaryInstallFailure(err)
1318
1335
  }
1319
1336
  _ = os.Chmod(dest, 0o755)
1320
1337
  fmt.Fprintf(r.opts.Stdout, " engine binary: installed %s\n", dest)
@@ -1324,12 +1341,23 @@ func (r *runner) installBinary() error {
1324
1341
  return nil
1325
1342
  }
1326
1343
 
1327
- func (r *runner) acquireBinary(tag string) (string, func(), error) {
1344
+ func (r *runner) binaryInstallFailure(err error) error {
1345
+ if r.preparedBinary != "" {
1346
+ return err
1347
+ }
1348
+ fmt.Fprintf(r.opts.Stderr, "warning: engine binary: %v - hooks fail open until you install it.\n", err)
1349
+ return nil
1350
+ }
1351
+
1352
+ func (r *runner) acquireBinary(tag, baseURL string) (string, func(), error) {
1328
1353
  if v := os.Getenv("DEVRITES_ENGINE_CLI"); v != "" {
1329
1354
  if exists(v) {
1330
- return v, func() {}, nil
1355
+ if r.releaseBinaryTag == "" || strings.TrimPrefix(engineVersion(v), "v") == strings.TrimPrefix(r.releaseBinaryTag, "v") {
1356
+ return v, func() {}, nil
1357
+ }
1358
+ } else {
1359
+ return "", func() {}, fmt.Errorf("DEVRITES_ENGINE_CLI points to a missing binary: %s", v)
1331
1360
  }
1332
- return "", func() {}, fmt.Errorf("DEVRITES_ENGINE_CLI points to a missing binary: %s", v)
1333
1361
  }
1334
1362
  tmp, err := os.MkdirTemp("", "devrites-engine-*")
1335
1363
  if err != nil {
@@ -1337,19 +1365,60 @@ func (r *runner) acquireBinary(tag string) (string, func(), error) {
1337
1365
  }
1338
1366
  cleanup := func() { _ = os.RemoveAll(tmp) }
1339
1367
  staged := filepath.Join(tmp, "devrites-engine")
1368
+ var releaseErr error
1369
+ if r.releaseBinaryTag != "" && os.Getenv("DEVRITES_UPDATE_BUNDLE") == "" {
1370
+ repo := os.Getenv("DEVRITES_REPO")
1371
+ if repo == "" {
1372
+ repo = defaultRepo
1373
+ }
1374
+ downloaded, err := downloadReleaseBinary(baseURL, repo, r.releaseBinaryTag, staged)
1375
+ if err == nil {
1376
+ return staged, cleanup, nil
1377
+ }
1378
+ if downloaded {
1379
+ return "", cleanup, err
1380
+ }
1381
+ releaseErr = err
1382
+ }
1340
1383
  if r.source != "" && exists(filepath.Join(r.source, "engine", "go.mod")) {
1341
1384
  ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
1342
1385
  defer cancel()
1343
- cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-ldflags", "-s -w -X github.com/devrites/devrites/internal/version.Version="+tag, "-o", staged, ".")
1386
+ cmd := exec.CommandContext(ctx, "go", "build", "-buildvcs=false", "-trimpath", "-ldflags", "-s -w -X github.com/devrites/devrites/internal/version.Version="+tag, "-o", staged, ".")
1344
1387
  cmd.Dir = filepath.Join(r.source, "engine")
1345
1388
  if out, err := cmd.CombinedOutput(); err != nil {
1389
+ if releaseErr != nil {
1390
+ return "", cleanup, fmt.Errorf("could not download release binary: %v; source fallback failed: %w: %s", releaseErr, err, strings.TrimSpace(string(out)))
1391
+ }
1346
1392
  return "", cleanup, fmt.Errorf("could not build from source: %w: %s", err, strings.TrimSpace(string(out)))
1347
1393
  }
1348
1394
  return staged, cleanup, nil
1349
1395
  }
1396
+ if releaseErr != nil {
1397
+ return "", cleanup, fmt.Errorf("could not obtain a binary: %v; no Go source fallback", releaseErr)
1398
+ }
1350
1399
  return "", cleanup, fmt.Errorf("could not obtain a binary (no DEVRITES_ENGINE_CLI handoff, no Go source)")
1351
1400
  }
1352
1401
 
1402
+ func downloadReleaseBinary(baseURL, repo, tag, dest string) (bool, error) {
1403
+ asset := fmt.Sprintf("devrites-%s-%s", runtime.GOOS, runtime.GOARCH)
1404
+ if runtime.GOOS == "windows" {
1405
+ asset += ".exe"
1406
+ }
1407
+ url := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(baseURL, "/"), repo, tag, asset)
1408
+ if err := iohooks.DownloadFile(url, dest); err != nil {
1409
+ return false, fmt.Errorf("download %s: %w", asset, err)
1410
+ }
1411
+ sumPath := dest + ".sha256"
1412
+ defer os.Remove(sumPath)
1413
+ if err := iohooks.DownloadFile(url+".sha256", sumPath); err != nil {
1414
+ return true, fmt.Errorf("could not verify release binary %s: missing checksum: %w", asset, err)
1415
+ }
1416
+ if err := verifySHA256(dest, sumPath); err != nil {
1417
+ return true, fmt.Errorf("could not verify release binary %s: %w", asset, err)
1418
+ }
1419
+ return true, nil
1420
+ }
1421
+
1353
1422
  func (r *runner) removeBinary() error {
1354
1423
  if r.opts.KeepBinary {
1355
1424
  fmt.Fprintln(r.opts.Stdout, " kept the global devrites-engine binary (--keep-binary).")
@@ -4,9 +4,12 @@ import (
4
4
  "archive/tar"
5
5
  "bytes"
6
6
  "compress/gzip"
7
+ "crypto/sha256"
7
8
  "encoding/json"
8
9
  "fmt"
9
10
  "io/fs"
11
+ "net/http"
12
+ "net/http/httptest"
10
13
  "os"
11
14
  "path/filepath"
12
15
  "runtime"
@@ -347,6 +350,111 @@ func TestInstallBinaryUsesEngineHandoff(t *testing.T) {
347
350
  }
348
351
  }
349
352
 
353
+ func TestAcquireBinaryBuildsWithoutVCSMetadata(t *testing.T) {
354
+ t.Setenv("DEVRITES_ENGINE_CLI", "")
355
+ t.Setenv("DEVRITES_UPDATE_BUNDLE", "")
356
+ t.Setenv("GOFLAGS", "-buildvcs=true")
357
+ source := t.TempDir()
358
+ if err := os.Mkdir(filepath.Join(source, ".git"), 0o755); err != nil {
359
+ t.Fatal(err)
360
+ }
361
+ testutil.WriteFile(t, filepath.Join(source, "engine", "go.mod"), "module github.com/devrites/devrites\n\ngo 1.23\n")
362
+ testutil.WriteFile(t, filepath.Join(source, "engine", "main.go"), "package main\nfunc main() {}\n")
363
+
364
+ r := runner{source: source}
365
+ staged, cleanup, err := r.acquireBinary("v1.2.3", githubBaseURL)
366
+ defer cleanup()
367
+ if err != nil {
368
+ t.Fatalf("acquireBinary with unavailable VCS metadata: %v", err)
369
+ }
370
+ if !exists(staged) {
371
+ t.Fatalf("acquireBinary did not build %s", staged)
372
+ }
373
+ }
374
+
375
+ func TestAcquireBinaryPrefersVerifiedReleaseOverStaleHandoff(t *testing.T) {
376
+ stale := filepath.Join(t.TempDir(), "devrites-engine")
377
+ testutil.WriteExecutable(t, stale, "#!/bin/sh\nif [ \"$1\" = version ]; then echo v1.2.2; fi\n")
378
+ t.Setenv("DEVRITES_ENGINE_CLI", stale)
379
+ t.Setenv("DEVRITES_UPDATE_BUNDLE", "")
380
+ t.Setenv("DEVRITES_REPO", "owner/repo")
381
+ tag := "v1.2.3"
382
+ asset := fmt.Sprintf("devrites-%s-%s", runtime.GOOS, runtime.GOARCH)
383
+ if runtime.GOOS == "windows" {
384
+ asset += ".exe"
385
+ }
386
+ path := "/owner/repo/releases/download/" + tag + "/" + asset
387
+ body := []byte("verified release binary")
388
+ sum := fmt.Sprintf("%x %s\n", sha256.Sum256(body), asset)
389
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
390
+ switch req.URL.Path {
391
+ case path:
392
+ _, _ = w.Write(body)
393
+ case path + ".sha256":
394
+ _, _ = w.Write([]byte(sum))
395
+ default:
396
+ http.NotFound(w, req)
397
+ }
398
+ }))
399
+ defer server.Close()
400
+
401
+ r := runner{releaseBinaryTag: tag}
402
+ staged, cleanup, err := r.acquireBinary(tag, server.URL)
403
+ defer cleanup()
404
+ if err != nil {
405
+ t.Fatalf("acquire verified release binary: %v", err)
406
+ }
407
+ got, err := os.ReadFile(staged)
408
+ if err != nil {
409
+ t.Fatal(err)
410
+ }
411
+ if !bytes.Equal(got, body) {
412
+ t.Fatalf("acquired binary = %q, want %q", got, body)
413
+ }
414
+ }
415
+
416
+ func TestAcquireBinaryRejectsReleaseChecksumMismatch(t *testing.T) {
417
+ t.Setenv("DEVRITES_ENGINE_CLI", "")
418
+ t.Setenv("DEVRITES_UPDATE_BUNDLE", "")
419
+ t.Setenv("DEVRITES_REPO", "owner/repo")
420
+ tag := "v1.2.3"
421
+ source := t.TempDir()
422
+ testutil.WriteFile(t, filepath.Join(source, "engine", "go.mod"), "module github.com/devrites/devrites\n\ngo 1.23\n")
423
+ testutil.WriteFile(t, filepath.Join(source, "engine", "main.go"), "package main\nfunc main() {}\n")
424
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
425
+ if strings.HasSuffix(req.URL.Path, ".sha256") {
426
+ _, _ = w.Write([]byte("deadbeef\n"))
427
+ return
428
+ }
429
+ _, _ = w.Write([]byte("tampered release binary"))
430
+ }))
431
+ defer server.Close()
432
+
433
+ r := runner{source: source, releaseBinaryTag: tag}
434
+ _, cleanup, err := r.acquireBinary(tag, server.URL)
435
+ defer cleanup()
436
+ if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
437
+ t.Fatalf("acquireBinary error = %v, want checksum mismatch", err)
438
+ }
439
+ }
440
+
441
+ func TestInstallBinaryFailsWhenPreparedUpdateCannotBeWritten(t *testing.T) {
442
+ prepared := filepath.Join(t.TempDir(), "devrites-engine")
443
+ testutil.WriteExecutable(t, prepared, "#!/bin/sh\n")
444
+ blocked := filepath.Join(t.TempDir(), "not-a-directory")
445
+ testutil.WriteFile(t, blocked, "file\n")
446
+ t.Setenv("DEVRITES_BIN_DIR", filepath.Join(blocked, "bin"))
447
+ t.Setenv("DEVRITES_REF", "v1.2.3")
448
+
449
+ r := runner{
450
+ opts: DefaultOptions(ModeInstall),
451
+ preparedBinary: prepared,
452
+ }
453
+ if err := r.installBinary(); err == nil {
454
+ t.Fatal("installBinary accepted a prepared update it could not write")
455
+ }
456
+ }
457
+
350
458
  func TestInstallBinaryWarnsWhenHooksCannotResolveEngine(t *testing.T) {
351
459
  payload := testPayload(t)
352
460
  target := t.TempDir()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.0.6",
3
+ "version": "3.0.7",
4
4
  "description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",