Skip to content

Commit dc8e4a5

Browse files
committed
feat: Add Guardian Protocol (v1.0.4)
1 parent 1a07838 commit dc8e4a5

3 files changed

Lines changed: 130 additions & 119 deletions

File tree

omnipkg/cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def create_parser():
4646
info_parser.add_argument('package', help='Package name to inspect')
4747
info_parser.add_argument('--version', default='active', help='Specific version to inspect')
4848

49+
revert_parser = subparsers.add_parser('revert', help="Revert environment to the last known good state")
50+
revert_parser.add_argument('--yes', '-y', action='store_true', help='Skip confirmation and revert immediately')
51+
4952
list_parser = subparsers.add_parser('list', help='List installed packages')
5053
list_parser.add_argument('filter', nargs='?', help='Optional filter pattern for package names')
5154

@@ -91,12 +94,14 @@ def main():
9194

9295
# Now, create the main instance, PASSING IN the loaded config
9396
pkg_instance = omnipkg(cm.config)
94-
9597
try:
9698
if args.command == 'install':
9799
return pkg_instance.smart_install(args.packages)
98100
elif args.command == 'uninstall':
99101
return pkg_instance.smart_uninstall(args.packages, force=args.yes)
102+
103+
elif args.command == 'revert':
104+
return pkg_instance.revert_to_last_known_good(force=args.yes)
100105
elif args.command == 'info':
101106
return pkg_instance.show_package_info(args.package, args.version)
102107
elif args.command == 'list':

omnipkg/core.py

Lines changed: 123 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -858,30 +858,18 @@ def _show_version_details(self, package_name: str, version: str):
858858
print(f" {display_name.ljust(18)}: {value}")
859859

860860
print(f"\n💡 For all raw data, use Redis key: \"{version_key}\"")
861-
862-
def _sort_packages_newest_first(self, packages: List[str]) -> List[str]:
863-
"""Sorts packages by version, newest first, to ensure proper bubble creation."""
864-
from packaging.version import parse as parse_version, InvalidVersion
865-
import re
866-
867-
def get_version_key(pkg_spec):
868-
"""Extracts a sortable version key from a package spec."""
869-
# Match version strings like ==, >=, <, etc.
870-
match = re.search(r'(==|>=|<=|>|<)(.+)', pkg_spec)
871-
if match:
872-
version_str = match.group(2).strip()
873-
try:
874-
# Return a valid version object for sorting
875-
return parse_version(version_str)
876-
except InvalidVersion:
877-
# If version is weird (e.g., a URL), treat it as very old
878-
return parse_version('0.0.0')
879-
# If no version is specified, treat it as the "newest possible"
880-
return parse_version('9999.0.0')
881861

882-
# Sort the list of packages. The key function determines the order.
883-
# `reverse=True` puts the newest (highest) versions first.
884-
return sorted(packages, key=get_version_key, reverse=True)
862+
def _save_last_known_good_snapshot(self):
863+
"""Saves the current environment state to Redis."""
864+
print("📸 Saving snapshot of the current environment as 'last known good'...")
865+
try:
866+
current_state = self.get_installed_packages(live=True)
867+
snapshot_key = f"{self.config['redis_key_prefix']}snapshot:last_known_good"
868+
# We store the package list as a JSON string
869+
self.redis_client.set(snapshot_key, json.dumps(current_state))
870+
print(" ✅ Snapshot saved.")
871+
except Exception as e:
872+
print(f" ⚠️ Could not save environment snapshot: {e}")
885873

886874
def smart_install(self, packages: List[str], dry_run: bool = False) -> int:
887875
if not self.connect_redis():
@@ -890,64 +878,58 @@ def smart_install(self, packages: List[str], dry_run: bool = False) -> int:
890878
if dry_run:
891879
print("🔬 Running in --dry-run mode. No changes will be made.")
892880
return 0
893-
894-
# Sort packages newest to oldest to ensure proper bubble creation
895-
sorted_packages = self._sort_packages_newest_first(packages)
896-
897-
if sorted_packages != packages:
898-
print(f"🔄 Reordered packages for optimal installation: {', '.join(sorted_packages)}")
899-
900-
# Process each package individually to handle version conflicts
901-
for package_spec in sorted_packages:
902-
print("\n" + "─"*60)
903-
print(f"📦 Processing: {package_spec}")
904-
print("─"*60)
905-
906-
satisfaction_check = self._check_package_satisfaction([package_spec])
907881

908-
if satisfaction_check['all_satisfied']:
909-
print(f"✅ Requirement already satisfied: {package_spec}")
910-
continue
882+
print(f"🔍 Checking satisfaction for: {', '.join(packages)}")
911883

912-
packages_to_install = satisfaction_check['needs_install']
913-
914-
print("\n📸 Taking LIVE pre-installation snapshot...")
915-
packages_before = self.get_installed_packages(live=True)
916-
print(f" - Found {len(packages_before)} packages")
884+
satisfaction_check = self._check_package_satisfaction(packages)
917885

918-
print(f"\n⚙️ Running pip install for: {', '.join(packages_to_install)}...")
919-
return_code = self._run_pip_install(packages_to_install)
920-
921-
if return_code != 0:
922-
print(f"❌ Pip installation for {package_spec} failed. Continuing with next package.")
923-
continue
886+
if satisfaction_check['all_satisfied']:
887+
print("\n✅ All requirements already satisfied. No work needed. 🛡️")
888+
return 0
924889

925-
print("\n🔬 Analyzing post-installation changes...")
926-
packages_after = self.get_installed_packages(live=True)
927-
# THIS IS THE NEW LINE: Capture the state containing the new version
928-
packages_to_index = packages_after
929-
downgrades_to_fix = self._detect_downgrades(packages_before, packages_after)
930-
931-
if downgrades_to_fix:
932-
print("\n🛡️ DOWNGRADE PROTECTION ACTIVATED!")
933-
for fix in downgrades_to_fix:
934-
print(f" -> Fixing downgrade: {fix['package']} from v{fix['good_version']} to v{fix['bad_version']}")
935-
self.bubble_manager.create_isolated_bubble(fix['package'], fix['bad_version'])
936-
print(f" 🔄 Restoring '{fix['package']}' to safe version v{fix['good_version']} in main environment...")
937-
subprocess.run([self.config["python_executable"], "-m", "pip", "install", "--quiet", f"{fix['package']}=={fix['good_version']}"], capture_output=True, text=True)
938-
print("\n✅ Environment protection complete!")
939-
else:
940-
print("✅ No downgrades detected. Installation completed safely.")
890+
packages_to_install = satisfaction_check['needs_install']
891+
892+
if satisfaction_check['partial_satisfied']:
893+
print("\nℹ️ Some packages already satisfied:")
894+
for pkg in satisfaction_check['satisfied']:
895+
print(f" ✅ {pkg}")
896+
print("\n📦 Proceeding to install remaining packages:")
897+
for pkg in packages_to_install:
898+
print(f" 📥 {pkg}")
899+
900+
print("\n📸 Taking LIVE pre-installation snapshot...")
901+
packages_before = self.get_installed_packages(live=True)
902+
print(f" - Found {len(packages_before)} packages")
903+
904+
print(f"\n⚙️ Running pip install for: {', '.join(packages_to_install)}...")
905+
return_code = self._run_pip_install(packages_to_install)
906+
907+
if return_code != 0:
908+
print("❌ Pip installation failed. Aborting cleanup.")
909+
return return_code
910+
911+
print("\n🔬 Analyzing post-installation changes...")
912+
packages_after = self.get_installed_packages(live=True)
913+
downgrades_to_fix = self._detect_downgrades(packages_before, packages_after)
914+
915+
if downgrades_to_fix:
916+
print("\n🛡️ DOWNGRADE PROTECTION ACTIVATED!")
917+
for fix in downgrades_to_fix:
918+
print(f" -> Fixing downgrade: {fix['package']} from v{fix['good_version']} to v{fix['bad_version']}")
919+
self.bubble_manager.create_isolated_bubble(fix['package'], fix['bad_version'])
920+
print(f" 🔄 Restoring '{fix['package']}' to safe version v{fix['good_version']} in main environment...")
921+
subprocess.run([self.config["python_executable"], "-m", "pip", "install", f"{fix['package']}=={fix['good_version']}"], capture_output=True, text=True)
922+
print("\n✅ Environment protection complete!")
923+
else:
924+
print("✅ No downgrades detected. Installation completed safely.")
941925

942-
print("\n🧠 Updating knowledge base with final environment state...")
943-
self._run_metadata_builder_for_delta(packages_before, packages_to_index)
926+
print("\n🧠 Updating knowledge base with final environment state...")
927+
self._run_metadata_builder_for_delta(packages_before, packages_after)
928+
self._update_hash_index_for_delta(packages_before, packages_after)
929+
self._save_last_known_good_snapshot()
944930

945-
final_packages_state = self.get_installed_packages(live=True)
946-
self._update_hash_index_for_delta(packages_before, final_packages_state)
931+
return 0
947932

948-
print("\n" + "="*60)
949-
print("🎉 All package operations complete.")
950-
951933
def _find_package_installations(self, package_name: str) -> List[Dict]:
952934
"""Find all installations of a package, both active and bubbled."""
953935
found = []
@@ -1042,80 +1024,104 @@ def smart_uninstall(self, packages: List[str], force: bool = False) -> int:
10421024
pipe.execute()
10431025

10441026
print("✅ Uninstallation complete.")
1027+
1028+
self._save_last_known_good_snapshot()
1029+
10451030
return 0
1031+
1032+
def revert_to_last_known_good(self, force: bool = False):
1033+
"""Compares the current env to the last snapshot and restores it."""
1034+
if not self.connect_redis(): return 1
10461035

1047-
def _check_package_satisfaction(self, packages: List[str]) -> dict:
1048-
"""Check satisfaction with bubble pre-check optimization"""
1049-
satisfied = set()
1050-
remaining_packages = []
1036+
snapshot_key = f"{self.config['redis_key_prefix']}snapshot:last_known_good"
1037+
snapshot_data = self.redis_client.get(snapshot_key)
1038+
1039+
if not snapshot_data:
1040+
print("❌ No 'last known good' snapshot found. Cannot revert.")
1041+
print(" Run an `omnipkg install` or `omnipkg uninstall` command to create one.")
1042+
return 1
1043+
1044+
print("⚖️ Comparing current environment to the last known good snapshot...")
1045+
snapshot_state = json.loads(snapshot_data)
1046+
current_state = self.get_installed_packages(live=True)
1047+
1048+
# Calculate the "diff"
1049+
snapshot_keys = set(snapshot_state.keys())
1050+
current_keys = set(current_state.keys())
1051+
1052+
to_install = [f"{pkg}=={ver}" for pkg, ver in snapshot_state.items() if pkg not in current_keys]
1053+
to_uninstall = [pkg for pkg in current_keys if pkg not in snapshot_keys]
1054+
to_fix = [f"{pkg}=={snapshot_state[pkg]}" for pkg in (snapshot_keys & current_keys) if snapshot_state[pkg] != current_state[pkg]]
10511055

1052-
# FAST PATH: Check for pre-existing bubbles BEFORE calling pip
1053-
for pkg_spec in packages:
1054-
try:
1055-
if '==' in pkg_spec:
1056-
pkg_name, version = pkg_spec.split('==', 1) # Handle edge cases like pkg==1.0.0==extra
1057-
bubble_path = self.multiversion_base / f"{pkg_name}-{version}"
1058-
if bubble_path.exists() and bubble_path.is_dir():
1059-
satisfied.add(pkg_spec)
1060-
print(f" ⚡ Found existing bubble: {pkg_spec}")
1061-
continue
1062-
# Not version-pinned or no bubble found - needs pip check
1063-
remaining_packages.append(pkg_spec)
1064-
except ValueError:
1065-
# Malformed spec, let pip handle it
1066-
remaining_packages.append(pkg_spec)
1056+
if not to_install and not to_uninstall and not to_fix:
1057+
print("✅ Your environment is already in the last known good state. No action needed.")
1058+
return 0
10671059

1068-
# Early return if all packages have bubbles
1069-
if not remaining_packages:
1070-
return {
1071-
'all_satisfied': True,
1072-
'satisfied': sorted(list(satisfied)),
1073-
'needs_install': []
1074-
}
1060+
print("\n📝 The following actions will be taken to restore the environment:")
1061+
if to_uninstall:
1062+
print(f" - Uninstall: {', '.join(to_uninstall)}")
1063+
if to_install:
1064+
print(f" - Install: {', '.join(to_install)}")
1065+
if to_fix:
1066+
print(f" - Fix Version: {', '.join(to_fix)}")
1067+
1068+
if not force:
1069+
confirm = input("\n🤔 Are you sure you want to proceed? (y/N): ").lower().strip()
1070+
if confirm != 'y':
1071+
print("🚫 Revert cancelled.")
1072+
return 1
1073+
1074+
print("\n🚀 Starting revert operation...")
1075+
if to_uninstall:
1076+
self.smart_uninstall(to_uninstall, force=True)
10751077

1076-
# SLOW PATH: Only call pip for packages without bubbles
1078+
packages_to_install = to_install + to_fix
1079+
if packages_to_install:
1080+
self.smart_install(packages_to_install)
1081+
1082+
print("\n✅ Environment successfully reverted to the last known good state.")
1083+
return 0
1084+
1085+
def _check_package_satisfaction(self, packages: List[str]) -> dict:
10771086
req_file_path = None
10781087
try:
10791088
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
1080-
f.write("\n".join(remaining_packages))
1089+
f.write("\n".join(packages))
10811090
req_file_path = f.name
1082-
1091+
10831092
cmd = [self.config["python_executable"], "-m", "pip", "install", "--dry-run", "-r", req_file_path]
10841093
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
1085-
1086-
# Parse pip output for additional satisfied packages
1094+
1095+
satisfied = set()
10871096
output_lines = result.stdout.splitlines()
1097+
10881098
for line in output_lines:
10891099
if line.startswith("Requirement already satisfied:"):
10901100
try:
10911101
satisfied_spec = line.split("Requirement already satisfied: ")[1].strip()
10921102
req_name = satisfied_spec.split('==')[0].lower()
1093-
for user_req in remaining_packages:
1103+
for user_req in packages:
10941104
if user_req.lower().startswith(req_name):
10951105
satisfied.add(user_req)
1096-
except (IndexError, AttributeError):
1106+
except IndexError:
10971107
continue
1098-
1108+
10991109
needs_install = [pkg for pkg in packages if pkg not in satisfied]
1110+
11001111
return {
11011112
'all_satisfied': len(needs_install) == 0,
11021113
'partial_satisfied': len(satisfied) > 0 and len(needs_install) > 0,
11031114
'satisfied': sorted(list(satisfied)),
11041115
'needs_install': needs_install
11051116
}
1106-
1117+
11071118
except Exception as e:
1108-
print(f" ⚠️ Satisfaction check failed ({e}). Assuming remaining packages need installation.")
1109-
return {
1110-
'all_satisfied': False,
1111-
'partial_satisfied': len(satisfied) > 0,
1112-
'satisfied': sorted(list(satisfied)),
1113-
'needs_install': remaining_packages
1114-
}
1119+
print(f" ⚠️ Satisfaction check failed ({e}). Assuming all packages need installation.")
1120+
return {'all_satisfied': False, 'partial_satisfied': False, 'satisfied': [], 'needs_install': packages}
11151121
finally:
11161122
if req_file_path and Path(req_file_path).exists():
11171123
Path(req_file_path).unlink()
1118-
1124+
11191125
def get_package_info(self, package_name: str, version: str) -> Optional[Dict]:
11201126
if not self.redis_client: self.connect_redis()
11211127

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "omnipkg"
7-
version = "1.0.3"
7+
version = "1.0.4"
88
authors = [
99
{ name = "1minds3t", email = "omnipkg@proton.me" },
1010
]

0 commit comments

Comments
 (0)