Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .cursor/rules/program-directory-and-file-definitions.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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 (bootstraps Python 3.10+ via winget when missing, then delegates to moto_launcher.py)
├── Click To Launch MOTO.bat # The authoritative Windows launcher entrypoint (bootstraps Python 3.10+ via winget when missing, refreshes/relaunches if needed, 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)
Expand All @@ -378,9 +378,9 @@ project-root/

### Launcher and Updater

- `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.
- `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, refreshes/relaunches once if the current console cannot see the new runtime, 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_launcher.py`: Orchestrates the launcher flow in order: update check, runtime resolution, dependency install, LM Studio detection, detached backend/frontend startup, and browser launch. After fresh Windows Node.js installs, it refreshes/prepends Node paths so npm child scripts can resolve `node` immediately.
- `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.
- `.moto_launcher_state.json`: Local-only state written by the launcher so future launches can detect still-open backend/frontend windows from the same install and skip update-apply until those windows are closed.

Expand Down
22 changes: 22 additions & 0 deletions Click To Launch MOTO.bat
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,25 @@ set "PYTHON_CMD="

call :find_python
if not defined PYTHON_CMD (
if defined MOTO_PYTHON_BOOTSTRAP_ATTEMPTED goto python_missing
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
set "MOTO_PYTHON_BOOTSTRAP_ATTEMPTED=1"
echo.
echo Python installation completed. Refreshing launcher environment...
call :refresh_environment
timeout /t 2 /nobreak >nul
call :find_python
if not defined PYTHON_CMD (
echo.
echo Python installed successfully, but this launcher window still cannot see it.
echo Restarting MOTO launcher in a fresh window...
start "" "%ComSpec%" /d /c call "%~f0" %*
exit /b 0
)
)

if not defined PYTHON_CMD goto python_missing
Expand Down Expand Up @@ -78,6 +91,15 @@ echo User-scope Python install did not complete. Trying the default winget insta
winget install --id Python.Python.3.12 -e --source winget --accept-package-agreements --accept-source-agreements
exit /b %ERRORLEVEL%

:refresh_environment
set "MOTO_USER_PATH="
set "MOTO_MACHINE_PATH="
for /f "tokens=1,2,*" %%A in ('reg query "HKCU\Environment" /v Path 2^>nul') do if /i "%%A"=="Path" set "MOTO_USER_PATH=%%C"
for /f "tokens=1,2,*" %%A in ('reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path 2^>nul') do if /i "%%A"=="Path" set "MOTO_MACHINE_PATH=%%C"
if defined MOTO_MACHINE_PATH call set "PATH=%%MOTO_MACHINE_PATH%%;%PATH%"
if defined MOTO_USER_PATH call set "PATH=%%MOTO_USER_PATH%%;%PATH%"
exit /b 0

:python_missing
echo.
echo ============================================================
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ Before installation, you need:

1. **Python 3.10+** - [Download here](https://www.python.org/downloads/)
- Windows one-click launches try to install Python 3.12 automatically with `winget` if Python is missing.
- If Windows does not expose the new Python runtime to the current console immediately, the launcher refreshes its environment and relaunches itself once.
- ⚠️ **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.
- The launcher refreshes its environment after Node.js installation so `npm install` child scripts can resolve `node` immediately.
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.**
Expand Down
93 changes: 80 additions & 13 deletions moto_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,78 @@ def get_standard_windows_node_file(filename: str) -> str | None:
)


def refresh_windows_path_from_registry() -> None:
"""Refresh PATH after installers update Windows environment variables."""
if sys.platform != "win32":
return

try:
import winreg
except ImportError:
return

registry_paths: list[str] = []
keys = [
(winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
(winreg.HKEY_CURRENT_USER, "Environment"),
]
for hive, subkey in keys:
try:
with winreg.OpenKey(hive, subkey) as key:
value, _ = winreg.QueryValueEx(key, "Path")
except OSError:
continue
if isinstance(value, str) and value.strip():
registry_paths.extend(
os.path.expandvars(part.strip())
for part in value.split(os.pathsep)
if part.strip()
)

for path_entry in reversed(registry_paths):
prepend_process_path_entry(path_entry)


def prepend_process_path_entry(path_entry: str | Path | None) -> None:
"""Prepend a directory to this launcher's PATH if it is not already present."""
if not path_entry:
return

try:
normalized_entry = str(Path(path_entry).resolve())
except OSError:
normalized_entry = str(path_entry)

current_parts = [part for part in os.environ.get("PATH", "").split(os.pathsep) if part]
normalized_parts = set()
for part in current_parts:
try:
normalized_parts.add(str(Path(part).resolve()))
except OSError:
normalized_parts.add(part)

if normalized_entry not in normalized_parts:
os.environ["PATH"] = normalized_entry + os.pathsep + os.environ.get("PATH", "")


def ensure_windows_node_on_path(*commands: str | None) -> None:
"""Ensure npm child scripts can resolve plain `node` after a fresh install."""
if sys.platform != "win32":
return

for filename in ("node.exe", "npm.cmd"):
standard_path = get_standard_windows_node_file(filename)
if standard_path:
prepend_process_path_entry(Path(standard_path).parent)

for command in commands:
if not command:
continue
candidate = Path(command)
if candidate.is_absolute() and candidate.parent.is_dir():
prepend_process_path_entry(candidate.parent)


def get_node_command() -> str | None:
if sys.platform == "win32":
return resolve_command("node.exe", "node") or get_standard_windows_node_file("node.exe")
Expand Down Expand Up @@ -763,6 +835,7 @@ def check_node_installation() -> None:
node_cmd = get_node_command()
if not node_cmd:
if sys.platform == "win32" and install_windows_nodejs():
refresh_windows_path_from_registry()
node_cmd = get_node_command()
if not node_cmd:
print()
Expand All @@ -779,6 +852,10 @@ def check_node_installation() -> None:
exit_with_pause(1)

npm_cmd = get_npm_command()
if not npm_cmd:
if sys.platform == "win32":
refresh_windows_path_from_registry()
npm_cmd = get_npm_command()
if not npm_cmd:
print()
cprint("============================================================", RED)
Expand All @@ -796,6 +873,7 @@ def check_node_installation() -> None:
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():
refresh_windows_path_from_registry()
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()
Expand All @@ -815,6 +893,7 @@ def check_node_installation() -> None:
exit_with_pause(1)

npm_version = subprocess.check_output([npm_cmd, "--version"], text=True).strip()
ensure_windows_node_on_path(node_cmd, npm_cmd)
cprint(f"Node: {node_version}", GREEN)
cprint(f"npm: {npm_version}", GREEN)
print()
Expand Down Expand Up @@ -956,19 +1035,7 @@ def _prepend_path_entry(path_entry: str, env: dict[str, str]) -> None:
"""Prepend a directory to PATH for the current process and child services."""
if not path_entry:
return
current_parts = [part for part in os.environ.get("PATH", "").split(os.pathsep) if part]
try:
normalized_entry = str(Path(path_entry).resolve())
except OSError:
normalized_entry = path_entry
normalized_parts = set()
for part in current_parts:
try:
normalized_parts.add(str(Path(part).resolve()))
except OSError:
normalized_parts.add(part)
if normalized_entry not in normalized_parts:
os.environ["PATH"] = normalized_entry + os.pathsep + os.environ.get("PATH", "")
prepend_process_path_entry(path_entry)
env["PATH"] = os.environ.get("PATH", "")


Expand Down
18 changes: 18 additions & 0 deletions tests/test_moto_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,24 @@ def test_check_node_installation_uses_winget_when_missing_on_windows(self) -> No

installer.assert_called_once()

def test_check_node_installation_prepends_detected_node_dir_for_npm_scripts(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
node_dir = Path(temp_dir) / "nodejs"
node_dir.mkdir()
node_path = str(node_dir / "node.exe")
npm_path = str(node_dir / "npm.cmd")
Path(node_path).write_text("", encoding="utf-8")
Path(npm_path).write_text("", encoding="utf-8")

with mock.patch.dict(os.environ, {"PATH": r"C:\Windows\System32"}, clear=False):
with mock.patch.object(moto_launcher.sys, "platform", "win32"):
with mock.patch.object(moto_launcher, "get_node_command", return_value=node_path):
with mock.patch.object(moto_launcher, "get_npm_command", return_value=npm_path):
with mock.patch.object(moto_launcher.subprocess, "check_output", side_effect=["v24.16.0", "11.13.0"]):
moto_launcher.check_node_installation()

self.assertEqual(os.environ["PATH"].split(os.pathsep)[0], str(node_dir.resolve()))


class LinuxLauncherStrategyTests(TestCase):
def test_using_repo_local_venv_detects_repo_scoped_interpreter(self) -> None:
Expand Down
Loading