@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
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
550
|
+
targets.append(platform_home / "skills" / "r2p" / "SKILL.md")
|
|
508
551
|
for src in sorted((template_dir / "commands").glob("r2p-*.md")):
|
|
509
|
-
targets.
|
|
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.
|
|
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.
|
|
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.
|
|
1
|
+
R2P_VERSION = "0.4.4"
|