diff options
Diffstat (limited to 'src/sailfish_devel_mcp/tools.py')
| -rw-r--r-- | src/sailfish_devel_mcp/tools.py | 559 |
1 files changed, 541 insertions, 18 deletions
diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py index 854cd00..979e699 100644 --- a/src/sailfish_devel_mcp/tools.py +++ b/src/sailfish_devel_mcp/tools.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from dataclasses import dataclass from datetime import datetime, timezone import json @@ -14,7 +15,7 @@ import time from typing import Any, Callable from urllib.parse import quote -from .config import Config, DeviceConfig +from .config import AndroidBuildHostConfig, Config, DeviceConfig from .runner import ( CommandResult, remote_command, @@ -75,6 +76,18 @@ def build_registry(config: Config) -> dict[str, Tool]: Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)), Tool(_spec_build_status(), lambda args: handle_build_status(config, args)), Tool( + _spec_android_build_hosts(), + lambda args: handle_android_build_hosts(config, args), + ), + Tool( + _spec_android_build(), + lambda args: handle_android_build(config, args), + ), + Tool( + _spec_android_build_status(), + lambda args: handle_android_build_status(config, args), + ), + Tool( _spec_sdk_refresh_metadata(), lambda args: handle_sdk_refresh_metadata(config, args), ), @@ -364,29 +377,79 @@ def handle_device_user_session_command(config: Config, args: dict[str, Any]) -> def handle_device_install_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: device = _device(config, args) - rpm_path = _safe_input_path(config, _str_arg(args, "rpm_path"), allow_tmp=True) - remote_path = _optional_str(args, "remote_path") or f"/tmp/{rpm_path.name}" + rpm_path_arg = _optional_str(args, "rpm_path") + rpm_paths_arg = args.get("rpm_paths") + if rpm_path_arg and rpm_paths_arg is not None: + return tool_error("use either rpm_path or rpm_paths, not both") + if rpm_paths_arg is not None: + rpm_path_values = _string_list_arg(args, "rpm_paths") + if not rpm_path_values: + return tool_error("rpm_paths must contain at least one RPM") + elif rpm_path_arg: + rpm_path_values = [rpm_path_arg] + else: + return tool_error("rpm_path or rpm_paths is required") + + rpm_paths = [ + _safe_input_path(config, value, allow_tmp=True) + for value in rpm_path_values + ] + remote_path = _optional_str(args, "remote_path") + remote_dir = _optional_str(args, "remote_dir") + if len(rpm_paths) > 1 and remote_path: + return tool_error("remote_path only supports a single RPM; use remote_dir with rpm_paths") + if len(rpm_paths) > 1 and len({path.name for path in rpm_paths}) != len(rpm_paths): + return tool_error("rpm_paths must not contain duplicate file names") + installer = _enum_arg(args, "installer", ["pkcon", "rpm"], default="pkcon") timeout = _int_arg(args, "timeout", default=180, minimum=1, maximum=1200) - copy_result = run( - scp_to_argv(device, config.paths.ssh_config, rpm_path, remote_path), - timeout=timeout, - ) - if not copy_result.ok: - return command_result("copy RPM to device", copy_result) + mkdir_result: CommandResult | None = None + if len(rpm_paths) == 1 and not remote_dir: + remote_paths = [remote_path or f"/tmp/{rpm_paths[0].name}"] + else: + remote_dir = _remote_absolute_path(remote_dir or _new_remote_rpm_dir(), "remote_dir") + mkdir_result = _run_ssh(config, device, ["mkdir", "-p", remote_dir], timeout=timeout) + if not mkdir_result.ok: + return command_result("prepare RPM directory", mkdir_result, {"remote_dir": remote_dir}) + remote_paths = [ + str(PurePosixPath(remote_dir) / rpm_path.name) + for rpm_path in rpm_paths + ] + + copy_results = [] + for rpm_path, target_path in zip(rpm_paths, remote_paths): + copy_result = run( + scp_to_argv(device, config.paths.ssh_config, rpm_path, target_path), + timeout=timeout, + ) + copy_results.append(copy_result) + if not copy_result.ok: + return command_result( + "copy RPM to device", + copy_result, + { + "copies": [result.public_dict() for result in copy_results], + "remote_paths": remote_paths, + }, + ) if installer == "pkcon": - remote = ["pkcon", "install-local", "-y", remote_path] + remote = ["pkcon", "install-local", "-y", *remote_paths] else: - remote = ["rpm", "-Uvh", "--replacepkgs", remote_path] + remote = ["rpm", "-Uvh", "--replacepkgs", *remote_paths] install_result = _run_ssh(config, device, remote, timeout=timeout) structured = { - "copy": copy_result.public_dict(), + "copy": copy_results[0].public_dict(), + "copies": [result.public_dict() for result in copy_results], "install": install_result.public_dict(), - "remote_path": remote_path, + "remote_path": remote_paths[0], + "remote_paths": remote_paths, + "remote_dir": remote_dir, "installer": installer, } + if mkdir_result is not None: + structured["mkdir"] = mkdir_result.public_dict() return command_result("install RPM", install_result, structured) @@ -523,6 +586,123 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: } +def handle_android_build_hosts(config: Config, args: dict[str, Any]) -> dict[str, Any]: + text = "\n".join( + ( + f"{name}{' (default)' if name == config.default_android_build_host else ''}: " + f"{host.ssh_target} project={host.project_dir} state={host.state_dir}" + ) + for name, host in sorted(config.android_build_hosts.items(), key=lambda item: item[0]) + ) + structured = { + "default_android_build_host": config.default_android_build_host, + "android_build_hosts": { + name: host.public_dict() + for name, host in config.android_build_hosts.items() + }, + } + return ok_text(text or "No configured Android build hosts", structured) + + +def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + project_dir_arg = _optional_str(args, "project_dir") or host.project_dir + if not project_dir_arg: + return tool_error( + "project_dir is required; configure android_build_hosts.<host>.project_dir " + "or pass project_dir" + ) + project_dir = _remote_absolute_path( + project_dir_arg, + "project_dir", + ) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + shell_command = _str_arg(args, "shell_command") + shell = _enum_arg(args, "shell", ["bash", "sh"], default="bash") + timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600) + job_id = _optional_str(args, "job_id") or _new_android_build_job_id() + _validate_remote_job_id(job_id) + + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + remote = _android_build_start_command( + host=host, + project_dir=project_dir, + state_dir=state_dir, + job_id=job_id, + shell=shell, + shell_command=shell_command, + ) + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + structured = { + "host": host.public_dict(), + "project_dir": project_dir, + "state_dir": state_dir, + "job_id": job_id, + "job_dir": job_dir, + "log_path": log_path, + "shell": shell, + } + if not result.ok: + return command_result("start Android build", result, structured) + + text = "\n".join( + [ + f"started Android build job {job_id} on {host.name}", + f"host: {host.ssh_target}", + f"project: {project_dir}", + f"state: {job_dir}", + f"log: {log_path}", + "poll with sailfish_android_build_status", + ] + ) + if result.stdout.strip(): + text += "\n\n" + result.stdout.strip() + data = result.public_dict() + data.update(structured) + return { + "content": [{"type": "text", "text": text}], + "structuredContent": data, + "isError": False, + } + + +def handle_android_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + job_id = _optional_str(args, "job_id") + lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600) + + if job_id: + _validate_remote_job_id(job_id) + remote = _android_build_status_command(state_dir, job_id, lines) + structured: dict[str, Any] = { + "host": host.public_dict(), + "state_dir": state_dir, + "job_id": job_id, + } + else: + remote = _android_build_list_command(state_dir) + structured = {"host": host.public_dict(), "state_dir": state_dir} + + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + if job_id: + structured.update(_parse_android_build_status(result.stdout)) + response = command_result("Android build status", result, structured) + returncode = response["structuredContent"].get("returncode") + state = response["structuredContent"].get("state") + if result.ok and state == "finished" and returncode not in (0, None): + response["isError"] = True + return response + + def _mcp_state_dir() -> Path: value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR") if value: @@ -859,6 +1039,243 @@ def _run_ssh( ) +def _android_build_host(config: Config, args: dict[str, Any]) -> AndroidBuildHostConfig: + return config.android_build_host(_optional_str(args, "host")) + + +def _android_build_ssh_argv( + config: Config, + host: AndroidBuildHostConfig, + remote: str, +) -> list[str]: + argv = ["ssh"] + if config.paths.ssh_config: + argv += ["-F", str(config.paths.ssh_config)] + argv += [host.ssh_target, remote] + return argv + + +def _new_android_build_job_id() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + return f"android-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + + +def _validate_remote_job_id(job_id: str) -> None: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", job_id): + raise ValueError("job_id contains unsupported characters") + + +def _remote_absolute_path(value: str, name: str) -> str: + if "\0" in value or "\n" in value: + raise ValueError(f"{name} contains unsupported characters") + if not value.startswith("/"): + raise ValueError(f"{name} must be an absolute remote path") + return value + + +def _new_remote_rpm_dir() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + return f"/tmp/sailfish-devel-mcp-rpms-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + + +def _android_build_start_command( + *, + host: AndroidBuildHostConfig, + project_dir: str, + state_dir: str, + job_id: str, + shell: str, + shell_command: str, +) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + run_path = str(PurePosixPath(job_dir) / "run.sh") + command_path = str(PurePosixPath(job_dir) / "command") + pid_path = str(PurePosixPath(job_dir) / "pid") + created_at_path = str(PurePosixPath(job_dir) / "created_at") + started_at_path = str(PurePosixPath(job_dir) / "started_at") + finished_at_path = str(PurePosixPath(job_dir) / "finished_at") + returncode_path = str(PurePosixPath(job_dir) / "returncode") + run_script = f"""#!/bin/sh +set +e +project_dir={shlex.quote(project_dir)} +log_path={shlex.quote(log_path)} +started_at_path={shlex.quote(started_at_path)} +finished_at_path={shlex.quote(finished_at_path)} +returncode_path={shlex.quote(returncode_path)} +host_name={shlex.quote(host.name)} +shell_bin={shlex.quote(shell)} +shell_command={shlex.quote(shell_command)} + +date -Is > "$started_at_path" +printf '[%s] starting Android build on %s\\n' "$(date -Is)" "$host_name" >> "$log_path" +printf '[%s] project_dir=%s\\n' "$(date -Is)" "$project_dir" >> "$log_path" +printf '[%s] command=%s\\n' "$(date -Is)" "$shell_command" >> "$log_path" + +cd "$project_dir" +cd_rc=$? +if [ "$cd_rc" -ne 0 ]; then + printf '[%s] failed to cd to %s\\n' "$(date -Is)" "$project_dir" >> "$log_path" + printf '%s\\n' "$cd_rc" > "$returncode_path" + date -Is > "$finished_at_path" + exit "$cd_rc" +fi + +"$shell_bin" -lc "$shell_command" >> "$log_path" 2>&1 +rc=$? +printf '%s\\n' "$rc" > "$returncode_path" +date -Is > "$finished_at_path" +printf '[%s] finished returncode=%s\\n' "$(date -Is)" "$rc" >> "$log_path" +exit "$rc" +""" + encoded_run_script = base64.b64encode(run_script.encode("utf-8")).decode("ascii") + script = f""" +set -eu +state_dir={shlex.quote(state_dir)} +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +run_path={shlex.quote(run_path)} +log_path={shlex.quote(log_path)} +command_path={shlex.quote(command_path)} +pid_path={shlex.quote(pid_path)} +created_at_path={shlex.quote(created_at_path)} +mkdir -p "$state_dir" "$job_dir" +if [ -e "$pid_path" ] || [ -e {shlex.quote(returncode_path)} ]; then + echo "job already exists: $job_id" >&2 + exit 2 +fi +umask 077 +printf '%s' {shlex.quote(encoded_run_script)} | base64 -d > "$run_path" +chmod +x "$run_path" +: > "$log_path" +printf '%s\\n' {shlex.quote(shell_command)} > "$command_path" +date -Is > "$created_at_path" +nohup "$run_path" >/dev/null 2>&1 </dev/null & +pid=$! +printf '%s\\n' "$pid" > "$pid_path" +printf 'job_id: %s\\n' "$job_id" +printf 'pid: %s\\n' "$pid" +printf 'host: %s\\n' {shlex.quote(host.name)} +printf 'project_dir: %s\\n' {shlex.quote(project_dir)} +printf 'job_dir: %s\\n' "$job_dir" +printf 'log_path: %s\\n' "$log_path" +""" + return remote_command(["sh", "-lc", script]) + + +def _android_build_list_command(state_dir: str) -> str: + script = f""" +set -eu +state_dir={shlex.quote(state_dir)} +if [ ! -d "$state_dir" ]; then + echo "no android build jobs found" + exit 0 +fi +output=$( + for job_dir in "$state_dir"/*; do + [ -d "$job_dir" ] || continue + job_id=${{job_dir##*/}} + pid=$(cat "$job_dir/pid" 2>/dev/null || true) + returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) + created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) + started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) + timestamp=${{started_at:-$created_at}} + if [ -n "$returncode" ]; then + state=finished + elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + state=running + elif [ -n "$pid" ]; then + state=unknown + else + state=starting + fi + printf '%s\\t%s: %s returncode=%s pid=%s\\n' "$timestamp" "$job_id" "$state" "$returncode" "$pid" + done | sort | tail -n 20 | cut -f2- +) +if [ -n "$output" ]; then + printf '%s\\n' "$output" +else + echo "no android build jobs found" +fi +""" + return remote_command(["sh", "-lc", script]) + + +def _android_build_status_command(state_dir: str, job_id: str, lines: int) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + script = f""" +set -eu +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +log_path={shlex.quote(log_path)} +lines={lines} +if [ ! -d "$job_dir" ]; then + echo "unknown android build job: $job_id" >&2 + exit 2 +fi +pid=$(cat "$job_dir/pid" 2>/dev/null || true) +returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) +created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) +started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) +finished_at=$(cat "$job_dir/finished_at" 2>/dev/null || true) +command=$(cat "$job_dir/command" 2>/dev/null || true) +if [ -n "$returncode" ]; then + state=finished +elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + state=running +elif [ -n "$pid" ]; then + state=unknown +else + state=starting +fi +printf 'job_id: %s\\n' "$job_id" +printf 'state: %s\\n' "$state" +printf 'returncode: %s\\n' "$returncode" +printf 'pid: %s\\n' "$pid" +printf 'created_at: %s\\n' "$created_at" +printf 'started_at: %s\\n' "$started_at" +printf 'finished_at: %s\\n' "$finished_at" +printf 'job_dir: %s\\n' "$job_dir" +printf 'log_path: %s\\n' "$log_path" +printf 'command: %s\\n' "$command" +if [ "$lines" -gt 0 ] && [ -f "$log_path" ]; then + printf '\\n' + tail -n "$lines" "$log_path" +fi +""" + return remote_command(["sh", "-lc", script]) + + +def _parse_android_build_status(stdout: str) -> dict[str, Any]: + fields: dict[str, Any] = {} + for line in stdout.splitlines(): + if ": " not in line: + continue + key, value = line.split(": ", 1) + if key not in { + "job_id", + "state", + "returncode", + "pid", + "created_at", + "started_at", + "finished_at", + "job_dir", + "log_path", + "command", + }: + continue + if key in {"returncode", "pid"} and value: + try: + fields[key] = int(value) + except ValueError: + fields[key] = value + else: + fields[key] = value or None + return fields + + def _default_screenshot_path(device: DeviceConfig, label: str) -> str: timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") return f"{_device_home_path(device)}/Pictures/Screenshots/{label}-{timestamp}.png" @@ -1752,16 +2169,30 @@ def _spec_device_install_rpm() -> dict[str, Any]: return { "name": "sailfish_device_install_rpm", "title": "Install Device RPM", - "description": "Copy a local RPM to a Sailfish OS device and install it.", + "description": "Copy one or more local RPMs to a Sailfish OS device and install them.", "inputSchema": _object_schema( { "device": _device_prop(), - "rpm_path": {"type": "string"}, - "remote_path": {"type": "string"}, + "rpm_path": { + "type": "string", + "description": "Single local RPM path. Use rpm_paths for dependency sets.", + }, + "rpm_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Local RPM paths to copy and install in one transaction.", + }, + "remote_path": { + "type": "string", + "description": "Remote file path override for a single rpm_path.", + }, + "remote_dir": { + "type": "string", + "description": "Remote directory for copied RPMs. Created when missing.", + }, "installer": {"type": "string", "enum": ["pkcon", "rpm"], "default": "pkcon"}, "timeout": _timeout_prop(180), - }, - ["rpm_path"], + } ), "annotations": _mutating_annotations("Install Device RPM"), } @@ -1871,6 +2302,98 @@ def _spec_build_status() -> dict[str, Any]: } +def _spec_android_build_hosts() -> dict[str, Any]: + return { + "name": "sailfish_android_build_hosts", + "title": "Android Build Hosts", + "description": "List configured remote Android/AppSupport build hosts.", + "inputSchema": _object_schema({}), + "annotations": _read_only_annotations("Android Build Hosts"), + } + + +def _spec_android_build() -> dict[str, Any]: + return { + "name": "sailfish_android_build", + "title": "Start Android Build", + "description": ( + "Start a remote Android/AppSupport build under nohup on the configured " + "build host; project_dir must be configured for the host or supplied." + ), + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "project_dir": { + "type": "string", + "description": "Remote Android tree. Defaults to the host config.", + }, + "state_dir": { + "type": "string", + "description": "Remote directory where job state and logs are stored.", + }, + "shell_command": { + "type": "string", + "description": ( + "Build command run from project_dir, for example " + "'source build/envsetup.sh && lunch ... && m ...'." + ), + }, + "job_id": { + "type": "string", + "description": "Optional stable job id; autogenerated when omitted.", + }, + "shell": { + "type": "string", + "enum": ["bash", "sh"], + "default": "bash", + }, + "timeout": _timeout_prop(60), + }, + ["shell_command"], + ), + "annotations": _mutating_annotations("Start Android Build"), + } + + +def _spec_android_build_status() -> dict[str, Any]: + return { + "name": "sailfish_android_build_status", + "title": "Android Build Status", + "description": ( + "List remote Android/AppSupport build jobs or read one job's status and " + "recent log output." + ), + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "job_id": { + "type": "string", + "description": "Job id returned by sailfish_android_build. Omit to list jobs.", + }, + "state_dir": { + "type": "string", + "description": "Remote directory where job state and logs are stored.", + }, + "lines": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 80, + "description": "Number of trailing log lines to include.", + }, + "timeout": _timeout_prop(30), + } + ), + "annotations": _read_only_annotations("Android Build Status"), + } + + def _spec_sdk_refresh_metadata() -> dict[str, Any]: return { "name": "sailfish_sdk_refresh_metadata", |
