diff --git a/.cursor/rules/program-directory-and-file-definitions.mdc b/.cursor/rules/program-directory-and-file-definitions.mdc index a2c2636..a2a701d 100644 --- a/.cursor/rules/program-directory-and-file-definitions.mdc +++ b/.cursor/rules/program-directory-and-file-definitions.mdc @@ -368,7 +368,7 @@ project-root/ ├── package.json # Root scripts ├── moto-update-manifest.json # Build 0 updater/build identity manifest committed on main ├── SECURITY.md # Security policy and private vulnerability reporting -├── Click To Launch MOTO.bat # The authoritative Windows launcher entrypoint (thin wrapper that delegates to moto_launcher.py) +├── Click To Launch MOTO.bat # The authoritative Windows launcher entrypoint (bootstraps Python 3.10+ via winget when missing, then delegates to moto_launcher.py) ├── linux-ubuntu-launcher.sh # Linux/Ubuntu launcher entrypoint (thin bash wrapper that delegates to moto_launcher.py) ├── moto_launcher.py # Internal Python launcher orchestration (update check, runtime resolution, dependency install, service startup) ├── moto_updater.py # Build 1 updater helper (manifest fetch, install classification, ZIP/git apply flow, launcher state tracking) @@ -378,7 +378,7 @@ project-root/ ### Launcher and Updater -- `Click To Launch MOTO.bat`: The only Windows consumer entrypoint. It stays thin and always delegates to the Python launcher. +- `Click To Launch MOTO.bat`: The only Windows consumer entrypoint. It bootstraps a usable Python 3.10+ interpreter on fresh Windows installs when `winget` is available, then delegates to the Python launcher. - `linux-ubuntu-launcher.sh`: The Linux/Ubuntu consumer entrypoint. Same thin-wrapper contract as the `.bat`; delegates to `moto_launcher.py`. - `moto_launcher.py`: Orchestrates the launcher flow in order: update check, runtime resolution, dependency install, LM Studio detection, detached backend/frontend startup, and browser launch. - `moto_updater.py`: Owns Build 1 updater behavior, including GitHub REST contents metadata + branch-HEAD resolution, install-state classification, clean-git fast-forward apply, ZIP overlay apply with post-apply manifest stamping, rollback-aware relaunch, and launcher-managed instance safety checks. diff --git a/Click To Launch MOTO.bat b/Click To Launch MOTO.bat index 42db875..45aa0ca 100644 --- a/Click To Launch MOTO.bat +++ b/Click To Launch MOTO.bat @@ -1,8 +1,23 @@ @echo off -setlocal +setlocal EnableExtensions set "SCRIPT_DIR=%~dp0" -python "%SCRIPT_DIR%moto_launcher.py" %* +set "LAUNCHER_SCRIPT=%SCRIPT_DIR%moto_launcher.py" +set "PYTHON_CMD=" + +call :find_python +if not defined PYTHON_CMD ( + echo. + echo Python 3.10+ was not found. MOTO will try to install Python 3.12 with winget. + echo. + call :install_python + if errorlevel 1 goto python_missing + call :find_python +) + +if not defined PYTHON_CMD goto python_missing + +%PYTHON_CMD% "%LAUNCHER_SCRIPT%" %* set "EXIT_CODE=%ERRORLEVEL%" if %EXIT_CODE% NEQ 0 ( echo. @@ -10,3 +25,70 @@ if %EXIT_CODE% NEQ 0 ( pause >nul ) exit /b %EXIT_CODE% + +:find_python +call :check_python py -3.12 +if defined PYTHON_CMD exit /b 0 +call :check_python py -3.11 +if defined PYTHON_CMD exit /b 0 +call :check_python py -3.10 +if defined PYTHON_CMD exit /b 0 +call :check_python "%LocalAppData%\Programs\Python\Python312\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%LocalAppData%\Programs\Python\Python311\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%LocalAppData%\Programs\Python\Python310\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles%\Python312\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles%\Python311\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles%\Python310\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles(x86)%\Python312\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles(x86)%\Python311\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles(x86)%\Python310\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python python +if defined PYTHON_CMD exit /b 0 +call :check_python py -3 +if defined PYTHON_CMD exit /b 0 +call :check_python "%LocalAppData%\Programs\Python\Python313\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles%\Python313\python.exe" +if defined PYTHON_CMD exit /b 0 +call :check_python "%ProgramFiles(x86)%\Python313\python.exe" +if defined PYTHON_CMD exit /b 0 +exit /b 1 + +:check_python +%* -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul +if not errorlevel 1 set "PYTHON_CMD=%*" +exit /b 0 + +:install_python +where winget >nul 2>nul +if errorlevel 1 exit /b 1 +winget install --id Python.Python.3.12 -e --source winget --scope user --accept-package-agreements --accept-source-agreements +if not errorlevel 1 exit /b 0 +echo. +echo User-scope Python install did not complete. Trying the default winget install scope... +winget install --id Python.Python.3.12 -e --source winget --accept-package-agreements --accept-source-agreements +exit /b %ERRORLEVEL% + +:python_missing +echo. +echo ============================================================ +echo ERROR: Python 3.10+ is required to launch MOTO. +echo ============================================================ +echo. +echo Automatic Python installation was unavailable or did not complete. +echo Install Python 3.12 from https://www.python.org/downloads/ +echo IMPORTANT: Check "Add Python to PATH" during installation. +echo. +start "" "https://www.python.org/downloads/" +echo Press Enter to close... +pause >nul +exit /b 1 diff --git a/README.md b/README.md index b89919a..684af43 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,10 @@ MOTO (Multi-Output Token Orchestrator) is a high-risk high-reward (novelty seeki Before installation, you need: 1. **Python 3.10+** - [Download here](https://www.python.org/downloads/) - - ⚠️ **IMPORTANT**: Check "Add Python to PATH" during installation -2. **Node.js 20.19+** - [Download here](https://nodejs.org/) + - Windows one-click launches try to install Python 3.12 automatically with `winget` if Python is missing. + - ⚠️ **IMPORTANT**: If installing manually, check "Add Python to PATH" during installation. +2. **Node.js 20.19+ or 22.12+** - [Download here](https://nodejs.org/) + - Windows one-click launches try to install Node.js LTS automatically with `winget` if Node.js is missing or too old. 3. **LM Studio** (optional but HIGHLY recommended - otherwise your system will need to pay OpenRouter for RAG embedding calls, which is very slow compared to LM Studio's local embeddings) - [Download here](https://lmstudio.ai/) - If using OpenRouter, then download and load at least one model (e.g., DeepSeek, Llama, Qwen - older models and some models below 12 billion parameters may struggle; however, it is always worth a try!) - **Load the LM Studio RAG agent [optional but HIGHLY recommended for much faster outputs/answers]**: Load the embedding model `nomic-ai/nomic-embed-text-v1.5` in your LM Studio "Developer" tab (server tab) (search for "nomic-ai/nomic-embed-text-v1.5" to download it in the LM Studio downloads center). Please note: you may need to enable "Power User" or "Developer" to see this developer tab - this server will let you load the amount and capacity of simultaneous models that your PC will support. In this developer tab is where you load both your nomic-ai embedding agent and any optional local hosted agents you want to use in the program (e.g., GPT OSS 20b, DeepSeek 32B, etc.). **If you do not download LM Studio and enable the Nomic agent the system will run much slower and cost slightly more due to having to use the paid service OpenRouter for RAG calls.** @@ -95,7 +97,8 @@ Lean 4 proof verification is optional. The launcher prepares it when available, - Then open Settings to keep the recommended profile or switch to your saved team profile / another default profile 5. The launcher will: - Check all prerequisites - - Install Python and Node.js dependencies automatically + - Install missing Windows Python/Node.js runtimes with `winget` when available + - Install Python and Node.js package dependencies automatically - Create necessary directories - Check the official GitHub `main` build manifest before startup - Offer a prompted update flow for supported installs when `main` is ahead @@ -273,11 +276,13 @@ All configurable per role: ### Installation Issues **"Python not recognized"** -- Reinstall Python and check "Add Python to PATH" +- Double-click `Click To Launch MOTO.bat` again so it can try the `winget` Python install path +- If automatic install is unavailable, reinstall Python and check "Add Python to PATH" - Verify: `python --version` in terminal **"Node not recognized"** -- Install Node.js from nodejs.org +- Double-click `Click To Launch MOTO.bat` again so it can try the `winget` Node.js LTS install path +- If automatic install is unavailable, install Node.js LTS from nodejs.org - Verify: `node --version` in terminal **"pip install failed"** diff --git a/linux-ubuntu-launcher.sh b/linux-ubuntu-launcher.sh index 6ede60d..ef4ce9e 100644 --- a/linux-ubuntu-launcher.sh +++ b/linux-ubuntu-launcher.sh @@ -7,20 +7,30 @@ PYTHON_BIN="$VENV_DIR/bin/python" resolve_bootstrap_python() { if command -v python3 >/dev/null 2>&1; then - command -v python3 - return 0 + local candidate + candidate="$(command -v python3)" + if "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi fi if command -v python >/dev/null 2>&1; then - command -v python - return 0 + local candidate + candidate="$(command -v python)" + if "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi fi return 1 } -if [[ ! -x "$PYTHON_BIN" ]]; then - BOOTSTRAP_PYTHON="$(resolve_bootstrap_python || true)" +create_repo_venv() { + if [[ -z "${BOOTSTRAP_PYTHON:-}" ]]; then + BOOTSTRAP_PYTHON="$(resolve_bootstrap_python || true)" + fi if [[ -z "${BOOTSTRAP_PYTHON:-}" ]]; then - echo "ERROR: Python 3.8+ is required to launch MOTO on Ubuntu 24.04." + echo "ERROR: Python 3.10+ is required to launch MOTO on Ubuntu 24.04." echo "Install Python 3 and python3-venv, then run this launcher again." echo "Example: sudo apt install python3 python3-venv" exit 1 @@ -33,6 +43,21 @@ if [[ ! -x "$PYTHON_BIN" ]]; then echo " sudo apt install python3-venv" exit 1 fi +} + +if [[ ! -x "$PYTHON_BIN" ]]; then + create_repo_venv +elif ! "$PYTHON_BIN" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; then + BOOTSTRAP_PYTHON="$(resolve_bootstrap_python || true)" + if [[ -z "${BOOTSTRAP_PYTHON:-}" ]]; then + echo "ERROR: Existing repo-local .venv uses Python older than 3.10, and no replacement Python 3.10+ was found." + echo "Install Python 3 and python3-venv, then run this launcher again." + echo "Example: sudo apt install python3 python3-venv" + exit 1 + fi + echo "Existing repo-local .venv uses Python older than 3.10. Recreating it ..." + rm -rf "$VENV_DIR" + create_repo_venv fi if [[ ! -x "$PYTHON_BIN" ]]; then diff --git a/moto_launcher.py b/moto_launcher.py index 499ace2..abe8397 100644 --- a/moto_launcher.py +++ b/moto_launcher.py @@ -51,6 +51,10 @@ WHITE = "\033[97m" RESET = "\033[0m" +MIN_PYTHON_VERSION = (3, 10) +MIN_NODE_VERSION = (20, 19, 0) +MIN_NODE_ALT_VERSION = (22, 12, 0) + @dataclass(frozen=True) class InstanceRuntime: @@ -112,6 +116,17 @@ def resolve_command(*names: str) -> str | None: return None +def resolve_existing_file(*paths: str | Path) -> str | None: + for path in paths: + try: + candidate = Path(path).expanduser() + if candidate.is_file(): + return str(candidate) + except (OSError, RuntimeError): + continue + return None + + def command_exists(name: str) -> bool: return resolve_command(name) is not None @@ -120,6 +135,22 @@ def get_python_command() -> str: return sys.executable or resolve_command("python3", "python") or "python" +def parse_version_tuple(raw: str) -> tuple[int, int, int] | None: + match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", raw) + if not match: + return None + major, minor, patch = match.groups() + return int(major), int(minor), int(patch or 0) + + +def format_version_tuple(version: tuple[int, ...]) -> str: + return ".".join(str(part) for part in version) + + +def node_version_is_supported(version: tuple[int, int, int]) -> bool: + return (version[0] == 20 and version >= MIN_NODE_VERSION) or version >= MIN_NODE_ALT_VERSION + + def _path_is_within(root: Path, candidate: str | Path) -> bool: try: Path(candidate).resolve().relative_to(root.resolve()) @@ -150,15 +181,29 @@ def shell_join(args: Sequence[str]) -> str: return " ".join(shlex.quote(part) for part in args) +def get_standard_windows_node_file(filename: str) -> str | None: + local_app_data = os.environ.get("LocalAppData") + local_node_path = ( + Path(local_app_data) / "Programs" / "nodejs" / filename + if local_app_data + else Path.home() / "AppData" / "Local" / "Programs" / "nodejs" / filename + ) + return resolve_existing_file( + local_node_path, + Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "nodejs" / filename, + Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "nodejs" / filename, + ) + + def get_node_command() -> str | None: if sys.platform == "win32": - return resolve_command("node.exe", "node") + return resolve_command("node.exe", "node") or get_standard_windows_node_file("node.exe") return resolve_command("node") def get_npm_command() -> str | None: if sys.platform == "win32": - return resolve_command("npm.cmd", "npm.exe", "npm") + return resolve_command("npm.cmd", "npm.exe", "npm") or get_standard_windows_node_file("npm.cmd") return resolve_command("npm") @@ -653,20 +698,33 @@ def check_python_installation() -> None: if not python_cmd: print() cprint("============================================================", RED) - cprint("ERROR: Python 3.8+ is required to run the launcher", RED) + cprint(f"ERROR: Python {format_version_tuple(MIN_PYTHON_VERSION)}+ is required to run the launcher", RED) cprint("============================================================", RED) print() if is_linux(): cprint("Install Python 3 and python3-venv, then launch via `bash linux-ubuntu-launcher.sh`.", YELLOW) cprint("Example: sudo apt install python3 python3-venv", YELLOW) else: - cprint("Please install Python 3.8+ from:", YELLOW) + cprint(f"Please install Python {format_version_tuple(MIN_PYTHON_VERSION)}+ from:", YELLOW) cprint("https://www.python.org/downloads/", YELLOW) print() cprint("IMPORTANT: Check 'Add Python to PATH' during installation", YELLOW) exit_with_pause(1) version = subprocess.check_output([python_cmd, "--version"], text=True).strip() + if sys.version_info < MIN_PYTHON_VERSION: + print() + cprint("============================================================", RED) + cprint(f"ERROR: Python {format_version_tuple(MIN_PYTHON_VERSION)}+ is required", RED) + cprint("============================================================", RED) + print() + cprint(f"Current interpreter: {version} ({python_cmd})", YELLOW) + if is_linux(): + cprint("Install a newer Python and relaunch via `bash linux-ubuntu-launcher.sh`.", YELLOW) + else: + cprint("The Windows one-click launcher can install Python 3.12 automatically when Python is missing.", YELLOW) + cprint("If you launched this script directly, install Python 3.12 or double-click `Click To Launch MOTO.bat`.", YELLOW) + exit_with_pause(1) cprint(version, GREEN) cprint(f"Interpreter: {python_cmd}", WHITE) if is_linux(): @@ -677,21 +735,48 @@ def check_python_installation() -> None: print() +def install_windows_nodejs() -> bool: + if sys.platform != "win32": + return False + + winget_cmd = resolve_command("winget.exe", "winget") + if not winget_cmd: + return False + + cprint("Attempting to install Node.js LTS with winget...", YELLOW) + command = [ + winget_cmd, + "install", + "--id", + "OpenJS.NodeJS.LTS", + "-e", + "--source", + "winget", + "--accept-package-agreements", + "--accept-source-agreements", + ] + return run_visible(command, cwd=str(SCRIPT_DIR), check=False) == 0 + + def check_node_installation() -> None: cprint("[2/8] Checking Node.js installation...", YELLOW) node_cmd = get_node_command() if not node_cmd: - print() - cprint("============================================================", RED) - cprint("ERROR: Node.js is not installed or not in PATH", RED) - cprint("============================================================", RED) - print() - if is_linux(): - cprint("Install Node.js 16+ from nodejs.org or your Ubuntu package source, then retry.", YELLOW) - else: - cprint("Please install Node.js 16+ from:", YELLOW) - cprint("https://nodejs.org/", YELLOW) - exit_with_pause(1) + if sys.platform == "win32" and install_windows_nodejs(): + node_cmd = get_node_command() + if not node_cmd: + print() + cprint("============================================================", RED) + cprint("ERROR: Node.js is not installed or not in PATH", RED) + cprint("============================================================", RED) + print() + if is_linux(): + cprint("Install Node.js 20.19+ or 22.12+ from nodejs.org or your Ubuntu package source, then retry.", YELLOW) + else: + cprint("Please install Node.js 20.19+ or 22.12+ from:", YELLOW) + cprint("https://nodejs.org/", YELLOW) + cprint("The Windows launcher tried `winget install OpenJS.NodeJS.LTS`, but it was unavailable or failed.", YELLOW) + exit_with_pause(1) npm_cmd = get_npm_command() if not npm_cmd: @@ -708,6 +793,27 @@ def check_node_installation() -> None: exit_with_pause(1) node_version = subprocess.check_output([node_cmd, "--version"], text=True).strip() + parsed_node_version = parse_version_tuple(node_version) + if not parsed_node_version or not node_version_is_supported(parsed_node_version): + if sys.platform == "win32" and install_windows_nodejs(): + node_cmd = get_standard_windows_node_file("node.exe") or get_node_command() or node_cmd + npm_cmd = get_standard_windows_node_file("npm.cmd") or get_npm_command() or npm_cmd + node_version = subprocess.check_output([node_cmd, "--version"], text=True).strip() + parsed_node_version = parse_version_tuple(node_version) + + if not parsed_node_version or not node_version_is_supported(parsed_node_version): + print() + cprint("============================================================", RED) + cprint("ERROR: Node.js 20.19+ or 22.12+ is required", RED) + cprint("============================================================", RED) + print() + cprint(f"Current Node.js version: {node_version}", YELLOW) + if is_linux(): + cprint("Install Node.js 20.19+ or 22.12+ from nodejs.org or your Ubuntu package source, then retry.", YELLOW) + else: + cprint("Install Node.js LTS from https://nodejs.org/ or rerun the launcher after winget is available.", YELLOW) + exit_with_pause(1) + npm_version = subprocess.check_output([npm_cmd, "--version"], text=True).strip() cprint(f"Node: {node_version}", GREEN) cprint(f"npm: {npm_version}", GREEN) diff --git a/tests/test_moto_launcher.py b/tests/test_moto_launcher.py index 3907fe2..b372213 100644 --- a/tests/test_moto_launcher.py +++ b/tests/test_moto_launcher.py @@ -224,6 +224,26 @@ def test_launch_windows_service_falls_back_to_direct_launch_for_unsafe_absolute_ self.assertEqual(popen.call_args.args[0], [tool_path, "run", "dev"]) +class LauncherDependencyVersionTests(TestCase): + def test_node_version_support_matches_vite_engine_floor(self) -> None: + self.assertFalse(moto_launcher.node_version_is_supported((20, 18, 1))) + self.assertTrue(moto_launcher.node_version_is_supported((20, 19, 0))) + self.assertFalse(moto_launcher.node_version_is_supported((21, 7, 0))) + self.assertFalse(moto_launcher.node_version_is_supported((22, 11, 0))) + self.assertTrue(moto_launcher.node_version_is_supported((22, 12, 0))) + self.assertTrue(moto_launcher.node_version_is_supported((24, 0, 0))) + + def test_check_node_installation_uses_winget_when_missing_on_windows(self) -> None: + with mock.patch.object(moto_launcher.sys, "platform", "win32"): + with mock.patch.object(moto_launcher, "get_node_command", side_effect=[None, r"C:\Program Files\nodejs\node.exe"]): + with mock.patch.object(moto_launcher, "get_npm_command", return_value=r"C:\Program Files\nodejs\npm.cmd"): + with mock.patch.object(moto_launcher, "install_windows_nodejs", return_value=True) as installer: + with mock.patch.object(moto_launcher.subprocess, "check_output", side_effect=["v22.12.0", "10.9.0"]): + moto_launcher.check_node_installation() + + installer.assert_called_once() + + class LinuxLauncherStrategyTests(TestCase): def test_using_repo_local_venv_detects_repo_scoped_interpreter(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: