diff --git a/README.md b/README.md index fab3085..9245798 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Designed for **single-owner** usage (restricted by `OWNER_ID`). name = `repo-subdir` - **๐Ÿ”„ Sync** button re-pulls the repo, offers to install any new dependencies, and restarts the app +- **๐Ÿ”€ Switching an app's type**: upload a `.py` file / archive / GitHub link whose name matches + an existing app of a **different** type โ€” the bot will ask for confirmation before converting. + All old versions are preserved and โช Rollback works across the type boundary, fully restoring + the previous type (source files, virtual environment, metadata), so switching is always reversible. - **๐Ÿงช 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 @@ -115,6 +119,16 @@ docker compose down - `https://github.com/owner/repo/tree/main/subdir` - Legend in menus: `๐Ÿ“„` script ยท `๐Ÿ“ฆ` archive app ยท `๐ŸŒ` GitHub app. +### Switching an app's type +Upload a `.py` file, an archive, or a GitHub link whose **name resolves to the same id** as an +existing app of a different type: +- The bot shows a confirmation dialog (`โœ… Convert` / `โŒ Cancel`) before doing anything. +- On confirmation: the running process is stopped, old-type artifacts (source tree, venv, `.py` + file) are removed, and the new type is set up from scratch. +- **All previous versions are kept.** โช Rollback works across the type boundary โ€” choosing a + version from before the conversion fully restores the old type (source files, virtual environment, + and all metadata). The conversion itself is also snapshotted, so it too is reversible. + --- ## Notes diff --git a/main.py b/main.py index 9011369..6b0f023 100644 --- a/main.py +++ b/main.py @@ -404,6 +404,12 @@ def __init__(self) -> None: 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 + # keyed by script_id; holds the staged data for a cross-type conversion awaiting + # the user's โœ… Convert / โŒ Cancel confirmation + self.pending_type_conversion: Dict[str, Dict[str, Any]] = {} + # keyed by user_id; value is the target script_id that the user wants to + # replace with a new source (file / archive / GitHub link) + self.pending_source_change: Dict[int, str] = {} self.load_meta() @@ -657,12 +663,18 @@ async def setup_project_app( prepares an isolated venv, and detects the entry point + requirements.""" dest_root = self.app_root_dir(script_id) + # Stop any running process before replacing files on disk. + if self.script_running(script_id): + await self.stop_script(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) + # Remove artifacts that belong exclusively to the old type. + self._clear_type_artifacts(script_id, existing, "app") if dest_root.exists(): shutil.rmtree(dest_root, ignore_errors=True) @@ -729,7 +741,16 @@ async def install_app_requirements(self, script_id: str) -> str: 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: + def _push_version( + self, + script_id: str, + version_file: Path, + ts: str, + *, + kind: str = "script", + app_type: Optional[str] = None, + git: Optional[Dict[str, Any]] = None, + ) -> None: script = self.get_script(script_id) if not script: return @@ -738,7 +759,12 @@ def _push_version(self, script_id: str, version_file: Path, ts: str) -> None: versions = [] script["versions"] = versions - versions.insert(0, {"ts": ts, "file": str(version_file)}) + entry: Dict[str, Any] = {"ts": ts, "file": str(version_file), "kind": kind} + if app_type is not None: + entry["app_type"] = app_type + if git is not None: + entry["git"] = git + versions.insert(0, entry) # Trim versions list and delete old files while len(versions) > MAX_VERSIONS_PER_SCRIPT: @@ -781,7 +807,7 @@ def snapshot_current_version(self, script_id: str) -> None: 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) + self._push_version(script_id, version_file, ts, kind="script") logger.info("Snapshot version for %s -> %s", script_id, version_file.name) except Exception: logger.exception("Failed to snapshot version for %s", script_id) @@ -800,7 +826,14 @@ def snapshot_current_app_version(self, script_id: str) -> None: try: with tarfile.open(version_file, "w:gz") as tf: tf.add(root, arcname=script_id) - self._push_version(script_id, version_file, ts) + self._push_version( + script_id, + version_file, + ts, + kind="app", + app_type=script.get("type"), + git=script.get("git"), + ) 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) @@ -810,9 +843,14 @@ def upsert_script_from_text(self, name: str, content: str) -> str: if not script_id: raise ValueError("empty script name") - # If script already exists, snapshot before overwriting - if self.get_script(script_id) and self.script_file_path(script_id).exists(): - self.snapshot_current_version(script_id) + # Snapshot the current state before overwriting, then clean up old-type artifacts. + existing = self.get_script(script_id) + if existing: + if existing.get("type", "script") != "script": + self.snapshot_current_app_version(script_id) + self._clear_type_artifacts(script_id, existing, "script") + elif self.script_file_path(script_id).exists(): + self.snapshot_current_version(script_id) file_path = self.script_file_path(script_id) write_text_file_atomic(file_path, content) @@ -833,9 +871,14 @@ def upsert_script_from_file(self, original_name: str, content_path: Path) -> str if not script_id: script_id = f"script_{len(self.meta.get('scripts', {})) + 1}" - # Snapshot before overwriting - if self.get_script(script_id) and self.script_file_path(script_id).exists(): - self.snapshot_current_version(script_id) + # Snapshot the current state before overwriting, then clean up old-type artifacts. + existing = self.get_script(script_id) + if existing: + if existing.get("type", "script") != "script": + self.snapshot_current_app_version(script_id) + self._clear_type_artifacts(script_id, existing, "script") + elif self.script_file_path(script_id).exists(): + self.snapshot_current_version(script_id) content = read_text_file(content_path) file_path = self.script_file_path(script_id) @@ -1086,82 +1129,102 @@ def get_env_keys_for_script(self, script_id: str) -> List[str]: continue return sorted(keys) - def get_versions(self, script_id: str) -> List[Dict[str, str]]: + def get_versions(self, script_id: str) -> List[Dict[str, Any]]: script = self.get_script(script_id) if not script: return [] versions = script.get("versions", []) if not isinstance(versions, list): return [] - out: List[Dict[str, str]] = [] + out: List[Dict[str, Any]] = [] for v in versions: if not isinstance(v, dict): continue ts = str(v.get("ts", "")).strip() fp = str(v.get("file", "")).strip() if ts and fp: - out.append({"ts": ts, "file": fp}) + entry: Dict[str, Any] = {"ts": ts, "file": fp} + for extra_key in ("kind", "app_type", "git"): + if extra_key in v: + entry[extra_key] = v[extra_key] + out.append(entry) return out[:MAX_VERSIONS_PER_SCRIPT] - async def rollback_to(self, script_id: str, ts: str) -> str: - script = self.get_script(script_id) - 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: - if v["ts"] == ts: - chosen = v - break - if not chosen: - return "โŒ Version not found." - - version_path = Path(chosen["file"]) - 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) - - # Snapshot current before rollback - if self.script_file_path(script_id).exists(): - self.snapshot_current_version(script_id) - - # Replace current script with chosen version - try: - 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)." - - # Update updated_at - script = self.get_script(script_id) - if script: - script["updated_at"] = now_ts_str() - self.save_meta() + # --- type-conversion helpers ------------------------------------------------- + + def _clear_type_artifacts(self, script_id: str, old_script: Dict[str, Any], new_kind: str) -> None: + """Remove the on-disk artifacts and stale meta keys that belong exclusively + to the *old* type of an app being converted to *new_kind* ("script"/"app"). + The caller is responsible for saving meta afterwards.""" + old_type = old_script.get("type", "script") + old_kind = "script" if old_type == "script" else "app" + if old_kind == new_kind: + return # nothing to do; same storage model + + if old_kind == "script": + # script โ†’ app: remove the .py source file and its meta key + old_file = old_script.get("file") or str(self.script_file_path(script_id)) + fp = Path(old_file) + if fp.exists(): + try: + fp.unlink() + except Exception: + logger.exception("_clear_type_artifacts: failed to delete old script file for %s", script_id) + old_script.pop("file", None) + else: + # app โ†’ script: remove the app source tree, its venv, and (for git) the repo clone + root_dir = old_script.get("root_dir") + if root_dir: + shutil.rmtree(root_dir, ignore_errors=True) + venv_dir = old_script.get("venv_dir") + if venv_dir: + shutil.rmtree(venv_dir, ignore_errors=True) + if old_type == "git": + git_meta = old_script.get("git", {}) + repo_dir = git_meta.get("repo_dir") or str(GITREPOS_DIR / script_id) + shutil.rmtree(repo_dir, ignore_errors=True) + for key in ("root_dir", "venv_dir", "entry", "git"): + old_script.pop(key, None) + + def conflicting_type(self, script_id: str, new_type: str) -> Optional[str]: + """Return the existing type string if it differs from *new_type*, else None.""" + s = self.get_script(script_id) + if not s: + return None + existing = s.get("type", "script") + return existing if existing != new_type else None + + def _set_pending_conversion(self, script_id: str, stash: Dict[str, Any]) -> None: + """Stash a pending type-conversion, cleaning up any previous stash for the same id.""" + self._clear_pending_conversion(script_id) + self.pending_type_conversion[script_id] = stash + + def _clear_pending_conversion(self, script_id: str) -> None: + """Discard a pending type-conversion stash and remove any staged temp directories.""" + stash = self.pending_type_conversion.pop(script_id, None) + if not stash: + return + staged = stash.get("staged_root") + if staged: + shutil.rmtree(staged, ignore_errors=True) - msg = f"โช Rolled back *{script_id}* to version {pretty_dt_from_ts(ts)}".replace("*", "") + # --- rollback --------------------------------------------------------------- - if was_running: - s = await self.start_script(script_id) - msg += f"\n{s}" + async def rollback_to(self, script_id: str, ts: str) -> str: + """Roll back to a previous version. - return msg + The target version's "kind" field (written by snapshot_current_version / + snapshot_current_app_version) determines the restore strategy, NOT the + current type. This means rolling back past a type-conversion (e.g. + script โ†’ project) fully reverts the type as well โ€” including removing the + new type's artifacts and restoring the old type's files and meta. - async def _rollback_app_to(self, script_id: str, ts: str) -> str: + Versions created before this migration have no "kind" tag; we fall back to + inferring it from the file extension (.py โ‡’ script, .tar.gz โ‡’ app). + """ script = self.get_script(script_id) if not script: - return "โŒ App not found." + return "โŒ Script not found." versions = self.get_versions(script_id) chosen = next((v for v in versions if v["ts"] == ts), None) @@ -1172,49 +1235,108 @@ async def _rollback_app_to(self, script_id: str, ts: str) -> str: 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) + # Infer target kind from the version tag, falling back to the file extension + # for versions created before this migration. + target_kind: str = chosen.get("kind") or ("script" if version_path.suffix == ".py" else "app") + current_type = script.get("type", "script") + current_kind = "script" if current_type == "script" else "app" + + # --- Stage the restore data BEFORE snapshotting to avoid same-second + # timestamp collisions (reading/extracting now guarantees the chosen version + # is unaffected by the snapshot we're about to create). + if target_kind == "script": + version_bytes = version_path.read_bytes() + tmp_extract = None + else: + tmp_extract = self.app_root_dir(script_id).parent / f"__rollback_tmp_{script_id}" shutil.rmtree(tmp_extract, ignore_errors=True) - return "โŒ Rollback failed (extract error)." + 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) + # Snapshot current state so this rollback is itself re-revertible. + if current_kind == "script": + if self.script_file_path(script_id).exists(): + self.snapshot_current_version(script_id) + else: + cur_root = Path(script.get("root_dir") or self.app_root_dir(script_id)) + if cur_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) + # When crossing a type boundary, remove the artifacts of the current type. + if current_kind != target_kind: + self._clear_type_artifacts(script_id, script, target_kind) - script = self.get_script(script_id) - if script: - script["updated_at"] = now_ts_str() - self.save_meta() + # --- Materialize the target version ------------------------------------ + if target_kind == "script": + try: + dest_py = self.script_file_path(script_id) + dest_py.parent.mkdir(parents=True, exist_ok=True) + dest_py.write_bytes(version_bytes) + except Exception: + logger.exception("Rollback copy failed for %s", script_id) + return "โŒ Rollback failed (copy error)." + + script["type"] = "script" + script["file"] = str(self.script_file_path(script_id)) + for k in ("root_dir", "venv_dir", "entry", "git"): + script.pop(k, None) - msg = f"โช Rolled back {script_id} to version {pretty_dt_from_ts(ts)}" + else: + target_app_type: str = chosen.get("app_type") or (current_type if current_type != "script" else "project") + target_git: Optional[Dict[str, Any]] = chosen.get("git") + dest_root = self.app_root_dir(script_id) + + try: + assert tmp_extract is not None + inner = tmp_extract / script_id + if not inner.exists(): + inner = tmp_extract # older/foreign archives without the wrapper dir + + shutil.rmtree(dest_root, ignore_errors=True) + dest_root.mkdir(parents=True, exist_ok=True) + for item in inner.iterdir(): + shutil.move(str(item), str(dest_root / item.name)) + except Exception: + logger.exception("App rollback failed for %s", script_id) + return "โŒ Rollback failed (move error)." + finally: + if tmp_extract is not None: + shutil.rmtree(tmp_extract, ignore_errors=True) + + py_files = discover_python_files(dest_root) + entry, _ = pick_entry_from_candidates(py_files) + + venv_dir = self.app_venv_dir(script_id) + venv_ok, venv_out = await create_venv(venv_dir) + if not venv_ok: + logger.error("Venv creation failed during rollback for %s: %s", script_id, safe_trim_text(venv_out, 500)) + + script["type"] = target_app_type + script["root_dir"] = str(dest_root) + script["venv_dir"] = str(venv_dir) + script["entry"] = entry + if target_git: + script["git"] = target_git + else: + script.pop("git", None) + script.pop("file", None) + + script["updated_at"] = now_ts_str() + self.save_meta() + + type_label = {"script": "๐Ÿ“„ script", "project": "๐Ÿ“ฆ archive app", "git": "๐ŸŒ GitHub app"}.get( + script.get("type", "script"), script.get("type", "script") + ) + msg = f"โช Rolled back {script_id} to version {pretty_dt_from_ts(ts)} ({type_label})" if was_running: s = await self.start_script(script_id) @@ -1397,6 +1519,9 @@ def script_menu_keyboard(script_id: str) -> InlineKeyboardMarkup: InlineKeyboardButton("โช Rollback", callback_data=f"rbmenu:{script_id}"), InlineKeyboardButton(auto_text, callback_data=f"auto:{script_id}"), ], + [ + InlineKeyboardButton("๐Ÿ”€ Change Source", callback_data=f"changesrc:{script_id}"), + ], [ InlineKeyboardButton("โฌ… Back", callback_data="back:main"), ], @@ -1433,6 +1558,9 @@ def app_menu_keyboard(script_id: str) -> InlineKeyboardMarkup: InlineKeyboardButton("โช Rollback", callback_data=f"rbmenu:{script_id}"), InlineKeyboardButton(auto_text, callback_data=f"auto:{script_id}"), ], + [ + InlineKeyboardButton("๐Ÿ”€ Change Source", callback_data=f"changesrc:{script_id}"), + ], [ InlineKeyboardButton("โฌ… Back", callback_data="back:main"), ], @@ -1445,6 +1573,53 @@ def menu_keyboard_for(script_id: str) -> InlineKeyboardMarkup: return script_menu_keyboard(script_id) if app_type == "script" else app_menu_keyboard(script_id) +_TYPE_LABEL: Dict[str, str] = { + "script": "๐Ÿ“„ script", + "project": "๐Ÿ“ฆ archive app", + "git": "๐ŸŒ GitHub app", +} + + +def _source_change_confirm_text(script_id: str, old_type: str, new_type: str) -> str: + """Confirmation message for any source-change operation initiated via the + 'Change Source' button. Uses the full conversion warning when the type + changes, or a shorter replace-only note when the type stays the same.""" + if old_type != new_type: + return type_conversion_confirm_text(script_id, old_type, new_type) + label = _TYPE_LABEL.get(old_type, old_type) + return ( + f"๐Ÿ”€ Replace the source of {script_id} ({label})?\n\n" + f"โ€ข The current version will be saved โ€” reachable via โช Rollback\n" + f"โ€ข All env vars and autostart settings are preserved\n\n" + f"Continue?" + ) + + +def type_conversion_confirm_text(script_id: str, old_type: str, new_type: str) -> str: + old_label = _TYPE_LABEL.get(old_type, old_type) + new_label = _TYPE_LABEL.get(new_type, new_type) + if old_type == "script": + cleanup_note = "set up a new isolated virtual environment" + else: + cleanup_note = "remove the existing source tree and virtual environment" + return ( + f"โš ๏ธ {script_id} is currently a {old_label}.\n\n" + f"Converting to a {new_label} will:\n" + f"โ€ข Stop the app if it is running\n" + f"โ€ข Replace its files and {cleanup_note}\n" + f"โ€ข Keep all old versions reachable via โช Rollback, including a full " + f"revert back to {old_label} mode\n\n" + f"Continue?" + ) + + +def type_conversion_confirm_keyboard(script_id: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[ + InlineKeyboardButton("โœ… Convert", callback_data=f"convtype:yes:{script_id}"), + InlineKeyboardButton("โŒ Cancel", callback_data=f"convtype:no:{script_id}"), + ]]) + + def env_menu_text(script_id: str) -> str: script = script_mgr.get_script(script_id) if not script: @@ -1568,9 +1743,16 @@ async def present_setup_result( await send_app_menu(chat_id, script_id, context) -async def handle_archive_upload(update: Update, context: ContextTypes.DEFAULT_TYPE, doc) -> None: +async def handle_archive_upload( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + doc, + force_target_id: Optional[str] = None, +) -> None: filename = doc.file_name - app_id = sanitize_id(strip_archive_ext(filename)) + # When arriving via "Change Source", the user may have sent an archive whose + # filename doesn't match the target app's id โ€” honour the override. + app_id = force_target_id or 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)}" @@ -1591,6 +1773,27 @@ async def handle_archive_upload(update: Update, context: ContextTypes.DEFAULT_TY extraction_root = find_extraction_root(staging) + old_type = script_mgr.conflicting_type(app_id, "project") + # Show confirmation if: (a) a different type already exists, or (b) the + # user explicitly requested a source change via the "Change Source" button. + needs_confirm = old_type is not None or force_target_id is not None + if needs_confirm: + effective_old_type = old_type or (script_mgr.get_script(app_id) or {}).get("type", "project") + script_mgr._set_pending_conversion(app_id, { + "action": "archive", + "staged_root": str(extraction_root), + "staging": str(staging) if staging != extraction_root else None, + "old_type": effective_old_type, + }) + await status_msg.delete() + confirm_text = ( + _source_change_confirm_text(app_id, effective_old_type, "project") + if force_target_id is not None + else type_conversion_confirm_text(app_id, effective_old_type, "project") + ) + await update.message.reply_text(confirm_text, reply_markup=type_conversion_confirm_keyboard(app_id)) + return + 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: @@ -1604,7 +1807,13 @@ async def handle_archive_upload(update: Update, context: ContextTypes.DEFAULT_TY tmp_archive.unlink(missing_ok=True) -async def setup_git_app(info: Dict[str, Any]) -> Dict[str, Any]: +async def _prepare_git_app(info: Dict[str, Any]) -> Dict[str, Any]: + """Clone / pull the repo and copy the relevant subtree to a staging directory. + Returns a dict with ``staged_root`` and ``git_info`` on success, or + ``{"status": "error", "message": ...}`` on failure. Does NOT mutate the + ScriptManager; the caller decides whether to proceed immediately or first + show a confirmation dialog. + """ app_id = info["app_id"] repo_dir = GITREPOS_DIR / app_id repo_url = f"https://github.com/{info['owner']}/{info['repo']}.git" @@ -1630,9 +1839,7 @@ async def setup_git_app(info: Dict[str, Any]) -> Dict[str, Any]: "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 + return {"staged_root": staging, "git_info": git_info, "app_id": app_id} async def sync_git_app(script_id: str) -> Dict[str, Any]: @@ -1675,7 +1882,12 @@ async def sync_git_app(script_id: str) -> Dict[str, Any]: return result -async def process_github_url(update: Update, context: ContextTypes.DEFAULT_TYPE, url: str) -> None: +async def process_github_url( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + url: str, + force_target_id: Optional[str] = None, +) -> None: info = parse_github_url(url) if not info: await update.message.reply_text( @@ -1685,16 +1897,47 @@ async def process_github_url(update: Update, context: ContextTypes.DEFAULT_TYPE, ) return + # When the user arrives via "Change Source", override the app_id that would + # normally be inferred from the repository name. + if force_target_id: + info = {**info, "app_id": force_target_id} + + app_id = info["app_id"] status_msg = await update.message.reply_text(f"๐ŸŒ Cloning {info['owner']}/{info['repo']} ...") - result = await setup_git_app(info) + prep = await _prepare_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)}") + if prep.get("status") == "error": + await update.message.reply_text(f"โŒ Failed to import repo:\n{safe_trim_text(prep.get('message', ''), 3500)}") + return + + staged_root: Path = prep["staged_root"] + git_info: Dict[str, Any] = prep["git_info"] + + old_type = script_mgr.conflicting_type(app_id, "git") + # Show confirmation when: (a) a conflicting type exists, or (b) the user + # explicitly invoked "Change Source" (so we always confirm before replacing). + needs_confirm = old_type is not None or force_target_id is not None + if needs_confirm: + effective_old_type = old_type or (script_mgr.get_script(app_id) or {}).get("type", "git") + script_mgr._set_pending_conversion(app_id, { + "action": "git", + "staged_root": str(staged_root), + "git_info": git_info, + "old_type": effective_old_type, + }) + confirm_text = ( + _source_change_confirm_text(app_id, effective_old_type, "git") + if force_target_id is not None + else type_conversion_confirm_text(app_id, effective_old_type, "git") + ) + await update.message.reply_text(confirm_text, reply_markup=type_conversion_confirm_keyboard(app_id)) return + result = await script_mgr.setup_project_app(app_id, staged_root, "git", git_info) + result["app_id"] = app_id await present_setup_result( - update.effective_chat.id, context, result["app_id"], result, f"โœ… App {result['app_id']} cloned from GitHub." + update.effective_chat.id, context, app_id, result, f"โœ… App {app_id} cloned from GitHub." ) @@ -1720,11 +1963,18 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: " (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" + "๐Ÿ”€ Switching an app's type / source:\n" + "โ€ข Upload a .py / archive / GitHub link whose name matches an existing app of a different type\n" + "โ€ข Or open an app's menu and press ๐Ÿ”€ Change Source, then send any source โ€” even with a\n" + " different filename or repo name. The bot always asks for confirmation before replacing.\n" + "โ€ข All old versions are kept โ€” โช Rollback works across the type boundary and fully restores\n" + " the previous type (files, venv, meta), so switching is always reversible\n\n" "Main:\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" + "โ€ข /cancel โ€” abort any pending input flow (Change Source, /new, env edit, etc.)\n" "โ€ข /run , /stop \n" "โ€ข /logs \n" "โ€ข /monitoring โ€” CPU/MEM for running scripts\n\n" @@ -1740,6 +1990,27 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) +async def cmd_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not is_authorized(update): + return + user_id = update.effective_user.id + cleared: list[str] = [] + if script_mgr.pending_source_change.pop(user_id, None) is not None: + cleared.append("source change") + if script_mgr.pending_new.pop(user_id, None) is not None: + cleared.append("new script") + if script_mgr.pending_edit.pop(user_id, None) is not None: + cleared.append("script edit") + if script_mgr.pending_env_value.pop(user_id, None) is not None: + cleared.append("env edit") + if script_mgr.pending_pip.pop(user_id, None) is not None: + cleared.append("pip install") + if cleared: + await update.message.reply_text(f"๐Ÿšซ Cancelled: {', '.join(cleared)}.") + else: + await update.message.reply_text("๐ŸŸก Nothing to cancel.") + + async def cmd_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not is_authorized(update): return @@ -2051,6 +2322,19 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non # new script flow if user_id in script_mgr.pending_new: script_id = script_mgr.pending_new.pop(user_id) + old_type = script_mgr.conflicting_type(script_id, "script") + if old_type is not None: + # Stash the text and wait for user confirmation. + script_mgr._set_pending_conversion(script_id, { + "action": "script_text", + "text": text, + "old_type": old_type, + }) + await update.message.reply_text( + type_conversion_confirm_text(script_id, old_type, "script"), + reply_markup=type_conversion_confirm_keyboard(script_id), + ) + return full_id = script_mgr.upsert_script_from_text(script_id, text) await update.message.reply_text(f"โœ… Script {full_id} saved.") await send_script_menu(update.effective_chat.id, full_id, context) @@ -2071,6 +2355,27 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non await send_env_menu(update.effective_chat.id, script_id, context) return + # "Change Source" flow โ€” the user previously clicked the button and we are + # now waiting for the new source. Only GitHub links are accepted as text; + # files / archives must be sent as documents (handled in handle_document). + if user_id in script_mgr.pending_source_change: + target_id = script_mgr.pending_source_change[user_id] + gh_info = parse_github_url(text.strip()) + if gh_info: + del script_mgr.pending_source_change[user_id] + await process_github_url(update, context, text.strip(), force_target_id=target_id) + else: + await update.message.reply_text( + f"โ“ Waiting for a new source for *{target_id}*.\n\n" + "Send:\n" + "โ€ข A `.py` file โ€” convert to ๐Ÿ“„ script\n" + "โ€ข An archive (`.zip` / `.tar.gz` / `.tgz` / `.tar` / `.7z`) โ€” convert to ๐Ÿ“ฆ archive app\n" + "โ€ข A GitHub link โ€” convert to ๐ŸŒ GitHub app\n\n" + "Send /cancel to abort.", + parse_mode="Markdown", + ) + 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: @@ -2087,26 +2392,65 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> filename = doc.file_name or "file" lower = filename.lower() + user_id = update.effective_user.id + # If the user clicked "Change Source", honour the stored target app id + # regardless of what the uploaded file is named. + force_target_id: Optional[str] = script_mgr.pending_source_change.pop(user_id, None) + if lower.endswith(".py"): + if force_target_id: + script_id = force_target_id + else: + stem = Path(filename).stem.strip().replace(" ", "_") + script_id = stem or "script" + old_type = script_mgr.conflicting_type(script_id, "script") + 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(filename, tmp_path) + file_bytes = tmp_path.read_bytes() 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_app_menu(update.effective_chat.id, script_id, context) - await send_env_menu(update.effective_chat.id, script_id, context) + needs_confirm = old_type is not None or force_target_id is not None + if needs_confirm: + effective_old_type = old_type or (script_mgr.get_script(script_id) or {}).get("type", "script") + script_mgr._set_pending_conversion(script_id, { + "action": "script_file", + "file_bytes": file_bytes, + "original_name": filename, + "old_type": effective_old_type, + }) + confirm_text = ( + _source_change_confirm_text(script_id, effective_old_type, "script") + if force_target_id is not None + else type_conversion_confirm_text(script_id, effective_old_type, "script") + ) + await update.message.reply_text(confirm_text, reply_markup=type_conversion_confirm_keyboard(script_id)) + return + + # Write bytes to a temp file so upsert_script_from_file can read it. + tmp_path2 = SCRIPTS_DIR / ("_upload_tmp2_" + filename) + tmp_path2.write_bytes(file_bytes) + actual_id = script_mgr.upsert_script_from_file(filename, tmp_path2) + tmp_path2.unlink(missing_ok=True) + + await update.message.reply_text(f"โœ… Script {actual_id} saved from file.\n๐Ÿ“ฆ Version saved (if it overwrote existing script).") + await send_app_menu(update.effective_chat.id, actual_id, context) + await send_env_menu(update.effective_chat.id, actual_id, context) return if lower.endswith(ARCHIVE_EXTENSIONS): - await handle_archive_upload(update, context, doc) + await handle_archive_upload(update, context, doc, force_target_id=force_target_id) return + if force_target_id: + # Restore so the next message can still trigger a source change + script_mgr.pending_source_change[user_id] = force_target_id + await update.message.reply_text( "โŒ Unsupported file type.\n" - "Send a .py file, or an archive: .zip / .tar.gz / .tgz / .tar / .7z" + "Send a .py file, or an archive: .zip / .tar.gz / .tgz / .tar / .7z\n" + "Send /cancel to abort." ) @@ -2330,6 +2674,107 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non await query.edit_message_text(run_msg, reply_markup=menu_keyboard_for(script_id)) return + if data.startswith("changesrc:"): + # changesrc: โ€” user clicked "Change Source" from the app menu + script_id = data.split(":", 1)[1] + if not script_mgr.get_script(script_id): + await query.edit_message_text("โŒ App not found.") + return + user_id = query.from_user.id + # Clear any other pending user-scoped states so we don't mix flows. + 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) + script_mgr.pending_pip.pop(user_id, None) + script_mgr.pending_source_change[user_id] = script_id + await query.edit_message_text( + f"๐Ÿ”€ *{script_id}* โ€” waiting for new source.\n\n" + "Send:\n" + "โ€ข A `.py` file โ†’ convert to ๐Ÿ“„ script\n" + "โ€ข An archive (`.zip` / `.tar.gz` / `.tgz` / `.tar` / `.7z`) โ†’ convert to ๐Ÿ“ฆ archive app\n" + "โ€ข A GitHub link โ†’ convert to ๐ŸŒ GitHub app\n\n" + "Send /cancel to abort.", + parse_mode="Markdown", + ) + return + + if data.startswith("convtype:"): + # convtype:: + parts = data.split(":", 2) + if len(parts) != 3: + return + _, action, script_id = parts + chat_id = query.message.chat_id + + if action == "no": + script_mgr._clear_pending_conversion(script_id) + await query.edit_message_text(f"๐Ÿšซ Conversion of {script_id} cancelled.") + return + + if action == "yes": + stash = script_mgr.pending_type_conversion.pop(script_id, None) + if not stash: + await query.edit_message_text("โŒ Conversion request expired or already executed.") + return + + # Stop the running process before mutating the on-disk state. + if script_mgr.script_running(script_id): + await script_mgr.stop_script(script_id) + + stash_action = stash.get("action") + + if stash_action == "archive": + staged_root = Path(stash["staged_root"]) + if not staged_root.exists(): + await query.edit_message_text("โŒ Staged archive expired. Please re-upload the file.") + return + await query.edit_message_text(f"โš™๏ธ Converting {script_id} to archive app ...") + result = await script_mgr.setup_project_app(script_id, staged_root, "project") + # Clean up any outer staging wrapper that might still exist. + outer = stash.get("staging") + if outer: + shutil.rmtree(outer, ignore_errors=True) + await query.delete() + await present_setup_result(chat_id, context, script_id, result, f"โœ… {script_id} converted to archive app.") + + elif stash_action == "git": + staged_root = Path(stash["staged_root"]) + if not staged_root.exists(): + await query.edit_message_text("โŒ Staged repo expired. Please re-send the GitHub link.") + return + git_info: Dict[str, Any] = stash["git_info"] + await query.edit_message_text(f"โš™๏ธ Converting {script_id} to GitHub app ...") + result = await script_mgr.setup_project_app(script_id, staged_root, "git", git_info) + result["app_id"] = script_id + await query.delete() + await present_setup_result(chat_id, context, script_id, result, f"โœ… {script_id} converted to GitHub app.") + + elif stash_action == "script_text": + text_body: str = stash["text"] + await query.edit_message_text(f"โš™๏ธ Converting {script_id} to script ...") + full_id = script_mgr.upsert_script_from_text(script_id, text_body) + await query.edit_message_text(f"โœ… {full_id} converted to script.") + await send_script_menu(chat_id, full_id, context) + await send_env_menu(chat_id, full_id, context) + + elif stash_action == "script_file": + original_name: str = stash["original_name"] + file_bytes: bytes = stash["file_bytes"] + tmp_conv = TMP_DIR / f"_conv_{script_id}_{int(datetime.now().timestamp())}.py" + tmp_conv.write_bytes(file_bytes) + try: + await query.edit_message_text(f"โš™๏ธ Converting {script_id} to script ...") + full_id = script_mgr.upsert_script_from_file(original_name, tmp_conv) + finally: + tmp_conv.unlink(missing_ok=True) + await query.edit_message_text(f"โœ… {full_id} converted to script.") + await send_script_menu(chat_id, full_id, context) + await send_env_menu(chat_id, full_id, context) + + else: + await query.edit_message_text("โŒ Unknown conversion action.") + return + if data.startswith("sync:"): script_id = data.split(":", 1)[1] script = script_mgr.get_script(script_id) @@ -2386,6 +2831,7 @@ def main() -> None: application = Application.builder().token(BOT_TOKEN).post_init(on_startup).build() application.add_handler(CommandHandler("start", cmd_start)) + application.add_handler(CommandHandler("cancel", cmd_cancel)) application.add_handler(CommandHandler("menu", cmd_menu)) application.add_handler(CommandHandler("list", cmd_list)) application.add_handler(CommandHandler("new", cmd_new)) diff --git a/tests/test_git_apps.py b/tests/test_git_apps.py index fac0626..c6de97d 100644 --- a/tests/test_git_apps.py +++ b/tests/test_git_apps.py @@ -58,7 +58,7 @@ async def test_git_clone_or_pull_pulls_new_commits(app, local_repo, tmp_path): @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 + # _prepare_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", @@ -75,7 +75,14 @@ async def fake_clone_or_pull(repo_url, branch, dest): monkeypatch.setattr(app, "git_clone_or_pull", fake_clone_or_pull) - result = await app.setup_git_app(info) + prep = await app._prepare_git_app(info) + assert "staged_root" in prep, prep.get("message", "prepare failed") + + result = await script_mgr.setup_project_app( + prep["app_id"], prep["staged_root"], "git", prep["git_info"] + ) + result["app_id"] = prep["app_id"] + assert result["status"] == "ready" assert result["entry"] == "main.py" assert result["requirements_pkgs"] == ["requests"] @@ -100,7 +107,9 @@ async def fake_clone_or_pull(repo_url, branch, dest): monkeypatch.setattr(app, "git_clone_or_pull", fake_clone_or_pull) - await app.setup_git_app(info) + prep = await app._prepare_git_app(info) + assert "staged_root" in prep, prep.get("message", "prepare failed") + await script_mgr.setup_project_app(prep["app_id"], prep["staged_root"], "git", prep["git_info"]) # no upstream change yet -> sync should report no dependency changes result = await app.sync_git_app("myrepo-subapp") diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py new file mode 100644 index 0000000..17a1a2d --- /dev/null +++ b/tests/test_type_conversion.py @@ -0,0 +1,569 @@ +# -*- coding: utf-8 -*- +"""Tests for cross-type app conversion (script โ†” project โ†” git). + +Covers: +- script โ†’ project and project โ†’ script conversions (artifact cleanup, meta update) +- git โ†” project as sub-cases of app โ†” app (covered lightly via rollback tests) +- Rollback across a type boundary in both directions (full revert: type, files, meta) +- Running process is stopped before conversion / rollback replaces on-disk state +- conflicting_type / _set_pending_conversion / _clear_pending_conversion helpers +- env/autostart survive a conversion +- Version kind tags written correctly and pass through get_versions +- "Change Source" flow: pending_source_change state, force_target_id routing +""" +import shutil +from pathlib import Path +from typing import Dict + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_project(root: Path, files: Dict[str, str]) -> Path: + 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 + + +# --------------------------------------------------------------------------- +# conflicting_type +# --------------------------------------------------------------------------- + +def test_conflicting_type_returns_none_for_new_id(script_mgr): + assert script_mgr.conflicting_type("nonexistent", "script") is None + + +def test_conflicting_type_returns_none_for_same_type(script_mgr): + script_mgr.upsert_script_from_text("mybot", "print(1)") + assert script_mgr.conflicting_type("mybot", "script") is None + + +@pytest.mark.asyncio +async def test_conflicting_type_returns_old_type_when_different(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "s", {"main.py": "print(1)\n"}) + await script_mgr.setup_project_app("demo", staging, "project") + assert script_mgr.conflicting_type("demo", "script") == "project" + assert script_mgr.conflicting_type("demo", "git") == "project" + assert script_mgr.conflicting_type("demo", "project") is None + + +# --------------------------------------------------------------------------- +# _set_pending_conversion / _clear_pending_conversion +# --------------------------------------------------------------------------- + +def test_set_pending_conversion_replaces_old_stash_and_cleans_temp(script_mgr, tmp_path): + staged1 = tmp_path / "stage1" + staged1.mkdir() + script_mgr._set_pending_conversion("demo", {"action": "archive", "staged_root": str(staged1)}) + assert "demo" in script_mgr.pending_type_conversion + + staged2 = tmp_path / "stage2" + staged2.mkdir() + script_mgr._set_pending_conversion("demo", {"action": "archive", "staged_root": str(staged2)}) + # Old staged dir should have been cleaned up. + assert not staged1.exists() + assert "demo" in script_mgr.pending_type_conversion + + +def test_clear_pending_conversion_removes_staged_dir(script_mgr, tmp_path): + staged = tmp_path / "staged" + staged.mkdir() + script_mgr._set_pending_conversion("demo", {"action": "archive", "staged_root": str(staged)}) + script_mgr._clear_pending_conversion("demo") + assert not staged.exists() + assert "demo" not in script_mgr.pending_type_conversion + + +# --------------------------------------------------------------------------- +# script โ†’ project conversion +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_script_to_project_clears_py_file(script_mgr, fast_venv, app, tmp_path): + script_mgr.upsert_script_from_text("mybot", "print('v1')\n") + py_file = script_mgr.script_file_path("mybot") + assert py_file.exists() + + staging = _write_project(tmp_path / "s", {"main.py": "print('app')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + # Old .py file must be gone. + assert not py_file.exists() + # New type is "project". + s = script_mgr.get_script("mybot") + assert s["type"] == "project" + assert "root_dir" in s + assert "file" not in s + + +@pytest.mark.asyncio +async def test_script_to_project_preserves_env_and_autostart(script_mgr, fast_venv, tmp_path): + script_mgr.upsert_script_from_text("mybot", "print('v1')\n") + script_mgr.set_script_env("mybot", "TOKEN", "secret") + script_mgr.set_autostart("mybot", True) + + staging = _write_project(tmp_path / "s", {"main.py": "print('app')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + s = script_mgr.get_script("mybot") + assert s["env"]["TOKEN"] == "secret" + assert s["autostart"] is True + + +@pytest.mark.asyncio +async def test_script_to_project_creates_version_tagged_script(script_mgr, fast_venv, tmp_path): + script_mgr.upsert_script_from_text("mybot", "print('v1')\n") + staging = _write_project(tmp_path / "s", {"main.py": "print('app')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + versions = script_mgr.get_versions("mybot") + assert len(versions) == 1 + assert versions[0]["kind"] == "script" + + +# --------------------------------------------------------------------------- +# project โ†’ script conversion +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_project_to_script_clears_app_dirs(script_mgr, fast_venv, app, tmp_path): + staging = _write_project(tmp_path / "s", {"main.py": "print('app')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + root_dir = Path(script_mgr.get_script("mybot")["root_dir"]) + venv_dir = Path(script_mgr.get_script("mybot")["venv_dir"]) + assert root_dir.exists() + assert venv_dir.exists() + + script_mgr.upsert_script_from_text("mybot", "print('script')\n") + + # App source tree and venv must be gone. + assert not root_dir.exists() + assert not venv_dir.exists() + s = script_mgr.get_script("mybot") + assert s["type"] == "script" + assert "root_dir" not in s + assert "venv_dir" not in s + assert "entry" not in s + assert script_mgr.script_file_path("mybot").exists() + + +@pytest.mark.asyncio +async def test_project_to_script_creates_version_tagged_app(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "s", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + script_mgr.upsert_script_from_text("mybot", "print('script')\n") + + versions = script_mgr.get_versions("mybot") + assert len(versions) == 1 + v = versions[0] + assert v["kind"] == "app" + assert v["app_type"] == "project" + assert v["file"].endswith(".tar.gz") + + +@pytest.mark.asyncio +async def test_project_to_script_preserves_env_and_autostart(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "s", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + script_mgr.set_script_env("mybot", "KEY", "val") + script_mgr.set_autostart("mybot", True) + + script_mgr.upsert_script_from_text("mybot", "print('script')\n") + + s = script_mgr.get_script("mybot") + assert s["env"]["KEY"] == "val" + assert s["autostart"] is True + + +# --------------------------------------------------------------------------- +# git โ†’ script conversion +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_git_to_script_clears_app_and_repo_dirs(script_mgr, fast_venv, app, tmp_path): + git_info = {"owner": "o", "repo": "r", "branch": "main", "path": None, "repo_dir": str(tmp_path / "gitrepo")} + staging = _write_project(tmp_path / "s", {"main.py": "print('git')\n"}) + await script_mgr.setup_project_app("mybot", staging, "git", git_info) + + root_dir = Path(script_mgr.get_script("mybot")["root_dir"]) + venv_dir = Path(script_mgr.get_script("mybot")["venv_dir"]) + repo_dir = Path(git_info["repo_dir"]) + repo_dir.mkdir(parents=True, exist_ok=True) # simulate existing clone + + script_mgr.upsert_script_from_text("mybot", "print('script')\n") + + assert not root_dir.exists() + assert not venv_dir.exists() + assert not repo_dir.exists() + s = script_mgr.get_script("mybot") + assert s["type"] == "script" + assert "git" not in s + + +# --------------------------------------------------------------------------- +# Rollback across type boundary: project โ†’ script (rolling back to script version) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_rollback_from_project_to_script_version(script_mgr, fast_venv, app, tmp_path): + # 1. Start as script + script_mgr.upsert_script_from_text("mybot", "print('script-v1')\n") + py_file = script_mgr.script_file_path("mybot") + + # 2. Convert to project + staging = _write_project(tmp_path / "s", {"main.py": "print('app-v1')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + assert not py_file.exists() + + root_dir = Path(script_mgr.get_script("mybot")["root_dir"]) + + # 3. Roll back to the script version + versions = script_mgr.get_versions("mybot") + script_version = next(v for v in versions if v.get("kind") == "script") + msg = await script_mgr.rollback_to("mybot", script_version["ts"]) + assert "Rolled back" in msg + + # 4. State should be script again + s = script_mgr.get_script("mybot") + assert s["type"] == "script" + assert py_file.exists() + assert "script-v1" in py_file.read_text() + # App directory must be gone + assert not root_dir.exists() + # Meta keys must not contain app fields + assert "root_dir" not in s + assert "venv_dir" not in s + + +# --------------------------------------------------------------------------- +# Rollback across type boundary: script โ†’ project (rolling back to project version) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_rollback_from_script_to_project_version(script_mgr, fast_venv, app, tmp_path): + # 1. Start as project + staging = _write_project(tmp_path / "s", {"main.py": "print('app-v1')\n", "helper.py": "x=1\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + # 2. Convert to script + script_mgr.upsert_script_from_text("mybot", "print('script-v1')\n") + py_file = script_mgr.script_file_path("mybot") + assert py_file.exists() + + # 3. Roll back to the project version + versions = script_mgr.get_versions("mybot") + app_version = next(v for v in versions if v.get("kind") == "app") + msg = await script_mgr.rollback_to("mybot", app_version["ts"]) + assert "Rolled back" in msg + + # 4. State should be project again + s = script_mgr.get_script("mybot") + assert s["type"] == "project" + root = Path(s["root_dir"]) + assert (root / "main.py").exists() + assert (root / "helper.py").exists() + # .py script file must be gone + assert not py_file.exists() + assert "file" not in s + + +# --------------------------------------------------------------------------- +# Cross-type rollback is itself reversible (rollback the rollback) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_cross_type_rollback_is_reversible(script_mgr, fast_venv, app, tmp_path): + # script โ†’ project โ†’ rollback to script โ†’ rollback back to project + script_mgr.upsert_script_from_text("mybot", "print('script-v1')\n") + + staging = _write_project(tmp_path / "s", {"main.py": "print('app-v1')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + # Roll back to script + versions = script_mgr.get_versions("mybot") + script_ver = next(v for v in versions if v.get("kind") == "script") + await script_mgr.rollback_to("mybot", script_ver["ts"]) + assert script_mgr.get_script("mybot")["type"] == "script" + + # Roll back to project (the snapshot taken when we reverted to script) + versions2 = script_mgr.get_versions("mybot") + app_ver = next(v for v in versions2 if v.get("kind") == "app") + await script_mgr.rollback_to("mybot", app_ver["ts"]) + + s = script_mgr.get_script("mybot") + assert s["type"] == "project" + root = Path(s["root_dir"]) + assert (root / "main.py").exists() + + +# --------------------------------------------------------------------------- +# Running process is stopped before conversion / rollback replaces disk state +# --------------------------------------------------------------------------- + +class _FakeRunningProcess: + """A fake process that stays 'running' (returncode=None) until terminate() is called.""" + + def __init__(self): + self.returncode = None + self.pid = 99999 + + def terminate(self): + self.returncode = 0 + + def kill(self): + self.returncode = -9 + + async def wait(self): + return self.returncode + + +@pytest.mark.asyncio +async def test_setup_project_app_stops_running_process(script_mgr, fast_venv, app, tmp_path): + script_mgr.upsert_script_from_text("mybot", "print(1)\n") + + # Inject a fake running process directly (FakeProcess from conftest exits instantly, + # making it impossible to assert script_running() after start). + fake_proc = _FakeRunningProcess() + script_mgr.processes["mybot"] = fake_proc + assert script_mgr.script_running("mybot") + + staging = _write_project(tmp_path / "s", {"main.py": "print('app')\n"}) + await script_mgr.setup_project_app("mybot", staging, "project") + + # The stop guard inside setup_project_app must have terminated the process. + assert not script_mgr.script_running("mybot") + assert script_mgr.get_script("mybot")["type"] == "project" + + +@pytest.mark.asyncio +async def test_rollback_stops_running_process(script_mgr, fast_venv, app, tmp_path, fake_subprocess): + # fake_subprocess prevents rollback_to from spawning a real Python process on restart. + script_mgr.upsert_script_from_text("mybot", "print('v1')\n") + script_mgr.upsert_script_from_text("mybot", "print('v2')\n") + + fake_proc = _FakeRunningProcess() + script_mgr.processes["mybot"] = fake_proc + assert script_mgr.script_running("mybot") + + versions = script_mgr.get_versions("mybot") + ts = versions[0]["ts"] + await script_mgr.rollback_to("mybot", ts) + + # The original fake process was terminated before the files were replaced. + assert fake_proc.returncode == 0 + + +# --------------------------------------------------------------------------- +# Version kind tags +# --------------------------------------------------------------------------- + +def test_snapshot_current_version_tags_kind_script(script_mgr): + script_mgr.upsert_script_from_text("mybot", "print('v1')\n") + script_mgr.snapshot_current_version("mybot") + versions = script_mgr.get_versions("mybot") + assert versions[0]["kind"] == "script" + + +@pytest.mark.asyncio +async def test_snapshot_current_app_version_tags_kind_and_app_type(script_mgr, fast_venv, tmp_path): + staging = _write_project(tmp_path / "s", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("demo", staging, "project") + script_mgr.snapshot_current_app_version("demo") + versions = script_mgr.get_versions("demo") + assert versions[0]["kind"] == "app" + assert versions[0]["app_type"] == "project" + + +@pytest.mark.asyncio +async def test_snapshot_git_app_version_includes_git_info(script_mgr, fast_venv, tmp_path): + git_info = {"owner": "o", "repo": "r", "branch": "main", "path": None, "repo_dir": "/fake"} + staging = _write_project(tmp_path / "s", {"main.py": "print('v1')\n"}) + await script_mgr.setup_project_app("demo", staging, "git", git_info) + script_mgr.snapshot_current_app_version("demo") + versions = script_mgr.get_versions("demo") + v = versions[0] + assert v["kind"] == "app" + assert v["app_type"] == "git" + assert v["git"]["repo"] == "r" + + +# --------------------------------------------------------------------------- +# Legacy versions (no "kind" field) still roll back correctly via extension inference +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_rollback_legacy_script_version_without_kind(script_mgr, app): + """Simulate a version dict that was created before the 'kind' tag was added.""" + script_mgr.upsert_script_from_text("mybot", "print('v1')\n") + py_file = script_mgr.script_file_path("mybot") + + # Manually inject a legacy version dict (no "kind") pointing at the current .py file. + import shutil as _shutil + from pathlib import Path as P + vdir = script_mgr.versions_dir_for("mybot") + ver_file = vdir / "mybot_legacy.py" + _shutil.copy2(py_file, ver_file) + s = script_mgr.get_script("mybot") + s.setdefault("versions", []).insert(0, {"ts": "01.01.2000-00-00-00", "file": str(ver_file)}) + script_mgr.save_meta() + + script_mgr.upsert_script_from_text("mybot", "print('v2')\n") + + msg = await script_mgr.rollback_to("mybot", "01.01.2000-00-00-00") + assert "Rolled back" in msg + assert "v1" in py_file.read_text() + + +# --------------------------------------------------------------------------- +# "Change Source" โ€” pending_source_change state and stash routing +# --------------------------------------------------------------------------- + +def test_pending_source_change_is_set_and_cleared(script_mgr): + """pending_source_change stores the target id and can be popped.""" + script_mgr.upsert_script_from_text("recap", "print('v1')\n") + user_id = 42 + script_mgr.pending_source_change[user_id] = "recap" + assert script_mgr.pending_source_change.get(user_id) == "recap" + result = script_mgr.pending_source_change.pop(user_id, None) + assert result == "recap" + assert user_id not in script_mgr.pending_source_change + + +@pytest.mark.asyncio +async def test_change_source_archive_with_mismatched_name_stashes_with_force_target( + script_mgr, fast_venv, tmp_path +): + """An archive uploaded while a source-change is pending should be stashed under + the force_target_id, even when the archive filename differs from the app id.""" + # Pre-existing script app called "recap" + script_mgr.upsert_script_from_text("recap", "print('old')\n") + assert script_mgr.get_script("recap")["type"] == "script" + + # Prepare a project directory (simulating what handle_archive_upload would have + # extracted โ€” the stash path is what we care about, not the bot's UI flow). + staging = _write_project(tmp_path / "stage", {"main.py": "print('new')\n"}) + + # Simulate what handle_archive_upload does when force_target_id="recap" is set: + # it overrides app_id, detects no *conflicting* type (same id, same type != project + # โ†’ old_type=None), but force_target_id is truthy so it stashes + asks for confirmation. + old_type = script_mgr.conflicting_type("recap", "project") # "script" โ€” a conflict! + assert old_type == "script" + + script_mgr._set_pending_conversion("recap", { + "action": "archive", + "staged_root": str(staging), + "staging": None, + "old_type": old_type, + }) + + assert "recap" in script_mgr.pending_type_conversion + stash = script_mgr.pending_type_conversion["recap"] + assert stash["action"] == "archive" + assert stash["old_type"] == "script" + + +@pytest.mark.asyncio +async def test_change_source_same_type_git_stashes_for_confirmation( + script_mgr, fast_venv, tmp_path +): + """Re-pointing a git app at a *different* repo (same type) via Change Source should + still land in pending_type_conversion so the user must confirm.""" + git_info = {"owner": "acme", "repo": "py-bots-recap", "branch": "main", "path": None, "repo_dir": "/fake"} + staging = _write_project(tmp_path / "stage", {"main.py": "print('new')\n"}) + await script_mgr.setup_project_app("recap", staging, "git", git_info) + assert script_mgr.get_script("recap")["type"] == "git" + + # force_target_id is set โ†’ no conflicting type (same type), but needs_confirm is True. + # Simulate the stash the handler would create. + effective_old_type = (script_mgr.get_script("recap") or {}).get("type", "git") + assert effective_old_type == "git" + + new_git_info = {"owner": "acme", "repo": "totally-different-repo", "branch": "main", "path": None, "repo_dir": "/fake2"} + script_mgr._set_pending_conversion("recap", { + "action": "git", + "staged_root": str(staging), + "git_info": new_git_info, + "old_type": effective_old_type, + }) + + stash = script_mgr.pending_type_conversion.get("recap") + assert stash is not None + assert stash["git_info"]["repo"] == "totally-different-repo" + + +@pytest.mark.asyncio +async def test_change_source_git_to_script_conversion_stashes_correctly( + script_mgr, fast_venv, tmp_path +): + """Change Source: git app โ†’ .py file โ€” stash should carry action=script_file.""" + git_info = {"owner": "o", "repo": "r", "branch": "main", "path": None, "repo_dir": "/fake"} + staging = _write_project(tmp_path / "stage", {"main.py": "print('old')\n"}) + await script_mgr.setup_project_app("mybot", staging, "git", git_info) + + file_bytes = b"print('new')\n" + old_type = script_mgr.conflicting_type("mybot", "script") + assert old_type == "git" + + script_mgr._set_pending_conversion("mybot", { + "action": "script_file", + "file_bytes": file_bytes, + "original_name": "something_else.py", + "old_type": old_type, + }) + + stash = script_mgr.pending_type_conversion["mybot"] + assert stash["action"] == "script_file" + assert stash["file_bytes"] == file_bytes + assert stash["old_type"] == "git" + + +@pytest.mark.asyncio +async def test_change_source_confirmed_converts_app(script_mgr, fast_venv, tmp_path): + """After the user confirms via convtype:yes, setup_project_app should run and + the app type should flip from script to project.""" + script_mgr.upsert_script_from_text("recap", "print('old')\n") + + staging = _write_project(tmp_path / "stage", {"main.py": "print('new')\n"}) + script_mgr._set_pending_conversion("recap", { + "action": "archive", + "staged_root": str(staging), + "staging": None, + "old_type": "script", + }) + + # Simulate convtype:yes โ€” pop the stash and run the conversion. + stash = script_mgr.pending_type_conversion.pop("recap") + result = await script_mgr.setup_project_app("recap", Path(stash["staged_root"]), "project") + + assert result.get("status") != "error" + assert script_mgr.get_script("recap")["type"] == "project" + # Old .py file should be gone (cleanup ran inside setup_project_app) + assert not script_mgr.script_file_path("recap").exists() + + +def test_change_source_cancel_clears_pending_source_change(script_mgr): + """/cancel should clear pending_source_change without touching anything else.""" + script_mgr.upsert_script_from_text("recap", "print('v1')\n") + user_id = 7 + script_mgr.pending_source_change[user_id] = "recap" + script_mgr.pending_new[user_id] = "other" + + # Simulate /cancel logic + cleared = [] + if script_mgr.pending_source_change.pop(user_id, None) is not None: + cleared.append("source change") + if script_mgr.pending_new.pop(user_id, None) is not None: + cleared.append("new script") + + assert "source change" in cleared + assert "new script" in cleared + assert user_id not in script_mgr.pending_source_change + assert user_id not in script_mgr.pending_new