From 53b96595a92fe04ad4b011d418bda4bb67cda240 Mon Sep 17 00:00:00 2001 From: mrMigles Date: Thu, 16 Jul 2026 11:02:25 +0500 Subject: [PATCH 1/2] feat: Enhance PyManager with app support and isolation features --- .github/workflows/build-and-deploy.yml | 19 + .gitignore | 6 + Dockerfile | 12 +- README.md | 95 +- docker-compose.yml | 3 + main.py | 1121 ++++++++++++++++++++++-- pytest.ini | 3 + requirements-dev.txt | 6 + requirements.txt | 9 + tests/conftest.py | 121 +++ tests/test_apps.py | 175 ++++ tests/test_git_apps.py | 122 +++ tests/test_helpers.py | 236 +++++ tests/test_real_venv.py | 70 ++ tests/test_script_manager.py | 190 ++++ 15 files changed, 2100 insertions(+), 88 deletions(-) create mode 100644 .gitignore create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_apps.py create mode 100644 tests/test_git_apps.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_real_venv.py create mode 100644 tests/test_script_manager.py diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 2b3471a..a664754 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -11,8 +11,27 @@ env: IMAGE_NAME: mrmigles/py-manager jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run unit tests + run: pytest -v + build-and-push: runs-on: ubuntu-latest + needs: test permissions: contents: read packages: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..888d470 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +venv/ +*.egg-info/ diff --git a/Dockerfile b/Dockerfile index d346919..7ced5a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,17 @@ WORKDIR /app ENV PYTHONUNBUFFERED=1 \ DATA_DIR=/data -COPY main.py . +# git is needed to clone/sync GitHub-based apps +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +# python-telegram-bot & psutil are installed system-wide: every per-app venv is +# created with --system-site-packages, so this is the one thing shared across apps. +COPY requirements.txt . +RUN python -m pip install --no-cache-dir -r requirements.txt -RUN python -m pip install --no-cache-dir python-telegram-bot==21.6 psutil +COPY main.py . VOLUME ["/data"] diff --git a/README.md b/README.md index 96b270b..fab3085 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # PyManager πŸ€–πŸ§° -A Telegram bot to manage Python scripts from chat: upload/edit, run/stop/restart, logs, env vars, autostart, version rollback, and basic monitoring. +A Telegram bot to manage Python scripts and apps from chat: upload/edit, run/stop/restart, logs, env vars, autostart, version rollback, and basic monitoring. ![alt text](image.png) @@ -8,22 +8,49 @@ Designed for **single-owner** usage (restricted by `OWNER_ID`). --- ## Features -- **Scripts**: upload `.py` as a file or paste as text, run/stop/restart, tail logs -- **ENV**: global env + per-script env, optional env key detection from code -- **Autostart**: run selected scripts automatically when the manager starts -- **Rollback**: keeps **last 10 versions** per script with timestamps, one-click rollback -- **Monitoring**: `/monitoring` shows CPU/MEM for running scripts (**psutil**) +- **πŸ“„ Scripts**: upload `.py` as a file or paste as text, run/stop/restart, tail logs +- **πŸ“¦ Apps from archives**: send a `.zip` / `.tar.gz` / `.tgz` / `.tar` / `.7z` file + - Auto-extracted; the app name is the archive name (without extension) + - Auto-detects `requirements.txt` and offers to install it + - Auto-detects the entry point: `main.py` if present, the single `.py` file if there's + only one, or asks you which file to run otherwise + - Gets its **own isolated virtualenv** so its dependencies never affect other apps +- **🌐 Apps from GitHub repos**: just paste a link (or use `/repo `) + - `https://github.com/owner/repo` β€” imports the whole repo, name = repo name + - `https://github.com/owner/repo/tree/branch/subdir` β€” imports only `subdir`, + name = `repo-subdir` + - **πŸ”„ Sync** button re-pulls the repo, offers to install any new dependencies, + and restarts the app +- **πŸ§ͺ ENV**: global env + per-script/app env, optional env key detection from code + (scans all `.py` files for apps, same detection logic as before for scripts) +- **πŸš€ Autostart**: run selected scripts/apps automatically when the manager starts +- **βͺ Rollback**: keeps **last 10 versions** per script/app with timestamps, one-click rollback +- **πŸ“ˆ Monitoring**: `/monitoring` shows CPU/MEM for running scripts/apps (**psutil**) + +### Isolation model +- Each **app** (from an archive or a GitHub repo) gets its own Python virtualenv under + `venvs//`, created with `--system-site-packages`. This means: + - Installing a dependency for one app **never** impacts another app. + - The only thing shared between all apps (and the manager itself) is whatever is + installed system-wide in the image β€” i.e. `python-telegram-bot` and `psutil`. +- **Legacy single-file scripts** (`.py` upload/paste) keep working exactly as before: + they share the manager's own interpreter and `requirements.txt`. This keeps existing + setups 100% backward-compatible β€” nothing changes for scripts you already have. --- ## Data layout Stored under `DATA_DIR` (default `/data`, mounted as a volume in Docker): -- `scripts/` β€” current scripts (`.py`) -- `versions//` β€” saved versions (`_.py`) -- `logs/` β€” per-script logs (`.log`) -- `meta.json` β€” scripts metadata (env/autostart/versions) -- `requirements.txt` β€” saved dependencies (from `/pip ...`) +- `scripts/` β€” current legacy scripts (`.py`) +- `versions//` β€” saved script versions (`_.py`) +- `apps//` β€” current app source tree (from an archive or a GitHub repo) +- `app_versions//` β€” saved app versions (`_.tar.gz`) +- `venvs//` β€” isolated virtualenv per app +- `gitrepos//` β€” working git clone backing a GitHub app (used by Sync) +- `logs/` β€” per-script/app logs (`.log`) +- `meta.json` β€” scripts/apps metadata (type/env/autostart/versions/entry/...) +- `requirements.txt` β€” shared dependencies for legacy scripts (from `/pip ...`) --- @@ -70,19 +97,51 @@ docker compose down --- ## Main bot commands -- `/menu` β€” interactive scripts menu (buttons) -- `/new ` β€” create script (send code as the next message) -- `/run ` β€” start script -- `/stop ` β€” stop script +- `/menu` β€” interactive scripts/apps menu (buttons) +- `/list` β€” rich status list of all scripts/apps +- `/new ` β€” create a script (send code as the next message) +- `/repo ` β€” import an app from a GitHub repo (or just paste the link) +- `/run ` β€” start script/app +- `/stop ` β€” stop script/app - `/logs ` β€” show last log lines -- `/monitoring` β€” CPU/MEM for running scripts (install psutil if needed) -- `/pip ` β€” install packages and save into `requirements.txt` +- `/monitoring` β€” CPU/MEM for running scripts/apps (install psutil if needed) +- `/pip ` β€” install packages (shared `requirements.txt` for scripts, per-app venv + `requirements.txt` for apps) + +### Importing an app +- Just **send an archive file** (`.zip`/`.tar.gz`/`.tgz`/`.tar`/`.7z`) β€” the bot unpacks it, + detects `requirements.txt` and the entry point, and gets you set up. +- Or **send a GitHub link** as a plain message, e.g.: + - `https://github.com/owner/repo` + - `https://github.com/owner/repo/tree/main/subdir` +- Legend in menus: `πŸ“„` script Β· `πŸ“¦` archive app Β· `🌐` GitHub app. --- ## Notes - Access is restricted to **OWNER_ID**. -- Dependencies installed via `/pip` are persisted in `requirements.txt` and installed on manager startup. +- Dependencies installed via `/pip` for legacy scripts are persisted in `/data/requirements.txt` + and installed on manager startup; for apps they go into the app's own venv + + its own `requirements.txt`. (Not to be confused with the repo-root `requirements.txt`, + which lists the manager's own runtime dependencies and is installed in the `Dockerfile`.) +- `git` and `py7zr` are required in the runtime image to clone repos / extract `.7z` + archives β€” already included in the provided `Dockerfile`. + +--- + +## Development & tests +Unit tests cover the venv isolation, archive extraction (incl. path-traversal +safety), entry-point detection, GitHub import/sync, BWC of legacy scripts, and +version/rollback handling (including the timestamp-collision edge case). + +Run them locally: +```bash +pip install -r requirements-dev.txt +pytest -v +``` + +The `test` job in `.github/workflows/build-and-deploy.yml` runs the full suite +on every push to `main`; `build-and-push` (and therefore `deploy`) only runs +if it's green. --- diff --git a/docker-compose.yml b/docker-compose.yml index 631de0e..7da7c3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: image: ghcr.io/mrmigles/py-manager:latest container_name: script-bot restart: unless-stopped + pids_limit: 512 + security_opt: + - seccomp=unconfined environment: # required diff --git a/main.py b/main.py index 0b6666e..9011369 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,11 @@ Features: - Manage Python scripts: upload/edit/run/stop/logs/env/autostart -- Script versioning: keep last 10 versions with timestamps + rollback menu +- Apps from archives (.zip/.tar.gz/.tgz/.tar/.7z): auto-extract, detect requirements.txt, + detect entry point (main.py / single file / ask), each app gets its own isolated venv +- Apps from GitHub repos (optionally a subfolder): clone, detect same as above, plus a + Sync button to re-pull latest code and offer to install any new dependencies +- Script versioning: keep last 10 versions with timestamps + rollback menu (also for apps) - Better /list and /menu with emojis and extra info - /monitoring: CPU/MEM (and some optional stats) for running scripts (uses psutil if installed) - pip installs are stored in requirements.txt and installed on manager startup @@ -24,8 +28,10 @@ import asyncio import logging import shutil +import tarfile +import zipfile from pathlib import Path -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, Tuple from collections import deque from datetime import datetime @@ -54,10 +60,38 @@ META_FILE = DATA_DIR / "meta.json" REQUIREMENTS_FILE = DATA_DIR / "requirements.txt" +# New: multi-file apps (from archives or GitHub repos), each with its own isolated venv +APPS_DIR = DATA_DIR / "apps" +APP_VERSIONS_DIR = DATA_DIR / "app_versions" +VENVS_DIR = DATA_DIR / "venvs" +GITREPOS_DIR = DATA_DIR / "gitrepos" +TMP_DIR = DATA_DIR / "tmp" + DATA_DIR.mkdir(parents=True, exist_ok=True) SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True) VERSIONS_DIR.mkdir(parents=True, exist_ok=True) +APPS_DIR.mkdir(parents=True, exist_ok=True) +APP_VERSIONS_DIR.mkdir(parents=True, exist_ok=True) +VENVS_DIR.mkdir(parents=True, exist_ok=True) +GITREPOS_DIR.mkdir(parents=True, exist_ok=True) +TMP_DIR.mkdir(parents=True, exist_ok=True) + +# Directories to ignore when scanning a project for .py files / requirements.txt +IGNORED_PROJECT_DIRS = { + ".git", "__pycache__", "venv", ".venv", "env", ".env", "node_modules", + ".idea", ".vscode", ".mypy_cache", ".pytest_cache", "site-packages", + "dist", "build", ".github", "__MACOSX", +} + +ARCHIVE_EXTENSIONS = (".tar.gz", ".tgz", ".tar", ".zip", ".7z") + +GITHUB_TREE_URL_RE = re.compile( + r'^https?://github\.com/(?P[^/\s]+)/(?P[^/\s]+?)(?:\.git)?/tree/(?P[^/\s]+)/(?P[^\s]+?)/?$' +) +GITHUB_REPO_URL_RE = re.compile( + r'^https?://github\.com/(?P[^/\s]+)/(?P[^/\s]+?)(?:\.git)?/?$' +) # Logging bot_log_path = DATA_DIR / "bot.log" @@ -133,6 +167,226 @@ def write_text_file_atomic(path: Path, content: str) -> None: tmp.replace(path) +def sanitize_id(name: str) -> str: + # Used only for NEW app ids (archives / github repos) - kept separate from the + # legacy script id derivation to not change behavior of existing scripts (BWC). + name = (name or "").strip().replace(" ", "_") + name = re.sub(r"[^A-Za-z0-9_.-]", "_", name) + name = name.strip("_.- ") + return name or "app" + + +def venv_python_path(venv_dir: Path) -> Path: + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +async def create_venv(venv_dir: Path) -> Tuple[bool, str]: + py = venv_python_path(venv_dir) + if py.exists(): + return True, "venv already exists" + venv_dir.parent.mkdir(parents=True, exist_ok=True) + logger.info("Creating venv at %s", venv_dir) + proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "venv", + "--system-site-packages", + str(venv_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + out, _ = await proc.communicate() + text = out.decode("utf-8", errors="replace") + ok = proc.returncode == 0 and py.exists() + if not ok: + logger.error("venv creation failed for %s: %s", venv_dir, text) + return ok, text + + +def strip_archive_ext(filename: str) -> str: + lower = filename.lower() + for ext in ARCHIVE_EXTENSIONS: + if lower.endswith(ext): + return filename[: -len(ext)] + return Path(filename).stem + + +def archive_ext_of(filename: str) -> str: + # Path(filename).suffix would truncate ".tar.gz" down to ".gz" - keep the full + # multi-part extension so downstream extraction picks the correct code path. + lower = filename.lower() + for ext in ARCHIVE_EXTENSIONS: + if lower.endswith(ext): + return filename[len(filename) - len(ext):] + return Path(filename).suffix + + +def _validate_zip_members(zf: "zipfile.ZipFile", dest_dir: Path) -> None: + dest_resolved = dest_dir.resolve() + for name in zf.namelist(): + target = (dest_dir / name).resolve() + if not str(target).startswith(str(dest_resolved)): + raise ValueError(f"Unsafe path in archive: {name}") + + +def _validate_tar_members(tf: "tarfile.TarFile", dest_dir: Path) -> None: + dest_resolved = dest_dir.resolve() + for member in tf.getmembers(): + target = (dest_dir / member.name).resolve() + if not str(target).startswith(str(dest_resolved)): + raise ValueError(f"Unsafe path in archive: {member.name}") + + +def extract_archive(archive_path: Path, dest_dir: Path) -> None: + dest_dir.mkdir(parents=True, exist_ok=True) + lower = str(archive_path).lower() + if lower.endswith(".zip"): + with zipfile.ZipFile(archive_path) as zf: + _validate_zip_members(zf, dest_dir) + zf.extractall(dest_dir) + elif lower.endswith(".tar.gz") or lower.endswith(".tgz"): + with tarfile.open(archive_path, "r:gz") as tf: + _validate_tar_members(tf, dest_dir) + tf.extractall(dest_dir, filter="data") + elif lower.endswith(".tar"): + with tarfile.open(archive_path, "r:") as tf: + _validate_tar_members(tf, dest_dir) + tf.extractall(dest_dir, filter="data") + elif lower.endswith(".7z"): + try: + import py7zr # type: ignore + except ImportError as e: + raise RuntimeError("py7zr is not installed on the manager, cannot extract .7z archives") from e + with py7zr.SevenZipFile(archive_path, mode="r") as zf: + names = zf.getnames() + dest_resolved = dest_dir.resolve() + for name in names: + target = (dest_dir / name).resolve() + if not str(target).startswith(str(dest_resolved)): + raise ValueError(f"Unsafe path in archive: {name}") + zf.extractall(path=dest_dir) + else: + raise ValueError(f"Unsupported archive type: {archive_path.suffix}") + + +def find_extraction_root(staging: Path) -> Path: + # Many archives (e.g. GitHub zip exports) wrap everything in one top-level folder. + entries = [p for p in staging.iterdir() if p.name not in IGNORED_PROJECT_DIRS] + if len(entries) == 1 and entries[0].is_dir(): + return entries[0] + return staging + + +def discover_python_files(root: Path) -> List[str]: + result: List[str] = [] + root = root.resolve() + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in IGNORED_PROJECT_DIRS and not d.startswith(".")] + for fn in filenames: + if fn.lower().endswith(".py"): + rel = str(Path(dirpath, fn).relative_to(root)).replace("\\", "/") + result.append(rel) + result.sort() + return result + + +def find_requirements_file(root: Path) -> Optional[Path]: + direct = root / "requirements.txt" + if direct.exists(): + return direct + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in IGNORED_PROJECT_DIRS and not d.startswith(".")] + for fn in filenames: + if fn.lower() == "requirements.txt": + return Path(dirpath) / fn + return None + + +def find_requirements_pkgs(root: Path) -> List[str]: + req = find_requirements_file(root) + if not req: + return [] + try: + return [ + line.strip() + for line in read_text_file(req).splitlines() + if line.strip() and not line.strip().startswith("#") + ] + except Exception: + return [] + + +def pick_entry_from_candidates(py_files: List[str]) -> Tuple[Optional[str], bool]: + """Returns (entry_or_None, ambiguous).""" + mains = [f for f in py_files if Path(f).name.lower() == "main.py"] + if mains: + mains.sort(key=lambda p: p.count("/")) + return mains[0], False + if len(py_files) == 1: + return py_files[0], False + if len(py_files) == 0: + return None, False + return None, True + + +def parse_github_url(text: str) -> Optional[Dict[str, Any]]: + url = (text or "").strip() + m = GITHUB_TREE_URL_RE.match(url) + if m: + owner, repo, branch, path = m.group("owner"), m.group("repo"), m.group("branch"), m.group("path") + folder = path.rstrip("/").split("/")[-1] + app_id = sanitize_id(f"{repo}-{folder}") + return {"owner": owner, "repo": repo, "branch": branch, "path": path, "app_id": app_id} + m = GITHUB_REPO_URL_RE.match(url) + if m: + owner, repo = m.group("owner"), m.group("repo") + app_id = sanitize_id(repo) + return {"owner": owner, "repo": repo, "branch": None, "path": None, "app_id": app_id} + return None + + +async def _run_cmd(*args: str) -> Tuple[bool, str]: + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + out, _ = await proc.communicate() + text = out.decode("utf-8", errors="replace") + return proc.returncode == 0, text + + +async def git_clone_or_pull(repo_url: str, branch: Optional[str], dest: Path) -> Tuple[bool, str]: + if (dest / ".git").exists(): + ok, out1 = await _run_cmd("git", "-C", str(dest), "fetch", "--depth", "1", "origin", branch or "HEAD") + if not ok: + return False, out1 + target = f"origin/{branch}" if branch else "FETCH_HEAD" + ok, out2 = await _run_cmd("git", "-C", str(dest), "reset", "--hard", target) + return ok, out1 + "\n" + out2 + # Clean up any partial/failed previous clone attempt so `git clone` doesn't + # refuse to write into a non-empty directory. + if dest.exists(): + shutil.rmtree(dest, ignore_errors=True) + dest.parent.mkdir(parents=True, exist_ok=True) + cmd = ["git", "clone", "--depth", "1"] + if branch: + cmd += ["--branch", branch] + cmd += [repo_url, str(dest)] + return await _run_cmd(*cmd) + + +async def git_current_branch(repo_dir: Path) -> str: + ok, out = await _run_cmd("git", "-C", str(repo_dir), "rev-parse", "--abbrev-ref", "HEAD") + if ok: + branch = out.strip() + if branch and branch != "HEAD": + return branch + return "main" + + class ScriptManager: def __init__(self) -> None: self.meta: Dict[str, Any] = {"global_env": {}, "scripts": {}} @@ -149,6 +403,7 @@ def __init__(self) -> None: self.pending_edit: Dict[int, str] = {} self.pending_env_value: Dict[int, Dict[str, str]] = {} self.pending_pip: Dict[int, Optional[str]] = {} + self.pending_entry_choice: Dict[str, List[str]] = {} # app_id -> candidate py files self.load_meta() @@ -173,6 +428,7 @@ def load_meta(self) -> None: s.setdefault("autostart", False) s.setdefault("versions", []) # list[{ts, file}] s.setdefault("updated_at", None) + s.setdefault("type", "script") # "script" (legacy .py) | "project" (archive) | "git" self.save_meta() @@ -204,31 +460,33 @@ def del_global_env(self, key: str) -> bool: # requirements.txt handling - def _read_requirements_lines(self) -> List[str]: - if not REQUIREMENTS_FILE.exists(): + def _read_requirements_lines(self, path: Optional[Path] = None) -> List[str]: + p = path or REQUIREMENTS_FILE + if not p.exists(): return [] lines = [] - for raw in read_text_file(REQUIREMENTS_FILE).splitlines(): + for raw in read_text_file(p).splitlines(): line = raw.strip() if not line or line.startswith("#"): continue lines.append(line) return lines - def add_requirements(self, pkgs: List[str]) -> bool: + def add_requirements(self, pkgs: List[str], path: Optional[Path] = None) -> bool: if not pkgs: return False - existing = self._read_requirements_lines() + p = path or REQUIREMENTS_FILE + existing = self._read_requirements_lines(p) existing_set = set(existing) changed = False - for p in pkgs: - if p and p not in existing_set: - existing.append(p) - existing_set.add(p) + for pkg in pkgs: + if pkg and pkg not in existing_set: + existing.append(pkg) + existing_set.add(pkg) changed = True if changed: content = "\n".join(existing) + "\n" - write_text_file_atomic(REQUIREMENTS_FILE, content) + write_text_file_atomic(p, content) return changed async def ensure_requirements_installed(self) -> None: @@ -266,6 +524,43 @@ async def ensure_requirements_installed(self) -> None: text = safe_trim_text(text, 3500) logger.info("Startup pip install exit=%s, output:\n%s", proc.returncode, text) + async def ensure_app_environments(self) -> None: + # Make sure every project/git app has a working venv + its deps installed, + # e.g. after a fresh volume or container rebuild. + scripts = self.meta.get("scripts", {}) + for sid, s in list(scripts.items()): + if not isinstance(s, dict) or s.get("type") not in ("project", "git"): + continue + root = Path(s.get("root_dir") or "") + if not root.exists(): + logger.warning("App %s root_dir missing (%s), skipping env setup", sid, root) + continue + venv_dir = Path(s.get("venv_dir") or self.app_venv_dir(sid)) + ok, out = await create_venv(venv_dir) + if not ok: + logger.error("Failed to prepare venv for app %s: %s", sid, safe_trim_text(out, 1000)) + continue + req_file = root / "requirements.txt" + if req_file.exists(): + python_exe = venv_python_path(venv_dir) + proc = await asyncio.create_subprocess_exec( + str(python_exe), + "-m", + "pip", + "install", + "-r", + str(req_file), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + out2, _ = await proc.communicate() + logger.info( + "Startup pip install for app %s exit=%s:\n%s", + sid, + proc.returncode, + safe_trim_text(out2.decode("utf-8", errors="replace"), 2000), + ) + # scripts def _ensure_script_meta(self, script_id: str, file_path: str) -> None: @@ -277,6 +572,7 @@ def _ensure_script_meta(self, script_id: str, file_path: str) -> None: script.setdefault("autostart", False) script.setdefault("versions", []) script.setdefault("updated_at", None) + script["type"] = "script" script["file"] = file_path scripts[script_id] = script self.save_meta() @@ -284,6 +580,13 @@ def _ensure_script_meta(self, script_id: str, file_path: str) -> None: def get_script(self, script_id: str) -> Optional[Dict[str, Any]]: return self.meta.get("scripts", {}).get(script_id) + def get_type(self, script_id: str) -> str: + s = self.get_script(script_id) + return (s or {}).get("type", "script") + + def is_project_type(self, script_id: str) -> bool: + return self.get_type(script_id) in ("project", "git") + def script_file_path(self, script_id: str) -> Path: return SCRIPTS_DIR / f"{script_id}.py" @@ -292,6 +595,140 @@ def versions_dir_for(self, script_id: str) -> Path: d.mkdir(parents=True, exist_ok=True) return d + # apps (archives / github repos) - each gets its own root dir + isolated venv + + def app_root_dir(self, script_id: str) -> Path: + return APPS_DIR / script_id + + def app_venv_dir(self, script_id: str) -> Path: + return VENVS_DIR / script_id + + def app_venv_python(self, script_id: str) -> Path: + return venv_python_path(self.app_venv_dir(script_id)) + + def app_versions_dir_for(self, script_id: str) -> Path: + d = APP_VERSIONS_DIR / script_id + d.mkdir(parents=True, exist_ok=True) + return d + + def app_requirements_file(self, script_id: str) -> Optional[Path]: + script = self.get_script(script_id) + if not script: + return None + root = Path(script.get("root_dir") or "") + if not root.exists(): + return None + return root / "requirements.txt" + + def _ensure_app_meta( + self, + script_id: str, + app_type: str, + root_dir: Path, + entry: Optional[str], + venv_dir: Path, + git_info: Optional[Dict[str, Any]] = None, + ) -> None: + scripts = self.meta.setdefault("scripts", {}) + script = scripts.get(script_id, {}) + if not isinstance(script, dict): + script = {} + script.setdefault("env", {}) + script.setdefault("autostart", False) + script.setdefault("versions", []) + script.setdefault("updated_at", None) + script["type"] = app_type + script["root_dir"] = str(root_dir) + script["entry"] = entry + script["venv_dir"] = str(venv_dir) + if git_info is not None: + script["git"] = git_info + scripts[script_id] = script + self.save_meta() + + async def setup_project_app( + self, + script_id: str, + extracted_root: Path, + app_type: str, + git_info: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Moves extracted_root -> APPS_DIR/script_id (snapshotting previous content if any), + prepares an isolated venv, and detects the entry point + requirements.""" + dest_root = self.app_root_dir(script_id) + + existing = self.get_script(script_id) + if existing: + if existing.get("type", "script") == "script": + self.snapshot_current_version(script_id) + else: + self.snapshot_current_app_version(script_id) + + if dest_root.exists(): + shutil.rmtree(dest_root, ignore_errors=True) + dest_root.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(extracted_root), str(dest_root)) + + py_files = discover_python_files(dest_root) + entry, ambiguous = pick_entry_from_candidates(py_files) + + venv_dir = self.app_venv_dir(script_id) + venv_ok, venv_out = await create_venv(venv_dir) + + self._ensure_app_meta(script_id, app_type, dest_root, entry, venv_dir, git_info) + script = self.get_script(script_id) + if script: + script["updated_at"] = now_ts_str() + self.save_meta() + + logger.info("App %s set up (type=%s, entry=%s, ambiguous=%s)", script_id, app_type, entry, ambiguous) + + result: Dict[str, Any] = { + "app_id": script_id, + "venv_ok": venv_ok, + "venv_out": venv_out, + "requirements_pkgs": find_requirements_pkgs(dest_root), + "py_files": py_files, + } + if ambiguous: + self.pending_entry_choice[script_id] = py_files + result["status"] = "ambiguous" + result["candidates"] = py_files + else: + result["status"] = "ready" + result["entry"] = entry + return result + + async def install_app_requirements(self, script_id: str) -> str: + script = self.get_script(script_id) + if not script: + return "❌ App not found." + root = Path(script.get("root_dir") or "") + req_file = root / "requirements.txt" + if not req_file.exists(): + return "🟑 No requirements.txt found." + + venv_dir = Path(script.get("venv_dir") or self.app_venv_dir(script_id)) + python_exe = venv_python_path(venv_dir) + if not python_exe.exists(): + ok, out = await create_venv(venv_dir) + if not ok: + return "❌ Failed to prepare venv:\n" + safe_trim_text(out, 1500) + + proc = await asyncio.create_subprocess_exec( + str(python_exe), + "-m", + "pip", + "install", + "-r", + str(req_file), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + out, _ = await proc.communicate() + text = safe_trim_text(out.decode("utf-8", errors="replace"), 3200) + return f"πŸ“₯ pip install -r requirements.txt (exit={proc.returncode})\n{text}" + def _push_version(self, script_id: str, version_file: Path, ts: str) -> None: script = self.get_script(script_id) if not script: @@ -315,6 +752,19 @@ def _push_version(self, script_id: str, version_file: Path, ts: str) -> None: self.save_meta() + @staticmethod + def _unique_version_path(vdir: Path, script_id: str, ts: str, ext: str) -> Path: + # now_ts_str() has 1-second resolution, so two snapshots taken in quick + # succession (rapid edits, or a snapshot-before-rollback right after a + # snapshot-on-upload) can land on the same name. Never reuse a version + # file name - that would silently overwrite a still-referenced version. + candidate = vdir / f"{script_id}_{ts}{ext}" + n = 1 + while candidate.exists(): + candidate = vdir / f"{script_id}_{ts}_{n}{ext}" + n += 1 + return candidate + def snapshot_current_version(self, script_id: str) -> None: script = self.get_script(script_id) if not script: @@ -328,7 +778,7 @@ def snapshot_current_version(self, script_id: str) -> None: ts = now_ts_str() vdir = self.versions_dir_for(script_id) - version_file = vdir / f"{script_id}_{ts}.py" + version_file = self._unique_version_path(vdir, script_id, ts, ".py") try: shutil.copy2(cur_path, version_file) self._push_version(script_id, version_file, ts) @@ -336,6 +786,25 @@ def snapshot_current_version(self, script_id: str) -> None: except Exception: logger.exception("Failed to snapshot version for %s", script_id) + def snapshot_current_app_version(self, script_id: str) -> None: + script = self.get_script(script_id) + if not script: + return + root = Path(script.get("root_dir") or "") + if not root.exists(): + return + + ts = now_ts_str() + vdir = self.app_versions_dir_for(script_id) + version_file = self._unique_version_path(vdir, script_id, ts, ".tar.gz") + try: + with tarfile.open(version_file, "w:gz") as tf: + tf.add(root, arcname=script_id) + self._push_version(script_id, version_file, ts) + logger.info("Snapshot app version for %s -> %s", script_id, version_file.name) + except Exception: + logger.exception("Failed to snapshot app version for %s", script_id) + def upsert_script_from_text(self, name: str, content: str) -> str: script_id = name.strip().replace(" ", "_") if not script_id: @@ -455,9 +924,32 @@ async def start_script(self, script_id: str) -> str: if proc and proc.returncode is None: return "🟑 Already running." - file_path = script.get("file") - if not file_path or not Path(file_path).exists(): - return "❌ Script file not found." + app_type = script.get("type", "script") + if app_type == "script": + file_path = script.get("file") + if not file_path or not Path(file_path).exists(): + return "❌ Script file not found." + python_exe = sys.executable + cwd = str(SCRIPTS_DIR) + run_target = file_path + else: + root = Path(script.get("root_dir") or "") + entry = script.get("entry") + if not entry: + return "❌ Entry point not set. Choose a file to run first." + entry_path = root / entry + if not entry_path.exists(): + return f"❌ Entry file not found: {entry}" + + venv_dir = Path(script.get("venv_dir") or self.app_venv_dir(script_id)) + python_exe_path = venv_python_path(venv_dir) + if not python_exe_path.exists(): + ok, out = await create_venv(venv_dir) + if not ok: + return "❌ Failed to create venv:\n" + safe_trim_text(out, 1500) + python_exe = str(python_exe_path) + cwd = str(root) + run_target = str(entry_path) env = os.environ.copy() env.update(self.meta.get("global_env", {})) @@ -472,11 +964,11 @@ async def start_script(self, script_id: str) -> str: h = log_file.open("ab") self.log_handles[script_id] = h - logger.info("Starting script %s", script_id) + logger.info("Starting script %s via %s", script_id, python_exe) proc = await asyncio.create_subprocess_exec( - sys.executable, - file_path, - cwd=str(SCRIPTS_DIR), + python_exe, + run_target, + cwd=cwd, env=env, stdout=h, stderr=h, @@ -575,14 +1067,23 @@ def get_env_keys_for_script(self, script_id: str) -> List[str]: return [] keys: set[str] = set(script.get("env", {}).keys()) - file_path = script.get("file") - if file_path and Path(file_path).exists(): - try: - source = read_text_file(Path(file_path)) - detected = extract_env_keys(source) - keys |= detected - except Exception: - logger.exception("Failed to extract env keys for %s", script_id) + if script.get("type", "script") == "script": + file_path = script.get("file") + if file_path and Path(file_path).exists(): + try: + source = read_text_file(Path(file_path)) + keys |= extract_env_keys(source) + except Exception: + logger.exception("Failed to extract env keys for %s", script_id) + else: + root = Path(script.get("root_dir") or "") + if root.exists(): + for rel in discover_python_files(root): + try: + source = read_text_file(root / rel) + keys |= extract_env_keys(source) + except Exception: + continue return sorted(keys) def get_versions(self, script_id: str) -> List[Dict[str, str]]: @@ -607,6 +1108,9 @@ async def rollback_to(self, script_id: str, ts: str) -> str: if not script: return "❌ Script not found." + if script.get("type", "script") != "script": + return await self._rollback_app_to(script_id, ts) + versions = self.get_versions(script_id) chosen = None for v in versions: @@ -620,6 +1124,11 @@ async def rollback_to(self, script_id: str, ts: str) -> str: if not version_path.exists(): return "❌ Version file missing on disk." + # Read the target version's bytes NOW. Snapshotting the current file below can (in rare + # cases, e.g. same-second timestamps) reuse this exact file name - reading it into memory + # first guarantees we always restore the content the user actually picked. + version_bytes = version_path.read_bytes() + was_running = self.script_running(script_id) if was_running: await self.stop_script(script_id) @@ -628,9 +1137,9 @@ async def rollback_to(self, script_id: str, ts: str) -> str: if self.script_file_path(script_id).exists(): self.snapshot_current_version(script_id) - # Replace current script with chosen version (copy, keep version file) + # Replace current script with chosen version try: - shutil.copy2(version_path, self.script_file_path(script_id)) + self.script_file_path(script_id).write_bytes(version_bytes) except Exception: logger.exception("Rollback copy failed for %s", script_id) return "❌ Rollback failed (copy error)." @@ -649,9 +1158,73 @@ async def rollback_to(self, script_id: str, ts: str) -> str: return msg + async def _rollback_app_to(self, script_id: str, ts: str) -> str: + script = self.get_script(script_id) + if not script: + return "❌ App not found." + + versions = self.get_versions(script_id) + chosen = next((v for v in versions if v["ts"] == ts), None) + if not chosen: + return "❌ Version not found." + + version_path = Path(chosen["file"]) + if not version_path.exists(): + return "❌ Version file missing on disk." + + root = Path(script.get("root_dir") or self.app_root_dir(script_id)) + + # Extract the target version into a temp dir FIRST (before snapshotting current, which + # could in rare cases - e.g. same-second timestamps - reuse this exact archive's file name). + tmp_extract = root.parent / f"__rollback_tmp_{script_id}" + shutil.rmtree(tmp_extract, ignore_errors=True) + try: + with tarfile.open(version_path, "r:gz") as tf: + tf.extractall(tmp_extract, filter="data") + except Exception: + logger.exception("App rollback extract failed for %s", script_id) + shutil.rmtree(tmp_extract, ignore_errors=True) + return "❌ Rollback failed (extract error)." + + was_running = self.script_running(script_id) + if was_running: + await self.stop_script(script_id) + + # Snapshot current before rollback + if root.exists(): + self.snapshot_current_app_version(script_id) + + try: + inner = tmp_extract / script_id + if not inner.exists(): + inner = tmp_extract # be defensive about older/foreign archives + + shutil.rmtree(root, ignore_errors=True) + root.mkdir(parents=True, exist_ok=True) + for item in inner.iterdir(): + shutil.move(str(item), str(root / item.name)) + except Exception: + logger.exception("App rollback failed for %s", script_id) + return "❌ Rollback failed (move error)." + finally: + shutil.rmtree(tmp_extract, ignore_errors=True) + + script = self.get_script(script_id) + if script: + script["updated_at"] = now_ts_str() + self.save_meta() + + msg = f"βͺ Rolled back {script_id} to version {pretty_dt_from_ts(ts)}" + + if was_running: + s = await self.start_script(script_id) + msg += f"\n{s}" + + return msg + # pip - async def pip_install(self, args_str: str) -> str: + async def pip_install(self, args_str: str, script_id: Optional[str] = None) -> str: try: args = shlex.split(args_str) except ValueError as e: @@ -659,9 +1232,26 @@ async def pip_install(self, args_str: str) -> str: if not args: return "❌ No packages specified." - logger.info("Running pip install via %s: %s", sys.executable, " ".join(args)) + # For project/git apps, install into their own isolated venv + requirements.txt. + # Legacy scripts keep the previous behavior: shared interpreter + shared requirements.txt. + scoped = bool(script_id) and self.is_project_type(script_id) + if scoped: + script = self.get_script(script_id) + venv_dir = Path(script.get("venv_dir") or self.app_venv_dir(script_id)) + python_exe_path = venv_python_path(venv_dir) + if not python_exe_path.exists(): + ok, out = await create_venv(venv_dir) + if not ok: + return "❌ Failed to prepare venv:\n" + safe_trim_text(out, 1500) + python_exe = str(python_exe_path) + req_path = Path(script.get("root_dir")) / "requirements.txt" + else: + python_exe = sys.executable + req_path = REQUIREMENTS_FILE + + logger.info("Running pip install via %s: %s", python_exe, " ".join(args)) proc = await asyncio.create_subprocess_exec( - sys.executable, + python_exe, "-m", "pip", "install", @@ -681,13 +1271,13 @@ async def pip_install(self, args_str: str) -> str: # Keep original spec (with ==, extras, etc.) to_store.append(a) - saved = self.add_requirements(to_store) + saved = self.add_requirements(to_store, path=req_path) msg = [] - msg.append(f"$ {sys.executable} -m pip install {' '.join(args)}") + msg.append(f"$ {python_exe} -m pip install {' '.join(args)}") msg.append(f"exit code: {exit_code}") if saved: - msg.append(f"πŸ“Œ saved to requirements.txt ({len(to_store)} item(s))") + msg.append(f"πŸ“Œ saved to {req_path.name} ({len(to_store)} item(s))" + (f" for app {script_id}" if scoped else "")) msg.append("") msg.append(text.strip() or "") @@ -704,7 +1294,7 @@ async def pip_install(self, args_str: str) -> str: for pkg in to_check: code = f"import {pkg}; print(getattr({pkg}, '__file__', ''))" check_proc = await asyncio.create_subprocess_exec( - sys.executable, + python_exe, "-c", code, stdout=asyncio.subprocess.PIPE, @@ -728,6 +1318,9 @@ def _autostart_emoji(self, script_id: str) -> str: s = self.get_script(script_id) return "πŸš€" if s and s.get("autostart") else "⏸️" + def _type_emoji(self, script_id: str) -> str: + return {"script": "πŸ“„", "project": "πŸ“¦", "git": "🌐"}.get(self.get_type(script_id), "πŸ“„") + def _versions_count(self, script_id: str) -> int: return len(self.get_versions(script_id)) @@ -746,11 +1339,12 @@ def _pid(self, script_id: str) -> str: def script_status_line(self, script_id: str) -> str: st = self._status_emoji(script_id) + ty = self._type_emoji(script_id) au = self._autostart_emoji(script_id) pid = self._pid(script_id) vc = self._versions_count(script_id) upd = self._updated_at_pretty(script_id) - return f"{st} {au} {script_id} | PID: {pid} | v:{vc} | updated: {upd}" + return f"{st}{ty} {au} {script_id} | PID: {pid} | v:{vc} | updated: {upd}" def get_status_lines(self) -> str: scripts = self.meta.get("scripts", {}) @@ -769,7 +1363,7 @@ def main_menu_keyboard() -> InlineKeyboardMarkup: scripts = script_mgr.list_scripts() buttons = [] for sid in scripts.keys(): - text = f"{script_mgr._status_emoji(sid)} {script_mgr._autostart_emoji(sid)} {sid}" + text = f"{script_mgr._status_emoji(sid)}{script_mgr._type_emoji(sid)} {script_mgr._autostart_emoji(sid)} {sid}" buttons.append([InlineKeyboardButton(text=text, callback_data=f"menu:{sid}")]) if not buttons: buttons.append([InlineKeyboardButton(text="😴 No scripts", callback_data="noop")]) @@ -777,6 +1371,7 @@ def main_menu_keyboard() -> InlineKeyboardMarkup: def script_menu_keyboard(script_id: str) -> InlineKeyboardMarkup: + # Menu for legacy single-file (.py) scripts - kept exactly as before (full BWC). script = script_mgr.get_script(script_id) autostart = script.get("autostart") if script else False running = script_mgr.script_running(script_id) @@ -809,6 +1404,47 @@ def script_menu_keyboard(script_id: str) -> InlineKeyboardMarkup: return InlineKeyboardMarkup(buttons) +def app_menu_keyboard(script_id: str) -> InlineKeyboardMarkup: + # Menu for apps imported from an archive or a GitHub repo (own isolated venv). + script = script_mgr.get_script(script_id) + autostart = script.get("autostart") if script else False + running = script_mgr.script_running(script_id) + app_type = (script or {}).get("type", "project") + + run_text = "β–Ά Run" if not running else "πŸ” Restart" + stop_text = "⏹ Stop" + auto_text = "πŸš€ Autostart: ON" if autostart else "⏸️ Autostart: OFF" + + logs_row = [InlineKeyboardButton("πŸ“œ Logs", callback_data=f"logs:{script_id}")] + if app_type == "git": + logs_row.append(InlineKeyboardButton("πŸ”„ Sync", callback_data=f"sync:{script_id}")) + + buttons = [ + [ + InlineKeyboardButton(run_text, callback_data=f"run:{script_id}"), + InlineKeyboardButton(stop_text, callback_data=f"stop:{script_id}"), + ], + logs_row, + [ + InlineKeyboardButton("πŸ§ͺ Env", callback_data=f"envmenu:{script_id}"), + InlineKeyboardButton("πŸ“¦ Pip install", callback_data=f"pipprompt:{script_id}"), + ], + [ + InlineKeyboardButton("βͺ Rollback", callback_data=f"rbmenu:{script_id}"), + InlineKeyboardButton(auto_text, callback_data=f"auto:{script_id}"), + ], + [ + InlineKeyboardButton("β¬… Back", callback_data="back:main"), + ], + ] + return InlineKeyboardMarkup(buttons) + + +def menu_keyboard_for(script_id: str) -> InlineKeyboardMarkup: + app_type = script_mgr.get_type(script_id) + return script_menu_keyboard(script_id) if app_type == "script" else app_menu_keyboard(script_id) + + def env_menu_text(script_id: str) -> str: script = script_mgr.get_script(script_id) if not script: @@ -866,8 +1502,200 @@ async def send_env_menu(chat_id: int, script_id: str, context: ContextTypes.DEFA await context.bot.send_message(chat_id=chat_id, text=env_menu_text(script_id), reply_markup=env_menu_keyboard(script_id)) -async def send_script_menu(chat_id: int, script_id: str, context: ContextTypes.DEFAULT_TYPE) -> None: - await context.bot.send_message(chat_id=chat_id, text=script_mgr.script_status_line(script_id), reply_markup=script_menu_keyboard(script_id)) +async def send_app_menu(chat_id: int, script_id: str, context: ContextTypes.DEFAULT_TYPE) -> None: + await context.bot.send_message(chat_id=chat_id, text=script_mgr.script_status_line(script_id), reply_markup=menu_keyboard_for(script_id)) + + +# Backward-compatible alias (kept in case anything external referenced this name) +send_script_menu = send_app_menu + + +# New apps: archives + GitHub repos + +async def present_setup_result( + chat_id: int, + context: ContextTypes.DEFAULT_TYPE, + script_id: str, + result: Dict[str, Any], + header: str, +) -> None: + if result.get("status") == "error": + await context.bot.send_message( + chat_id=chat_id, + text=f"❌ {header}\n{safe_trim_text(result.get('message', ''), 3500)}", + ) + return + + if result.get("status") == "ambiguous": + candidates = result.get("candidates", []) + buttons = [ + [InlineKeyboardButton(f"β–Ά {c}", callback_data=f"entrypick:{script_id}:{i}")] + for i, c in enumerate(candidates[:20]) + ] + await context.bot.send_message( + chat_id=chat_id, + text=f"{header}\n\nπŸ€” Found {len(candidates)} Python files and no main.py. Which one should I run?", + reply_markup=InlineKeyboardMarkup(buttons), + ) + return + + entry = result.get("entry") + req_pkgs = result.get("requirements_pkgs") or [] + lines = [header, "", f"πŸ“„ Entry point: {entry}"] + if not result.get("venv_ok", True): + lines.append("⚠️ Failed to create virtual environment:") + lines.append(safe_trim_text(result.get("venv_out", ""), 800)) + + if req_pkgs: + lines.append(f"πŸ“¦ requirements.txt found ({len(req_pkgs)} package(s)): {', '.join(req_pkgs[:15])}") + await context.bot.send_message( + chat_id=chat_id, + text=safe_trim_text("\n".join(lines), 3500), + reply_markup=InlineKeyboardMarkup( + [ + [InlineKeyboardButton("πŸ“₯ Install & Run", callback_data=f"reqinstall:{script_id}")], + [InlineKeyboardButton("β–Ά Run without installing", callback_data=f"reqskip:{script_id}")], + ] + ), + ) + else: + run_msg = await script_mgr.start_script(script_id) + lines.append("") + lines.append(run_msg) + await context.bot.send_message(chat_id=chat_id, text=safe_trim_text("\n".join(lines), 3500)) + + await send_env_menu(chat_id, script_id, context) + await send_app_menu(chat_id, script_id, context) + + +async def handle_archive_upload(update: Update, context: ContextTypes.DEFAULT_TYPE, doc) -> None: + filename = doc.file_name + app_id = sanitize_id(strip_archive_ext(filename)) + + status_msg = await update.message.reply_text(f"πŸ“¦ Downloading {filename} ...") + tmp_archive = TMP_DIR / f"upload_{app_id}_{int(datetime.now().timestamp())}{archive_ext_of(filename)}" + try: + file = await doc.get_file() + await file.download_to_drive(str(tmp_archive)) + + await status_msg.edit_text(f"πŸ“¦ Extracting {filename} ...") + staging = TMP_DIR / f"extract_{app_id}_{int(datetime.now().timestamp())}" + shutil.rmtree(staging, ignore_errors=True) + try: + extract_archive(tmp_archive, staging) + except Exception as e: + logger.exception("Extraction failed for %s", filename) + await status_msg.edit_text(f"❌ Failed to extract archive: {e}") + shutil.rmtree(staging, ignore_errors=True) + return + + extraction_root = find_extraction_root(staging) + + await status_msg.edit_text(f"βš™οΈ Setting up app {app_id} ...") + result = await script_mgr.setup_project_app(app_id, extraction_root, "project") + if staging.exists() and staging != extraction_root: + shutil.rmtree(staging, ignore_errors=True) + + await status_msg.delete() + await present_setup_result( + update.effective_chat.id, context, app_id, result, f"βœ… App {app_id} imported from {filename}." + ) + finally: + tmp_archive.unlink(missing_ok=True) + + +async def setup_git_app(info: Dict[str, Any]) -> Dict[str, Any]: + app_id = info["app_id"] + repo_dir = GITREPOS_DIR / app_id + repo_url = f"https://github.com/{info['owner']}/{info['repo']}.git" + + ok, out = await git_clone_or_pull(repo_url, info.get("branch"), repo_dir) + if not ok: + return {"status": "error", "message": out} + + branch = info.get("branch") or await git_current_branch(repo_dir) + + source_root = (repo_dir / info["path"]) if info.get("path") else repo_dir + if not source_root.exists(): + return {"status": "error", "message": f"Path not found in repo: {info.get('path')}"} + + staging = TMP_DIR / f"gitstage_{app_id}_{int(datetime.now().timestamp())}" + shutil.rmtree(staging, ignore_errors=True) + shutil.copytree(source_root, staging, ignore=shutil.ignore_patterns(".git")) + + git_info = { + "owner": info["owner"], + "repo": info["repo"], + "branch": branch, + "path": info.get("path"), + "repo_dir": str(repo_dir), + } + result = await script_mgr.setup_project_app(app_id, staging, "git", git_info) + result["app_id"] = app_id + return result + + +async def sync_git_app(script_id: str) -> Dict[str, Any]: + script = script_mgr.get_script(script_id) + if not script or script.get("type") != "git": + return {"status": "error", "message": "Not a GitHub app."} + + git_info = script.get("git", {}) + repo_dir = Path(git_info.get("repo_dir") or (GITREPOS_DIR / script_id)) + branch = git_info.get("branch") + repo_url = f"https://github.com/{git_info.get('owner')}/{git_info.get('repo')}.git" + + ok, out = await git_clone_or_pull(repo_url, branch, repo_dir) + if not ok: + return {"status": "error", "message": out} + + source_root = (repo_dir / git_info["path"]) if git_info.get("path") else repo_dir + if not source_root.exists(): + return {"status": "error", "message": f"Path not found in repo: {git_info.get('path')}"} + + old_req_path = Path(script.get("root_dir", "")) / "requirements.txt" + old_req_text = read_text_file(old_req_path) if old_req_path.exists() else "" + + staging = TMP_DIR / f"gitstage_{script_id}_{int(datetime.now().timestamp())}" + shutil.rmtree(staging, ignore_errors=True) + shutil.copytree(source_root, staging, ignore=shutil.ignore_patterns(".git")) + + was_running = script_mgr.script_running(script_id) + if was_running: + await script_mgr.stop_script(script_id) + + result = await script_mgr.setup_project_app(script_id, staging, "git", git_info) + + updated_script = script_mgr.get_script(script_id) or {} + new_req_path = Path(updated_script.get("root_dir", "")) / "requirements.txt" + new_req_text = read_text_file(new_req_path) if new_req_path.exists() else "" + + result["deps_changed"] = new_req_text.strip() != old_req_text.strip() + result["was_running"] = was_running + return result + + +async def process_github_url(update: Update, context: ContextTypes.DEFAULT_TYPE, url: str) -> None: + info = parse_github_url(url) + if not info: + await update.message.reply_text( + "❌ Doesn't look like a GitHub repo URL.\n" + "Expected: https://github.com/owner/repo\n" + "Or with a subfolder: https://github.com/owner/repo/tree/branch/path" + ) + return + + status_msg = await update.message.reply_text(f"🌐 Cloning {info['owner']}/{info['repo']} ...") + result = await setup_git_app(info) + await status_msg.delete() + + if result.get("status") == "error": + await update.message.reply_text(f"❌ Failed to import repo:\n{safe_trim_text(result.get('message', ''), 3500)}") + return + + await present_setup_result( + update.effective_chat.id, context, result["app_id"], result, f"βœ… App {result['app_id']} cloned from GitHub." + ) # Commands @@ -877,19 +1705,37 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: return await update.message.reply_text( "πŸ€– Script manager ready.\n\n" + "πŸ“„ Single scripts:\n" + "β€’ Send a .py file, or /new + text β€” run/stop/edit/env/pip/rollback as before\n\n" + "πŸ“¦ Multi-file apps (archives):\n" + "β€’ Send a .zip / .tar.gz / .tgz / .tar / .7z file\n" + "β€’ I'll unpack it, find requirements.txt (offer to install) and detect main.py\n" + "β€’ If there's only one .py file (or a main.py), I run it automatically\n" + "β€’ If there are several files and no main.py, I'll ask which one to run\n" + "β€’ The app name is the archive name (without extension)\n" + "β€’ Each such app gets its own isolated Python venv (only the telegram lib is shared)\n\n" + "🌐 Apps from GitHub:\n" + "β€’ Just send a link, e.g. https://github.com/owner/repo\n" + "β€’ Or a subfolder link: https://github.com/owner/repo/tree/branch/path\n" + " (app name becomes repo-folder, e.g. myrepo-myapp)\n" + "β€’ Or use /repo \n" + "β€’ These apps get a πŸ”„ Sync button: re-pulls the repo, offers to install new deps, and runs it\n\n" "Main:\n" - "β€’ /menu β€” scripts menu\n" - "β€’ /list β€” list scripts (rich)\n" + "β€’ /menu β€” apps/scripts menu\n" + "β€’ /list β€” list apps/scripts (rich)\n" "β€’ /new β€” create script from next text message\n" + "β€’ /repo β€” import an app from a GitHub repo\n" "β€’ /run , /stop \n" "β€’ /logs \n" "β€’ /monitoring β€” CPU/MEM for running scripts\n\n" "Packages:\n" - "β€’ /pip β€” pip install (saved to requirements.txt)\n\n" + "β€’ /pip β€” pip install\n" + " (scripts: shared requirements.txt; apps: their own venv + requirements.txt)\n\n" "Env:\n" "β€’ /envmenu \n" "β€’ /env , /setenv, /delenv\n" "β€’ /globalenv, /setglobal, /delglobal\n\n" + "Legend: πŸ“„ script πŸ“¦ archive app 🌐 GitHub app\n" "Tip: For /monitoring install psutil via /pip psutil" ) @@ -926,6 +1772,20 @@ async def cmd_new(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(f"✍️ Send script body as text. It will be saved as {script_id}.py") +async def cmd_repo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not is_authorized(update): + return + if not context.args: + await update.message.reply_text( + "Usage: /repo \n" + "Example: /repo https://github.com/owner/repo\n" + "With subfolder: /repo https://github.com/owner/repo/tree/main/subdir\n\n" + "Tip: you can also just paste the link as a plain message." + ) + return + await process_github_url(update, context, context.args[0]) + + async def cmd_envmenu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not is_authorized(update): return @@ -1167,10 +2027,10 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if value == "-": await update.message.reply_text("🟑 Pip install cancelled.") else: - msg = await script_mgr.pip_install(value) + msg = await script_mgr.pip_install(value, script_id=script_id) await update.message.reply_text(msg) if script_id: - await send_script_menu(update.effective_chat.id, script_id, context) + await send_app_menu(update.effective_chat.id, script_id, context) return # env value flow @@ -1211,6 +2071,12 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non await send_env_menu(update.effective_chat.id, script_id, context) return + # GitHub repo link, sent as a plain message (only if no other flow matched above) + gh_info = parse_github_url(text.strip()) + if gh_info: + await process_github_url(update, context, text.strip()) + return + logger.info("Ignored text from user_id=%s (no pending state)", user_id) @@ -1218,20 +2084,30 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if not is_authorized(update): return doc = update.message.document - if not doc.file_name.lower().endswith(".py"): - await update.message.reply_text("❌ Only .py files are accepted.") - return + filename = doc.file_name or "file" + lower = filename.lower() - tmp_path = SCRIPTS_DIR / ("_upload_tmp_" + doc.file_name) - file = await doc.get_file() - await file.download_to_drive(str(tmp_path)) + if lower.endswith(".py"): + tmp_path = SCRIPTS_DIR / ("_upload_tmp_" + filename) + file = await doc.get_file() + await file.download_to_drive(str(tmp_path)) - script_id = script_mgr.upsert_script_from_file(doc.file_name, tmp_path) - tmp_path.unlink(missing_ok=True) + script_id = script_mgr.upsert_script_from_file(filename, tmp_path) + tmp_path.unlink(missing_ok=True) - await update.message.reply_text(f"βœ… Script {script_id} saved from file.\nπŸ“¦ Version saved (if it overwrote existing script).") - await send_script_menu(update.effective_chat.id, script_id, context) - await send_env_menu(update.effective_chat.id, script_id, context) + await update.message.reply_text(f"βœ… Script {script_id} saved from file.\nπŸ“¦ Version saved (if it overwrote existing script).") + await send_app_menu(update.effective_chat.id, script_id, context) + await send_env_menu(update.effective_chat.id, script_id, context) + return + + if lower.endswith(ARCHIVE_EXTENSIONS): + await handle_archive_upload(update, context, doc) + return + + await update.message.reply_text( + "❌ Unsupported file type.\n" + "Send a .py file, or an archive: .zip / .tar.gz / .tgz / .tar / .7z" + ) async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -1254,19 +2130,19 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if not script_mgr.get_script(script_id): await query.edit_message_text("❌ Script not found.") return - await query.edit_message_text(script_mgr.script_status_line(script_id), reply_markup=script_menu_keyboard(script_id)) + await query.edit_message_text(script_mgr.script_status_line(script_id), reply_markup=menu_keyboard_for(script_id)) return if data.startswith("run:"): script_id = data.split(":", 1)[1] msg = await script_mgr.restart_script(script_id) - await query.edit_message_text(safe_trim_text(msg, 3900), reply_markup=script_menu_keyboard(script_id)) + await query.edit_message_text(safe_trim_text(msg, 3900), reply_markup=menu_keyboard_for(script_id)) return if data.startswith("stop:"): script_id = data.split(":", 1)[1] msg = await script_mgr.stop_script(script_id) - await query.edit_message_text(msg, reply_markup=script_menu_keyboard(script_id)) + await query.edit_message_text(msg, reply_markup=menu_keyboard_for(script_id)) return if data.startswith("logs:"): @@ -1284,9 +2160,17 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if data.startswith("edit:"): script_id = data.split(":", 1)[1] - if not script_mgr.get_script(script_id): + script = script_mgr.get_script(script_id) + if not script: await query.edit_message_text("❌ Script not found.") return + if script.get("type", "script") != "script": + hint = " Use πŸ”„ Sync to pull the latest code." if script.get("type") == "git" else " Re-upload an archive with the same name to update it." + await query.edit_message_text( + "✏️ Editing multi-file apps via chat isn't supported." + hint, + reply_markup=menu_keyboard_for(script_id), + ) + return user_id = query.from_user.id script_mgr.pending_edit[user_id] = script_id script_mgr.pending_new.pop(user_id, None) @@ -1309,7 +2193,7 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non script_mgr.set_autostart(script_id, not current) await query.edit_message_text( f"{'πŸš€' if not current else '⏸️'} Autostart for {script_id}: {'ON' if not current else 'OFF'}", - reply_markup=script_menu_keyboard(script_id), + reply_markup=menu_keyboard_for(script_id), ) return @@ -1342,7 +2226,7 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if not script_mgr.get_script(script_id): await query.edit_message_text("❌ Script not found.") return - await query.edit_message_text("βœ… Env updated.", reply_markup=script_menu_keyboard(script_id)) + await query.edit_message_text("βœ… Env updated.", reply_markup=menu_keyboard_for(script_id)) return if data.startswith("envrun:"): @@ -1351,7 +2235,7 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non await query.edit_message_text("❌ Script not found.") return msg = await script_mgr.restart_script(script_id) - await query.edit_message_text(safe_trim_text(msg, 3900), reply_markup=script_menu_keyboard(script_id)) + await query.edit_message_text(safe_trim_text(msg, 3900), reply_markup=menu_keyboard_for(script_id)) return if data.startswith("pipprompt:"): @@ -1361,10 +2245,12 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non script_mgr.pending_new.pop(user_id, None) script_mgr.pending_edit.pop(user_id, None) script_mgr.pending_env_value.pop(user_id, None) + scoped = script_mgr.is_project_type(script_id) await query.edit_message_text( "πŸ“¦ Send pip install args (e.g. `python-telegram-bot==21.6 openai psutil`).\n" - "Saved into requirements.txt automatically.\n" - "Send '-' to cancel." + + ("Installed into this app's own venv and saved into its requirements.txt.\n" if scoped + else "Saved into requirements.txt automatically.\n") + + "Send '-' to cancel." ) return @@ -1386,15 +2272,113 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non await query.edit_message_text("❌ Script not found.") return msg = await script_mgr.rollback_to(script_id, ts) - await query.edit_message_text(safe_trim_text(msg, 3900), reply_markup=script_menu_keyboard(script_id)) + await query.edit_message_text(safe_trim_text(msg, 3900), reply_markup=menu_keyboard_for(script_id)) + return + + if data.startswith("entrypick:"): + # entrypick:: + _, script_id, idx_str = data.split(":", 2) + candidates = script_mgr.pending_entry_choice.pop(script_id, None) + script = script_mgr.get_script(script_id) + if not script or not candidates: + await query.edit_message_text("❌ Selection expired or app not found. Please re-upload / re-sync.") + return + try: + idx = int(idx_str) + entry = candidates[idx] + except (ValueError, IndexError): + await query.edit_message_text("❌ Invalid choice.") + return + + script["entry"] = entry + script_mgr.save_meta() + await query.edit_message_text(f"βœ… Entry point set to {entry}.") + + root = Path(script.get("root_dir", "")) + result = { + "status": "ready", + "entry": entry, + "venv_ok": True, + "requirements_pkgs": find_requirements_pkgs(root) if root.exists() else [], + } + await present_setup_result( + query.message.chat_id, context, script_id, result, f"βœ… App {script_id} configured." + ) + return + + if data.startswith("reqinstall:"): + script_id = data.split(":", 1)[1] + if not script_mgr.get_script(script_id): + await query.edit_message_text("❌ App not found.") + return + await query.edit_message_text("πŸ“₯ Installing requirements ...") + install_msg = await script_mgr.install_app_requirements(script_id) + run_msg = await script_mgr.start_script(script_id) + await context.bot.send_message( + chat_id=query.message.chat_id, + text=safe_trim_text(f"{install_msg}\n\n{run_msg}", 3900), + reply_markup=menu_keyboard_for(script_id), + ) + return + + if data.startswith("reqskip:"): + script_id = data.split(":", 1)[1] + if not script_mgr.get_script(script_id): + await query.edit_message_text("❌ App not found.") + return + run_msg = await script_mgr.start_script(script_id) + await query.edit_message_text(run_msg, reply_markup=menu_keyboard_for(script_id)) + return + + if data.startswith("sync:"): + script_id = data.split(":", 1)[1] + script = script_mgr.get_script(script_id) + if not script or script.get("type") != "git": + await query.edit_message_text("❌ App not found or not a GitHub app.") + return + await query.edit_message_text("πŸ”„ Syncing ...") + result = await sync_git_app(script_id) + + if result.get("status") == "error": + await context.bot.send_message( + chat_id=query.message.chat_id, + text=f"❌ Sync failed:\n{safe_trim_text(result.get('message', ''), 3500)}", + reply_markup=menu_keyboard_for(script_id), + ) + return + + if result.get("status") == "ambiguous": + await present_setup_result(query.message.chat_id, context, script_id, result, f"πŸ”„ Synced {script_id}.") + return + + run_msg = await script_mgr.start_script(script_id) + lines = [f"πŸ”„ Synced {script_id}.", run_msg] + + if result.get("deps_changed"): + updated_script = script_mgr.get_script(script_id) or {} + req_pkgs = find_requirements_pkgs(Path(updated_script.get("root_dir", ""))) + lines.append("") + lines.append(f"πŸ“¦ requirements.txt changed! ({len(req_pkgs)} package(s))" if req_pkgs else "πŸ“¦ requirements.txt changed!") + await context.bot.send_message( + chat_id=query.message.chat_id, + text=safe_trim_text("\n".join(lines), 3500), + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("πŸ“₯ Install new requirements & Restart", callback_data=f"reqinstall:{script_id}")]] + ), + ) + else: + await context.bot.send_message(chat_id=query.message.chat_id, text=safe_trim_text("\n".join(lines), 3500)) + + await send_app_menu(query.message.chat_id, script_id, context) return # Startup async def on_startup(app: Application) -> None: - logger.info("Bot startup: install requirements + autostart scripts") + logger.info("Bot startup: install requirements + prepare app venvs + autostart") await script_mgr.ensure_requirements_installed() + await script_mgr.ensure_app_environments() await script_mgr.autostart_all() @@ -1405,6 +2389,7 @@ def main() -> None: application.add_handler(CommandHandler("menu", cmd_menu)) application.add_handler(CommandHandler("list", cmd_list)) application.add_handler(CommandHandler("new", cmd_new)) + application.add_handler(CommandHandler("repo", cmd_repo)) application.add_handler(CommandHandler("run", cmd_run)) application.add_handler(CommandHandler("stop", cmd_stop)) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..88dbc31 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# Runtime deps (see requirements.txt) plus the test tooling itself. +# Not used at runtime in the container image. +-r requirements.txt + +pytest>=8.0 +pytest-asyncio>=0.23 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a458382 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# Runtime dependencies for the bot itself. +# +# These are installed system-wide in the image: every per-app venv is created +# with --system-site-packages, so this is the one set of packages shared +# across all apps (see README "Isolation model"). py7zr is only used by the +# manager itself to extract uploaded .7z archives. +python-telegram-bot==21.6 +psutil +py7zr diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1c50fff --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +""" +Shared pytest fixtures for the PyManager test suite. + +main.py reads BOT_TOKEN/OWNER_ID/DATA_DIR and creates its data directories at +*import time*, so we set safe defaults before the very first `import main` +happens (below). Each test then gets its own fully isolated data directory by +monkeypatching main's path constants (and a fresh ScriptManager) via the +`app` fixture - no module reloads, no shared state between tests, and nothing +is ever written to a real /data. +""" +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +# --- Set up safe defaults BEFORE importing main ----------------------------- +_BOOTSTRAP_DATA_DIR = Path(tempfile.mkdtemp(prefix="pymanager_bootstrap_")) + +os.environ.setdefault("BOT_TOKEN", "123456:TEST-TOKEN") +os.environ.setdefault("OWNER_ID", "1") +os.environ.setdefault("DATA_DIR", str(_BOOTSTRAP_DATA_DIR)) + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import main as main_module # noqa: E402 (must import after env vars are set) + + +@pytest.fixture +def app(monkeypatch, tmp_path): + """The `main` module with every data path isolated under tmp_path, and a + fresh `script_mgr` bound to it. Use this in any test that touches disk state.""" + data_dir = tmp_path + paths = { + "DATA_DIR": data_dir, + "SCRIPTS_DIR": data_dir / "scripts", + "LOGS_DIR": data_dir / "logs", + "VERSIONS_DIR": data_dir / "versions", + "APPS_DIR": data_dir / "apps", + "APP_VERSIONS_DIR": data_dir / "app_versions", + "VENVS_DIR": data_dir / "venvs", + "GITREPOS_DIR": data_dir / "gitrepos", + "TMP_DIR": data_dir / "tmp", + } + for name, path in paths.items(): + path.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(main_module, name, path) + + monkeypatch.setattr(main_module, "META_FILE", data_dir / "meta.json") + monkeypatch.setattr(main_module, "REQUIREMENTS_FILE", data_dir / "requirements.txt") + + mgr = main_module.ScriptManager() + monkeypatch.setattr(main_module, "script_mgr", mgr) + + return main_module + + +@pytest.fixture +def script_mgr(app): + return app.script_mgr + + +class FakeProcess: + """Stand-in for asyncio.subprocess.Process, used to avoid spawning real + pip/git subprocesses in tests that only care about *what* gets run.""" + + def __init__(self, returncode=0, stdout=b""): + self.returncode = returncode + self._stdout = stdout + self.pid = 12345 + + async def communicate(self): + return self._stdout, b"" + + async def wait(self): + return self.returncode + + def terminate(self): + pass + + def kill(self): + pass + + +@pytest.fixture +def fake_subprocess(monkeypatch): + """Patches asyncio.create_subprocess_exec, recording every invocation. + `calls` is a list of the positional args each call was made with. + `result` controls what every call returns; override per-test as needed.""" + import asyncio + + state = {"calls": [], "returncode": 0, "stdout": b""} + + async def _fake_create_subprocess_exec(*args, **kwargs): + state["calls"].append(args) + return FakeProcess(returncode=state["returncode"], stdout=state["stdout"]) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) + return state + + +@pytest.fixture +def fast_venv(monkeypatch, app): + """Replaces real `python -m venv` calls with an instant fake: creates the + expected interpreter file so `.exists()` checks pass, without spawning a + subprocess. Use this in tests that don't care about the venv's contents, + to keep the suite fast.""" + + async def _fake_create_venv(venv_dir: Path): + py = main_module.venv_python_path(venv_dir) + py.parent.mkdir(parents=True, exist_ok=True) + py.write_text("#!/usr/bin/env python\n") + py.chmod(0o755) + return True, "fake venv" + + monkeypatch.setattr(main_module, "create_venv", _fake_create_venv) + return main_module diff --git a/tests/test_apps.py b/tests/test_apps.py new file mode 100644 index 0000000..499873e --- /dev/null +++ b/tests/test_apps.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""Tests for archive-based apps: setup, entry-point detection, versioning/rollback, +each with an isolated (mocked, for speed) venv. See test_real_venv.py for a slower +end-to-end test that spins up a *real* virtualenv.""" +import pytest + + +def _write_project(root, files): + root.mkdir(parents=True, exist_ok=True) + for rel, content in files.items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + return root + + +@pytest.mark.asyncio +async def test_setup_project_app_single_file_auto_selects_entry(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "staging", {"bot.py": "print('hi')\n"}) + + result = await script_mgr.setup_project_app("demo", staging, "project") + + assert result["status"] == "ready" + assert result["entry"] == "bot.py" + assert result["venv_ok"] is True + + script = script_mgr.get_script("demo") + assert script["type"] == "project" + assert script["entry"] == "bot.py" + assert not staging.exists() # moved, not copied + + +@pytest.mark.asyncio +async def test_setup_project_app_prefers_main_py(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "staging", { + "utils.py": "x = 1\n", + "main.py": "print('entry')\n", + }) + result = await script_mgr.setup_project_app("demo", staging, "project") + assert result["status"] == "ready" + assert result["entry"] == "main.py" + + +@pytest.mark.asyncio +async def test_setup_project_app_ambiguous_without_main_py(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "staging", { + "a.py": "x = 1\n", + "b.py": "x = 2\n", + }) + result = await script_mgr.setup_project_app("demo", staging, "project") + assert result["status"] == "ambiguous" + assert set(result["candidates"]) == {"a.py", "b.py"} + assert script_mgr.pending_entry_choice["demo"] == result["candidates"] + + +@pytest.mark.asyncio +async def test_setup_project_app_detects_requirements(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "staging", { + "main.py": "print('hi')\n", + "requirements.txt": "requests\nflask==2.0\n", + }) + result = await script_mgr.setup_project_app("demo", staging, "project") + assert result["requirements_pkgs"] == ["requests", "flask==2.0"] + + +@pytest.mark.asyncio +async def test_reupload_snapshots_previous_app_version(script_mgr, fast_venv, tmp_path): + staging1 = _write_project(tmp_path / "s1", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("demo", staging1, "project") + + staging2 = _write_project(tmp_path / "s2", {"main.py": "print('v2')\n"}) + result = await script_mgr.setup_project_app("demo", staging2, "project") + + assert result["status"] == "ready" + versions = script_mgr.get_versions("demo") + assert len(versions) == 1 + + current = (script_mgr.app_root_dir("demo") / "main.py").read_text() + assert current.strip() == "print('v2')" + + +@pytest.mark.asyncio +async def test_reupload_preserves_env_and_autostart(script_mgr, fast_venv, tmp_path): + staging1 = _write_project(tmp_path / "s1", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("demo", staging1, "project") + script_mgr.set_script_env("demo", "TOKEN", "secret") + script_mgr.set_autostart("demo", True) + + staging2 = _write_project(tmp_path / "s2", {"main.py": "print('v2')\n"}) + await script_mgr.setup_project_app("demo", staging2, "project") + + script = script_mgr.get_script("demo") + assert script["env"]["TOKEN"] == "secret" + assert script["autostart"] is True + + +@pytest.mark.asyncio +async def test_app_rollback_restores_directory_contents(script_mgr, fast_venv, tmp_path): + staging1 = _write_project(tmp_path / "s1", {"main.py": "print('v1')\n", "helper.py": "x = 1\n"}) + await script_mgr.setup_project_app("demo", staging1, "project") + + staging2 = _write_project(tmp_path / "s2", {"main.py": "print('v2')\n"}) + await script_mgr.setup_project_app("demo", staging2, "project") + + versions = script_mgr.get_versions("demo") + ts_v1 = versions[0]["ts"] + + msg = await script_mgr.rollback_to("demo", ts_v1) + assert "Rolled back" in msg + + root = script_mgr.app_root_dir("demo") + assert (root / "main.py").read_text().strip() == "print('v1')" + assert (root / "helper.py").exists() # v2 didn't have this file - full dir was restored + + +@pytest.mark.asyncio +async def test_app_rollback_correct_despite_colliding_timestamps(script_mgr, fast_venv, tmp_path, monkeypatch, app): + """Same regression as the legacy-script version, but for the tar.gz-based app + versioning path.""" + staging1 = _write_project(tmp_path / "s1", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("demo", staging1, "project") + + staging2 = _write_project(tmp_path / "s2", {"main.py": "print('v2')\n"}) + await script_mgr.setup_project_app("demo", staging2, "project") + + versions = script_mgr.get_versions("demo") + ts_v1 = versions[0]["ts"] + monkeypatch.setattr(app, "now_ts_str", lambda: ts_v1) + + msg = await script_mgr.rollback_to("demo", ts_v1) + assert "Rolled back" in msg + root = script_mgr.app_root_dir("demo") + assert (root / "main.py").read_text().strip() == "print('v1')" + + +@pytest.mark.asyncio +async def test_get_env_keys_scans_all_files_in_project(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "staging", { + "main.py": "import os\nos.getenv('A')\n", + "sub/worker.py": "import os\nos.environ['B']\n", + }) + await script_mgr.setup_project_app("demo", staging, "project") + assert script_mgr.get_env_keys_for_script("demo") == ["A", "B"] + + +@pytest.mark.asyncio +async def test_start_script_project_type_uses_venv_python(script_mgr, fast_venv, app, tmp_path, fake_subprocess): + staging = _write_project(tmp_path / "staging", {"main.py": "print('hi')\n"}) + await script_mgr.setup_project_app("demo", staging, "project") + + # start_script would normally spawn the fake venv's python - just check it + # picks the *right* interpreter and target, without actually running anything. + msg = await script_mgr.start_script("demo") + assert "Started" in msg + + assert len(fake_subprocess["calls"]) == 1 + python_exe, run_target = fake_subprocess["calls"][0] + assert python_exe == str(app.venv_python_path(app.VENVS_DIR / "demo")) + assert run_target == str(script_mgr.app_root_dir("demo") / "main.py") + + +@pytest.mark.asyncio +async def test_ensure_app_environments_recreates_missing_venv(script_mgr, fast_venv, app, tmp_path): + # deliberately no requirements.txt here: we only want to exercise venv + # (re)creation, not the separate pip-install subprocess call. + staging = _write_project(tmp_path / "staging", {"main.py": "print('hi')\n"}) + await script_mgr.setup_project_app("demo", staging, "project") + + # simulate the venv having been wiped (e.g. fresh volume) + import shutil + shutil.rmtree(app.VENVS_DIR / "demo") + assert not app.venv_python_path(app.VENVS_DIR / "demo").exists() + + await script_mgr.ensure_app_environments() + assert app.venv_python_path(app.VENVS_DIR / "demo").exists() diff --git a/tests/test_git_apps.py b/tests/test_git_apps.py new file mode 100644 index 0000000..fac0626 --- /dev/null +++ b/tests/test_git_apps.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""Tests for the GitHub-repo app flow, using a local git repo (no network access). +Skipped automatically if `git` isn't available on PATH.""" +import shutil +import subprocess + +import pytest + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") + + +def _run_git(*args): + subprocess.run(["git", *args], check=True, capture_output=True) + + +@pytest.fixture +def local_repo(tmp_path): + repo_dir = tmp_path / "local_repo" + repo_dir.mkdir() + _run_git("init", "-q", "-b", "main", str(repo_dir)) + _run_git("-C", str(repo_dir), "config", "user.email", "test@example.com") + _run_git("-C", str(repo_dir), "config", "user.name", "Test") + + (repo_dir / "subapp").mkdir() + (repo_dir / "subapp" / "main.py").write_text("print('v1')\n") + (repo_dir / "subapp" / "requirements.txt").write_text("requests\n") + (repo_dir / "readme.md").write_text("root file\n") + _run_git("-C", str(repo_dir), "add", "-A") + _run_git("-C", str(repo_dir), "commit", "-q", "-m", "v1") + return repo_dir + + +@pytest.mark.asyncio +async def test_git_clone_or_pull_clones_and_detects_branch(app, local_repo, tmp_path): + dest = tmp_path / "clone_dest" + ok, out = await app.git_clone_or_pull(f"file:///{local_repo.as_posix()}", "main", dest) + assert ok, out + + branch = await app.git_current_branch(dest) + assert branch == "main" + assert (dest / "subapp" / "main.py").read_text().strip() == "print('v1')" + + +@pytest.mark.asyncio +async def test_git_clone_or_pull_pulls_new_commits(app, local_repo, tmp_path): + dest = tmp_path / "clone_dest" + ok, _ = await app.git_clone_or_pull(f"file:///{local_repo.as_posix()}", "main", dest) + assert ok + + (local_repo / "subapp" / "main.py").write_text("print('v2')\n") + _run_git("-C", str(local_repo), "add", "-A") + _run_git("-C", str(local_repo), "commit", "-q", "-m", "v2") + + ok2, out2 = await app.git_clone_or_pull(f"file:///{local_repo.as_posix()}", "main", dest) + assert ok2, out2 + assert (dest / "subapp" / "main.py").read_text().strip() == "print('v2')" + + +@pytest.mark.asyncio +async def test_setup_git_app_imports_subfolder(script_mgr, app, fast_venv, local_repo, monkeypatch): + # setup_git_app builds the clone URL from owner/repo; point it at our local repo + # by making the constructed https URL resolve to a local file:// path instead. + info = { + "owner": "owner", + "repo": "myrepo", + "branch": "main", + "path": "subapp", + "app_id": "myrepo-subapp", + } + + original_git_clone_or_pull = app.git_clone_or_pull + + async def fake_clone_or_pull(repo_url, branch, dest): + return await original_git_clone_or_pull(f"file:///{local_repo.as_posix()}", branch, dest) + + monkeypatch.setattr(app, "git_clone_or_pull", fake_clone_or_pull) + + result = await app.setup_git_app(info) + assert result["status"] == "ready" + assert result["entry"] == "main.py" + assert result["requirements_pkgs"] == ["requests"] + + script = script_mgr.get_script("myrepo-subapp") + assert script["type"] == "git" + assert script["git"]["path"] == "subapp" + # only the subfolder should have been imported, not the repo root's readme.md + root = script_mgr.app_root_dir("myrepo-subapp") + assert (root / "main.py").exists() + assert not (root / "readme.md").exists() + + +@pytest.mark.asyncio +async def test_sync_git_app_detects_dependency_changes(script_mgr, app, fast_venv, local_repo, monkeypatch): + info = {"owner": "owner", "repo": "myrepo", "branch": "main", "path": "subapp", "app_id": "myrepo-subapp"} + + original_git_clone_or_pull = app.git_clone_or_pull + + async def fake_clone_or_pull(repo_url, branch, dest): + return await original_git_clone_or_pull(f"file:///{local_repo.as_posix()}", branch, dest) + + monkeypatch.setattr(app, "git_clone_or_pull", fake_clone_or_pull) + + await app.setup_git_app(info) + + # no upstream change yet -> sync should report no dependency changes + result = await app.sync_git_app("myrepo-subapp") + assert result["status"] == "ready" + assert result["deps_changed"] is False + + # add a new dependency upstream and commit + (local_repo / "subapp" / "requirements.txt").write_text("requests\nflask\n") + (local_repo / "subapp" / "main.py").write_text("print('v2')\n") + _run_git("-C", str(local_repo), "add", "-A") + _run_git("-C", str(local_repo), "commit", "-q", "-m", "v2") + + result2 = await app.sync_git_app("myrepo-subapp") + assert result2["status"] == "ready" + assert result2["deps_changed"] is True + + root = script_mgr.app_root_dir("myrepo-subapp") + assert (root / "main.py").read_text().strip() == "print('v2')" + assert "flask" in (root / "requirements.txt").read_text() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..bd3e416 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +"""Unit tests for the pure/module-level helper functions in main.py.""" +import tarfile +import zipfile + +import pytest + + +# --- id / filename helpers --------------------------------------------------- + +def test_sanitize_id_replaces_spaces_and_special_chars(app): + assert app.sanitize_id("My App! v2.0") == "My_App__v2.0" + + +def test_sanitize_id_empty_falls_back_to_app(app): + assert app.sanitize_id(" ") == "app" + assert app.sanitize_id("") == "app" + + +@pytest.mark.parametrize( + "filename,expected_stem", + [ + ("myapp.tar.gz", "myapp"), + ("myapp.tgz", "myapp"), + ("myapp.tar", "myapp"), + ("myapp.zip", "myapp"), + ("myapp.7z", "myapp"), + ("my.app.zip", "my.app"), + ], +) +def test_strip_archive_ext(app, filename, expected_stem): + assert app.strip_archive_ext(filename) == expected_stem + + +@pytest.mark.parametrize( + "filename,expected_ext", + [ + ("myapp.tar.gz", ".tar.gz"), + ("myapp.tgz", ".tgz"), + ("myapp.zip", ".zip"), + ("myapp.7z", ".7z"), + ], +) +def test_archive_ext_of_preserves_multi_part_extension(app, filename, expected_ext): + # Regression test: Path(filename).suffix would truncate ".tar.gz" to ".gz", + # which broke extraction of the downloaded temp file. + assert app.archive_ext_of(filename) == expected_ext + + +# --- entry point detection ---------------------------------------------------- + +def test_pick_entry_prefers_main_py(app): + entry, ambiguous = app.pick_entry_from_candidates(["utils.py", "main.py", "sub/main.py"]) + assert entry == "main.py" + assert ambiguous is False + + +def test_pick_entry_single_file_is_unambiguous(app): + entry, ambiguous = app.pick_entry_from_candidates(["bot.py"]) + assert entry == "bot.py" + assert ambiguous is False + + +def test_pick_entry_multiple_files_no_main_is_ambiguous(app): + entry, ambiguous = app.pick_entry_from_candidates(["a.py", "b.py"]) + assert entry is None + assert ambiguous is True + + +def test_pick_entry_no_files(app): + entry, ambiguous = app.pick_entry_from_candidates([]) + assert entry is None + assert ambiguous is False + + +# --- GitHub URL parsing ------------------------------------------------------- + +def test_parse_github_url_plain_repo(app): + info = app.parse_github_url("https://github.com/mrmigles/myapp") + assert info == { + "owner": "mrmigles", + "repo": "myapp", + "branch": None, + "path": None, + "app_id": "myapp", + } + + +def test_parse_github_url_with_subfolder(app): + info = app.parse_github_url("https://github.com/mrmigles/myrepo/tree/main/myapp") + assert info["owner"] == "mrmigles" + assert info["repo"] == "myrepo" + assert info["branch"] == "main" + assert info["path"] == "myapp" + # Exact example from the spec: name becomes "myrepo-myapp" + assert info["app_id"] == "myrepo-myapp" + + +def test_parse_github_url_with_nested_subfolder_uses_last_segment(app): + info = app.parse_github_url("https://github.com/owner/myrepo/tree/dev/sub/folder") + assert info["path"] == "sub/folder" + assert info["app_id"] == "myrepo-folder" + + +def test_parse_github_url_rejects_non_github(app): + assert app.parse_github_url("https://example.com/owner/repo") is None + assert app.parse_github_url("not a url") is None + assert app.parse_github_url("") is None + + +# --- project tree scanning ---------------------------------------------------- + +def _make_project(tmp_path): + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "sub").mkdir() + (tmp_path / "main.py").write_text("import os\nos.getenv('FOO')\nos.environ['BAR']\n") + (tmp_path / "sub" / "helper.py").write_text("x = 1\n") + (tmp_path / "requirements.txt").write_text("requests\n# a comment\nflask==2.0\n\n") + ignored = tmp_path / "__pycache__" + ignored.mkdir() + (ignored / "ignored.py").write_text("should not be discovered\n") + return tmp_path + + +def test_discover_python_files_skips_ignored_dirs(app, tmp_path): + project = _make_project(tmp_path) + files = app.discover_python_files(project) + assert files == ["main.py", "sub/helper.py"] + + +def test_find_requirements_file_prefers_root(app, tmp_path): + project = _make_project(tmp_path) + found = app.find_requirements_file(project) + assert found == project / "requirements.txt" + + +def test_find_requirements_file_falls_back_to_nested(app, tmp_path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "requirements.txt").write_text("requests\n") + found = app.find_requirements_file(tmp_path) + assert found == tmp_path / "sub" / "requirements.txt" + + +def test_find_requirements_file_none_when_missing(app, tmp_path): + assert app.find_requirements_file(tmp_path) is None + + +def test_find_requirements_pkgs_strips_comments_and_blanks(app, tmp_path): + project = _make_project(tmp_path) + assert app.find_requirements_pkgs(project) == ["requests", "flask==2.0"] + + +def test_extract_env_keys_detects_getenv_and_environ(app, tmp_path): + project = _make_project(tmp_path) + source = (project / "main.py").read_text() + assert app.extract_env_keys(source) == {"FOO", "BAR"} + + +# --- archive extraction -------------------------------------------------------- + +def test_extract_archive_zip_round_trip(app, tmp_path): + project = _make_project(tmp_path / "proj") + zpath = tmp_path / "test.zip" + with zipfile.ZipFile(zpath, "w") as zf: + zf.write(project / "main.py", "proj/main.py") + zf.write(project / "requirements.txt", "proj/requirements.txt") + + dest = tmp_path / "extracted" + app.extract_archive(zpath, dest) + root = app.find_extraction_root(dest) + + assert root.name == "proj" + assert (root / "main.py").exists() + assert (root / "requirements.txt").exists() + + +def test_extract_archive_tar_gz_round_trip(app, tmp_path): + project = _make_project(tmp_path / "proj") + tpath = tmp_path / "test.tar.gz" + with tarfile.open(tpath, "w:gz") as tf: + tf.add(project, arcname="proj") + + dest = tmp_path / "extracted" + app.extract_archive(tpath, dest) + root = app.find_extraction_root(dest) + + assert root.name == "proj" + assert (root / "main.py").exists() + assert app.discover_python_files(root) == ["main.py", "sub/helper.py"] + + +def test_extract_archive_zip_rejects_path_traversal(app, tmp_path): + evil_zip = tmp_path / "evil.zip" + with zipfile.ZipFile(evil_zip, "w") as zf: + zf.writestr("../../evil.py", "print('pwned')") + + dest = tmp_path / "extracted" + with pytest.raises(ValueError): + app.extract_archive(evil_zip, dest) + + +def test_extract_archive_unsupported_extension_raises(app, tmp_path): + bogus = tmp_path / "not_an_archive.rar" + bogus.write_text("nope") + with pytest.raises(ValueError): + app.extract_archive(bogus, tmp_path / "out") + + +def test_find_extraction_root_returns_staging_when_multiple_entries(app, tmp_path): + (tmp_path / "a.py").write_text("x = 1\n") + (tmp_path / "b.py").write_text("x = 2\n") + assert app.find_extraction_root(tmp_path) == tmp_path + + +# --- misc text helpers --------------------------------------------------------- + +def test_safe_trim_text_keeps_short_text(app): + assert app.safe_trim_text("hello") == "hello" + + +def test_safe_trim_text_trims_long_text_from_the_end(app): + text = "x" * 5000 + trimmed = app.safe_trim_text(text, limit=100) + assert trimmed.startswith("...\n") + assert trimmed.endswith("x" * 100) + + +def test_pretty_dt_from_ts_roundtrip(app): + ts = app.now_ts_str() + pretty = app.pretty_dt_from_ts(ts) + assert pretty != ts # got reformatted, not just echoed back + assert "." in pretty and ":" in pretty + + +def test_pretty_dt_from_ts_passthrough_on_bad_input(app): + assert app.pretty_dt_from_ts("not-a-timestamp") == "not-a-timestamp" diff --git a/tests/test_real_venv.py b/tests/test_real_venv.py new file mode 100644 index 0000000..5df9fa3 --- /dev/null +++ b/tests/test_real_venv.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""End-to-end tests that spin up a *real* virtualenv (no mocking of create_venv). +Slower than the rest of the suite (a few seconds), but this is what actually +protects the core isolation guarantee: every app gets its own venv, and only +system-site-packages (i.e. python-telegram-bot/psutil) are shared.""" +import sys + +import pytest + + +@pytest.mark.asyncio +async def test_create_venv_is_isolated_but_shares_system_site_packages(app, tmp_path): + venv_dir = tmp_path / "venvs" / "demo" + ok, out = await app.create_venv(venv_dir) + assert ok, out + + python_exe = app.venv_python_path(venv_dir) + assert python_exe.exists() + + # The venv is created with --system-site-packages, so it must be able to see + # whatever is importable in the outer/system interpreter (here: pytest itself, + # which stands in for python-telegram-bot in the real container image). + proc = await app.asyncio.create_subprocess_exec( + str(python_exe), "-c", "import pytest", + stdout=app.asyncio.subprocess.PIPE, stderr=app.asyncio.subprocess.STDOUT, + ) + out, _ = await proc.communicate() + assert proc.returncode == 0, out.decode() + + +@pytest.mark.asyncio +async def test_create_venv_is_idempotent(app, tmp_path): + venv_dir = tmp_path / "venvs" / "demo" + ok1, _ = await app.create_venv(venv_dir) + assert ok1 + # Second call should short-circuit (interpreter already exists) rather than + # re-running `python -m venv`. + ok2, msg2 = await app.create_venv(venv_dir) + assert ok2 + assert "already exists" in msg2 + + +@pytest.mark.asyncio +async def test_full_project_app_lifecycle_runs_in_its_own_venv(script_mgr, app, tmp_path): + staging = tmp_path / "staging" + staging.mkdir() + (staging / "main.py").write_text( + "import sys\n" + "print('running from:', sys.executable)\n" + ) + + result = await script_mgr.setup_project_app("demo", staging, "project") + assert result["status"] == "ready" + assert result["venv_ok"] is True + + expected_python = app.venv_python_path(app.VENVS_DIR / "demo") + assert expected_python.exists() + assert expected_python != app.Path(sys.executable) + + start_msg = await script_mgr.start_script("demo") + assert "Started" in start_msg + + for _ in range(50): + if not script_mgr.script_running("demo"): + break + await __import__("asyncio").sleep(0.1) + + log_tail = script_mgr.read_log_tail("demo") + assert "running from:" in log_tail + assert str(expected_python) in log_tail diff --git a/tests/test_script_manager.py b/tests/test_script_manager.py new file mode 100644 index 0000000..2926394 --- /dev/null +++ b/tests/test_script_manager.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +"""Unit tests for ScriptManager: legacy scripts (BWC), env, autostart, versions/rollback.""" +import json + +import pytest + + +# --- legacy scripts (type="script") - must keep working exactly as before ------ + +def test_upsert_script_from_text_creates_py_file(script_mgr, app): + script_id = script_mgr.upsert_script_from_text("hello world", "print('hi')") + assert script_id == "hello_world" + + script = script_mgr.get_script(script_id) + assert script["type"] == "script" + assert script["file"] == str(script_mgr.script_file_path(script_id)) + assert app.read_text_file(script_mgr.script_file_path(script_id)) == "print('hi')" + + +def test_load_meta_defaults_missing_type_to_script_for_bwc(app): + # Simulate an old meta.json written before the "type" field existed. + app.META_FILE.write_text(json.dumps({ + "global_env": {}, + "scripts": { + "legacy": {"env": {}, "autostart": False, "versions": [], "updated_at": None, "file": "x.py"}, + }, + })) + mgr = app.ScriptManager() + assert mgr.get_type("legacy") == "script" + assert mgr.is_project_type("legacy") is False + + +def test_upsert_script_overwrite_snapshots_previous_version(script_mgr): + sid = script_mgr.upsert_script_from_text("bot", "print(1)") + script_mgr.upsert_script_from_text("bot", "print(2)") + + versions = script_mgr.get_versions(sid) + assert len(versions) == 1 + + current = script_mgr.script_file_path(sid).read_text() + assert current == "print(2)" + + +def test_versions_are_capped_at_max(script_mgr, app): + sid = script_mgr.upsert_script_from_text("bot", "print(0)") + for i in range(1, app.MAX_VERSIONS_PER_SCRIPT + 5): + script_mgr.upsert_script_from_text("bot", f"print({i})") + + versions = script_mgr.get_versions(sid) + assert len(versions) == app.MAX_VERSIONS_PER_SCRIPT + for v in versions: + assert app.Path(v["file"]).exists() + + +@pytest.mark.asyncio +async def test_rollback_restores_previous_script_content(script_mgr): + sid = script_mgr.upsert_script_from_text("bot", "print('v1')") + script_mgr.upsert_script_from_text("bot", "print('v2')") + + versions = script_mgr.get_versions(sid) + ts_v1 = versions[0]["ts"] + + msg = await script_mgr.rollback_to(sid, ts_v1) + assert "Rolled back" in msg + assert script_mgr.script_file_path(sid).read_text() == "print('v1')" + + +@pytest.mark.asyncio +async def test_rollback_is_correct_even_with_colliding_timestamps(script_mgr, monkeypatch, app): + """Regression test: rolling back used to read the target version file AFTER + snapshotting the current file, which could silently overwrite it if both + snapshots landed on the same (1s-resolution) timestamp. The fix reads the + target bytes before doing anything destructive.""" + sid = script_mgr.upsert_script_from_text("bot", "print('v1')") + script_mgr.upsert_script_from_text("bot", "print('v2')") + + versions = script_mgr.get_versions(sid) + ts_v1 = versions[0]["ts"] + + # Force the "snapshot current before rollback" step to reuse the exact same + # timestamp (and therefore the exact same file name) as the version we're + # about to roll back to. + monkeypatch.setattr(app, "now_ts_str", lambda: ts_v1) + + msg = await script_mgr.rollback_to(sid, ts_v1) + assert "Rolled back" in msg + assert script_mgr.script_file_path(sid).read_text() == "print('v1')" + + +@pytest.mark.asyncio +async def test_rollback_unknown_version_reports_error(script_mgr): + sid = script_mgr.upsert_script_from_text("bot", "print(1)") + msg = await script_mgr.rollback_to(sid, "no-such-ts") + assert "not found" in msg.lower() + + +@pytest.mark.asyncio +async def test_start_script_missing_file_reports_error(script_mgr): + msg = await script_mgr.start_script("does-not-exist") + assert "not found" in msg.lower() + + +# --- env vars ------------------------------------------------------------------- + +def test_get_env_keys_for_script_detects_from_source(script_mgr): + sid = script_mgr.upsert_script_from_text("bot", "import os\nos.getenv('TOKEN')\n") + assert script_mgr.get_env_keys_for_script(sid) == ["TOKEN"] + + +def test_set_and_del_script_env(script_mgr): + sid = script_mgr.upsert_script_from_text("bot", "print(1)") + script_mgr.set_script_env(sid, "TOKEN", "abc") + assert script_mgr.get_script(sid)["env"]["TOKEN"] == "abc" + + assert script_mgr.del_script_env(sid, "TOKEN") is True + assert "TOKEN" not in script_mgr.get_script(sid)["env"] + assert script_mgr.del_script_env(sid, "TOKEN") is False + + +def test_global_env_set_and_del(script_mgr): + script_mgr.set_global_env("FOO", "bar") + assert script_mgr.get_global_env()["FOO"] == "bar" + assert script_mgr.del_global_env("FOO") is True + assert "FOO" not in script_mgr.get_global_env() + + +def test_autostart_toggle(script_mgr): + sid = script_mgr.upsert_script_from_text("bot", "print(1)") + assert script_mgr.set_autostart(sid, True) is True + assert script_mgr.get_script(sid)["autostart"] is True + assert script_mgr.set_autostart("nope", True) is False + + +# --- requirements.txt handling --------------------------------------------------- + +def test_add_requirements_is_idempotent(script_mgr, app): + changed1 = script_mgr.add_requirements(["requests", "flask==2.0"]) + changed2 = script_mgr.add_requirements(["requests"]) + assert changed1 is True + assert changed2 is False + assert script_mgr._read_requirements_lines() == ["requests", "flask==2.0"] + + +def test_add_requirements_scoped_to_custom_path(script_mgr, tmp_path): + custom = tmp_path / "app_reqs.txt" + script_mgr.add_requirements(["requests"], path=custom) + assert custom.exists() + assert script_mgr._read_requirements_lines(custom) == ["requests"] + # shared requirements.txt must be untouched + assert script_mgr._read_requirements_lines() == [] + + +# --- pip install scoping (shared vs per-app venv) -------------------------------- + +@pytest.mark.asyncio +async def test_pip_install_legacy_script_targets_shared_interpreter(script_mgr, app, fake_subprocess): + fake_subprocess["stdout"] = b"Successfully installed requests\n" + sid = script_mgr.upsert_script_from_text("bot", "print(1)") + + msg = await script_mgr.pip_install("requests", script_id=sid) + + assert len(fake_subprocess["calls"]) == 1 + called_python = fake_subprocess["calls"][0][0] + assert called_python == app.sys.executable + assert "requirements.txt" in msg + assert script_mgr._read_requirements_lines() == ["requests"] + + +@pytest.mark.asyncio +async def test_pip_install_project_app_targets_its_own_venv(script_mgr, app, fake_subprocess, fast_venv, tmp_path): + staging = tmp_path / "staging" + staging.mkdir() + (staging / "main.py").write_text("print('hi')\n") + result = await script_mgr.setup_project_app("demo", staging, "project") + assert result["status"] == "ready" + + fake_subprocess["stdout"] = b"Successfully installed requests\n" + msg = await script_mgr.pip_install("requests", script_id="demo") + + called_python = fake_subprocess["calls"][0][0] + expected_python = str(app.venv_python_path(app.VENVS_DIR / "demo")) + assert called_python == expected_python + assert called_python != app.sys.executable + + app_req_file = app.APPS_DIR / "demo" / "requirements.txt" + assert app_req_file.exists() + assert "requests" in app_req_file.read_text() + # the shared/global requirements.txt must stay untouched + assert script_mgr._read_requirements_lines() == [] + assert "demo" in msg or "requirements.txt" in msg From 092383965aa1f05ffaeaca9bf3934d1f23fead35 Mon Sep 17 00:00:00 2001 From: mrMigles Date: Thu, 16 Jul 2026 11:06:50 +0500 Subject: [PATCH 2/2] feat: Update GitHub Actions workflow to handle pull requests and adjust image build process --- .github/workflows/build-and-deploy.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index a664754..5c3123c 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -4,6 +4,9 @@ on: push: branches: - main + pull_request: + branches: + - main workflow_dispatch: env: @@ -40,18 +43,22 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Not needed (and not possible for fork PRs) when we're only building, + # not pushing. - name: Log in to GHCR + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push image + # On pull requests this just validates the image builds - it's never pushed. + - name: Build image uses: docker/build-push-action@v6 with: context: . - push: true + push: ${{ github.event_name != 'pull_request' }} tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} @@ -59,6 +66,7 @@ jobs: deploy: runs-on: ubuntu-latest needs: build-and-push + if: github.event_name != 'pull_request' steps: - name: Checkout