@xenonbyte/req-2-plan 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -10,6 +10,8 @@ import json
10
10
  import hashlib
11
11
  import shutil
12
12
  import shlex
13
+ import stat
14
+ from dataclasses import dataclass
13
15
  from datetime import datetime, timezone
14
16
  from pathlib import Path
15
17
  from typing import Any
@@ -40,6 +42,21 @@ KNOWN_OBSOLETE_PLATFORM_TARGETS = {
40
42
  }
41
43
 
42
44
 
45
+ @dataclass
46
+ class _FileSnapshot:
47
+ path: Path
48
+ content: bytes
49
+ mode: int
50
+
51
+
52
+ @dataclass
53
+ class _InstallSnapshot:
54
+ manifest_path: Path
55
+ manifest_text: str
56
+ files: list[_FileSnapshot]
57
+ backup_files: list[_FileSnapshot]
58
+
59
+
43
60
  def _now_ts() -> str:
44
61
  """Return UTC timestamp for backup filenames."""
45
62
  return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
@@ -84,17 +101,24 @@ class InstallService:
84
101
  )
85
102
 
86
103
  manifest_path = self._manifest_path(platform)
87
- if manifest_path.exists():
88
- self.uninstall(platform)
104
+ backup_dir = self.manifest_root / "install" / "backups" / platform
105
+ self._validate_install_plan(platform, manifest_path, backup_dir)
106
+ prior_install = (
107
+ self._capture_install_snapshot(platform, manifest_path)
108
+ if manifest_path.exists()
109
+ else None
110
+ )
89
111
 
90
112
  installed_paths: list[str] = []
91
113
  backups: list[dict[str, str]] = []
92
114
  written: list[Path] = []
93
- backup_dir = self.manifest_root / "install" / "backups" / platform
115
+ manifest_written = False
94
116
 
95
117
  try:
118
+ if prior_install is not None:
119
+ self.uninstall(platform)
120
+
96
121
  bin_dir = self.manifest_root / "bin"
97
- bin_dir.mkdir(parents=True, exist_ok=True)
98
122
 
99
123
  # Copy bin scripts
100
124
  for src in sorted(self.repo_root.glob("tools/r2p-*")):
@@ -104,7 +128,7 @@ class InstallService:
104
128
  src.read_text(encoding="utf-8"),
105
129
  self.repo_root,
106
130
  )
107
- _safe_write(
131
+ self._write_managed_file(
108
132
  dest, content, backups, installed_paths, written, backup_dir
109
133
  )
110
134
  shutil.copymode(str(src), str(dest))
@@ -120,7 +144,7 @@ class InstallService:
120
144
  skill_src = template_dir / "SKILL.md"
121
145
  skill_dest = platform_home / "skills" / "r2p" / "SKILL.md"
122
146
  content = _render(skill_src.read_text(), R2P_VERSION, str(bin_dir))
123
- _safe_write(
147
+ self._write_managed_file(
124
148
  skill_dest, content, backups, installed_paths, written, backup_dir
125
149
  )
126
150
 
@@ -129,7 +153,7 @@ class InstallService:
129
153
  for src in sorted(cmd_dir.glob("r2p-*.md")):
130
154
  dest = platform_home / "commands" / src.name
131
155
  content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
132
- _safe_write(
156
+ self._write_managed_file(
133
157
  dest, content, backups, installed_paths, written, backup_dir
134
158
  )
135
159
 
@@ -139,7 +163,7 @@ class InstallService:
139
163
  for src in sorted(skills_dir.glob("r2p-*/SKILL.md")):
140
164
  dest = platform_home / "skills" / src.parent.name / "SKILL.md"
141
165
  content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
142
- _safe_write(
166
+ self._write_managed_file(
143
167
  dest, content, backups, installed_paths, written, backup_dir
144
168
  )
145
169
 
@@ -149,7 +173,7 @@ class InstallService:
149
173
  for src in sorted(cmd_dir.glob("r2p-*.toml")):
150
174
  dest = platform_home / "commands" / src.name
151
175
  content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
152
- _safe_write(
176
+ self._write_managed_file(
153
177
  dest, content, backups, installed_paths, written, backup_dir
154
178
  )
155
179
 
@@ -162,8 +186,10 @@ class InstallService:
162
186
  "r2p_version": R2P_VERSION,
163
187
  "schema_version": SCHEMA_VERSION,
164
188
  }
189
+ self._validate_install_path(manifest_path, field="manifest")
165
190
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
166
191
  manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
192
+ manifest_written = True
167
193
 
168
194
  # Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
169
195
  # are no longer part of the current template set, across all manifests.
@@ -172,6 +198,12 @@ class InstallService:
172
198
 
173
199
  except Exception:
174
200
  # Rollback: remove written files, restore backups
201
+ if manifest_written:
202
+ # A pre-existing manifest is impossible here: a fresh install
203
+ # never had one, and a reinstall's uninstall already removed
204
+ # it — prior-manifest restoration is owned by
205
+ # _restore_install_snapshot below.
206
+ manifest_path.unlink(missing_ok=True)
175
207
  for path in reversed(written):
176
208
  try:
177
209
  path.unlink(missing_ok=True)
@@ -183,6 +215,9 @@ class InstallService:
183
215
  if backup_path.exists():
184
216
  target_path.parent.mkdir(parents=True, exist_ok=True)
185
217
  shutil.copy2(str(backup_path), str(target_path))
218
+ backup_path.unlink(missing_ok=True)
219
+ if prior_install is not None:
220
+ self._restore_install_snapshot(prior_install)
186
221
  raise
187
222
 
188
223
  def uninstall(self, platform: str) -> dict:
@@ -488,31 +523,39 @@ class InstallService:
488
523
  if platform not in SUPPORTED_PLATFORMS:
489
524
  raise ValueError(f"unsafe_manifest: unsupported_platform {platform!r}")
490
525
 
526
+ targets = set(self._install_targets(platform))
527
+ bin_dir = self.manifest_root / "bin"
528
+ targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
529
+
530
+ platform_home = self.platform_homes[platform]
531
+ for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
532
+ targets.add(platform_home.joinpath(*parts))
533
+
534
+ return targets
535
+
536
+ def _install_targets(self, platform: str) -> list[Path]:
491
537
  bin_dir = self.manifest_root / "bin"
492
- targets = {
538
+ targets = [
493
539
  bin_dir / src.name
494
540
  for src in sorted(self.repo_root.glob("tools/r2p-*"))
495
541
  if src.is_file()
496
- }
497
- targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
542
+ ]
498
543
 
499
544
  template_dir = (
500
545
  self.repo_root / "tools" / "workflow_cli" / "agent_templates" / platform
501
546
  )
502
547
  platform_home = self.platform_homes[platform]
503
- for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
504
- targets.add(platform_home.joinpath(*parts))
505
548
 
506
549
  if platform == "claude":
507
- targets.add(platform_home / "skills" / "r2p" / "SKILL.md")
550
+ targets.append(platform_home / "skills" / "r2p" / "SKILL.md")
508
551
  for src in sorted((template_dir / "commands").glob("r2p-*.md")):
509
- targets.add(platform_home / "commands" / src.name)
552
+ targets.append(platform_home / "commands" / src.name)
510
553
  elif platform == "codex":
511
554
  for src in sorted((template_dir / "skills").glob("r2p-*/SKILL.md")):
512
- targets.add(platform_home / "skills" / src.parent.name / "SKILL.md")
555
+ targets.append(platform_home / "skills" / src.parent.name / "SKILL.md")
513
556
  elif platform == "gemini":
514
557
  for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
515
- targets.add(platform_home / "commands" / src.name)
558
+ targets.append(platform_home / "commands" / src.name)
516
559
 
517
560
  return targets
518
561
 
@@ -703,6 +746,99 @@ class InstallService:
703
746
  except (OSError, UnicodeDecodeError, ValueError):
704
747
  return None
705
748
 
749
+ def _capture_install_snapshot(
750
+ self,
751
+ platform: str,
752
+ manifest_path: Path,
753
+ ) -> _InstallSnapshot:
754
+ manifest = _load_manifest(manifest_path)
755
+ self._validate_manifest_for_uninstall(platform, manifest)
756
+ files: list[_FileSnapshot] = []
757
+ backup_files: list[_FileSnapshot] = []
758
+
759
+ for path_str in manifest.get("installed_paths", []):
760
+ snapshot = self._snapshot_file(Path(path_str))
761
+ if snapshot is not None:
762
+ files.append(snapshot)
763
+
764
+ for backup in manifest.get("backups", []):
765
+ snapshot = self._snapshot_file(Path(backup["backup"]))
766
+ if snapshot is not None:
767
+ backup_files.append(snapshot)
768
+
769
+ return _InstallSnapshot(
770
+ manifest_path=manifest_path,
771
+ manifest_text=manifest_path.read_text(encoding="utf-8"),
772
+ files=files,
773
+ backup_files=backup_files,
774
+ )
775
+
776
+ def _snapshot_file(self, path: Path) -> _FileSnapshot | None:
777
+ if not path.is_file():
778
+ return None
779
+ return _FileSnapshot(
780
+ path=path,
781
+ content=path.read_bytes(),
782
+ mode=stat.S_IMODE(path.stat().st_mode),
783
+ )
784
+
785
+ def _restore_install_snapshot(self, snapshot: _InstallSnapshot) -> None:
786
+ for file_snapshot in snapshot.files + snapshot.backup_files:
787
+ file_snapshot.path.parent.mkdir(parents=True, exist_ok=True)
788
+ file_snapshot.path.write_bytes(file_snapshot.content)
789
+ try:
790
+ file_snapshot.path.chmod(file_snapshot.mode)
791
+ except OSError:
792
+ pass
793
+ snapshot.manifest_path.parent.mkdir(parents=True, exist_ok=True)
794
+ snapshot.manifest_path.write_text(snapshot.manifest_text, encoding="utf-8")
795
+
796
+ def _validate_install_path(self, path: Path, *, field: str) -> None:
797
+ """Reject install writes that would follow untrusted symlinks."""
798
+ if path.is_symlink():
799
+ raise ValueError(f"unsafe_install: {field}_is_symlink")
800
+ normalized = self._normalize_without_resolving_symlinks(path)
801
+ try:
802
+ self._reject_symlinked_ancestors(normalized, field=field)
803
+ except ValueError as exc:
804
+ raise ValueError(
805
+ str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
806
+ ) from exc
807
+
808
+ def _validate_install_backup_dir(self, backup_dir: Path) -> None:
809
+ probe = backup_dir / "__r2p_probe__"
810
+ normalized = self._normalize_without_resolving_symlinks(probe)
811
+ try:
812
+ self._reject_symlinked_ancestors(normalized, field="backup_dir")
813
+ except ValueError as exc:
814
+ raise ValueError(
815
+ str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
816
+ ) from exc
817
+
818
+ def _validate_install_plan(
819
+ self,
820
+ platform: str,
821
+ manifest_path: Path,
822
+ backup_dir: Path,
823
+ ) -> None:
824
+ for target in self._install_targets(platform):
825
+ self._validate_install_path(target, field="install_destination")
826
+ self._validate_install_path(manifest_path, field="manifest")
827
+ self._validate_install_backup_dir(backup_dir)
828
+
829
+ def _write_managed_file(
830
+ self,
831
+ dest: Path,
832
+ content: str,
833
+ backups: list[dict[str, str]],
834
+ installed_paths: list[str],
835
+ written: list[Path],
836
+ backup_dir: Path,
837
+ ) -> None:
838
+ self._validate_install_path(dest, field="install_destination")
839
+ self._validate_install_backup_dir(backup_dir)
840
+ _safe_write(dest, content, backups, installed_paths, written, backup_dir)
841
+
706
842
 
707
843
  # ---------------------------------------------------------------------------
708
844
  # Internal helpers
@@ -1 +1 @@
1
- R2P_VERSION = "0.4.3"
1
+ R2P_VERSION = "0.4.4"