← Files VeraARCHIVED FILE

scripts/check_for_update.py

9.07 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Notify the user when a newer published Mparanza plugin is available."""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
from typing import Any

__all__ = ["check_for_update", "is_newer_version", "session_start_output", "main"]

VERSION_MANIFEST_URL = "https://mparanza.com/static/shared/codex-plugin-versions.json"
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
NETWORK_TIMEOUT_SECONDS = 3.0
_SEMVER_PATTERN = re.compile(
    r"^(?P<major>0|[1-9]\d*)\."
    r"(?P<minor>0|[1-9]\d*)\."
    r"(?P<patch>0|[1-9]\d*)"
    r"(?:-(?P<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
    r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)


def _parse_semver(version: str) -> tuple[int, int, int, tuple[tuple[int, Any], ...]]:
    """Return a SemVer comparison key; build metadata is intentionally ignored."""

    match = _SEMVER_PATTERN.fullmatch(version.strip())
    if match is None:
        raise ValueError(f"Invalid semantic version: {version}")
    prerelease = match.group("prerelease")
    if prerelease is None:
        prerelease_key: tuple[tuple[int, Any], ...] = ((2, ""),)
    else:
        parts: list[tuple[int, Any]] = []
        for identifier in prerelease.split("."):
            if identifier.isdigit():
                parts.append((0, int(identifier)))
            else:
                parts.append((1, identifier))
        prerelease_key = tuple(parts)
    return (
        int(match.group("major")),
        int(match.group("minor")),
        int(match.group("patch")),
        prerelease_key,
    )


def is_newer_version(candidate: str, installed: str) -> bool:
    """Return whether ``candidate`` has greater SemVer precedence."""

    return _parse_semver(candidate) > _parse_semver(installed)


def _read_json(path: Path) -> dict[str, Any] | None:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    return payload if isinstance(payload, dict) else None


def _write_json(path: Path, payload: dict[str, Any]) -> None:
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        temporary = path.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(payload, sort_keys=True),
            encoding="utf-8",
        )
        temporary.replace(path)
    except OSError:
        return


def _download_manifest(
    opener: Callable[..., Any] = urllib.request.urlopen,
) -> dict[str, Any] | None:
    request = urllib.request.Request(
        VERSION_MANIFEST_URL,
        headers={
            "Accept": "application/json",
            "User-Agent": "Mparanza-Plugin-Update-Check/1",
        },
        method="GET",
    )
    try:
        with opener(request, timeout=NETWORK_TIMEOUT_SECONDS) as response:  # nosec B310
            payload = json.loads(response.read().decode("utf-8"))
    except (
        OSError,
        TimeoutError,
        UnicodeDecodeError,
        json.JSONDecodeError,
        urllib.error.URLError,
    ):
        return None
    return payload if isinstance(payload, dict) else None


def _manifest_entry(
    manifest: dict[str, Any], plugin_name: str
) -> tuple[str, str] | None:
    if manifest.get("schema_version") != 1:
        return None
    plugins = manifest.get("plugins")
    if not isinstance(plugins, dict):
        return None
    entry = plugins.get(plugin_name)
    if not isinstance(entry, dict):
        return None
    version = entry.get("published_version")
    install_url = entry.get("install_url")
    if not isinstance(version, str) or not isinstance(install_url, str):
        return None
    if not install_url.startswith("https://chatgpt.com/plugins/"):
        return None
    return version, install_url


def check_for_update(
    plugin_root: Path,
    plugin_data: Path | None,
    *,
    now: float | None = None,
    opener: Callable[..., Any] = urllib.request.urlopen,
) -> str | None:
    """Return a user-facing update message, or ``None`` when none is needed."""

    manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
    local_manifest = _read_json(manifest_path)
    if local_manifest is None:
        return None
    plugin_name = local_manifest.get("name")
    installed_version = local_manifest.get("version")
    if not isinstance(plugin_name, str) or not isinstance(installed_version, str):
        return None

    checked_at = now if now is not None else time.time()
    cache_path = plugin_data / "update-check.json" if plugin_data is not None else None
    cache = _read_json(cache_path) if cache_path is not None else None
    cached_manifest = cache.get("manifest") if cache is not None else None
    if not isinstance(cached_manifest, dict):
        cached_manifest = None
    remote_manifest: dict[str, Any] | None = None
    if cache is not None:
        cached_at = cache.get("checked_at")
        if (
            isinstance(cached_at, (int, float))
            and checked_at - float(cached_at) < CHECK_INTERVAL_SECONDS
            and cached_manifest is not None
        ):
            remote_manifest = cached_manifest

    if remote_manifest is None:
        downloaded_manifest = _download_manifest(opener)
        remote_manifest = downloaded_manifest or cached_manifest or {}
        if cache_path is not None:
            cache_payload: dict[str, Any] = {
                "checked_at": checked_at,
                "manifest": remote_manifest,
            }
            _write_json(
                cache_path,
                cache_payload,
            )

    entry = _manifest_entry(remote_manifest, plugin_name)
    if entry is None:
        return None
    published_version, install_url = entry
    try:
        update_available = is_newer_version(published_version, installed_version)
    except ValueError:
        return None
    if not update_available:
        return None
    interface = local_manifest.get("interface")
    display_name = (
        interface.get("displayName") if isinstance(interface, dict) else None
    ) or plugin_name
    return (
        f"{display_name} update available: installed {installed_version}, "
        f"published {published_version}. Visit {install_url} to get the latest "
        "published version. If you use a local or ZIP copy, verify the official "
        "installation before disabling the old local copy, then start a fresh "
        "conversation. Installing from the listing does not remove a local copy."
    )


def _check_fixed_change_requests(
    plugin_root: Path, plugin_data: Path | None
) -> str | None:
    """Poll stored CR receipts without making the SessionStart hook fragile."""

    try:
        from change_requests import check_fixed_requests

        return check_fixed_requests(
            plugin_root,
            plugin_data,
            timeout_seconds=2.0,
        )
    except (ImportError, OSError, RuntimeError, ValueError):
        return None


def session_start_output(*, include_change_requests: bool = True) -> dict[str, Any]:
    """Build one hook response; package identity is local, never sent upstream."""
    plugin_root = Path(
        os.environ.get("PLUGIN_ROOT", Path(__file__).resolve().parents[1])
    ).resolve()
    plugin_data_value = os.environ.get("PLUGIN_DATA")
    plugin_data = Path(plugin_data_value).resolve() if plugin_data_value else None
    manifest = _read_json(plugin_root / ".codex-plugin/plugin.json") or {}
    identity = (
        f"Installed plugin manifest: {manifest.get('name', 'unknown')} "
        f"{manifest.get('version', 'unknown')}. "
        "This identifies this package, not Marketplace publication or successful "
        "user-visible behavior. Do not claim a fix is active from source tests, "
        "a download or another cached version."
    )
    messages = []
    if include_change_requests:
        fixed_message = _check_fixed_change_requests(plugin_root, plugin_data)
        if fixed_message:
            messages.append(fixed_message)
    update_message = check_for_update(plugin_root, plugin_data)
    if update_message:
        messages.append(update_message)
    output: dict[str, Any] = {
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": identity,
        }
    }
    if messages:
        message = "\n".join(messages)
        output["systemMessage"] = message
        output["hookSpecificOutput"]["additionalContext"] += (
            " Tell the user the following notice in their language before "
            "continuing; do not treat it as proof they saw it: " + message
        )
    return output


def main(argv: list[str] | None = None) -> int:
    """Check from the installed package, including when a trusted hook is absent."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--version-only", action="store_true", help="Do not poll change requests."
    )
    args = parser.parse_args(argv)
    output = session_start_output(include_change_requests=not args.version_only)
    sys.stdout.write(json.dumps(output) + "\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 76ba154b77b43e280f550bd26c9ff194dedd29320978ce4f71306ec812f1590f