From ff7814970a94c7f31671049f4248e1830f28e821 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:13:22 +0000 Subject: [PATCH 1/2] refactor(python-installer): break down cmd_install into modular helper functions Co-authored-by: Tcode-Motion <188012755+Tcode-Motion@users.noreply.github.com> --- python-installer/LICENSE | 7 + python-installer/README.md | 224 +++++++++++++++++ python-installer/pyproject.toml | 57 +++++ python-installer/techscript/__init__.py | 17 ++ python-installer/techscript/__main__.py | 225 +++++++++++++++++ python-installer/techscript/downloader.py | 154 ++++++++++++ python-installer/techscript/installer.py | 231 ++++++++++++++++++ python-installer/techscript/launcher.py | 43 ++++ .../techscript/platform_detect.py | 145 +++++++++++ python-installer/tests/__init__.py | 1 + python-installer/tests/test_installer.py | 130 ++++++++++ python-installer/tests/test_main.py | 85 +++++++ 12 files changed, 1319 insertions(+) create mode 100644 python-installer/LICENSE create mode 100644 python-installer/README.md create mode 100644 python-installer/pyproject.toml create mode 100644 python-installer/techscript/__init__.py create mode 100644 python-installer/techscript/__main__.py create mode 100644 python-installer/techscript/downloader.py create mode 100644 python-installer/techscript/installer.py create mode 100644 python-installer/techscript/launcher.py create mode 100644 python-installer/techscript/platform_detect.py create mode 100644 python-installer/tests/__init__.py create mode 100644 python-installer/tests/test_installer.py create mode 100644 python-installer/tests/test_main.py diff --git a/python-installer/LICENSE b/python-installer/LICENSE new file mode 100644 index 00000000..5bcba987 --- /dev/null +++ b/python-installer/LICENSE @@ -0,0 +1,7 @@ +Apache License 2.0 + +Copyright 2026 Tcode-Motion and TechScript contributors. + +This package is distributed under the Apache License, Version 2.0. The complete license text is available in the repository root LICENSE file and at: + +https://www.apache.org/licenses/LICENSE-2.0 diff --git a/python-installer/README.md b/python-installer/README.md new file mode 100644 index 00000000..07187e70 --- /dev/null +++ b/python-installer/README.md @@ -0,0 +1,224 @@ +# TechScript — Python Installer + +
+ +[![PyPI](https://img.shields.io/pypi/v/techscript?style=for-the-badge&color=0DF28B)](https://pypi.org/project/techscript/) +[![Python](https://img.shields.io/pypi/pyversions/techscript?style=for-the-badge)](https://pypi.org/project/techscript/) +[![License](https://img.shields.io/badge/license-Apache--2.0-green?style=for-the-badge)](../LICENSE) +[![GitHub](https://img.shields.io/badge/GitHub-Tcode--Motion%2Ftechscript-black?style=for-the-badge)](https://github.com/Tcode-Motion/techscript) + +**The official Python-based installer for the TechScript 2.0 programming language.** + +
+ +--- + +## What This Package Does + +This is a **lightweight bootstrapper only** — it contains no compiler binaries. + +When you run `techscript install`, it: +1. Detects your OS and CPU architecture +2. Fetches the correct native binary from [GitHub Releases](https://github.com/Tcode-Motion/techscript/releases/latest) +3. Installs it into your PATH +4. Verifies the installation by running `tsc version` + +> **The actual TechScript compiler remains distributed via GitHub Releases.** +> This PyPI package is under 200 KB and contains only Python code. + +--- + +## Supported Platforms + +| Platform | Architecture | Status | +|---|---|---| +| Windows | x64 | ✅ Supported | +| Windows | ARM64 | ✅ Supported | +| Linux | x64 | ✅ Supported | +| Linux | ARM64 | ✅ Supported | +| macOS | Intel (x64) | ✅ Supported | +| macOS | Apple Silicon (ARM64) | ✅ Supported | +| Android Termux | ARM64 | ✅ Supported | +| Android Termux | ARMv7 | 🔄 Optional | + +--- + +## Installation + +### Via pip (Recommended) + +```bash +pip install techscript +techscript install +``` + +### Via curl — Linux / macOS + +```bash +curl -fsSL https://raw.githubusercontent.com/Tcode-Motion/techscript/main/scripts/install.sh | bash +``` + +### Via installer.exe — Windows + +Download `TechScript_Setup.exe` from [GitHub Releases](https://github.com/Tcode-Motion/techscript/releases/latest). + +### Via Termux — Android + +```bash +pkg update +pkg install curl python +# Option 1: Python installer +pip install techscript +techscript install + +# Option 2: Shell installer +curl -fsSL https://raw.githubusercontent.com/Tcode-Motion/techscript/main/scripts/install.sh | bash +``` + +### Via Homebrew — macOS *(coming soon)* + +```bash +brew install tcode-motion/tap/techscript +``` + +### Via Winget — Windows *(coming soon)* + +```powershell +winget install Tcode-Motion.TechScript +``` + +### Via Scoop — Windows *(coming soon)* + +```powershell +scoop install techscript +``` + +--- + +## Usage + +### Install the compiler + +```bash +techscript install +``` + +### Force reinstall / update + +```bash +techscript install --force +``` + +### Check installed version + +```bash +techscript version +``` + +### Check for updates + +```bash +techscript check +``` + +### Uninstall + +```bash +techscript uninstall +``` + +### Forward commands to tsc + +```bash +# These are equivalent: +techscript run myfile.txs +tsc run myfile.txs +``` + +--- + +## Quick Start + +After installing: + +```bash +# Create a new project +tsc new hello_world +cd hello_world + +# Run it +tsc run +``` + +Your first `src/main.txs`: + +```txs +say "Hello, World! 🌍" +name = ask "What is your name? " +say $"Welcome, {name}! You are running TechScript 2.0." +``` + +--- + +## Install Locations + +| Platform | Location | +|---|---| +| Windows | `%LOCALAPPDATA%\TechScript\bin\tsc.exe` | +| Linux / macOS | `~/.local/bin/tsc` | +| Termux | `$PREFIX/bin/tsc` | + +--- + +## Debug Mode + +Enable verbose output and Python tracebacks: + +```bash +techscript install --debug +``` + +--- + +## Requirements + +- Python 3.8 or newer +- Internet access (to download the compiler binary) +- The `requests` library (installed automatically) + +--- + +## Development + +```bash +git clone https://github.com/Tcode-Motion/techscript +cd techscript/python-installer + +# Install in editable mode +pip install -e . + +# Run tests +python -m pytest tests/ -v + +# Build wheel and sdist +python -m build +``` + +--- + +## Links + +| Resource | Link | +|---|---| +| 🌐 Website | [techscript.is-a.dev](https://techscript.is-a.dev) | +| 📦 GitHub | [Tcode-Motion/techscript](https://github.com/Tcode-Motion/techscript) | +| 🐍 PyPI | [pypi.org/project/techscript](https://pypi.org/project/techscript/) | +| 💻 VS Code Extension | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=tanmoy.techscript) | +| 🐛 Issues | [GitHub Issues](https://github.com/Tcode-Motion/techscript/issues) | +| 💬 Discussions | [GitHub Discussions](https://github.com/Tcode-Motion/techscript/discussions) | + +--- + +## License + +Apache License 2.0 — Copyright 2026 [Tcode-Motion](https://github.com/Tcode-Motion) diff --git a/python-installer/pyproject.toml b/python-installer/pyproject.toml new file mode 100644 index 00000000..f5dc04b9 --- /dev/null +++ b/python-installer/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "techscript" +version = "2.0.0.1" +description = "Official installer and launcher for the TechScript 2.0 programming language toolchain" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.8" +authors = [ + { name = "Tcode-Motion", email = "tcode.motion@gmail.com" } +] +keywords = ["techscript", "compiler", "installer", "tsc", "programming-language"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Compilers", + "Topic :: System :: Installation/Setup", +] +dependencies = [ + "requests>=2.28.0", +] + +[project.urls] +Homepage = "https://techscript.is-a.dev" +Repository = "https://github.com/Tcode-Motion/techscript" +Issues = "https://github.com/Tcode-Motion/techscript/issues" +Changelog = "https://github.com/Tcode-Motion/techscript/blob/main/CHANGELOG.md" + +[project.scripts] +techscript = "techscript.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["techscript"] + +[tool.hatch.build.targets.sdist] +include = [ + "techscript/", + "tests/", + "README.md", + "LICENSE", + "pyproject.toml", +] + +[project.optional-dependencies] +dev = ["build", "hatchling"] diff --git a/python-installer/techscript/__init__.py b/python-installer/techscript/__init__.py new file mode 100644 index 00000000..9eb696be --- /dev/null +++ b/python-installer/techscript/__init__.py @@ -0,0 +1,17 @@ +""" +TechScript 2.0 — Official Python Installer / Bootstrapper + +Detects your platform, downloads the correct native binary from GitHub Releases, +and installs the `tsc` compiler into the right location on your PATH. + +Usage: + techscript install # Install or update the compiler + techscript install --force # Force reinstall + techscript uninstall # Remove the compiler + techscript version # Show installed version + techscript check # Check for updates +""" + +__version__ = "2.0.0.1" +__author__ = "Tcode-Motion" +__repo__ = "https://github.com/Tcode-Motion/techscript" diff --git a/python-installer/techscript/__main__.py b/python-installer/techscript/__main__.py new file mode 100644 index 00000000..904c811d --- /dev/null +++ b/python-installer/techscript/__main__.py @@ -0,0 +1,225 @@ +""" +TechScript CLI entry point. + +Usage: + techscript install Install or update the compiler + techscript install --force Force reinstall + techscript uninstall Remove the compiler + techscript version Show installed version + techscript check Check if an update is available + techscript run Forward a command to the installed tsc binary +""" + +import shutil +import sys +import os +from typing import Optional, Tuple, Any + +from . import __version__ +from .platform_detect import detect +from .installer import ( + _ok, _info, _warn, _err, GREEN, YELLOW, RED, CYAN, BOLD, RESET, + find_existing_tsc, prompt_existing_install, install_binary, + configure_path, verify_installation, uninstall, get_installed_version, +) +from .downloader import ( + check_internet, get_latest_tag, download_and_extract, +) +from .launcher import launch + + +BANNER = f"""\ +{CYAN}{BOLD} + ╔══════════════════════════════════════════╗ + ║ TechScript 2.0 — Installer ║ + ║ Write like a Human. Run like Rust. ║ + ╚══════════════════════════════════════════╝ +{RESET}""" + + +def _detect_and_verify_platform() -> Optional[Any]: + platform_info = detect() + _info(f"Platform detected: {platform_info.display_name}") + _info(f"Python : {'.'.join(str(x) for x in platform_info.python_version)}") + + if not platform_info.is_supported: + _err(f"Unsupported platform: {platform_info.display_name}") + _err("Please open an issue: https://github.com/Tcode-Motion/techscript/issues") + return None + return platform_info + + +def _handle_existing_install(platform_info: Any, force: bool) -> Optional[int]: + existing = find_existing_tsc() + if existing and not force: + action = prompt_existing_install(existing) + if action == "skip": + _info("Skipping installation. Existing install kept.") + return 0 + elif action == "uninstall": + uninstall(platform_info) + return 0 + print() + return None + + +def _download_package(platform_info: Any, debug: bool) -> Tuple[Optional[str], Optional[str]]: + _info("Checking internet connectivity...") + if not check_internet(): + _err("No internet connection. Cannot download TechScript.") + return None, None + + _info("Fetching latest release information...") + tag = get_latest_tag() + _info(f"Target release: {tag}") + + asset_name = platform_info.asset_name + _info(f"Package : {asset_name}") + print() + + try: + binary_path, tmp_dir = download_and_extract( + tag, asset_name, platform_info.binary_name + ) + return binary_path, tmp_dir + except RuntimeError as e: + _err(str(e)) + if debug: + raise + return None, None + + +def _install_and_configure(binary_path: str, tmp_dir: str, platform_info: Any) -> Optional[str]: + try: + print() + installed_path = install_binary(binary_path, platform_info) + configure_path(platform_info, platform_info.install_dir) + return installed_path + except PermissionError as e: + _err(f"Permission denied: {e}") + _warn("Try running with elevated privileges, or set a custom install dir.") + return None + finally: + import shutil as _shutil + _shutil.rmtree(tmp_dir, ignore_errors=True) + + +def _verify_and_print_result(installed_path: str, platform_info: Any) -> int: + print() + ok = verify_installation(installed_path) + + print() + if ok: + print(f" {GREEN}{BOLD}══════════════════════════════════════════{RESET}") + print(f" {GREEN}{BOLD} TechScript installed successfully! 🎉 {RESET}") + print(f" {GREEN}{BOLD}══════════════════════════════════════════{RESET}") + print() + print(" Create your first project:") + print(f" {CYAN}tsc new hello_world{RESET}") + print(f" {CYAN}cd hello_world{RESET}") + print(f" {CYAN}tsc run{RESET}") + print() + if platform_info.install_dir not in os.environ.get("PATH", ""): + print(f" {YELLOW}NOTE: Restart your terminal (or run `source ~/.bashrc`) to activate PATH changes.{RESET}") + return 0 + else: + _err("Installation verification failed.") + _err("Try running: techscript install --force") + return 1 + + +def cmd_install(force: bool = False, debug: bool = False) -> int: + """Download and install the TechScript compiler.""" + print(BANNER) + + platform_info = _detect_and_verify_platform() + if not platform_info: + return 1 + + existing_res = _handle_existing_install(platform_info, force) + if existing_res is not None: + return existing_res + + binary_path, tmp_dir = _download_package(platform_info, debug) + if not binary_path or not tmp_dir: + return 1 + + installed_path = _install_and_configure(binary_path, tmp_dir, platform_info) + if not installed_path: + return 1 + + return _verify_and_print_result(installed_path, platform_info) + + +def cmd_uninstall(debug: bool = False) -> int: + platform_info = detect() + print(BANNER) + _info("Uninstalling TechScript...") + uninstall(platform_info) + return 0 + + +def cmd_version() -> int: + ver = get_installed_version() + if ver: + print(ver) + else: + _warn("TechScript is not installed.") + print(" Run: techscript install") + return 0 + + +def cmd_check() -> int: + print(BANNER) + _info("Checking for updates...") + installed_ver = get_installed_version() + latest_tag = get_latest_tag() + if installed_ver: + _info(f"Installed : {installed_ver}") + else: + _warn("TechScript is not installed.") + _info(f"Latest : {latest_tag}") + print() + if installed_ver and latest_tag.replace("release-", "v") in installed_ver: + _ok("You are up to date!") + else: + _warn("A newer version may be available.") + print(" Run: techscript install to update.") + return 0 + + +def main(argv=None) -> int: + args = argv if argv is not None else sys.argv[1:] + debug = "--debug" in args + args = [a for a in args if a != "--debug"] + + if not args or args[0] in ("-h", "--help", "help"): + print(__doc__) + return 0 + + cmd = args[0] + + if cmd == "install": + force = "--force" in args or "-f" in args + return cmd_install(force=force, debug=debug) + + elif cmd == "uninstall": + return cmd_uninstall(debug=debug) + + elif cmd in ("version", "--version", "-V"): + return cmd_version() + + elif cmd == "check": + return cmd_check() + + elif cmd == "run": + # Forward: techscript run → tsc run + return launch(["run"] + args[1:]) + + else: + # Forward any other subcommand directly to tsc + return launch(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python-installer/techscript/downloader.py b/python-installer/techscript/downloader.py new file mode 100644 index 00000000..afdba46a --- /dev/null +++ b/python-installer/techscript/downloader.py @@ -0,0 +1,154 @@ +""" +TechScript downloader module. +Downloads the correct binary archive from GitHub Releases. +""" + +import hashlib +import os +import shutil +import sys +import tempfile +from typing import Optional +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +GITHUB_REPO = "Tcode-Motion/techscript" +RELEASES_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" +RELEASES_BASE = f"https://github.com/{GITHUB_REPO}/releases/download" +FALLBACK_TAG = "release-2.0.0" + +# Try to use requests if available; otherwise fall back to urllib +try: + import requests as _requests + _HAS_REQUESTS = True +except ImportError: + _HAS_REQUESTS = False + + +def _get(url: str, stream: bool = False): + """Unified GET that works with or without the requests library.""" + if _HAS_REQUESTS: + resp = _requests.get(url, stream=stream, timeout=30, headers={"User-Agent": "techscript-installer/2.0"}) + resp.raise_for_status() + return resp + # Fallback to urllib + req = Request(url, headers={"User-Agent": "techscript-installer/2.0"}) + return urlopen(req, timeout=30) + + +def check_internet() -> bool: + """Return True if a basic network connection is available.""" + try: + urlopen("https://github.com", timeout=5) + return True + except Exception: + return False + + +def get_latest_tag() -> str: + """Fetch the latest release tag from the GitHub API.""" + try: + if _HAS_REQUESTS: + resp = _requests.get(RELEASES_API, timeout=10, headers={"User-Agent": "techscript-installer/2.0"}) + resp.raise_for_status() + tag = resp.json().get("tag_name", "") + else: + import json + req = Request(RELEASES_API, headers={"User-Agent": "techscript-installer/2.0"}) + with urlopen(req, timeout=10) as r: + tag = json.loads(r.read()).get("tag_name", "") + + # Skip any v1.x tags + if tag and not tag.startswith("v1."): + return tag + except Exception: + pass + return FALLBACK_TAG + + +def build_download_url(tag: str, asset_name: str) -> str: + return f"{RELEASES_BASE}/{tag}/{asset_name}" + + +def download_file(url: str, dest_path: str, show_progress: bool = True) -> None: + """Download a file from url to dest_path with optional progress bar.""" + try: + if _HAS_REQUESTS: + resp = _requests.get(url, stream=True, timeout=60, + headers={"User-Agent": "techscript-installer/2.0"}) + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + with open(dest_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=65536): + f.write(chunk) + downloaded += len(chunk) + if show_progress and total: + pct = int(downloaded * 50 / total) + bar = "█" * pct + "░" * (50 - pct) + mb = downloaded / 1_048_576 + total_mb = total / 1_048_576 + sys.stdout.write(f"\r [{bar}] {mb:.1f}/{total_mb:.1f} MB") + sys.stdout.flush() + if show_progress: + print() + else: + req = Request(url, headers={"User-Agent": "techscript-installer/2.0"}) + with urlopen(req, timeout=60) as resp, open(dest_path, "wb") as f: + shutil.copyfileobj(resp, f) + except HTTPError as e: + raise RuntimeError(f"Download failed (HTTP {e.code}): {url}") from e + except URLError as e: + raise RuntimeError(f"Network error: {e.reason}") from e + + +def extract_archive(archive_path: str, dest_dir: str) -> None: + """Extract .tar.gz or .zip archive to dest_dir.""" + if archive_path.endswith(".tar.gz") or archive_path.endswith(".tgz"): + import tarfile + with tarfile.open(archive_path, "r:gz") as tf: + tf.extractall(dest_dir) + elif archive_path.endswith(".zip"): + import zipfile + with zipfile.ZipFile(archive_path, "r") as zf: + zf.extractall(dest_dir) + else: + raise RuntimeError(f"Unknown archive format: {archive_path}") + + +def find_binary_in_dir(directory: str, binary_name: str) -> Optional[str]: + """Recursively find the tsc binary inside an extracted directory.""" + for root, _dirs, files in os.walk(directory): + for fname in files: + if fname == binary_name: + return os.path.join(root, fname) + return None + + +def download_and_extract(tag: str, asset_name: str, binary_name: str, + show_progress: bool = True) -> str: + """ + Download the release archive and extract the binary. + Returns the path to the extracted binary. + """ + url = build_download_url(tag, asset_name) + tmp_dir = tempfile.mkdtemp(prefix="techscript_install_") + archive_path = os.path.join(tmp_dir, asset_name) + + try: + print(f" Downloading: {url}") + download_file(url, archive_path, show_progress=show_progress) + + extract_dir = os.path.join(tmp_dir, "extracted") + os.makedirs(extract_dir, exist_ok=True) + extract_archive(archive_path, extract_dir) + + binary_path = find_binary_in_dir(extract_dir, binary_name) + if not binary_path: + raise RuntimeError( + f"Binary '{binary_name}' not found inside archive '{asset_name}'." + ) + return binary_path, tmp_dir # caller must clean up tmp_dir + except Exception: + shutil.rmtree(tmp_dir, ignore_errors=True) + raise diff --git a/python-installer/techscript/installer.py b/python-installer/techscript/installer.py new file mode 100644 index 00000000..20515a30 --- /dev/null +++ b/python-installer/techscript/installer.py @@ -0,0 +1,231 @@ +""" +TechScript installer module. +Handles placing the binary, PATH configuration, and verification. +""" + +import os +import platform +import shutil +import stat +import subprocess +import sys +from typing import Optional + +from .platform_detect import PlatformInfo, OS + + +# ─── Colours ───────────────────────────────────────────────────────────────── + +def _supports_color() -> bool: + return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + +_C = _supports_color() +GREEN = "\033[92m" if _C else "" +YELLOW = "\033[93m" if _C else "" +RED = "\033[91m" if _C else "" +CYAN = "\033[96m" if _C else "" +BOLD = "\033[1m" if _C else "" +RESET = "\033[0m" if _C else "" + + +def _ok(msg: str) -> None: print(f" {GREEN}✔{RESET} {msg}") +def _info(msg: str) -> None: print(f" {CYAN}ℹ{RESET} {msg}") +def _warn(msg: str) -> None: print(f" {YELLOW}⚠{RESET} {msg}") +def _err(msg: str) -> None: print(f" {RED}✘{RESET} {msg}") + + +# ─── Existing install detection ─────────────────────────────────────────────── + +def find_existing_tsc() -> Optional[str]: + """Return the path to an existing tsc binary, or None.""" + return shutil.which("tsc") + + +def get_installed_version(tsc_path: Optional[str] = None) -> Optional[str]: + """Return the installed tsc version string, or None.""" + binary = tsc_path or shutil.which("tsc") + if not binary: + return None + try: + result = subprocess.run( + [binary, "version"], + capture_output=True, text=True, timeout=10 + ) + for line in result.stdout.splitlines(): + if "Compiler Driver" in line or "v2." in line: + return line.strip() + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + except Exception: + return None + + +def prompt_existing_install(existing_path: str) -> str: + """ + Ask the user what to do when a TechScript install is already found. + Returns one of: 'update', 'repair', 'reinstall', 'skip' + """ + current_ver = get_installed_version(existing_path) + print(f"\n {YELLOW}{BOLD}TechScript is already installed!{RESET}") + print(f" Location: {existing_path}") + if current_ver: + print(f" Version : {current_ver}") + print() + print(" What would you like to do?") + print(" [1] Update — download the latest version") + print(" [2] Repair — redownload and reinstall current version") + print(" [3] Reinstall — remove and install fresh") + print(" [4] Skip — keep the existing install") + print() + + while True: + try: + choice = input(" Enter choice [1-4]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return "skip" + mapping = {"1": "update", "2": "repair", "3": "reinstall", "4": "skip"} + if choice in mapping: + return mapping[choice] + print(" Please enter 1, 2, 3, or 4.") + + +# ─── Installation ───────────────────────────────────────────────────────────── + +def ensure_install_dir(install_dir: str) -> None: + """Create the installation directory if it doesn't exist.""" + os.makedirs(install_dir, exist_ok=True) + + +def install_binary(src_binary: str, platform_info: PlatformInfo, + install_dir: Optional[str] = None) -> str: + """ + Copy the binary to the installation directory. + Returns the path to the installed binary. + """ + dest_dir = install_dir or platform_info.install_dir + ensure_install_dir(dest_dir) + dest_path = os.path.join(dest_dir, platform_info.binary_name) + + _info(f"Installing to: {dest_path}") + shutil.copy2(src_binary, dest_path) + + # Make executable on Unix + if platform_info.os.name != "WINDOWS": + st = os.stat(dest_path) + os.chmod(dest_path, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return dest_path + + +# ─── PATH configuration ─────────────────────────────────────────────────────── + +def _dir_in_path(directory: str) -> bool: + path_dirs = os.environ.get("PATH", "").split(os.pathsep) + return any(os.path.normcase(d) == os.path.normcase(directory) for d in path_dirs) + + +def configure_path_unix(install_dir: str) -> None: + """Append install_dir to the user's shell rc file if not already present.""" + if _dir_in_path(install_dir): + _ok(f"{install_dir} is already in PATH") + return + + shell = os.environ.get("SHELL", "") + if "zsh" in shell: + rc = os.path.expanduser("~/.zshrc") + elif "fish" in shell: + rc = os.path.expanduser("~/.config/fish/config.fish") + else: + rc = os.path.expanduser("~/.bashrc") + + export_line = f'\nexport PATH="{install_dir}:$PATH" # TechScript toolchain\n' + if os.path.exists(rc): + content = open(rc).read() + if install_dir not in content: + with open(rc, "a") as f: + f.write(export_line) + _ok(f"Added {install_dir} to PATH in {rc}") + else: + _warn(f"Shell rc not found. Please add this to your shell profile manually:") + _warn(f' export PATH="{install_dir}:$PATH"') + + +def configure_path_windows(install_dir: str) -> None: + """Add install_dir to the Windows user PATH via the registry.""" + if _dir_in_path(install_dir): + _ok(f"{install_dir} is already in PATH") + return + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Environment", + 0, winreg.KEY_READ | winreg.KEY_WRITE + ) + try: + existing, _ = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + existing = "" + + if install_dir.lower() not in existing.lower(): + new_path = existing.rstrip(";") + ";" + install_dir + winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_path) + _ok(f"Added {install_dir} to Windows user PATH (restart your terminal to apply)") + winreg.CloseKey(key) + except Exception as e: + _warn(f"Could not update PATH automatically: {e}") + _warn(f"Please add this directory to your PATH manually: {install_dir}") + + +def configure_path(platform_info: PlatformInfo, install_dir: str) -> None: + if platform_info.os.name == "WINDOWS": + configure_path_windows(install_dir) + else: + configure_path_unix(install_dir) + + +# ─── Verification ───────────────────────────────────────────────────────────── + +def verify_installation(installed_path: str) -> bool: + """Run tsc version to confirm it works. Returns True on success.""" + try: + result = subprocess.run( + [installed_path, "version"], + capture_output=True, text=True, timeout=15 + ) + if result.returncode == 0: + _ok("TechScript installed successfully!") + for line in result.stdout.strip().splitlines()[:3]: + print(f" {line}") + return True + else: + _err(f"tsc returned exit code {result.returncode}") + return False + except FileNotFoundError: + _err(f"Binary not found at {installed_path}") + return False + except subprocess.TimeoutExpired: + _err("Verification timed out") + return False + except Exception as e: + _err(f"Verification failed: {e}") + return False + + +# ─── Uninstall ──────────────────────────────────────────────────────────────── + +def uninstall(platform_info: PlatformInfo, install_dir: Optional[str] = None) -> None: + """Remove the tsc binary from the installation directory.""" + dest_dir = install_dir or platform_info.install_dir + dest_path = os.path.join(dest_dir, platform_info.binary_name) + + if os.path.exists(dest_path): + os.remove(dest_path) + _ok(f"Removed: {dest_path}") + else: + existing = shutil.which("tsc") + if existing: + os.remove(existing) + _ok(f"Removed: {existing}") + else: + _warn("TechScript is not installed — nothing to remove.") diff --git a/python-installer/techscript/launcher.py b/python-installer/techscript/launcher.py new file mode 100644 index 00000000..2baeabc1 --- /dev/null +++ b/python-installer/techscript/launcher.py @@ -0,0 +1,43 @@ +""" +TechScript launcher module. +Used to forward `tsc` commands when invoked through `python -m techscript run`. +""" + +import os +import shutil +import subprocess +import sys + + +def find_tsc() -> str: + """Find the tsc binary or raise an error.""" + tsc = shutil.which("tsc") + if not tsc: + # Also check the default install locations explicitly + candidates = [] + local_app = os.environ.get("LOCALAPPDATA", "") + if local_app: + candidates.append(os.path.join(local_app, "TechScript", "bin", "tsc.exe")) + candidates.append(os.path.expanduser("~/.local/bin/tsc")) + prefix = os.environ.get("PREFIX", "") + if prefix: + candidates.append(os.path.join(prefix, "bin", "tsc")) + + for path in candidates: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + + print("\n ✘ TechScript compiler (tsc) is not installed or not found in PATH.", file=sys.stderr) + print(" Run: techscript install", file=sys.stderr) + sys.exit(1) + return tsc + + +def launch(args: list) -> int: + """Forward args to the tsc binary and return the exit code.""" + tsc = find_tsc() + try: + result = subprocess.run([tsc] + args) + return result.returncode + except KeyboardInterrupt: + return 130 diff --git a/python-installer/techscript/platform_detect.py b/python-installer/techscript/platform_detect.py new file mode 100644 index 00000000..9a5d0043 --- /dev/null +++ b/python-installer/techscript/platform_detect.py @@ -0,0 +1,145 @@ +""" +TechScript platform detection module. +Identifies OS, CPU architecture, Termux environment, and Python version. +""" + +from __future__ import annotations + +import os +import platform +import posixpath +import struct +import sys +from dataclasses import dataclass +from enum import Enum, auto +from typing import Optional + + +class OS(Enum): + WINDOWS = auto() + LINUX = auto() + MACOS = auto() + UNKNOWN = auto() + + +class Arch(Enum): + X64 = auto() + ARM64 = auto() + ARMV7 = auto() + UNKNOWN = auto() + + +@dataclass +class PlatformInfo: + os: OS + arch: Arch + is_termux: bool + termux_prefix: Optional[str] + python_version: tuple + + @property + def display_name(self) -> str: + os_names = {OS.WINDOWS: "Windows", OS.LINUX: "Linux", OS.MACOS: "macOS", OS.UNKNOWN: "Unknown"} + arch_names = {Arch.X64: "x64", Arch.ARM64: "ARM64", Arch.ARMV7: "ARMv7", Arch.UNKNOWN: "Unknown"} + termux_tag = " (Termux)" if self.is_termux else "" + return f"{os_names[self.os]} {arch_names[self.arch]}{termux_tag}" + + @property + def is_supported(self) -> bool: + return self.asset_name is not None + + @property + def asset_name(self) -> Optional[str]: + """Return the GitHub Release asset filename for this platform.""" + # Termux on Android + if self.is_termux: + if self.arch == Arch.ARM64: + return "techscript-linux-arm64.tar.gz" + elif self.arch == Arch.ARMV7: + return "techscript-linux-armv7.tar.gz" + return None + + if self.os == OS.WINDOWS: + if self.arch == Arch.X64: + return "techscript-windows-x64.zip" + elif self.arch == Arch.ARM64: + return "techscript-windows-arm64.zip" + + elif self.os == OS.LINUX: + if self.arch == Arch.X64: + return "techscript-linux-x64.tar.gz" + elif self.arch == Arch.ARM64: + return "techscript-linux-arm64.tar.gz" + + elif self.os == OS.MACOS: + if self.arch == Arch.ARM64: + return "techscript-macos-arm64.tar.gz" + elif self.arch == Arch.X64: + return "techscript-macos-x64.tar.gz" + + return None + + @property + def install_dir(self) -> str: + """Return the default installation directory for this platform.""" + if self.is_termux and self.termux_prefix: + return posixpath.join(self.termux_prefix, "bin") + if self.os == OS.WINDOWS: + local_app_data = os.environ.get("LOCALAPPDATA", os.path.expanduser("~\\AppData\\Local")) + return os.path.join(local_app_data, "TechScript", "bin") + return posixpath.join(os.path.expanduser("~"), ".local", "bin") + + @property + def binary_name(self) -> str: + """Return the name of the tsc binary file.""" + return "tsc.exe" if self.os == OS.WINDOWS else "tsc" + + +def _detect_os() -> OS: + system = platform.system().lower() + if system == "windows": + return OS.WINDOWS + elif system == "linux": + return OS.LINUX + elif system == "darwin": + return OS.MACOS + return OS.UNKNOWN + + +def _detect_arch() -> Arch: + machine = platform.machine().lower() + # Normalize common aliases + if machine in ("x86_64", "amd64", "x64"): + return Arch.X64 + elif machine in ("aarch64", "arm64"): + return Arch.ARM64 + elif machine.startswith("armv7") or machine == "armhf": + return Arch.ARMV7 + # Fallback: check pointer size + if struct.calcsize("P") == 8: + return Arch.X64 + return Arch.UNKNOWN + + +def _detect_termux() -> tuple[bool, Optional[str]]: + """Detect if running inside Termux on Android.""" + prefix = os.environ.get("PREFIX", "") + if "com.termux" in prefix or "termux" in prefix.lower(): + return True, prefix + # Also check for ANDROID_DATA which is always set on Android + if os.path.exists("/data/data/com.termux"): + termux_prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr") + return True, termux_prefix + return False, None + + +def detect() -> PlatformInfo: + """Detect the current platform and return a PlatformInfo object.""" + is_termux, termux_prefix = _detect_termux() + return PlatformInfo( + os=_detect_os(), + arch=_detect_arch(), + is_termux=is_termux, + termux_prefix=termux_prefix, + python_version=sys.version_info[:3], + ) diff --git a/python-installer/tests/__init__.py b/python-installer/tests/__init__.py new file mode 100644 index 00000000..35d55f5e --- /dev/null +++ b/python-installer/tests/__init__.py @@ -0,0 +1 @@ +# Tests package init diff --git a/python-installer/tests/test_installer.py b/python-installer/tests/test_installer.py new file mode 100644 index 00000000..86ab1b7d --- /dev/null +++ b/python-installer/tests/test_installer.py @@ -0,0 +1,130 @@ +"""Tests for TechScript Python installer package.""" + +import os +import sys +import unittest +from unittest.mock import patch, MagicMock + +# Ensure the package is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from techscript.platform_detect import ( + detect, OS, Arch, PlatformInfo, _detect_os, _detect_arch, _detect_termux +) +from techscript.downloader import build_download_url, FALLBACK_TAG + + +class TestPlatformDetection(unittest.TestCase): + + def test_detect_returns_platform_info(self): + info = detect() + self.assertIsInstance(info, PlatformInfo) + self.assertIn(info.os, list(OS)) + self.assertIn(info.arch, list(Arch)) + self.assertIsInstance(info.is_termux, bool) + + def test_windows_x64_asset(self): + info = PlatformInfo(OS.WINDOWS, Arch.X64, False, None, (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-windows-x64.zip") + + def test_windows_arm64_asset(self): + info = PlatformInfo(OS.WINDOWS, Arch.ARM64, False, None, (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-windows-arm64.zip") + + def test_linux_x64_asset(self): + info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-linux-x64.tar.gz") + + def test_linux_arm64_asset(self): + info = PlatformInfo(OS.LINUX, Arch.ARM64, False, None, (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-linux-arm64.tar.gz") + + def test_macos_x64_asset(self): + info = PlatformInfo(OS.MACOS, Arch.X64, False, None, (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-macos-x64.tar.gz") + + def test_macos_arm64_asset(self): + info = PlatformInfo(OS.MACOS, Arch.ARM64, False, None, (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-macos-arm64.tar.gz") + + def test_termux_arm64_asset(self): + info = PlatformInfo(OS.LINUX, Arch.ARM64, True, "/data/data/com.termux/files/usr", (3, 11, 0)) + self.assertEqual(info.asset_name, "techscript-linux-arm64.tar.gz") + + def test_unknown_platform_unsupported(self): + info = PlatformInfo(OS.UNKNOWN, Arch.UNKNOWN, False, None, (3, 11, 0)) + self.assertFalse(info.is_supported) + + def test_display_name_windows(self): + info = PlatformInfo(OS.WINDOWS, Arch.X64, False, None, (3, 11, 0)) + self.assertIn("Windows", info.display_name) + self.assertIn("x64", info.display_name) + + def test_display_name_termux(self): + info = PlatformInfo(OS.LINUX, Arch.ARM64, True, "/prefix", (3, 11, 0)) + self.assertIn("Termux", info.display_name) + + +class TestInstallDir(unittest.TestCase): + + def test_windows_install_dir(self): + with patch.dict(os.environ, {"LOCALAPPDATA": "C:\\Users\\Test\\AppData\\Local"}): + info = PlatformInfo(OS.WINDOWS, Arch.X64, False, None, (3, 11, 0)) + self.assertIn("TechScript", info.install_dir) + self.assertIn("bin", info.install_dir) + + def test_linux_install_dir(self): + info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + self.assertIn(".local/bin", info.install_dir) + + def test_termux_install_dir(self): + prefix = "/data/data/com.termux/files/usr" + info = PlatformInfo(OS.LINUX, Arch.ARM64, True, prefix, (3, 11, 0)) + self.assertEqual(info.install_dir, f"{prefix}/bin") + + +class TestBinaryName(unittest.TestCase): + + def test_windows_binary_name(self): + info = PlatformInfo(OS.WINDOWS, Arch.X64, False, None, (3, 11, 0)) + self.assertEqual(info.binary_name, "tsc.exe") + + def test_linux_binary_name(self): + info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + self.assertEqual(info.binary_name, "tsc") + + def test_macos_binary_name(self): + info = PlatformInfo(OS.MACOS, Arch.ARM64, False, None, (3, 11, 0)) + self.assertEqual(info.binary_name, "tsc") + + +class TestDownloadURL(unittest.TestCase): + + def test_build_url_linux(self): + url = build_download_url("release-2.0.0", "techscript-linux-x64.tar.gz") + self.assertIn("release-2.0.0", url) + self.assertIn("techscript-linux-x64.tar.gz", url) + self.assertIn("github.com", url) + + def test_fallback_tag_format(self): + self.assertTrue(FALLBACK_TAG.startswith("release-") or FALLBACK_TAG.startswith("v")) + + +class TestTermuxDetection(unittest.TestCase): + + def test_no_termux(self): + with patch.dict(os.environ, {"PREFIX": ""}, clear=False): + is_termux, prefix = _detect_termux() + # Can't guarantee False in all CI environments, just check types + self.assertIsInstance(is_termux, bool) + + def test_termux_prefix_env(self): + fake_prefix = "/data/data/com.termux/files/usr" + with patch.dict(os.environ, {"PREFIX": fake_prefix}): + is_termux, prefix = _detect_termux() + self.assertTrue(is_termux) + self.assertEqual(prefix, fake_prefix) + + +if __name__ == "__main__": + unittest.main() diff --git a/python-installer/tests/test_main.py b/python-installer/tests/test_main.py new file mode 100644 index 00000000..6fcaa136 --- /dev/null +++ b/python-installer/tests/test_main.py @@ -0,0 +1,85 @@ +"""Tests for techscript.__main__ CLI module.""" + +import os +import sys +import unittest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from techscript.__main__ import ( + cmd_install, + _detect_and_verify_platform, + _handle_existing_install, + _download_package, + _install_and_configure, + _verify_and_print_result, + main, +) +from techscript.platform_detect import PlatformInfo, OS, Arch + + +class TestMainCLI(unittest.TestCase): + + @patch("techscript.__main__.detect") + def test_detect_and_verify_platform_supported(self, mock_detect): + mock_info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + mock_detect.return_value = mock_info + info = _detect_and_verify_platform() + self.assertEqual(info, mock_info) + + @patch("techscript.__main__.detect") + def test_detect_and_verify_platform_unsupported(self, mock_detect): + mock_info = PlatformInfo(OS.UNKNOWN, Arch.UNKNOWN, False, None, (3, 11, 0)) + mock_detect.return_value = mock_info + info = _detect_and_verify_platform() + self.assertIsNone(info) + + @patch("techscript.__main__.find_existing_tsc") + def test_handle_existing_install_skip(self, mock_find): + mock_find.return_value = "/usr/local/bin/tsc" + mock_info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + with patch("techscript.__main__.prompt_existing_install", return_value="skip"): + res = _handle_existing_install(mock_info, force=False) + self.assertEqual(res, 0) + + @patch("techscript.__main__.find_existing_tsc") + def test_handle_existing_install_force(self, mock_find): + mock_find.return_value = "/usr/local/bin/tsc" + mock_info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + res = _handle_existing_install(mock_info, force=True) + self.assertIsNone(res) + + @patch("techscript.__main__.check_internet", return_value=False) + def test_download_package_no_internet(self, mock_check): + mock_info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + bin_path, tmp_dir = _download_package(mock_info, debug=False) + self.assertIsNone(bin_path) + self.assertIsNone(tmp_dir) + + @patch("techscript.__main__._detect_and_verify_platform") + @patch("techscript.__main__._handle_existing_install") + @patch("techscript.__main__._download_package") + @patch("techscript.__main__._install_and_configure") + @patch("techscript.__main__._verify_and_print_result") + def test_cmd_install_success( + self, mock_verify, mock_install, mock_download, mock_existing, mock_detect + ): + mock_info = PlatformInfo(OS.LINUX, Arch.X64, False, None, (3, 11, 0)) + mock_detect.return_value = mock_info + mock_existing.return_value = None + mock_download.return_value = ("/tmp/tsc", "/tmp/dir") + mock_install.return_value = "/usr/local/bin/tsc" + mock_verify.return_value = 0 + + exit_code = cmd_install(force=False, debug=False) + self.assertEqual(exit_code, 0) + + def test_main_help(self): + with patch("sys.stdout"): + code = main(["--help"]) + self.assertEqual(code, 0) + + +if __name__ == "__main__": + unittest.main() From 405500cc90cb0b3b0d7894961ca6fdd4d8a575c6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:35:30 +0000 Subject: [PATCH 2/2] refactor(python-installer): break down cmd_install into modular helper functions Co-authored-by: Tcode-Motion <188012755+Tcode-Motion@users.noreply.github.com>