devrites 7.3.1 → 7.3.3

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/cli.md +15 -7
  5. package/docs/engine/commands.md +37 -5
  6. package/docs/engine/workspace-schema.md +4 -3
  7. package/docs/release.md +5 -2
  8. package/engine/Makefile +2 -2
  9. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  10. package/engine/internal/acceptance/preconditions.go +17 -11
  11. package/engine/internal/acceptance/transcript.go +23 -4
  12. package/engine/internal/gate/gate.go +8 -6
  13. package/engine/internal/gate/readiness_binding.go +50 -21
  14. package/engine/internal/hostpack/hostpack.go +1 -1
  15. package/engine/internal/install/apply.go +36 -21
  16. package/engine/internal/install/binary.go +67 -9
  17. package/engine/internal/install/install.go +63 -21
  18. package/engine/internal/install/manifest.go +4 -5
  19. package/engine/internal/install/merge.go +89 -33
  20. package/engine/internal/install/preflight.go +60 -19
  21. package/engine/internal/install/uninstall.go +19 -9
  22. package/engine/internal/install/update.go +0 -4
  23. package/engine/internal/lib/apply_bundle.go +25 -8
  24. package/engine/internal/lib/claims.go +170 -4
  25. package/engine/internal/lib/claims_holder.go +13 -16
  26. package/engine/internal/lib/closeout.go +52 -28
  27. package/engine/internal/lib/context_io.go +88 -4
  28. package/engine/internal/lib/continuation.go +111 -24
  29. package/engine/internal/lib/dispatch.go +157 -24
  30. package/engine/internal/lib/final_prerequisites.go +137 -0
  31. package/engine/internal/lib/manifest_union.go +62 -25
  32. package/engine/internal/lib/next.go +10 -10
  33. package/engine/internal/lib/packet.go +32 -8
  34. package/engine/internal/lib/packet_integrity.go +194 -2
  35. package/engine/internal/lib/regression.go +0 -5
  36. package/engine/internal/lib/resolve.go +148 -145
  37. package/engine/internal/lib/treebaseline.go +0 -1
  38. package/engine/internal/markdowntext/markdowntext.go +110 -31
  39. package/engine/internal/overhaul/records/check.go +45 -5
  40. package/engine/internal/overhaul/records/consistency.go +34 -8
  41. package/engine/internal/overhaul/records/run.go +55 -11
  42. package/engine/internal/parallel/landing.go +17 -0
  43. package/engine/internal/rootfacts/facts.go +11 -0
  44. package/engine/internal/state/acceptance.go +35 -5
  45. package/engine/internal/state/feature.go +3 -2
  46. package/engine/internal/state/lock_other.go +4 -0
  47. package/engine/internal/state/observation.go +1 -1
  48. package/engine/internal/state/write.go +56 -39
  49. package/engine/internal/workflow/commands.go +25 -3
  50. package/engine/main.go +21 -2
  51. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  52. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  53. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  54. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  55. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  56. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  57. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  58. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  59. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  60. package/pack/.claude/skills/rite-doctor/SKILL.md +70 -68
  61. package/pack/generated/README.md +1 -1
  62. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  63. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  64. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  65. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  66. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  67. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  68. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  69. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/claude/skills/rite-doctor/SKILL.md +70 -68
  72. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  73. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  74. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  75. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  76. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  77. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  78. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  79. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  80. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  81. package/pack/generated/codex/skills/rite-doctor/SKILL.md +70 -68
  82. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  83. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  84. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  85. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  86. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  87. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  88. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  89. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  90. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  91. package/pack/generated/devin/skills/rite-doctor/SKILL.md +70 -68
  92. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  93. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  94. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  95. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  96. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  97. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  98. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  99. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  100. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  101. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  102. package/pack/generated/dsh/skills/rite-doctor/SKILL.md +70 -68
  103. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  104. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +1 -1
  105. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  106. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  107. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  108. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  109. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  110. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  111. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +18 -18
  112. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  113. package/pack/generated/omp/skills/rite-doctor/SKILL.md +70 -68
  114. package/pack/generated/pi/AGENTS.md +1 -1
  115. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  116. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  117. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  118. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  119. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  120. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  121. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  122. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  123. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  124. package/pack/generated/pi/skills/rite-doctor/SKILL.md +70 -68
  125. package/package.json +1 -1
  126. package/scripts/build-host-artifacts.sh +1 -1
  127. package/scripts/dsh-generate.py +6 -2
  128. package/scripts/install-lib.sh +29 -0
  129. package/scripts/omp-generate.sh +4 -4
  130. package/scripts/pi-generate.sh +3 -3
  131. package/uninstall.sh +1 -0
@@ -1,15 +1,18 @@
1
1
  package gate
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "crypto/sha256"
5
6
  "encoding/binary"
6
7
  "encoding/hex"
7
8
  "errors"
8
9
  "fmt"
9
- "hash"
10
+ "io"
10
11
  "regexp"
12
+ "slices"
11
13
  "strings"
12
14
 
15
+ "github.com/devrites/devrites/internal/acceptance"
13
16
  "github.com/devrites/devrites/internal/markdowntext"
14
17
  "github.com/devrites/devrites/internal/state"
15
18
  )
@@ -17,7 +20,7 @@ import (
17
20
  const (
18
21
  readinessBindingLabel = "Readiness inputs SHA-256: "
19
22
  readinessBindingDomain = "devrites-readiness-inputs"
20
- readinessBindingVersion = "1"
23
+ readinessBindingVersion = "2"
21
24
  )
22
25
 
23
26
  type readinessInput struct {
@@ -37,10 +40,13 @@ var readinessInputs = [...]readinessInput{
37
40
  {logical: "design-brief.md"},
38
41
  {logical: "ai-spec.md"},
39
42
  {logical: ".devrites/principles.md"},
43
+ {logical: acceptance.GatesFile},
40
44
  }
41
45
 
42
46
  // ReadinessBinding returns the exact line Vet records in eng-review.md.
43
47
  // Explicit sources resolve live first, then safely archived planning bytes.
48
+ // Gate definitions are bound independently of proof progress. Older bindings
49
+ // omitted those definitions and require normal re-Vet, never automatic refresh.
44
50
  func ReadinessBinding(root, slug string) (string, error) {
45
51
  observation, err := state.ObserveReferencedWorkspace(root, slug)
46
52
  if err != nil {
@@ -50,10 +56,6 @@ func ReadinessBinding(root, slug string) (string, error) {
50
56
  }
51
57
 
52
58
  func readinessBindingFromObservation(observation *state.WorkspaceObservation) (string, error) {
53
- return bindReadinessInputs(observation, true)
54
- }
55
-
56
- func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress bool) (string, error) {
57
59
  digest := sha256.New()
58
60
  writeReadinessField(digest, readinessBindingDomain)
59
61
  writeReadinessField(digest, readinessBindingVersion)
@@ -63,7 +65,7 @@ func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress
63
65
  if err != nil {
64
66
  return "", err
65
67
  }
66
- if blankProgress && input.logical == "traceability.md" {
68
+ if input.logical == "traceability.md" {
67
69
  content = withoutProgressCells(content)
68
70
  }
69
71
  writeReadinessField(digest, string(input.logical))
@@ -85,12 +87,26 @@ func retainedReadinessInput(observation *state.WorkspaceObservation, input readi
85
87
  }
86
88
  switch fact.State() {
87
89
  case state.ArtifactAbsent:
88
- if input.required {
90
+ required := input.required
91
+ if input.logical == acceptance.GatesFile {
92
+ phase, err := observation.DeclaredPhase()
93
+ if err != nil {
94
+ return nil, false, err
95
+ }
96
+ policy, _ := state.PolicyFor(phase)
97
+ required = policyRequires(policy, input.logical)
98
+ }
99
+ if required {
89
100
  return nil, false, fmt.Errorf("readiness input %s is missing", input.logical)
90
101
  }
91
102
  return nil, false, nil
92
103
  case state.ArtifactEmpty, state.ArtifactPresent:
93
- return fact.Bytes(), true, nil
104
+ content := fact.Bytes()
105
+ if input.logical == acceptance.GatesFile {
106
+ content, err := normalizedReadinessGates(content)
107
+ return content, true, err
108
+ }
109
+ return content, true, nil
94
110
  case state.ArtifactMalformed, state.ArtifactUnsafe, state.ArtifactUnreadable:
95
111
  diagnostic, _ := fact.Diagnostic()
96
112
  return nil, false, readinessDiagnosticError(diagnostic)
@@ -99,6 +115,25 @@ func retainedReadinessInput(observation *state.WorkspaceObservation, input readi
99
115
  }
100
116
  }
101
117
 
118
+ // Vet approves obligations, not their checkbox, evidence, or progress prose.
119
+ func normalizedReadinessGates(content []byte) ([]byte, error) {
120
+ doc := acceptance.ParseLedger(string(content))
121
+ if len(doc.Errors) > 0 {
122
+ return nil, fmt.Errorf("readiness input gates.md is malformed: %s", strings.Join(doc.Errors, "; "))
123
+ }
124
+ slices.SortFunc(doc.Gates, func(a, b *acceptance.Gate) int {
125
+ return strings.Compare(a.ID, b.ID)
126
+ })
127
+ var normalized bytes.Buffer
128
+ writeReadinessLength(&normalized, uint64(len(doc.Gates)))
129
+ for _, gate := range doc.Gates {
130
+ for _, field := range [...]string{gate.ID, gate.Title, gate.Check, gate.Expect, gate.Cwd, gate.Inputs, gate.Run} {
131
+ writeReadinessField(&normalized, field)
132
+ }
133
+ }
134
+ return normalized.Bytes(), nil
135
+ }
136
+
102
137
  func readinessDiagnostics(observation *state.WorkspaceObservation) []state.ArtifactDiagnostic {
103
138
  var diagnostics []state.ArtifactDiagnostic
104
139
  for _, input := range readinessInputs {
@@ -118,12 +153,6 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
118
153
  if err != nil {
119
154
  return "", err
120
155
  }
121
- // A line recorded over the raw bytes, before progress cells were blanked,
122
- // still verifies while those bytes are unchanged.
123
- legacy, err := bindReadinessInputs(observation, false)
124
- if err != nil {
125
- return "", err
126
- }
127
156
  fact, ok := observation.Fact("eng-review.md")
128
157
  if !ok || fact.State() == state.ArtifactAbsent {
129
158
  return expected, errors.New("readiness input eng-review.md is missing")
@@ -144,7 +173,7 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
144
173
  continue
145
174
  }
146
175
  attempts++
147
- if line == expected || line == legacy {
176
+ if line == expected {
148
177
  matches++
149
178
  }
150
179
  }
@@ -155,9 +184,9 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
155
184
  }
156
185
 
157
186
  // ReadinessInputDigest is one readiness input's content identity at a moment
158
- // in time. The aggregate binding answers "did anything change"; the per-input
159
- // digest answers "which artifact changed", which is what a drift diagnosis and
160
- // a re-vet route need.
187
+ // in time (canonical definitions only for gates.md). The aggregate binding
188
+ // answers "did anything change"; the per-input digest answers "which artifact
189
+ // changed", which is what a drift diagnosis and a re-vet route need.
161
190
  type ReadinessInputDigest struct {
162
191
  Path string `json:"path"`
163
192
  Present bool `json:"present"`
@@ -322,12 +351,12 @@ func policyRequires(policy state.PhasePolicy, name state.ArtifactPath) bool {
322
351
  return false
323
352
  }
324
353
 
325
- func writeReadinessField(digest hash.Hash, value string) {
354
+ func writeReadinessField(digest io.Writer, value string) {
326
355
  writeReadinessLength(digest, uint64(len(value)))
327
356
  _, _ = digest.Write([]byte(value))
328
357
  }
329
358
 
330
- func writeReadinessLength(digest hash.Hash, value uint64) {
359
+ func writeReadinessLength(digest io.Writer, value uint64) {
331
360
  var encoded [8]byte
332
361
  binary.BigEndian.PutUint64(encoded[:], value)
333
362
  _, _ = digest.Write(encoded[:])
@@ -128,7 +128,7 @@ var CodexConfigMerge = MarkerMerge{
128
128
  Begin: "# BEGIN DEVRITES CODEX PERMISSIONS",
129
129
  End: "# END DEVRITES CODEX PERMISSIONS",
130
130
  MarkerRel: ".claude/devrites.codex-config-merge",
131
- MarkerText: ".codex/config.toml contains the DevRites read-only-root permission profile.",
131
+ MarkerText: ".codex/config.toml contains the DevRites workspace-capable root permission profile.",
132
132
  }
133
133
 
134
134
  // HostConfig is a host project config DevRites creates only when absent; an
@@ -16,6 +16,10 @@ import (
16
16
  )
17
17
 
18
18
  func (r *runner) install() error {
19
+ if err := r.openTargetRoot(); err != nil {
20
+ return err
21
+ }
22
+ defer r.closeTargetRoot()
19
23
  fmt.Fprintln(r.opts.Stdout, "DevRites installer")
20
24
  fmt.Fprintf(r.opts.Stdout, " target : %s\n", r.target)
21
25
  fmt.Fprintf(r.opts.Stdout, " payload: %s\n", r.payload)
@@ -43,6 +47,10 @@ func (r *runner) install() error {
43
47
  if err := r.preflightInstall(); err != nil {
44
48
  return err
45
49
  }
50
+ // Complete binary installation before changing payload ownership or version.
51
+ if err := r.installBinary(); err != nil {
52
+ return fmt.Errorf("install engine binary: %w", err)
53
+ }
46
54
 
47
55
  for _, tree := range hostpack.InstallTrees(r.opts.WithSkills, r.opts.WithAgents, r.opts.WithCodex, r.opts.WithOmp, r.opts.WithPi, r.opts.WithDevin, r.opts.WithDsh) {
48
56
  if err := r.installTree(tree.PayloadPrefix, tree.TargetPrefix); err != nil {
@@ -106,9 +114,6 @@ func (r *runner) install() error {
106
114
  return fmt.Errorf("write manifest: %w", err)
107
115
  }
108
116
  }
109
- if err := r.installBinary(); err != nil {
110
- return fmt.Errorf("install engine binary: %w", err)
111
- }
112
117
 
113
118
  fmt.Fprintln(r.opts.Stdout)
114
119
  if r.opts.DryRun {
@@ -218,19 +223,32 @@ func (r *runner) installData(data []byte, rel string) error {
218
223
  return nil
219
224
  }
220
225
 
221
- // writeConfined writes dest, which must lie under the install target, through
222
- // a root opened on the target so a directory component replaced by a symlink
223
- // after the path recheck cannot redirect the write.
224
- func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error {
225
- rel, err := filepath.Rel(r.target, dest)
226
+ func (r *runner) readConfined(path string) ([]byte, error) {
227
+ rel, err := filepath.Rel(r.target, path)
226
228
  if err != nil {
227
- return err
229
+ return nil, err
230
+ }
231
+ if r.targetRoot != nil {
232
+ return r.targetRoot.ReadFile(rel)
228
233
  }
229
234
  root, err := os.OpenRoot(r.target)
230
235
  if err != nil {
231
- return err
236
+ return nil, err
232
237
  }
233
238
  defer func() { _ = root.Close() }()
239
+ return root.ReadFile(rel)
240
+ }
241
+
242
+ // writeConfined uses the operation's validated root, not its mutable pathname.
243
+ func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error {
244
+ rel, err := filepath.Rel(r.target, dest)
245
+ if err != nil {
246
+ return err
247
+ }
248
+ root := r.targetRoot
249
+ if root == nil {
250
+ return fmt.Errorf("install target is not open")
251
+ }
234
252
  if info, err := root.Stat(rel); err == nil {
235
253
  perm = info.Mode().Perm()
236
254
  } else if !os.IsNotExist(err) {
@@ -239,18 +257,16 @@ func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error
239
257
  return fsutil.WriteFileAtomicIn(root, rel, data, perm)
240
258
  }
241
259
 
242
- // removeConfined removes dest, which must lie under the install target,
243
- // through a root opened on the target.
260
+ // removeConfined uses the same retained root as writes and managed reads.
244
261
  func (r *runner) removeConfined(dest string) error {
245
262
  rel, err := filepath.Rel(r.target, dest)
246
263
  if err != nil {
247
264
  return err
248
265
  }
249
- root, err := os.OpenRoot(r.target)
250
- if err != nil {
251
- return err
266
+ root := r.targetRoot
267
+ if root == nil {
268
+ return fmt.Errorf("install target is not open")
252
269
  }
253
- defer func() { _ = root.Close() }()
254
270
  return root.Remove(rel)
255
271
  }
256
272
 
@@ -285,11 +301,10 @@ func (r *runner) seedDevrites() error {
285
301
  fmt.Fprintln(r.opts.Stdout, " [seed] .devrites/ACTIVE (runtime state - preserved on uninstall)")
286
302
  return nil
287
303
  }
288
- if err := os.MkdirAll(filepath.Dir(active), 0o755); err != nil {
304
+ if err := r.targetRoot.MkdirAll(".devrites", 0o755); err != nil {
289
305
  return fmt.Errorf("create %s: %w", filepath.Dir(active), err)
290
306
  }
291
- // #nosec G304 -- active path is the operator-selected install dir (O_EXCL create)
292
- f, err := os.OpenFile(active, os.O_CREATE|os.O_EXCL, 0o644)
307
+ f, err := r.targetRoot.OpenFile(filepath.Join(".devrites", "ACTIVE"), os.O_CREATE|os.O_EXCL, 0o644)
293
308
  if err != nil {
294
309
  if os.IsExist(err) {
295
310
  return nil
@@ -342,7 +357,7 @@ func (r *runner) relinquishEditedHostConfigs() (map[string]bool, error) {
342
357
  func (r *runner) seedHostConfig(config hostpack.HostConfig) error {
343
358
  if _, managed := r.prev[config.Rel]; !managed {
344
359
  // #nosec G304 -- fixed host config path under the operator target
345
- data, err := os.ReadFile(filepath.Join(r.target, filepath.FromSlash(config.Rel)))
360
+ data, err := r.readConfined(filepath.Join(r.target, filepath.FromSlash(config.Rel)))
346
361
  if err == nil && string(data) != config.Content {
347
362
  if !strings.Contains(string(data), config.Applied) {
348
363
  fmt.Fprintf(r.opts.Stderr, "warning: %s exists and was not changed; to hide duplicate DevRites skills in %s, add: %s\n", config.Rel, config.Host, config.Advice)
@@ -408,7 +423,7 @@ func (r *runner) pruneDropped() error {
408
423
  if err := r.removeConfined(dead); err != nil && !os.IsNotExist(err) {
409
424
  return fmt.Errorf("remove %s: %w", rel, err)
410
425
  }
411
- pruneEmptyDirs(filepath.Dir(dead), r.target)
426
+ r.pruneEmptyDirs(filepath.Dir(dead))
412
427
  fmt.Fprintf(r.opts.Stdout, " [%s] %s\n", action, rel)
413
428
  }
414
429
  r.stats.pruned++
@@ -63,11 +63,20 @@ func (r *runner) installBinary() error {
63
63
  if err != nil {
64
64
  return err
65
65
  }
66
+ removeBackup := true
66
67
  if backup != "" {
67
- defer func() { _ = os.Remove(backup) }()
68
+ defer func() {
69
+ if removeBackup {
70
+ _ = os.Remove(backup)
71
+ }
72
+ }()
68
73
  }
69
74
  if err := fsutil.WriteFileAtomic(dest, data, 0o755); err != nil {
70
75
  if restoreErr := restoreBinary(dest, backup, oldMode, hadOld); restoreErr != nil {
76
+ removeBackup = false
77
+ if backup != "" {
78
+ restoreErr = fmt.Errorf("%w (previous binary retained at %s)", restoreErr, backup)
79
+ }
71
80
  return fmt.Errorf("install binary: %v; restore previous binary: %w", err, restoreErr)
72
81
  }
73
82
  return err
@@ -75,6 +84,10 @@ func (r *runner) installBinary() error {
75
84
  _ = os.Chmod(dest, 0o755)
76
85
  if _, err := verifyEngineBinary(dest, tag, 30*time.Second); err != nil {
77
86
  if restoreErr := restoreBinary(dest, backup, oldMode, hadOld); restoreErr != nil {
87
+ removeBackup = false
88
+ if backup != "" {
89
+ restoreErr = fmt.Errorf("%w (previous binary retained at %s)", restoreErr, backup)
90
+ }
78
91
  return fmt.Errorf("verify installed engine binary: %v; restore previous binary: %w", err, restoreErr)
79
92
  }
80
93
  if hadOld {
@@ -173,6 +186,44 @@ func (r *runner) acquireBinary(tag string) (string, func(), error) {
173
186
  return path, func() {}, nil
174
187
  }
175
188
 
189
+ func (r *runner) preflightBinaryRemoval() error {
190
+ if runtime.GOOS != "windows" || r.opts.DryRun || r.opts.KeepBinary || !r.opts.WithBinary || os.Getenv("DEVRITES_NO_BINARY") == "1" {
191
+ return nil
192
+ }
193
+ for _, path := range binaryCandidates() {
194
+ info, err := os.Lstat(path)
195
+ if os.IsNotExist(err) {
196
+ continue
197
+ }
198
+ if err != nil {
199
+ return fmt.Errorf("inspect engine binary %s: %w", path, err)
200
+ }
201
+ if info.Mode()&os.ModeSymlink != 0 {
202
+ info, err = os.Stat(path)
203
+ if os.IsNotExist(err) {
204
+ return nil
205
+ }
206
+ if err != nil {
207
+ return fmt.Errorf("inspect engine binary %s: %w", path, err)
208
+ }
209
+ }
210
+ executable, err := os.Executable()
211
+ if err != nil {
212
+ return fmt.Errorf("locate running engine binary: %w", err)
213
+ }
214
+ running, err := os.Stat(executable)
215
+ if err != nil {
216
+ return fmt.Errorf("inspect running engine binary %s: %w", executable, err)
217
+ }
218
+ if os.SameFile(info, running) {
219
+ return fmt.Errorf("cannot remove running engine binary %s on Windows; use npx devrites@latest uninstall --target %q or --keep-binary", path, r.target)
220
+ }
221
+ // Removal stops at the first existing candidate, even if another is running.
222
+ return nil
223
+ }
224
+ return nil
225
+ }
226
+
176
227
  func (r *runner) removeBinary() error {
177
228
  if r.opts.KeepBinary {
178
229
  fmt.Fprintln(r.opts.Stdout, " kept the global devrites-engine binary (--keep-binary).")
@@ -187,17 +238,24 @@ func (r *runner) removeBinary() error {
187
238
  return nil
188
239
  }
189
240
  for _, p := range binaryCandidates() {
190
- if p == "" || !exists(p) {
191
- continue
192
- }
193
241
  if r.opts.DryRun {
242
+ if _, err := os.Lstat(p); err != nil {
243
+ if os.IsNotExist(err) {
244
+ continue
245
+ }
246
+ return fmt.Errorf("inspect engine binary %s: %w", p, err)
247
+ }
194
248
  fmt.Fprintf(r.opts.Stdout, " [remove] %s (global engine binary)\n", p)
195
249
  continue
196
250
  }
197
- if err := os.Remove(p); err == nil {
198
- fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", p)
199
- return nil
251
+ if err := os.Remove(p); err != nil {
252
+ if os.IsNotExist(err) {
253
+ continue
254
+ }
255
+ return fmt.Errorf("remove %s: %w", p, err)
200
256
  }
257
+ fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", p)
258
+ return nil
201
259
  }
202
260
  return nil
203
261
  }
@@ -220,10 +278,10 @@ func engineBinaryName() string {
220
278
  }
221
279
 
222
280
  func binaryCandidates() []string {
223
- candidates := []string{}
224
281
  if dir := os.Getenv("DEVRITES_BIN_DIR"); dir != "" {
225
- candidates = append(candidates, filepath.Join(dir, engineBinaryName()))
282
+ return []string{filepath.Join(dir, engineBinaryName())}
226
283
  }
284
+ candidates := []string{}
227
285
  home, _ := os.UserHomeDir()
228
286
  if home != "" {
229
287
  candidates = append(candidates, filepath.Join(home, ".local", "bin", engineBinaryName()))
@@ -61,6 +61,8 @@ type stats struct {
61
61
  type runner struct {
62
62
  opts Options
63
63
  target string
64
+ targetInfo fs.FileInfo
65
+ targetRoot *os.Root
64
66
  payload string
65
67
  payloadFS fs.FS
66
68
  source string
@@ -246,6 +248,10 @@ func Apply(opts Options) error {
246
248
  return fmt.Errorf("prepare installer: %w", err)
247
249
  }
248
250
  if opts.Mode == ModeUninstall {
251
+ if err := r.openTargetRoot(); err != nil {
252
+ return err
253
+ }
254
+ defer r.closeTargetRoot()
249
255
  return r.uninstall()
250
256
  }
251
257
  if err := r.install(); err != nil {
@@ -297,27 +303,75 @@ func newRunner(opts Options) (*runner, error) {
297
303
  }
298
304
  }
299
305
  r := &runner{
300
- opts: opts,
301
- target: target,
302
- payload: payload,
303
- payloadFS: os.DirFS(payload),
304
- source: source,
305
- prev: map[string]managedRecord{},
306
- records: map[string]string{},
307
- preflight: map[string]pathSnapshot{},
306
+ opts: opts,
307
+ target: target,
308
+ targetInfo: info,
309
+ payload: payload,
310
+ payloadFS: os.DirFS(payload),
311
+ source: source,
312
+ prev: map[string]managedRecord{},
313
+ records: map[string]string{},
314
+ preflight: map[string]pathSnapshot{},
308
315
  }
309
316
  if opts.Mode != ModeUninstall {
310
317
  if err := r.validatePayload(); err != nil {
311
318
  return nil, fmt.Errorf("validate payload: %w", err)
312
319
  }
313
320
  }
314
- r.prev, err = readManifest(filepath.Join(target, ManifestName))
321
+ if err := r.openTargetRoot(); err != nil {
322
+ return nil, err
323
+ }
324
+ defer r.closeTargetRoot()
325
+ data, err := r.readConfined(filepath.Join(target, ManifestName))
326
+ if err != nil && !os.IsNotExist(err) {
327
+ return nil, fmt.Errorf("read manifest %s: %w", ManifestName, err)
328
+ }
329
+ r.prev, _, err = parseManifestData(data)
315
330
  if err != nil {
316
331
  return nil, err
317
332
  }
318
333
  return r, nil
319
334
  }
320
335
 
336
+ func (r *runner) openTargetRoot() error {
337
+ root, err := os.OpenRoot(r.target)
338
+ if err != nil {
339
+ return err
340
+ }
341
+ info, err := root.Stat(".")
342
+ if err != nil || r.targetInfo == nil || !os.SameFile(r.targetInfo, info) {
343
+ _ = root.Close()
344
+ return fmt.Errorf("install target changed before operation: %s", r.target)
345
+ }
346
+ r.targetRoot = root
347
+ return nil
348
+ }
349
+
350
+ func (r *runner) closeTargetRoot() {
351
+ if r.targetRoot != nil {
352
+ _ = r.targetRoot.Close()
353
+ r.targetRoot = nil
354
+ }
355
+ }
356
+
357
+ func (r *runner) checkTargetIdentity() error {
358
+ info, err := os.Stat(r.target)
359
+ if err != nil || r.targetInfo == nil || !os.SameFile(r.targetInfo, info) {
360
+ return fmt.Errorf("install target changed during operation: %s", r.target)
361
+ }
362
+ return nil
363
+ }
364
+
365
+ func (r *runner) pruneEmptyDirs(start string) {
366
+ for start != r.target && start != filepath.Dir(start) {
367
+ rel, err := filepath.Rel(r.target, start)
368
+ if err != nil || r.targetRoot == nil || r.targetRoot.Remove(rel) != nil {
369
+ return
370
+ }
371
+ start = filepath.Dir(start)
372
+ }
373
+ }
374
+
321
375
  func inferSourceDir() string {
322
376
  if v := os.Getenv("DEVRITES_SOURCE_DIR"); v != "" {
323
377
  return v
@@ -390,18 +444,6 @@ func isGlobalTarget(target string) bool {
390
444
  return false
391
445
  }
392
446
 
393
- func pruneEmptyDirs(start, stop string) {
394
- for {
395
- if start == stop || start == filepath.Dir(start) || start == string(os.PathSeparator) {
396
- return
397
- }
398
- if err := os.Remove(start); err != nil {
399
- return
400
- }
401
- start = filepath.Dir(start)
402
- }
403
- }
404
-
405
447
  func exists(path string) bool {
406
448
  // #nosec G703 -- existence probe on managed install paths
407
449
  _, err := os.Lstat(path)
@@ -19,11 +19,6 @@ func readManifest(path string) (map[string]managedRecord, error) {
19
19
  return records, err
20
20
  }
21
21
 
22
- func readManifestList(path string) ([]string, error) {
23
- _, entries, err := parseManifest(path)
24
- return entries, err
25
- }
26
-
27
22
  func parseManifest(path string) (map[string]managedRecord, []string, error) {
28
23
  // #nosec G304 -- install manifest under the operator-selected target
29
24
  data, err := os.ReadFile(path)
@@ -33,6 +28,10 @@ func parseManifest(path string) (map[string]managedRecord, []string, error) {
33
28
  }
34
29
  return nil, nil, fmt.Errorf("read manifest %s: %w", path, err)
35
30
  }
31
+ return parseManifestData(data)
32
+ }
33
+
34
+ func parseManifestData(data []byte) (map[string]managedRecord, []string, error) {
36
35
  hashes := map[string]string{}
37
36
  var out []string
38
37
  for _, line := range strings.Split(string(data), "\n") {