225 lines
11 KiB
Python
225 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Install the checked-out root Skill, or verify it, without another Skill."""
|
|
|
|
import argparse
|
|
import copy
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
|
|
if sys.version_info < (3, 12):
|
|
raise SystemExit("Use Python 3.12+ to install the complete native workflow.")
|
|
import tomllib
|
|
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def roster(root):
|
|
files = {}
|
|
for path in sorted(root.rglob("*")):
|
|
relative = path.relative_to(root)
|
|
if any(part.startswith(".") or part == "__pycache__" for part in relative.parts):
|
|
continue
|
|
if path.is_symlink():
|
|
raise ValueError(f"Source symlink is unsupported: {relative}")
|
|
if path.is_file() and path.suffix != ".pyc":
|
|
files[relative.as_posix()] = sha(path)
|
|
return files
|
|
|
|
|
|
def safe_target(path):
|
|
path = Path(os.path.abspath(path.expanduser()))
|
|
if any(item.is_symlink() for item in (path, *path.parents)):
|
|
raise ValueError(f"Destination symlink is unsupported: {path}")
|
|
return path
|
|
|
|
|
|
def compiler_integrity(source):
|
|
provenance = json.loads((source / "runtime/provenance.json").read_text())
|
|
root = source / "runtime/ppt-master"
|
|
for item in provenance["files"]:
|
|
path = root / item["path"]
|
|
if not path.resolve().is_relative_to(root) or sha(path) != item["sha256"]:
|
|
raise ValueError(f"Compiler integrity failed: {item['path']}")
|
|
return len(provenance["files"])
|
|
|
|
|
|
def runtime_check(python, source):
|
|
code = """
|
|
import importlib.metadata, json, pathlib, re, sys
|
|
if sys.version_info < (3, 12):
|
|
raise RuntimeError('Native runtime needs Python 3.12+')
|
|
source = pathlib.Path(sys.argv[1])
|
|
versions = {}
|
|
for line in (source / 'runtime/requirements.txt').read_text().splitlines():
|
|
if not line or line.startswith('#'): continue
|
|
name, expected = line.split('==')
|
|
actual = importlib.metadata.version(name)
|
|
if actual != expected: raise RuntimeError(f'{name}: expected {expected}, got {actual}')
|
|
versions[name] = actual
|
|
sys.path.insert(0, str(source / 'scripts'))
|
|
from native_export import load_compiler
|
|
load_compiler()
|
|
print(json.dumps({'python': sys.version.split()[0], 'packages': versions, 'compiler_loaded': True}))
|
|
"""
|
|
result = subprocess.run([str(python), "-c", code, str(source)],
|
|
text=True, capture_output=True, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def config_plan(codex, target):
|
|
path = safe_target(codex / "config.toml")
|
|
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
data = tomllib.loads(before)
|
|
entries = data.get("skills", {}).get("config", [])
|
|
root_entry = str(target / "SKILL.md")
|
|
nested = str(target / "runtime/ppt-master/SKILL.md")
|
|
if any(item.get("path") == root_entry and item.get("enabled") is False for item in entries):
|
|
raise ValueError("Root Skill is disabled in Codex; preserve that decision and resolve it explicitly.")
|
|
matches = [item for item in entries if item.get("path") == nested]
|
|
if matches:
|
|
if len(matches) != 1 or matches[0].get("enabled") is not False:
|
|
raise ValueError("Conflicting nested attribution entry; resolve this exact Codex entry explicitly.")
|
|
return path, before, before
|
|
addition = f"\n[[skills.config]]\npath = {json.dumps(nested, ensure_ascii=False)}\nenabled = false\n"
|
|
after = before + addition
|
|
parsed = tomllib.loads(after)
|
|
expected = copy.deepcopy(data)
|
|
expected.setdefault("skills", {}).setdefault("config", []).append({"path": nested, "enabled": False})
|
|
if parsed != expected:
|
|
raise ValueError("Codex configuration change affected unrelated fields.")
|
|
return path, before, after
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--dest", type=Path, required=True, help="Exact installed Skill directory")
|
|
parser.add_argument("--codex-home", type=Path, help="Exclude only the nested attribution Skill")
|
|
parser.add_argument("--venv", type=Path, help="Dedicated Python 3.12+ virtual environment")
|
|
parser.add_argument("--prepare-runtime", action="store_true", help="Create venv and install pinned packages")
|
|
parser.add_argument("--replace", action="store_true", help="Back up and replace an existing Skill")
|
|
parser.add_argument("--backup-root", type=Path, help="Backup root outside Skill discovery directories")
|
|
parser.add_argument("--check", action="store_true", help="Read-only payload, configuration and runtime check")
|
|
args = parser.parse_args()
|
|
source = Path(__file__).resolve().parents[1]
|
|
target = safe_target(args.dest)
|
|
if target == source or target.is_relative_to(source) or source.is_relative_to(target):
|
|
raise ValueError("Source and installed destination must be separate directories.")
|
|
if target.name != "ppt-template-workflow":
|
|
raise ValueError("Destination must end in ppt-template-workflow.")
|
|
version_match = re.search(r'^ version: "([0-9.]+)"$', (source / "SKILL.md").read_text(), re.M)
|
|
if not version_match:
|
|
raise ValueError("Missing root Skill version.")
|
|
files = roster(source)
|
|
compiler_files = compiler_integrity(source)
|
|
if args.prepare_runtime and (args.venv is None or args.check):
|
|
raise ValueError("--prepare-runtime requires --venv and cannot be combined with --check.")
|
|
codex = safe_target(args.codex_home) if args.codex_home else None
|
|
config = config_plan(codex, target) if codex else None
|
|
if args.check:
|
|
if not target.is_dir() or roster(target) != files:
|
|
raise ValueError("Installed payload differs from the selected source.")
|
|
compiler_integrity(target)
|
|
if config and config[1] != config[2]:
|
|
raise ValueError("Nested compiler attribution entry has not been disabled.")
|
|
elif target.exists() and not args.replace:
|
|
raise FileExistsError("Destination exists; an authorized upgrade needs --replace (with backup).")
|
|
elif target.exists() and not target.is_dir():
|
|
raise ValueError("Existing destination is not a Skill directory.")
|
|
runtime = None
|
|
if args.venv:
|
|
venv = safe_target(args.venv)
|
|
if venv == target or venv.is_relative_to(target) or venv == source or venv.is_relative_to(source):
|
|
raise ValueError("Runtime must be outside source and installed Skill.")
|
|
python = safe_target(venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")) if os.name == "nt" else venv / "bin/python"
|
|
# POSIX venv interpreters are normally symlinks to their base Python.
|
|
if args.prepare_runtime:
|
|
if not python.is_file():
|
|
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
|
|
subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check",
|
|
"-r", str(source / "runtime/requirements.txt")], check=True)
|
|
runtime = {"executable": str(python), **runtime_check(python, source)}
|
|
commit = subprocess.run(["git", "-C", str(source), "rev-parse", "HEAD"],
|
|
capture_output=True, text=True)
|
|
dirty = subprocess.run(["git", "-C", str(source), "status", "--porcelain"],
|
|
capture_output=True, text=True) if commit.returncode == 0 else None
|
|
receipt = {"schema_version": 1, "skill": "ppt-template-workflow",
|
|
"version": version_match[1], "source_commit": commit.stdout.strip() if commit.returncode == 0 else None,
|
|
"source_dirty": bool(dirty.stdout) if dirty and dirty.returncode == 0 else None,
|
|
"installed_path": str(target), "payload": files, "compiler_files": compiler_files,
|
|
"runtime": runtime, "status": "native-ready" if runtime else "files-only",
|
|
"codex_attribution_disabled": bool(config), "checked": args.check}
|
|
if not args.check:
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
|
|
backup_root = safe_target(args.backup_root or
|
|
((codex / "backups/ppt-template-workflow") if codex else target.parent.parent / "skill-backups"))
|
|
if (backup_root == target or backup_root.is_relative_to(target) or backup_root.is_relative_to(target.parent)
|
|
or backup_root == source or backup_root.is_relative_to(source)):
|
|
raise ValueError("Backup must be outside source and Skill discovery directory.")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
stage = Path(tempfile.mkdtemp(prefix=".ppt-install-", dir=target.parent))
|
|
backup = backup_root / stamp / "ppt-template-workflow"
|
|
moved = False
|
|
config_written = False
|
|
config_backup = None
|
|
try:
|
|
for relative in files:
|
|
destination = stage / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source / relative, destination)
|
|
if roster(stage) != files:
|
|
raise ValueError("Staged installation hash mismatch.")
|
|
if target.exists() or (config and config[1] != config[2]):
|
|
backup.parent.mkdir(parents=True, exist_ok=False, mode=0o700)
|
|
if target.exists():
|
|
target.rename(backup)
|
|
moved = True
|
|
if config and config[1] != config[2]:
|
|
path, before, after = config
|
|
if (path.read_text(encoding="utf-8") if path.is_file() else "") != before:
|
|
raise ValueError("Codex configuration changed during installation.")
|
|
config_backup = backup.parent / "config.toml.before"
|
|
config_backup.write_text(before, encoding="utf-8")
|
|
config_backup.chmod(0o600)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(after, encoding="utf-8")
|
|
if not before:
|
|
path.chmod(0o600)
|
|
config_written = True
|
|
receipt.update(backup=str(backup) if moved else None,
|
|
config_backup=str(config_backup) if config_backup else None,
|
|
installed_at=datetime.now(timezone.utc).isoformat())
|
|
(stage / ".installation.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
stage.rename(target)
|
|
except Exception:
|
|
if config_written:
|
|
config[0].write_text(config[1], encoding="utf-8")
|
|
if moved and not target.exists():
|
|
backup.rename(target)
|
|
raise
|
|
finally:
|
|
if stage.exists():
|
|
shutil.rmtree(stage)
|
|
summary = {key: value for key, value in receipt.items() if key != "payload"}
|
|
summary["payload_files"] = len(files)
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if sys.version_info < (3, 12):
|
|
raise SystemExit("Use Python 3.12+ to install the complete native workflow.")
|
|
try:
|
|
main()
|
|
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
|
raise SystemExit(f"Installation failed: {error}")
|