devrites 4.5.0 → 4.7.0

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 (191) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +0 -13
  3. package/README.md +4 -3
  4. package/docs/adr/0029-v5-workspace-schema-and-native-migration.md +62 -0
  5. package/docs/adr/README.md +3 -2
  6. package/docs/architecture.md +1 -1
  7. package/docs/cli.md +2 -2
  8. package/docs/command-map.md +2 -2
  9. package/docs/engine/commands.md +9 -0
  10. package/docs/engine/state-schema.md +2 -2
  11. package/docs/engine/workspace-schema.md +9 -3
  12. package/docs/usage.md +1 -1
  13. package/engine/commands.go +23 -0
  14. package/engine/internal/fsutil/copy_test.go +193 -0
  15. package/engine/internal/gate/gate_test.go +7 -7
  16. package/engine/internal/gate/readiness_binding_test.go +8 -2
  17. package/engine/internal/install/apply.go +314 -0
  18. package/engine/internal/install/binary.go +255 -0
  19. package/engine/internal/install/install.go +0 -1596
  20. package/engine/internal/install/manifest.go +109 -0
  21. package/engine/internal/install/merge.go +468 -0
  22. package/engine/internal/install/preflight.go +234 -0
  23. package/engine/internal/install/uninstall.go +119 -0
  24. package/engine/internal/install/update.go +184 -0
  25. package/engine/internal/lib/candidate_test.go +3 -0
  26. package/engine/internal/lib/closeout.go +4 -0
  27. package/engine/internal/lib/closeout_test.go +3 -3
  28. package/engine/internal/lib/migrate.go +219 -0
  29. package/engine/internal/lib/migrate_test.go +154 -0
  30. package/engine/internal/lib/resolve.go +3 -0
  31. package/engine/internal/lib/resolve_remediation_test.go +1 -1
  32. package/engine/internal/lib/secretscan_test.go +1 -0
  33. package/engine/internal/lib/taskgraph_test.go +2 -2
  34. package/engine/internal/parallel/ops.go +67 -16
  35. package/engine/internal/parallel/ops_test.go +211 -0
  36. package/engine/internal/state/cmd/workflowmanifest/main.go +24 -13
  37. package/engine/internal/state/cmd/workflowmanifest/main_test.go +96 -0
  38. package/engine/internal/state/cursor.go +74 -0
  39. package/engine/internal/state/schema.go +3 -2
  40. package/engine/internal/state/state_test.go +2 -2
  41. package/engine/internal/state/workflow_manifest.json +1 -1
  42. package/engine/internal/state/workspaceschema.go +58 -0
  43. package/engine/internal/state/workspaceschema_test.go +67 -0
  44. package/engine/main.go +7 -31
  45. package/engine/root_routing_test.go +10 -2
  46. package/engine/testdata/fixtures/basic/devrites-root/work/auth-tokens/state.md +1 -0
  47. package/engine/testdata/fixtures/basic/devrites-root/work/search-ranking/state.md +1 -0
  48. package/engine/testdata/golden/TestParityResolve/answer/state.md.golden +1 -0
  49. package/engine/testdata/golden/TestParityResolve/drop/state.md.golden +1 -0
  50. package/engine/tests/parity_closeout_test.go +3 -0
  51. package/engine/tests/parity_resolve_test.go +1 -0
  52. package/pack/.claude/agents/devrites-code-reviewer.md +8 -0
  53. package/pack/.claude/agents/devrites-devex-reviewer.md +7 -0
  54. package/pack/.claude/agents/devrites-doubt-reviewer.md +7 -0
  55. package/pack/.claude/agents/devrites-evidence-scout.md +4 -0
  56. package/pack/.claude/agents/devrites-frontend-reviewer.md +8 -0
  57. package/pack/.claude/agents/devrites-performance-reviewer.md +7 -0
  58. package/pack/.claude/agents/devrites-plan-reviewer.md +8 -0
  59. package/pack/.claude/agents/devrites-retrospector.md +2 -0
  60. package/pack/.claude/agents/devrites-security-auditor.md +7 -0
  61. package/pack/.claude/agents/devrites-simplifier-reviewer.md +7 -0
  62. package/pack/.claude/agents/devrites-spec-reviewer.md +7 -0
  63. package/pack/.claude/agents/devrites-strategy-reviewer.md +7 -0
  64. package/pack/.claude/agents/devrites-test-analyst.md +7 -0
  65. package/pack/.claude/skills/devrites-frontend-craft/SKILL.md +4 -7
  66. package/pack/.claude/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
  67. package/pack/.claude/skills/devrites-lib/reference/intent-map.md +7 -0
  68. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
  69. package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +9 -7
  70. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +1 -1
  71. package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +6 -1
  72. package/pack/.claude/skills/devrites-lib/reference/standards/coding-style.md +4 -3
  73. package/pack/.claude/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
  74. package/pack/.claude/skills/devrites-lib/reference/standards/core.md +3 -6
  75. package/pack/.claude/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
  76. package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
  77. package/pack/.claude/skills/devrites-lib/reference/standards/documentation.md +6 -0
  78. package/pack/.claude/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
  79. package/pack/.claude/skills/devrites-lib/reference/standards/elicitation.md +5 -0
  80. package/pack/.claude/skills/devrites-lib/reference/standards/error-handling.md +14 -0
  81. package/pack/.claude/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
  82. package/pack/.claude/skills/devrites-lib/reference/standards/patterns.md +16 -0
  83. package/pack/.claude/skills/devrites-lib/reference/standards/performance.md +4 -0
  84. package/pack/.claude/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
  85. package/pack/.claude/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
  86. package/pack/.claude/skills/devrites-lib/reference/standards/security.md +10 -2
  87. package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
  88. package/pack/.claude/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
  89. package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +2 -2
  90. package/pack/.claude/skills/devrites-lib/reference/standards/tooling.md +1 -1
  91. package/pack/.claude/skills/rite-define/reference/plan-template.md +5 -2
  92. package/pack/.claude/skills/rite-learn/SKILL.md +6 -0
  93. package/pack/.claude/skills/rite-polish/SKILL.md +4 -0
  94. package/pack/.claude/skills/rite-polish/reference/anti-ai-slop.md +12 -53
  95. package/pack/.claude/skills/rite-pr-feedback/SKILL.md +7 -2
  96. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -4
  97. package/pack/.claude/skills/rite-status/SKILL.md +2 -0
  98. package/pack/generated/claude/agents/devrites-code-reviewer.md +8 -0
  99. package/pack/generated/claude/agents/devrites-devex-reviewer.md +7 -0
  100. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +7 -0
  101. package/pack/generated/claude/agents/devrites-evidence-scout.md +4 -0
  102. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +8 -0
  103. package/pack/generated/claude/agents/devrites-performance-reviewer.md +7 -0
  104. package/pack/generated/claude/agents/devrites-plan-reviewer.md +8 -0
  105. package/pack/generated/claude/agents/devrites-retrospector.md +2 -0
  106. package/pack/generated/claude/agents/devrites-security-auditor.md +7 -0
  107. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +7 -0
  108. package/pack/generated/claude/agents/devrites-spec-reviewer.md +7 -0
  109. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +7 -0
  110. package/pack/generated/claude/agents/devrites-test-analyst.md +7 -0
  111. package/pack/generated/claude/skills/devrites-frontend-craft/SKILL.md +4 -7
  112. package/pack/generated/claude/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
  113. package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +7 -0
  114. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
  115. package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +9 -7
  116. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +1 -1
  117. package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +6 -1
  118. package/pack/generated/claude/skills/devrites-lib/reference/standards/coding-style.md +4 -3
  119. package/pack/generated/claude/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
  120. package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +3 -6
  121. package/pack/generated/claude/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
  122. package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
  123. package/pack/generated/claude/skills/devrites-lib/reference/standards/documentation.md +6 -0
  124. package/pack/generated/claude/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
  125. package/pack/generated/claude/skills/devrites-lib/reference/standards/elicitation.md +5 -0
  126. package/pack/generated/claude/skills/devrites-lib/reference/standards/error-handling.md +14 -0
  127. package/pack/generated/claude/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
  128. package/pack/generated/claude/skills/devrites-lib/reference/standards/patterns.md +16 -0
  129. package/pack/generated/claude/skills/devrites-lib/reference/standards/performance.md +4 -0
  130. package/pack/generated/claude/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
  131. package/pack/generated/claude/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
  132. package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +10 -2
  133. package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
  134. package/pack/generated/claude/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
  135. package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +2 -2
  136. package/pack/generated/claude/skills/devrites-lib/reference/standards/tooling.md +1 -1
  137. package/pack/generated/claude/skills/rite-define/reference/plan-template.md +5 -2
  138. package/pack/generated/claude/skills/rite-learn/SKILL.md +6 -0
  139. package/pack/generated/claude/skills/rite-polish/SKILL.md +4 -0
  140. package/pack/generated/claude/skills/rite-polish/reference/anti-ai-slop.md +12 -53
  141. package/pack/generated/claude/skills/rite-pr-feedback/SKILL.md +7 -2
  142. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -4
  143. package/pack/generated/claude/skills/rite-status/SKILL.md +2 -0
  144. package/pack/generated/codex/agents/devrites-code-reviewer.toml +8 -0
  145. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +7 -0
  146. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +7 -0
  147. package/pack/generated/codex/agents/devrites-evidence-scout.toml +4 -0
  148. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +8 -0
  149. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +7 -0
  150. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +8 -0
  151. package/pack/generated/codex/agents/devrites-retrospector.toml +2 -0
  152. package/pack/generated/codex/agents/devrites-security-auditor.toml +7 -0
  153. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +7 -0
  154. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +7 -0
  155. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +7 -0
  156. package/pack/generated/codex/agents/devrites-test-analyst.toml +7 -0
  157. package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +4 -7
  158. package/pack/generated/codex/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
  159. package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +7 -0
  160. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
  161. package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +9 -7
  162. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +1 -1
  163. package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +6 -1
  164. package/pack/generated/codex/skills/devrites-lib/reference/standards/coding-style.md +4 -3
  165. package/pack/generated/codex/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
  166. package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +3 -6
  167. package/pack/generated/codex/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
  168. package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
  169. package/pack/generated/codex/skills/devrites-lib/reference/standards/documentation.md +6 -0
  170. package/pack/generated/codex/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
  171. package/pack/generated/codex/skills/devrites-lib/reference/standards/elicitation.md +5 -0
  172. package/pack/generated/codex/skills/devrites-lib/reference/standards/error-handling.md +14 -0
  173. package/pack/generated/codex/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
  174. package/pack/generated/codex/skills/devrites-lib/reference/standards/patterns.md +16 -0
  175. package/pack/generated/codex/skills/devrites-lib/reference/standards/performance.md +4 -0
  176. package/pack/generated/codex/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
  177. package/pack/generated/codex/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
  178. package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +10 -2
  179. package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
  180. package/pack/generated/codex/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
  181. package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +2 -2
  182. package/pack/generated/codex/skills/devrites-lib/reference/standards/tooling.md +1 -1
  183. package/pack/generated/codex/skills/rite-define/reference/plan-template.md +5 -2
  184. package/pack/generated/codex/skills/rite-learn/SKILL.md +6 -0
  185. package/pack/generated/codex/skills/rite-polish/SKILL.md +4 -0
  186. package/pack/generated/codex/skills/rite-polish/reference/anti-ai-slop.md +12 -53
  187. package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +7 -2
  188. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -4
  189. package/pack/generated/codex/skills/rite-status/SKILL.md +2 -0
  190. package/package.json +1 -1
  191. package/docs/markdown-instruction-upgrade-2026-08-27.md +0 -127
@@ -1,31 +1,17 @@
1
1
  package install
2
2
 
3
3
  import (
4
- "bytes"
5
- "context"
6
- "crypto/sha256"
7
- "encoding/hex"
8
- "encoding/json"
9
4
  "errors"
10
5
  "flag"
11
6
  "fmt"
12
7
  "io"
13
8
  "io/fs"
14
9
  "os"
15
- "os/exec"
16
10
  "path/filepath"
17
- "runtime"
18
- "slices"
19
- "sort"
20
11
  "strings"
21
- "time"
22
12
 
23
13
  "github.com/devrites/devrites/internal/devritespaths"
24
- "github.com/devrites/devrites/internal/fsutil"
25
14
  "github.com/devrites/devrites/internal/hostpack"
26
- "github.com/devrites/devrites/internal/release"
27
- "github.com/devrites/devrites/internal/safepath"
28
- "github.com/devrites/devrites/internal/version"
29
15
  )
30
16
 
31
17
  const ManifestName = devritespaths.ManifestName
@@ -349,1588 +335,6 @@ func (r *runner) validatePayload() error {
349
335
  return nil
350
336
  }
351
337
 
352
- func (r *runner) install() error {
353
- fmt.Fprintln(r.opts.Stdout, "DevRites installer")
354
- fmt.Fprintf(r.opts.Stdout, " target : %s\n", r.target)
355
- fmt.Fprintf(r.opts.Stdout, " payload: %s\n", r.payload)
356
- fmt.Fprintf(r.opts.Stdout, " skills : %s\n", yesno(r.opts.WithSkills))
357
- fmt.Fprintln(r.opts.Stdout, " standards: ship inside the devrites-lib skill")
358
- fmt.Fprintf(r.opts.Stdout, " agents : %s\n", yesno(r.opts.WithAgents))
359
- fmt.Fprintf(r.opts.Stdout, " codex : %s\n", yesno(r.opts.WithCodex))
360
- fmt.Fprintf(r.opts.Stdout, " aliases: %s\n", r.opts.AliasMode)
361
- if r.opts.DryRun {
362
- fmt.Fprintln(r.opts.Stdout, " (dry run - no changes will be made)")
363
- }
364
- fmt.Fprintln(r.opts.Stdout)
365
- if r.preparedBinary != "" {
366
- if _, err := verifyEngineBinary(r.preparedBinary, r.binaryTag(), 30*time.Second); err != nil {
367
- return fmt.Errorf("verify staged engine binary %s: %w", r.preparedBinary, err)
368
- }
369
- }
370
- if err := r.preflightInstall(); err != nil {
371
- return err
372
- }
373
-
374
- for _, tree := range hostpack.InstallTrees(r.opts.WithSkills, r.opts.WithAgents, r.opts.WithCodex) {
375
- if err := r.installTree(tree.PayloadPrefix, tree.TargetPrefix); err != nil {
376
- return fmt.Errorf("install tree %s: %w", tree.TargetPrefix, err)
377
- }
378
- }
379
- if r.opts.WithSkills && r.opts.AliasMode == "all" {
380
- for _, alias := range hostpack.Aliases {
381
- data, err := hostpack.RenderAliasSkill(alias)
382
- if err != nil {
383
- return fmt.Errorf("render alias skill %s: %w", alias.Name, err)
384
- }
385
- for _, rel := range hostpack.AliasTargets(alias, r.opts.WithCodex) {
386
- if err := r.installData(data, rel); err != nil {
387
- return fmt.Errorf("install alias: %w", err)
388
- }
389
- }
390
- }
391
- }
392
- if r.opts.WithSkills && r.opts.WithCodex {
393
- if err := r.mergeMarkerFile(hostpack.CodexAgentsMerge); err != nil {
394
- return fmt.Errorf("merge %s: %w", hostpack.CodexAgentsMerge.TargetRel, err)
395
- }
396
- if err := r.mergeCodexConfig(); err != nil {
397
- return fmt.Errorf("merge %s: %w", hostpack.CodexConfigMerge.TargetRel, err)
398
- }
399
- }
400
- if r.opts.WithSkills {
401
- if err := r.seedClaudeSettings(); err != nil {
402
- return fmt.Errorf("seed claude settings: %w", err)
403
- }
404
- }
405
- if err := r.seedDevrites(); err != nil {
406
- return fmt.Errorf("seed .devrites: %w", err)
407
- }
408
- if err := r.pruneDropped(); err != nil {
409
- return fmt.Errorf("prune dropped files: %w", err)
410
- }
411
- if !r.opts.DryRun {
412
- if err := r.writeManifest(); err != nil {
413
- return fmt.Errorf("write manifest: %w", err)
414
- }
415
- }
416
- if err := r.installBinary(); err != nil {
417
- return fmt.Errorf("install engine binary: %w", err)
418
- }
419
-
420
- fmt.Fprintln(r.opts.Stdout)
421
- if r.opts.DryRun {
422
- fmt.Fprintln(r.opts.Stdout, "DevRites install plan complete (dry run)")
423
- } else {
424
- fmt.Fprintln(r.opts.Stdout, "DevRites installed")
425
- }
426
- fmt.Fprintf(r.opts.Stdout, " installed: %d overwritten: %d skipped(conflict): %d pruned: %d\n", r.stats.installed, r.stats.overwrote, r.stats.skipped, r.stats.pruned)
427
- if !r.opts.DryRun && r.opts.WithSkills {
428
- if r.opts.WithCodex {
429
- fmt.Fprintln(r.opts.Stdout, "Next: reopen the project, then run /rite (Claude) or $rite (Codex).")
430
- } else {
431
- fmt.Fprintln(r.opts.Stdout, "Next: reopen the project, then run /rite.")
432
- }
433
- }
434
- return nil
435
- }
436
-
437
- func (r *runner) installTree(srcPrefix, relPrefix string) error {
438
- return fs.WalkDir(r.payloadFS, srcPrefix, func(path string, d fs.DirEntry, err error) error {
439
- if err != nil {
440
- return fmt.Errorf("walk payload %s: %w", path, err)
441
- }
442
- if d.IsDir() {
443
- return nil
444
- }
445
- rel, err := filepath.Rel(srcPrefix, path)
446
- if err != nil {
447
- return fmt.Errorf("resolve relative path for %s: %w", path, err)
448
- }
449
- return r.installFile(path, filepath.ToSlash(filepath.Join(relPrefix, rel)))
450
- })
451
- }
452
-
453
- func (r *runner) installFile(src, rel string) error {
454
- data, err := fs.ReadFile(r.payloadFS, src)
455
- if err != nil {
456
- return fmt.Errorf("read payload %s: %w", src, err)
457
- }
458
- return r.installData(data, rel)
459
- }
460
-
461
- func (r *runner) preflightInstall() error {
462
- desired, err := r.desiredInstallPaths()
463
- if err != nil {
464
- return fmt.Errorf("preflight install paths: %w", err)
465
- }
466
- var conflicts []string
467
- for rel := range desired {
468
- snapshot, err := r.rememberPath(rel)
469
- if err != nil {
470
- return err
471
- }
472
- record, managed := r.prev[rel]
473
- if managed && !snapshot.missing {
474
- if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
475
- conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
476
- }
477
- }
478
- }
479
- for rel, record := range r.prev {
480
- if desired[rel] || hostpack.PreserveOnPrune(rel) {
481
- continue
482
- }
483
- snapshot, err := r.rememberPath(rel)
484
- if err != nil {
485
- return err
486
- }
487
- if !snapshot.missing {
488
- if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
489
- conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
490
- }
491
- }
492
- if merge, ok := hostpack.ManagedMergeForMarker(rel); ok {
493
- if _, err := r.rememberPath(merge.TargetRel); err != nil {
494
- return err
495
- }
496
- }
497
- }
498
- for _, rel := range r.installMergeTargets() {
499
- if _, err := r.rememberPath(rel); err != nil {
500
- return err
501
- }
502
- }
503
- if _, err := r.rememberPath(ManifestName); err != nil {
504
- return err
505
- }
506
- return managedConflictError(conflicts)
507
- }
508
-
509
- func (r *runner) preflightUninstall(entries []string) error {
510
- var conflicts []string
511
- for _, rel := range entries {
512
- if merge, ok := hostpack.ManagedMergeForMarker(rel); ok {
513
- if _, err := r.rememberPath(merge.TargetRel); err != nil {
514
- return err
515
- }
516
- }
517
- if !hostpack.ShouldRemoveOnUninstall(rel, entries) {
518
- continue
519
- }
520
- snapshot, err := r.rememberPath(rel)
521
- if err != nil {
522
- return err
523
- }
524
- if snapshot.missing {
525
- continue
526
- }
527
- if kind := managedConflict(r.prev[rel], snapshot); kind != "" && !r.opts.Force {
528
- conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
529
- }
530
- }
531
- if _, err := r.rememberPath(ManifestName); err != nil {
532
- return err
533
- }
534
- return managedConflictError(conflicts)
535
- }
536
-
537
- func (r *runner) desiredInstallPaths() (map[string]bool, error) {
538
- out := map[string]bool{".devrites/README.md": true}
539
- for _, tree := range hostpack.InstallTrees(r.opts.WithSkills, r.opts.WithAgents, r.opts.WithCodex) {
540
- err := fs.WalkDir(r.payloadFS, tree.PayloadPrefix, func(path string, d fs.DirEntry, err error) error {
541
- if err != nil {
542
- return err
543
- }
544
- if d.IsDir() {
545
- return nil
546
- }
547
- rel, err := filepath.Rel(tree.PayloadPrefix, path)
548
- if err != nil {
549
- return err
550
- }
551
- out[filepath.ToSlash(filepath.Join(tree.TargetPrefix, rel))] = true
552
- return nil
553
- })
554
- if err != nil {
555
- return nil, err
556
- }
557
- }
558
- if r.opts.WithSkills && r.opts.AliasMode == "all" {
559
- for _, alias := range hostpack.Aliases {
560
- for _, rel := range hostpack.AliasTargets(alias, r.opts.WithCodex) {
561
- out[rel] = true
562
- }
563
- }
564
- }
565
- if r.opts.WithSkills && r.opts.WithCodex {
566
- out[hostpack.CodexAgentsMerge.MarkerRel] = true
567
- out[hostpack.CodexConfigMerge.MarkerRel] = true
568
- }
569
- if r.opts.WithSkills {
570
- out[hostpack.ClaudeSettingsMerge.MarkerRel] = true
571
- }
572
- return out, nil
573
- }
574
-
575
- func (r *runner) installMergeTargets() []string {
576
- var out []string
577
- if r.opts.WithSkills {
578
- out = append(out, hostpack.ClaudeSettingsMerge.TargetRel)
579
- if r.opts.WithCodex {
580
- out = append(out, hostpack.CodexAgentsMerge.TargetRel, hostpack.CodexConfigMerge.TargetRel)
581
- }
582
- }
583
- return out
584
- }
585
-
586
- func (r *runner) rememberPath(rel string) (pathSnapshot, error) {
587
- rel = filepath.ToSlash(rel)
588
- if snapshot, ok := r.preflight[rel]; ok {
589
- return snapshot, nil
590
- }
591
- snapshot, err := inspectManagedPath(r.target, rel)
592
- if err != nil {
593
- return pathSnapshot{}, err
594
- }
595
- r.preflight[rel] = snapshot
596
- return snapshot, nil
597
- }
598
-
599
- func (r *runner) recheckPath(rel string) error {
600
- rel = filepath.ToSlash(rel)
601
- before, ok := r.preflight[rel]
602
- if !ok {
603
- return fmt.Errorf("path %s was not preflighted", rel)
604
- }
605
- now, err := inspectManagedPath(r.target, rel)
606
- if err != nil {
607
- return err
608
- }
609
- if now != before {
610
- return fmt.Errorf("refusing to change %s: file changed after preflight; retry the operation", rel)
611
- }
612
- return nil
613
- }
614
-
615
- func inspectManagedPath(target, rel string) (pathSnapshot, error) {
616
- native := filepath.FromSlash(rel)
617
- if !filepath.IsLocal(native) {
618
- return pathSnapshot{}, fmt.Errorf("refusing unsafe managed path %q", rel)
619
- }
620
- dest := filepath.Join(target, native)
621
- if !safepath.WithinResolved(dest, target) {
622
- return pathSnapshot{}, fmt.Errorf("refusing managed path outside target: %s", rel)
623
- }
624
- walk := target
625
- parts := strings.Split(native, string(filepath.Separator))
626
- for _, part := range parts {
627
- walk = filepath.Join(walk, part)
628
- info, err := os.Lstat(walk)
629
- if os.IsNotExist(err) {
630
- return pathSnapshot{missing: true}, nil
631
- }
632
- if err != nil {
633
- return pathSnapshot{}, fmt.Errorf("inspect managed path %s: %w", rel, err)
634
- }
635
- if info.Mode()&os.ModeSymlink != 0 {
636
- return pathSnapshot{}, fmt.Errorf("refusing symlink or junction in managed path: %s", rel)
637
- }
638
- }
639
- info, err := os.Lstat(dest)
640
- if os.IsNotExist(err) {
641
- return pathSnapshot{missing: true}, nil
642
- }
643
- if err != nil {
644
- return pathSnapshot{}, fmt.Errorf("inspect managed path %s: %w", rel, err)
645
- }
646
- if !info.Mode().IsRegular() {
647
- return pathSnapshot{}, fmt.Errorf("refusing non-regular managed path: %s", rel)
648
- }
649
- f, err := os.Open(dest)
650
- if err != nil {
651
- return pathSnapshot{}, fmt.Errorf("read managed path %s: %w", rel, err)
652
- }
653
- defer f.Close()
654
- sum := sha256.New()
655
- if _, err := io.Copy(sum, f); err != nil {
656
- return pathSnapshot{}, fmt.Errorf("hash managed path %s: %w", rel, err)
657
- }
658
- return pathSnapshot{hash: "sha256:" + hex.EncodeToString(sum.Sum(nil))}, nil
659
- }
660
-
661
- func managedConflict(record managedRecord, snapshot pathSnapshot) string {
662
- if record.Hash == "" {
663
- return "legacy manifest entry (no hash)"
664
- }
665
- if record.Hash != snapshot.hash {
666
- return "customized managed file"
667
- }
668
- return ""
669
- }
670
-
671
- func managedConflictError(conflicts []string) error {
672
- if len(conflicts) == 0 {
673
- return nil
674
- }
675
- sort.Strings(conflicts)
676
- return fmt.Errorf("managed files differ from the install manifest:\n %s\nrerun with --force to replace or remove these files", strings.Join(conflicts, "\n "))
677
- }
678
-
679
- func (r *runner) installData(data []byte, rel string) error {
680
- dest := filepath.Join(r.target, filepath.FromSlash(rel))
681
- action := "install"
682
- if exists(dest) {
683
- record, managed := r.prev[rel]
684
- switch {
685
- case managed && managedConflict(record, r.preflight[filepath.ToSlash(rel)]) != "" && r.opts.Force:
686
- action = "overwrite(force-customized)"
687
- case managed:
688
- action = "overwrite"
689
- case r.opts.Force:
690
- action = "overwrite(force)"
691
- default:
692
- if r.opts.DryRun {
693
- fmt.Fprintf(r.opts.Stdout, " [skip] %s (exists, not DevRites-managed)\n", rel)
694
- } else {
695
- fmt.Fprintf(r.opts.Stderr, "warning: skip %s (exists, not DevRites-managed; use --force to overwrite)\n", rel)
696
- }
697
- r.stats.skipped++
698
- return nil
699
- }
700
- }
701
- if r.opts.DryRun {
702
- fmt.Fprintf(r.opts.Stdout, " [%s] %s\n", action, rel)
703
- } else {
704
- if err := r.recheckPath(rel); err != nil {
705
- return err
706
- }
707
- if err := fsutil.WriteFileAtomic(dest, data, 0o644); err != nil {
708
- return fmt.Errorf("cannot write %s: %w", rel, err)
709
- }
710
- }
711
- r.addManifest(rel)
712
- r.addInstallRecord(rel, data)
713
- if action == "install" {
714
- r.stats.installed++
715
- } else {
716
- r.stats.overwrote++
717
- }
718
- return nil
719
- }
720
-
721
- func (r *runner) addManifest(rel string) {
722
- r.manifest = append(r.manifest, filepath.ToSlash(rel))
723
- }
724
-
725
- func (r *runner) addInstallRecord(rel string, data []byte) {
726
- if r.records == nil {
727
- r.records = map[string]string{}
728
- }
729
- sum := sha256.Sum256(data)
730
- r.records[filepath.ToSlash(rel)] = fmt.Sprintf("sha256:%x", sum[:])
731
- }
732
-
733
- func (r *runner) installMarker(rel, text string) error {
734
- return r.installData([]byte(text+"\n"), rel)
735
- }
736
-
737
- func (r *runner) installClaudeSettingsMarker(ownsDefaultMode bool) error {
738
- ownership := "preexisting"
739
- if ownsDefaultMode {
740
- ownership = "added"
741
- }
742
- text := hostpack.ClaudeSettingsMerge.MarkerText + "\ndefault-mode=" + ownership
743
- return r.installMarker(hostpack.ClaudeSettingsMerge.MarkerRel, text)
744
- }
745
-
746
- func (r *runner) claudeDefaultModeOwned() bool {
747
- data, err := os.ReadFile(filepath.Join(r.target, filepath.FromSlash(hostpack.ClaudeSettingsMerge.MarkerRel)))
748
- return err == nil && strings.Contains(string(data), "\ndefault-mode=added\n")
749
- }
750
-
751
- func (r *runner) mergeMarkerFile(merge hostpack.MarkerMerge) error {
752
- block, err := fs.ReadFile(r.payloadFS, merge.PayloadRel)
753
- if err != nil {
754
- return fmt.Errorf("read payload %s: %w", merge.PayloadRel, err)
755
- }
756
- dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
757
- if r.opts.DryRun {
758
- verb := "create DevRites block"
759
- current, readErr := os.ReadFile(dest)
760
- if readErr == nil {
761
- if bytes.Contains(current, []byte(merge.Begin)) {
762
- verb = "refresh DevRites block"
763
- } else {
764
- verb = "append DevRites block"
765
- }
766
- } else if !errors.Is(readErr, fs.ErrNotExist) {
767
- return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
768
- }
769
- fmt.Fprintf(r.opts.Stdout, " [merge] %s (%s)\n", merge.TargetRel, verb)
770
- } else {
771
- if err := r.recheckPath(merge.TargetRel); err != nil {
772
- return err
773
- }
774
- next := block
775
- current, readErr := os.ReadFile(dest)
776
- if readErr == nil {
777
- next = hostpack.MergeMarkerBlock(current, block, merge.Begin, merge.End)
778
- } else if !errors.Is(readErr, fs.ErrNotExist) {
779
- return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
780
- }
781
- if err := fsutil.WriteFileAtomic(dest, next, 0o644); err != nil {
782
- return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
783
- }
784
- }
785
- return r.installMarker(merge.MarkerRel, merge.MarkerText)
786
- }
787
-
788
- func (r *runner) mergeCodexConfig() error {
789
- merge := hostpack.CodexConfigMerge
790
- block, err := fs.ReadFile(r.payloadFS, merge.PayloadRel)
791
- if err != nil {
792
- return fmt.Errorf("read payload %s: %w", merge.PayloadRel, err)
793
- }
794
- dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
795
- if r.opts.DryRun {
796
- fmt.Fprintf(r.opts.Stdout, " [merge] %s (prepend DevRites permission block)\n", merge.TargetRel)
797
- return r.installMarker(merge.MarkerRel, merge.MarkerText)
798
- }
799
- if err := r.recheckPath(merge.TargetRel); err != nil {
800
- return err
801
- }
802
- var current []byte
803
- if data, readErr := os.ReadFile(dest); readErr == nil {
804
- current = stripMarkerBlock(data, merge.Begin, merge.End)
805
- current = stripMarkerBlock(current, "# BEGIN DEVRITES CODEX MCP", "# END DEVRITES CODEX MCP")
806
- current = stripMarkerBlock(current, "### BEGIN DEVRITES CODEX MCP", "### END DEVRITES CODEX MCP")
807
- if hasTopLevelTOMLKey(current, "default_permissions") {
808
- return fmt.Errorf("%s already sets top-level default_permissions; remove that project override before installing the DevRites read-only-root profile", merge.TargetRel)
809
- }
810
- } else if !errors.Is(readErr, fs.ErrNotExist) {
811
- return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
812
- }
813
- next := append([]byte(nil), block...)
814
- if len(next) == 0 || next[len(next)-1] != '\n' {
815
- next = append(next, '\n')
816
- }
817
- if len(bytes.TrimSpace(current)) > 0 {
818
- next = append(next, '\n')
819
- next = append(next, current...)
820
- }
821
- if err := fsutil.WriteFileAtomic(dest, next, 0o644); err != nil {
822
- return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
823
- }
824
- return r.installMarker(merge.MarkerRel, merge.MarkerText)
825
- }
826
-
827
- func (r *runner) mergeClaudeSettings(merge hostpack.JSONMerge) error {
828
- dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
829
- devrites, err := readJSONFS(r.payloadFS, merge.PayloadRel)
830
- if err != nil {
831
- return fmt.Errorf("load Claude settings payload: %w", err)
832
- }
833
- current := map[string]any{}
834
- if data, readErr := readJSON(dest); readErr == nil {
835
- current = data
836
- } else if !errors.Is(readErr, fs.ErrNotExist) {
837
- return fmt.Errorf("load existing Claude settings: %w", readErr)
838
- }
839
- next, ownsDefaultMode, err := mergeClaudeSettingsConfig(current, devrites, r.claudeDefaultModeOwned())
840
- if err != nil {
841
- return err
842
- }
843
- if r.opts.DryRun {
844
- fmt.Fprintf(r.opts.Stdout, " [merge] %s\n", merge.DryRunText)
845
- return r.installClaudeSettingsMarker(ownsDefaultMode)
846
- }
847
- if err := r.recheckPath(merge.TargetRel); err != nil {
848
- return err
849
- }
850
- data, err := json.MarshalIndent(next, "", " ")
851
- if err != nil {
852
- return fmt.Errorf("encode merged Claude settings: %w", err)
853
- }
854
- data = append(data, '\n')
855
- if err := fsutil.WriteFileAtomic(dest, data, 0o644); err != nil {
856
- return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
857
- }
858
- return r.installClaudeSettingsMarker(ownsDefaultMode)
859
- }
860
-
861
- func (r *runner) seedClaudeSettings() error {
862
- return r.mergeClaudeSettings(hostpack.ClaudeSettingsMerge)
863
- }
864
-
865
- func (r *runner) seedDevrites() error {
866
- readme, err := hostpack.RenderDevritesReadme()
867
- if err != nil {
868
- return fmt.Errorf("render readme: %w", err)
869
- }
870
- if err := r.installData(readme, ".devrites/README.md"); err != nil {
871
- return fmt.Errorf("install readme: %w", err)
872
- }
873
- active := filepath.Join(r.target, ".devrites", "ACTIVE")
874
- if exists(active) {
875
- return nil
876
- }
877
- if r.opts.DryRun {
878
- fmt.Fprintln(r.opts.Stdout, " [seed] .devrites/ACTIVE (runtime state - preserved on uninstall)")
879
- return nil
880
- }
881
- if err := os.MkdirAll(filepath.Dir(active), 0o755); err != nil {
882
- return fmt.Errorf("create %s: %w", filepath.Dir(active), err)
883
- }
884
- f, err := os.OpenFile(active, os.O_CREATE|os.O_EXCL, 0o644)
885
- if err != nil {
886
- if os.IsExist(err) {
887
- return nil
888
- }
889
- return fmt.Errorf("create %s: %w", active, err)
890
- }
891
- return f.Close()
892
- }
893
-
894
- func (r *runner) pruneDropped() error {
895
- if len(r.prev) == 0 {
896
- return nil
897
- }
898
- next := map[string]bool{}
899
- for _, rel := range r.manifest {
900
- next[rel] = true
901
- }
902
- keys := make([]string, 0, len(r.prev))
903
- for rel := range r.prev {
904
- keys = append(keys, rel)
905
- }
906
- sort.Strings(keys)
907
- for _, rel := range keys {
908
- if next[rel] || hostpack.PreserveOnPrune(rel) {
909
- continue
910
- }
911
- dead := filepath.Join(r.target, filepath.FromSlash(rel))
912
- if !exists(dead) {
913
- continue
914
- }
915
- action := "prune"
916
- if managedConflict(r.prev[rel], r.preflight[rel]) != "" && r.opts.Force {
917
- action = "prune(force-customized)"
918
- }
919
- if r.opts.DryRun {
920
- fmt.Fprintf(r.opts.Stdout, " [%s] %s (dropped from pack)\n", action, rel)
921
- } else {
922
- if err := r.recheckPath(rel); err != nil {
923
- return err
924
- }
925
- if merge, ok := hostpack.ManagedMergeForMarker(rel); ok {
926
- if err := r.recheckPath(merge.TargetRel); err != nil {
927
- return err
928
- }
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 {
934
- _ = r.stripClaudeSettings(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), true)
935
- } else {
936
- _ = stripMarkerPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), merge.Begin, merge.End)
937
- }
938
- }
939
- if err := os.Remove(dead); err != nil && !os.IsNotExist(err) {
940
- return fmt.Errorf("remove %s: %w", rel, err)
941
- }
942
- pruneEmptyDirs(filepath.Dir(dead), r.target)
943
- fmt.Fprintf(r.opts.Stdout, " [%s] %s\n", action, rel)
944
- }
945
- r.stats.pruned++
946
- }
947
- return nil
948
- }
949
-
950
- func (r *runner) writeManifest() error {
951
- sort.Strings(r.manifest)
952
- r.manifest = slices.Compact(r.manifest)
953
- var b strings.Builder
954
- b.WriteString("# DevRites install manifest - do not edit by hand.\n")
955
- b.WriteString("# Generated " + time.Now().UTC().Format(time.RFC3339) + ". Uninstall removes exactly these paths.\n")
956
- b.WriteString("# devrites-version: " + installedVersion(r.source) + "\n")
957
- b.WriteString("# devrites-flags: " + r.flagsString() + "\n")
958
- b.WriteString("# managed-records: source=npx payload=pack/generated format=rel sha256\n")
959
- for _, rel := range r.manifest {
960
- if hash := r.records[rel]; hash != "" {
961
- b.WriteString("# managed: " + rel + " " + hash + "\n")
962
- }
963
- }
964
- for _, rel := range r.manifest {
965
- b.WriteString(rel + "\n")
966
- }
967
- if err := r.recheckPath(ManifestName); err != nil {
968
- return err
969
- }
970
- return fsutil.WriteFileAtomic(filepath.Join(r.target, ManifestName), []byte(b.String()), 0o644)
971
- }
972
-
973
- func (r *runner) flagsString() string {
974
- var flags []string
975
- if !r.opts.WithSkills {
976
- flags = append(flags, "--no-skills")
977
- }
978
- if !r.opts.WithAgents {
979
- flags = append(flags, "--no-agents")
980
- }
981
- if !r.opts.WithCodex {
982
- flags = append(flags, "--no-codex")
983
- }
984
- if !r.opts.WithBinary {
985
- flags = append(flags, "--no-binary")
986
- }
987
- if r.opts.AliasMode == "off" {
988
- flags = append(flags, "--no-short-aliases")
989
- }
990
- if r.opts.AliasMode == "all" {
991
- flags = append(flags, "--short-aliases=all")
992
- }
993
- return strings.Join(flags, " ")
994
- }
995
-
996
- func (r *runner) uninstall() error {
997
- mf := filepath.Join(r.target, ManifestName)
998
- entries, err := readManifestList(mf)
999
- if err != nil {
1000
- return err
1001
- }
1002
- if len(entries) == 0 {
1003
- return fmt.Errorf("no DevRites manifest at %s - nothing to uninstall", mf)
1004
- }
1005
- fmt.Fprintln(r.opts.Stdout, "DevRites uninstaller")
1006
- fmt.Fprintf(r.opts.Stdout, " target : %s\n", r.target)
1007
- fmt.Fprintf(r.opts.Stdout, " manifest: %s\n", mf)
1008
- if r.opts.DryRun {
1009
- fmt.Fprintln(r.opts.Stdout, " (dry run - no changes)")
1010
- }
1011
- fmt.Fprintln(r.opts.Stdout)
1012
- if err := r.preflightUninstall(entries); err != nil {
1013
- return err
1014
- }
1015
-
1016
- for _, rel := range entries {
1017
- merge, ok := hostpack.ManagedMergeForMarker(rel)
1018
- if !ok {
1019
- continue
1020
- }
1021
- if r.opts.DryRun {
1022
- fmt.Fprintf(r.opts.Stdout, " [merge-remove] %s\n", merge.DryRun)
1023
- continue
1024
- }
1025
- if err := r.recheckPath(rel); err != nil {
1026
- return err
1027
- }
1028
- if err := r.recheckPath(merge.TargetRel); err != nil {
1029
- return err
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
- }
1037
- if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
1038
- if err := r.stripClaudeSettings(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), true); err != nil {
1039
- return fmt.Errorf("strip DevRites settings from %s: %w", merge.TargetRel, err)
1040
- }
1041
- continue
1042
- }
1043
- if err := stripMarkerPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), merge.Begin, merge.End); err != nil {
1044
- return fmt.Errorf("strip marker block from %s: %w", merge.TargetRel, err)
1045
- }
1046
- }
1047
-
1048
- var dirs []string
1049
- for _, rel := range entries {
1050
- if !hostpack.ShouldRemoveOnUninstall(rel, entries) {
1051
- continue
1052
- }
1053
- dest := filepath.Join(r.target, filepath.FromSlash(rel))
1054
- if exists(dest) {
1055
- action := "remove"
1056
- if managedConflict(r.prev[rel], r.preflight[rel]) != "" && r.opts.Force {
1057
- action = "remove(force-customized)"
1058
- }
1059
- if r.opts.DryRun {
1060
- fmt.Fprintf(r.opts.Stdout, " [%s] %s\n", action, rel)
1061
- } else {
1062
- if err := r.recheckPath(rel); err != nil {
1063
- return err
1064
- }
1065
- if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
1066
- return fmt.Errorf("remove %s: %w", rel, err)
1067
- }
1068
- }
1069
- dirs = append(dirs, filepath.Dir(dest))
1070
- r.stats.removed++
1071
- } else {
1072
- r.stats.missing++
1073
- }
1074
- }
1075
- if r.opts.DryRun {
1076
- fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", ManifestName)
1077
- } else {
1078
- if err := r.recheckPath(ManifestName); err != nil {
1079
- return err
1080
- }
1081
- _ = os.Remove(mf)
1082
- dirs = append(dirs, filepath.Dir(mf))
1083
- for _, d := range dirs {
1084
- pruneEmptyDirs(d, r.target)
1085
- }
1086
- }
1087
- if err := r.removeBinary(); err != nil {
1088
- return fmt.Errorf("remove engine binary: %w", err)
1089
- }
1090
- fmt.Fprintln(r.opts.Stdout)
1091
- if r.opts.DryRun {
1092
- fmt.Fprintln(r.opts.Stdout, "DevRites uninstall plan complete (dry run)")
1093
- } else {
1094
- fmt.Fprintln(r.opts.Stdout, "DevRites uninstalled")
1095
- }
1096
- fmt.Fprintf(r.opts.Stdout, " removed: %d already-absent: %d\n", r.stats.removed, r.stats.missing)
1097
- if exists(filepath.Join(r.target, ".devrites", "work")) {
1098
- fmt.Fprintln(r.opts.Stdout, " kept .devrites/work/ (your feature data)")
1099
- }
1100
- if exists(filepath.Join(r.target, ".devrites", "ACTIVE")) {
1101
- fmt.Fprintln(r.opts.Stdout, " kept .devrites/ACTIVE (active-feature cursor)")
1102
- }
1103
- return nil
1104
- }
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
-
1126
- func (r *runner) stripClaudeSettings(path string, preserveEmpty bool) error {
1127
- current, err := readJSON(path)
1128
- if err != nil {
1129
- if errors.Is(err, fs.ErrNotExist) {
1130
- return nil
1131
- }
1132
- return fmt.Errorf("load Claude settings: %w", err)
1133
- }
1134
- next := stripDevritesSettings(current, r.claudeDefaultModeOwned())
1135
- if len(next) == 0 && !preserveEmpty {
1136
- return os.Remove(path)
1137
- }
1138
- data, err := json.MarshalIndent(next, "", " ")
1139
- if err != nil {
1140
- return fmt.Errorf("encode Claude settings: %w", err)
1141
- }
1142
- data = append(data, '\n')
1143
- return fsutil.WriteFileAtomic(path, data, 0o644)
1144
- }
1145
-
1146
- func runUpdate(opts Options) error {
1147
- target, err := filepath.Abs(opts.Target)
1148
- if err != nil {
1149
- return fmt.Errorf("resolve target %s: %w", opts.Target, err)
1150
- }
1151
- mf := filepath.Join(target, ManifestName)
1152
- if !exists(mf) {
1153
- return fmt.Errorf("no DevRites install found at %s (missing %s)", target, ManifestName)
1154
- }
1155
- installed := manifestHeader(mf, "devrites-version")
1156
- if installed == "" {
1157
- installed = "unknown"
1158
- }
1159
- if opts.SourceDir == "" {
1160
- if opts.PayloadDir != "" {
1161
- return fmt.Errorf("local update with --payload-dir also requires --source-dir")
1162
- }
1163
- return runRemoteUpdate(opts, target, installed)
1164
- }
1165
- source, err := filepath.Abs(opts.SourceDir)
1166
- if err != nil {
1167
- return fmt.Errorf("resolve source %s: %w", opts.SourceDir, err)
1168
- }
1169
- candidate := installedVersion(source)
1170
- if candidate == "" || candidate == "unknown" {
1171
- return fmt.Errorf("derive update version from local source %s: package.json has no version", source)
1172
- }
1173
- candidate = strings.TrimPrefix(candidate, "v")
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
- }
1180
- if opts.UpdateCheck {
1181
- if installed == candidate || strings.TrimPrefix(installed, "v") == candidate {
1182
- fmt.Fprintln(opts.Stdout, "up to date.")
1183
- return nil
1184
- }
1185
- return fmt.Errorf("update available: %s -> %s", installed, candidate)
1186
- }
1187
- if !opts.Force && (installed == candidate || strings.TrimPrefix(installed, "v") == candidate) {
1188
- fmt.Fprintln(opts.Stdout, "already up to date (use --force to reinstall).")
1189
- return nil
1190
- }
1191
- if opts.PayloadDir == "" {
1192
- return fmt.Errorf("update requires --payload-dir with the locally prepared host payload")
1193
- }
1194
- payload, err := filepath.Abs(opts.PayloadDir)
1195
- if err != nil {
1196
- return fmt.Errorf("resolve payload %s: %w", opts.PayloadDir, err)
1197
- }
1198
- if err := hostpack.ValidatePayload(os.DirFS(payload), true); err != nil {
1199
- return fmt.Errorf("local update payload under %s is invalid: %w", payload, err)
1200
- }
1201
- installFlags := manifestHeader(mf, "devrites-flags")
1202
- next := DefaultOptions(ModeInstall)
1203
- next.Stdout = opts.Stdout
1204
- next.Stderr = opts.Stderr
1205
- if installFlags != "" {
1206
- if err := parseArgs(strings.Fields(installFlags), &next); err != nil {
1207
- return fmt.Errorf("parse manifest install flags: %w", err)
1208
- }
1209
- }
1210
- next.Target = target
1211
- next.PayloadDir = payload
1212
- next.SourceDir = source
1213
- next.Force = opts.Force
1214
- if opts.DryRun {
1215
- next.DryRun = true
1216
- }
1217
- r, err := newRunner(next)
1218
- if err != nil {
1219
- return fmt.Errorf("prepare installer: %w", err)
1220
- }
1221
- r.requiredBinaryTag = "v" + candidate
1222
- if next.WithBinary && os.Getenv("DEVRITES_NO_BINARY") != "1" && !next.DryRun {
1223
- staged, cleanup, err := r.acquireBinary(r.requiredBinaryTag)
1224
- if err != nil {
1225
- return fmt.Errorf("prepare engine binary: %w", err)
1226
- }
1227
- defer cleanup()
1228
- r.preparedBinary = staged
1229
- }
1230
- return r.install()
1231
- }
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
-
1316
- func stripMarkerPath(path, begin, end string) error {
1317
- data, err := os.ReadFile(path)
1318
- if err != nil {
1319
- if os.IsNotExist(err) {
1320
- return nil
1321
- }
1322
- return fmt.Errorf("read %s: %w", path, err)
1323
- }
1324
- next := stripMarkerBlock(data, begin, end)
1325
- if strings.TrimSpace(string(next)) == "" {
1326
- return os.Remove(path)
1327
- }
1328
- return fsutil.WriteFileAtomic(path, next, 0o644)
1329
- }
1330
-
1331
- func stripMarkerBlock(data []byte, begin, end string) []byte {
1332
- var out strings.Builder
1333
- inBlock := false
1334
- for _, line := range strings.SplitAfter(string(data), "\n") {
1335
- trim := strings.TrimSuffix(line, "\n")
1336
- trim = strings.TrimSuffix(trim, "\r")
1337
- switch trim {
1338
- case begin:
1339
- inBlock = true
1340
- continue
1341
- case end:
1342
- inBlock = false
1343
- continue
1344
- }
1345
- if !inBlock {
1346
- out.WriteString(line)
1347
- }
1348
- }
1349
- return []byte(out.String())
1350
- }
1351
-
1352
- func hasTopLevelTOMLKey(data []byte, key string) bool {
1353
- for _, line := range strings.Split(string(data), "\n") {
1354
- line = strings.TrimSpace(line)
1355
- if line == "" || strings.HasPrefix(line, "#") {
1356
- continue
1357
- }
1358
- if strings.HasPrefix(line, "[") {
1359
- return false
1360
- }
1361
- name, _, ok := strings.Cut(line, "=")
1362
- if ok && strings.TrimSpace(name) == key {
1363
- return true
1364
- }
1365
- }
1366
- return false
1367
- }
1368
-
1369
- func readJSON(path string) (map[string]any, error) {
1370
- data, err := os.ReadFile(path)
1371
- if err != nil {
1372
- return nil, fmt.Errorf("read %s: %w", path, err)
1373
- }
1374
- return decodeJSON(data)
1375
- }
1376
-
1377
- func readJSONFS(src fs.FS, path string) (map[string]any, error) {
1378
- data, err := fs.ReadFile(src, path)
1379
- if err != nil {
1380
- return nil, fmt.Errorf("read %s: %w", path, err)
1381
- }
1382
- return decodeJSON(data)
1383
- }
1384
-
1385
- func decodeJSON(data []byte) (map[string]any, error) {
1386
- var out map[string]any
1387
- if err := json.Unmarshal(data, &out); err != nil {
1388
- return nil, fmt.Errorf("decode JSON: %w", err)
1389
- }
1390
- return out, nil
1391
- }
1392
-
1393
- func isDevritesHookCommand(v any) bool {
1394
- hook, ok := v.(map[string]any)
1395
- if !ok {
1396
- return false
1397
- }
1398
- command, _ := hook["command"].(string)
1399
- return strings.Contains(command, "devrites-engine hook ") ||
1400
- strings.Contains(command, ".claude/hooks/devrites-") ||
1401
- strings.Contains(command, ".codex/hooks/devrites-") ||
1402
- (strings.Contains(command, "printf ") && strings.Contains(command, "DevRites:"))
1403
- }
1404
-
1405
- func isDevritesHooksComment(comment string) bool {
1406
- return comment == "DevRites hooks" ||
1407
- strings.HasPrefix(comment, "DevRites hooks: every event invokes the global `devrites-engine` engine binary") ||
1408
- strings.HasPrefix(comment, "DevRites hooks: auto-approve the read-only orientation/gate scripts") ||
1409
- strings.HasPrefix(comment, "DevRites hooks — every event invokes the global `devrites-engine` engine binary") ||
1410
- strings.HasPrefix(comment, "DevRites hooks — auto-approve the read-only orientation/gate scripts") ||
1411
- strings.HasPrefix(comment, "DevRites hooks for Codex. Project hooks load only after")
1412
- }
1413
-
1414
- func isDevritesSettingsComment(comment string) bool {
1415
- return isDevritesHooksComment(comment) ||
1416
- strings.HasPrefix(comment, "DevRites keeps the root orchestration context source-read-only.")
1417
- }
1418
-
1419
- func stripDevritesHooks(config map[string]any) map[string]any {
1420
- next := map[string]any{}
1421
- for k, v := range config {
1422
- if k == "$comment" {
1423
- if s, ok := v.(string); ok && isDevritesHooksComment(s) {
1424
- continue
1425
- }
1426
- }
1427
- if k == "statusLine" && isDevritesHookCommand(v) {
1428
- continue
1429
- }
1430
- next[k] = v
1431
- }
1432
- rawHooksValue, exists := next["hooks"]
1433
- if !exists {
1434
- return next
1435
- }
1436
- rawHooks, ok := rawHooksValue.(map[string]any)
1437
- if !ok {
1438
- return next
1439
- }
1440
- hooks := map[string]any{}
1441
- for event, entries := range rawHooks {
1442
- arr, ok := entries.([]any)
1443
- if !ok {
1444
- hooks[event] = entries
1445
- continue
1446
- }
1447
- var kept []any
1448
- for _, entry := range arr {
1449
- entryMap, ok := entry.(map[string]any)
1450
- if !ok {
1451
- kept = append(kept, entry)
1452
- continue
1453
- }
1454
- commands, ok := entryMap["hooks"].([]any)
1455
- if !ok {
1456
- kept = append(kept, entry)
1457
- continue
1458
- }
1459
- var keptCommands []any
1460
- for _, command := range commands {
1461
- if !isDevritesHookCommand(command) {
1462
- keptCommands = append(keptCommands, command)
1463
- }
1464
- }
1465
- if len(keptCommands) > 0 {
1466
- nextEntry := make(map[string]any, len(entryMap))
1467
- for k, v := range entryMap {
1468
- nextEntry[k] = v
1469
- }
1470
- nextEntry["hooks"] = keptCommands
1471
- kept = append(kept, nextEntry)
1472
- }
1473
- }
1474
- if len(kept) > 0 {
1475
- hooks[event] = kept
1476
- }
1477
- }
1478
- if len(hooks) == 0 {
1479
- delete(next, "hooks")
1480
- } else {
1481
- next["hooks"] = hooks
1482
- }
1483
- return next
1484
- }
1485
-
1486
- func stripDevritesSettings(config map[string]any, removeOwnedDefaultMode bool) map[string]any {
1487
- next := stripDevritesHooks(config)
1488
- if comment, ok := next["$comment"].(string); ok && isDevritesSettingsComment(comment) {
1489
- delete(next, "$comment")
1490
- }
1491
-
1492
- rawPermissions, exists := next["permissions"]
1493
- if !exists {
1494
- return next
1495
- }
1496
- permissions, ok := rawPermissions.(map[string]any)
1497
- if !ok {
1498
- return next
1499
- }
1500
- clean := make(map[string]any, len(permissions))
1501
- for key, value := range permissions {
1502
- switch key {
1503
- case "allow":
1504
- rules, ok := value.([]any)
1505
- if !ok {
1506
- clean[key] = value
1507
- continue
1508
- }
1509
- kept := make([]any, 0, len(rules))
1510
- for _, rule := range rules {
1511
- if !isDevritesPermissionRule(rule) {
1512
- kept = append(kept, rule)
1513
- }
1514
- }
1515
- if len(kept) > 0 {
1516
- clean[key] = kept
1517
- }
1518
- case "defaultMode":
1519
- if removeOwnedDefaultMode && value == "plan" {
1520
- continue
1521
- }
1522
- clean[key] = value
1523
- default:
1524
- clean[key] = value
1525
- }
1526
- }
1527
- if len(clean) == 0 {
1528
- delete(next, "permissions")
1529
- } else {
1530
- next["permissions"] = clean
1531
- }
1532
- return next
1533
- }
1534
-
1535
- func mergeClaudeSettingsConfig(current, desired map[string]any, defaultModeOwned bool) (map[string]any, bool, error) {
1536
- next := stripDevritesSettings(current, false)
1537
- permissions := map[string]any{}
1538
- if raw, exists := next["permissions"]; exists {
1539
- var ok bool
1540
- permissions, ok = raw.(map[string]any)
1541
- if !ok {
1542
- return nil, false, fmt.Errorf("existing Claude permissions must be a JSON object")
1543
- }
1544
- }
1545
-
1546
- switch mode, exists := permissions["defaultMode"]; {
1547
- case !exists:
1548
- defaultModeOwned = true
1549
- case mode == "plan":
1550
- case mode != "plan":
1551
- return nil, false, fmt.Errorf("existing Claude permissions.defaultMode is %q; DevRites requires plan mode for the read-only root orchestrator", mode)
1552
- }
1553
- permissions["defaultMode"] = "plan"
1554
-
1555
- desiredPermissions, ok := desired["permissions"].(map[string]any)
1556
- if !ok || desiredPermissions["defaultMode"] != "plan" {
1557
- return nil, false, fmt.Errorf("DevRites Claude settings payload must declare permissions.defaultMode=plan")
1558
- }
1559
- desiredAllow, ok := desiredPermissions["allow"].([]any)
1560
- if !ok {
1561
- return nil, false, fmt.Errorf("DevRites Claude settings payload permissions.allow must be an array")
1562
- }
1563
- var allow []any
1564
- if existingAllow, exists := permissions["allow"]; exists {
1565
- allow, ok = existingAllow.([]any)
1566
- if !ok {
1567
- return nil, false, fmt.Errorf("existing Claude permissions.allow must be an array")
1568
- }
1569
- }
1570
- seen := map[string]bool{}
1571
- for _, rule := range allow {
1572
- if value, ok := rule.(string); ok {
1573
- seen[value] = true
1574
- }
1575
- }
1576
- for _, rule := range desiredAllow {
1577
- value, ok := rule.(string)
1578
- if !ok {
1579
- return nil, false, fmt.Errorf("DevRites Claude permission rules must be strings")
1580
- }
1581
- if !seen[value] {
1582
- allow = append(allow, value)
1583
- seen[value] = true
1584
- }
1585
- }
1586
- permissions["allow"] = allow
1587
- next["permissions"] = permissions
1588
- if _, exists := next["$comment"]; !exists {
1589
- if comment, ok := desired["$comment"].(string); ok {
1590
- next["$comment"] = comment
1591
- }
1592
- }
1593
- return next, defaultModeOwned, nil
1594
- }
1595
-
1596
- func isDevritesPermissionRule(value any) bool {
1597
- rule, ok := value.(string)
1598
- return ok && strings.HasPrefix(strings.TrimSpace(rule), "Bash(devrites-engine ")
1599
- }
1600
-
1601
- func (r *runner) installBinary() error {
1602
- if !r.opts.WithBinary || os.Getenv("DEVRITES_NO_BINARY") == "1" {
1603
- fmt.Fprintln(r.opts.Stdout, " engine binary: skipped (--no-binary).")
1604
- return nil
1605
- }
1606
- if r.opts.DryRun {
1607
- fmt.Fprintln(r.opts.Stdout, " would install the global devrites-engine control-plane binary")
1608
- return nil
1609
- }
1610
- tag := r.binaryTag()
1611
- incoming := strings.TrimPrefix(tag, "v")
1612
- dest := binaryDest()
1613
- if exists(dest) {
1614
- if ev := engineVersion(dest); semverLike(ev) && semverLike(incoming) && verGT(ev, incoming) {
1615
- fmt.Fprintf(r.opts.Stderr, "warning: engine binary: installed %s is newer than %s - refusing to downgrade (kept).\n", ev, tag)
1616
- return nil
1617
- }
1618
- }
1619
- staged, cleanup := r.preparedBinary, func() {}
1620
- if staged == "" {
1621
- var err error
1622
- staged, cleanup, err = r.acquireBinary(tag)
1623
- if err != nil {
1624
- return r.binaryInstallFailure(err)
1625
- }
1626
- }
1627
- defer cleanup()
1628
- if _, err := verifyEngineBinary(staged, tag, 30*time.Second); err != nil {
1629
- return fmt.Errorf("verify staged engine binary %s: %w", staged, err)
1630
- }
1631
- if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
1632
- return err
1633
- }
1634
- data, err := os.ReadFile(staged)
1635
- if err != nil {
1636
- return err
1637
- }
1638
- backup, oldMode, hadOld, err := backupBinary(dest)
1639
- if err != nil {
1640
- return err
1641
- }
1642
- if backup != "" {
1643
- defer os.Remove(backup)
1644
- }
1645
- if err := fsutil.WriteFileAtomic(dest, data, 0o755); err != nil {
1646
- if restoreErr := restoreBinary(dest, backup, oldMode, hadOld); restoreErr != nil {
1647
- return fmt.Errorf("install binary: %v; restore previous binary: %w", err, restoreErr)
1648
- }
1649
- return err
1650
- }
1651
- _ = os.Chmod(dest, 0o755)
1652
- if _, err := verifyEngineBinary(dest, tag, 30*time.Second); err != nil {
1653
- if restoreErr := restoreBinary(dest, backup, oldMode, hadOld); restoreErr != nil {
1654
- return fmt.Errorf("verify installed engine binary: %v; restore previous binary: %w", err, restoreErr)
1655
- }
1656
- if hadOld {
1657
- return fmt.Errorf("verify installed engine binary: %w (previous binary restored)", err)
1658
- }
1659
- return fmt.Errorf("verify installed engine binary: %w (bad binary removed)", err)
1660
- }
1661
- fmt.Fprintf(r.opts.Stdout, " engine binary: installed %s\n", dest)
1662
- return nil
1663
- }
1664
-
1665
- func (r *runner) binaryTag() string {
1666
- if r.requiredBinaryTag != "" {
1667
- return r.requiredBinaryTag
1668
- }
1669
- if tag := os.Getenv("DEVRITES_REF"); semverLike(tag) {
1670
- return tag
1671
- }
1672
- return "v" + strings.TrimPrefix(installedVersion(r.source), "v")
1673
- }
1674
-
1675
- func backupBinary(dest string) (string, fs.FileMode, bool, error) {
1676
- info, err := os.Lstat(dest)
1677
- if os.IsNotExist(err) {
1678
- return "", 0, false, nil
1679
- }
1680
- if err != nil {
1681
- return "", 0, false, fmt.Errorf("inspect existing engine binary: %w", err)
1682
- }
1683
- if !info.Mode().IsRegular() {
1684
- return "", 0, false, fmt.Errorf("refusing to replace non-regular engine binary: %s", dest)
1685
- }
1686
- data, err := os.ReadFile(dest)
1687
- if err != nil {
1688
- return "", 0, false, fmt.Errorf("read existing engine binary: %w", err)
1689
- }
1690
- f, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".backup-*")
1691
- if err != nil {
1692
- return "", 0, false, fmt.Errorf("create engine binary backup: %w", err)
1693
- }
1694
- backup := f.Name()
1695
- if err := f.Close(); err != nil {
1696
- _ = os.Remove(backup)
1697
- return "", 0, false, fmt.Errorf("create engine binary backup: %w", err)
1698
- }
1699
- if err := fsutil.WriteFileAtomic(backup, data, info.Mode().Perm()); err != nil {
1700
- _ = os.Remove(backup)
1701
- return "", 0, false, fmt.Errorf("write engine binary backup: %w", err)
1702
- }
1703
- return backup, info.Mode().Perm(), true, nil
1704
- }
1705
-
1706
- func restoreBinary(dest, backup string, mode fs.FileMode, hadOld bool) error {
1707
- if !hadOld {
1708
- if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
1709
- return err
1710
- }
1711
- return nil
1712
- }
1713
- data, err := os.ReadFile(backup)
1714
- if err != nil {
1715
- return err
1716
- }
1717
- return fsutil.WriteFileAtomic(dest, data, mode)
1718
- }
1719
-
1720
- func (r *runner) binaryInstallFailure(err error) error {
1721
- if r.preparedBinary != "" {
1722
- return err
1723
- }
1724
- if os.Getenv("DEVRITES_ENGINE_CLI") != "" {
1725
- return err
1726
- }
1727
- fmt.Fprintf(r.opts.Stderr, "warning: engine binary: %v; continuing without it.\n", err)
1728
- return nil
1729
- }
1730
-
1731
- func (r *runner) acquireBinary(tag string) (string, func(), error) {
1732
- path := os.Getenv("DEVRITES_ENGINE_CLI")
1733
- if path == "" {
1734
- return "", func() {}, fmt.Errorf("no DEVRITES_ENGINE_CLI handoff")
1735
- }
1736
- if !exists(path) {
1737
- return "", func() {}, fmt.Errorf("DEVRITES_ENGINE_CLI points to a missing binary: %s", path)
1738
- }
1739
- if _, err := verifyEngineBinary(path, tag, 30*time.Second); err != nil {
1740
- return "", func() {}, fmt.Errorf("DEVRITES_ENGINE_CLI is incompatible: %w", err)
1741
- }
1742
- return path, func() {}, nil
1743
- }
1744
-
1745
- func (r *runner) removeBinary() error {
1746
- if r.opts.KeepBinary {
1747
- fmt.Fprintln(r.opts.Stdout, " kept the global devrites-engine binary (--keep-binary).")
1748
- return nil
1749
- }
1750
- for _, p := range binaryCandidates() {
1751
- if p == "" || !exists(p) {
1752
- continue
1753
- }
1754
- if r.opts.DryRun {
1755
- fmt.Fprintf(r.opts.Stdout, " [remove] %s (global engine binary)\n", p)
1756
- continue
1757
- }
1758
- if err := os.Remove(p); err == nil {
1759
- fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", p)
1760
- return nil
1761
- }
1762
- }
1763
- return nil
1764
- }
1765
-
1766
- func binaryDest() string {
1767
- if dir := os.Getenv("DEVRITES_BIN_DIR"); dir != "" {
1768
- return filepath.Join(dir, engineBinaryName())
1769
- }
1770
- if home, err := os.UserHomeDir(); err == nil {
1771
- return filepath.Join(home, ".local", "bin", engineBinaryName())
1772
- }
1773
- return filepath.Join("/usr/local/bin", engineBinaryName())
1774
- }
1775
-
1776
- func engineBinaryName() string {
1777
- if runtime.GOOS == "windows" {
1778
- return "devrites-engine.exe"
1779
- }
1780
- return "devrites-engine"
1781
- }
1782
-
1783
- func binaryCandidates() []string {
1784
- candidates := []string{}
1785
- if dir := os.Getenv("DEVRITES_BIN_DIR"); dir != "" {
1786
- candidates = append(candidates, filepath.Join(dir, engineBinaryName()))
1787
- }
1788
- home, _ := os.UserHomeDir()
1789
- if home != "" {
1790
- candidates = append(candidates, filepath.Join(home, ".local", "bin", engineBinaryName()))
1791
- }
1792
- return append(candidates, filepath.Join("/usr/local/bin", engineBinaryName()))
1793
- }
1794
-
1795
- func engineVersion(path string) string {
1796
- got, err := readEngineVersion(path, 30*time.Second)
1797
- if err != nil {
1798
- return ""
1799
- }
1800
- return got
1801
- }
1802
-
1803
- func verifyEngineBinary(path, want string, timeout time.Duration) (string, error) {
1804
- want = strings.TrimPrefix(strings.TrimSpace(want), "v")
1805
- if want == "" || want == "dev" || !semverLike(want) {
1806
- return "", fmt.Errorf("invalid requested version %q", want)
1807
- }
1808
- got, err := readEngineVersion(path, timeout)
1809
- if err != nil {
1810
- return "", err
1811
- }
1812
- if strings.TrimPrefix(got, "v") != want {
1813
- return "", fmt.Errorf("version mismatch: got %s want %s", got, want)
1814
- }
1815
- return got, nil
1816
- }
1817
-
1818
- func readEngineVersion(path string, timeout time.Duration) (string, error) {
1819
- ctx, cancel := context.WithTimeout(context.Background(), timeout)
1820
- defer cancel()
1821
- out, err := exec.CommandContext(ctx, path, "version").Output()
1822
- if err != nil {
1823
- if errors.Is(ctx.Err(), context.DeadlineExceeded) {
1824
- return "", fmt.Errorf("version command timed out after %s", timeout)
1825
- }
1826
- return "", fmt.Errorf("run exact path %s version: %w", path, err)
1827
- }
1828
- line := strings.TrimSuffix(string(out), "\n")
1829
- line = strings.TrimSuffix(line, "\r")
1830
- if strings.ContainsAny(line, "\r\n") {
1831
- return "", fmt.Errorf("invalid multi-line version output")
1832
- }
1833
- line = strings.TrimSpace(line)
1834
- if line == "" || line == "dev" || !semverLike(line) {
1835
- return "", fmt.Errorf("invalid version output %q", line)
1836
- }
1837
- return line, nil
1838
- }
1839
-
1840
- func readManifest(path string) (map[string]managedRecord, error) {
1841
- records, _, err := parseManifest(path)
1842
- return records, err
1843
- }
1844
-
1845
- func readManifestList(path string) ([]string, error) {
1846
- _, entries, err := parseManifest(path)
1847
- return entries, err
1848
- }
1849
-
1850
- func parseManifest(path string) (map[string]managedRecord, []string, error) {
1851
- data, err := os.ReadFile(path)
1852
- if err != nil {
1853
- if errors.Is(err, fs.ErrNotExist) {
1854
- return map[string]managedRecord{}, nil, nil
1855
- }
1856
- return nil, nil, fmt.Errorf("read manifest %s: %w", path, err)
1857
- }
1858
- hashes := map[string]string{}
1859
- var out []string
1860
- for _, line := range strings.Split(string(data), "\n") {
1861
- line = strings.TrimSpace(line)
1862
- if strings.HasPrefix(line, "# managed: ") {
1863
- record := strings.TrimPrefix(line, "# managed: ")
1864
- i := strings.LastIndex(record, " sha256:")
1865
- if i > 0 {
1866
- rel := strings.TrimSpace(record[:i])
1867
- hash := "sha256:" + strings.ToLower(strings.TrimSpace(record[i+len(" sha256:"):]))
1868
- if filepath.IsLocal(filepath.FromSlash(rel)) && validManagedHash(hash) {
1869
- hashes[rel] = hash
1870
- }
1871
- }
1872
- continue
1873
- }
1874
- if line == "" || strings.HasPrefix(line, "#") {
1875
- continue
1876
- }
1877
- // The manifest is hand-editable; an entry that escapes the target
1878
- // (absolute or ..-traversal) must never be overwritten or removed.
1879
- if !filepath.IsLocal(filepath.FromSlash(line)) {
1880
- continue
1881
- }
1882
- out = append(out, line)
1883
- }
1884
- records := make(map[string]managedRecord, len(out))
1885
- for _, rel := range out {
1886
- records[rel] = managedRecord{Hash: hashes[rel]}
1887
- }
1888
- return records, out, nil
1889
- }
1890
-
1891
- func validManagedHash(hash string) bool {
1892
- const prefix = "sha256:"
1893
- if !strings.HasPrefix(hash, prefix) || len(hash) != len(prefix)+sha256.Size*2 {
1894
- return false
1895
- }
1896
- _, err := hex.DecodeString(strings.TrimPrefix(hash, prefix))
1897
- return err == nil
1898
- }
1899
-
1900
- func manifestHeader(path, key string) string {
1901
- data, err := os.ReadFile(path)
1902
- if err != nil {
1903
- return ""
1904
- }
1905
- prefix := "# " + key + ":"
1906
- for _, line := range strings.Split(string(data), "\n") {
1907
- if strings.HasPrefix(line, prefix) {
1908
- return strings.TrimSpace(strings.TrimPrefix(line, prefix))
1909
- }
1910
- }
1911
- return ""
1912
- }
1913
-
1914
- func installedVersion(source string) string {
1915
- if source != "" {
1916
- data, err := os.ReadFile(filepath.Join(source, "package.json"))
1917
- if err != nil {
1918
- return "unknown"
1919
- }
1920
- var pkg struct {
1921
- Version string `json:"version"`
1922
- }
1923
- if json.Unmarshal(data, &pkg) != nil || pkg.Version == "" {
1924
- return "unknown"
1925
- }
1926
- return pkg.Version
1927
- }
1928
- if version.Version != "" && version.Version != "dev" {
1929
- return strings.TrimPrefix(version.Version, "v")
1930
- }
1931
- return "unknown"
1932
- }
1933
-
1934
338
  func isGlobalTarget(target string) bool {
1935
339
  home, _ := os.UserHomeDir()
1936
340
  if target == home {