From b621c4aca74ff5bcbc73a161523af29a2e72b590 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Apr 2026 10:44:37 +0800 Subject: [PATCH] feat: bootstrap Lingma from latest marketplace VSIX --- .env.example | 12 +++ .gitignore | 1 + Dockerfile | 2 +- README.md | 19 +++- app/bootstrap_lingma.py | 199 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 2 +- 6 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 app/bootstrap_lingma.py diff --git a/.env.example b/.env.example index 76aed6d..e5ef1f8 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,18 @@ API_KEYS=sk-your-api-key # 容器内 Lingma 二进制路径 LINGMA_BIN=/app/bin/Lingma +# Lingma 获取方式:marketplace 或 vsix +LINGMA_SOURCE_TYPE=marketplace +# Marketplace 发布者 +LINGMA_MARKETPLACE_PUBLISHER=Alibaba-Cloud +# Marketplace 扩展名 +LINGMA_MARKETPLACE_EXTENSION=tongyi-lingma +# VSIX 下载地址(最新优先) +LINGMA_VSIX_URL=https://tongyi-code.oss-cn-hangzhou.aliyuncs.com/vscode/tongyi-lingma-latest.vsix +# 启动时总是尝试从 VSIX 刷新二进制 +LINGMA_BOOTSTRAP_ALWAYS=true +# 强制刷新(true 时忽略本地缓存) +LINGMA_FORCE_REFRESH=false # Lingma 工作目录(登录/会话数据) LINGMA_WORK_DIR=/root/.lingma/vscode/sharedClientCache # Lingma WebSocket 端口 diff --git a/.gitignore b/.gitignore index 0230b89..a9af95d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc bin/ +runtime-bin/ diff --git a/Dockerfile b/Dockerfile index 96e9a2f..572e868 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,4 @@ COPY app /app/app EXPOSE 8317 -CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8317}"] +CMD ["sh", "-c", "python /app/app/bootstrap_lingma.py && uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8317}"] diff --git a/README.md b/README.md index bd673a3..2fcbfb5 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,14 @@ ## 1. 准备目录 ```bash -mkdir -p bin -cp ../Lingma bin/Lingma -chmod +x bin/Lingma +mkdir -p runtime-bin ``` +说明: + +- 启动时会自动获取最新插件并提取 `Lingma` 到 `runtime-bin`。 +- 默认通过 VSCode Marketplace 查询最新版本,再下载对应 VSIX。 + ## 2. 配置环境变量 ```bash @@ -41,6 +44,12 @@ cp .env.example .env - `PORT`:网关监听端口(外部调用端口) - `API_KEYS`:Bearer Key,多个用逗号分隔 - `LINGMA_BIN`:容器内 Lingma 路径 +- `LINGMA_SOURCE_TYPE`:二进制来源(`marketplace`/`vsix`) +- `LINGMA_MARKETPLACE_PUBLISHER`:Marketplace 发布者 +- `LINGMA_MARKETPLACE_EXTENSION`:Marketplace 扩展名 +- `LINGMA_VSIX_URL`:VSIX 下载地址(最新优先) +- `LINGMA_BOOTSTRAP_ALWAYS`:启动时是否总尝试刷新 Lingma +- `LINGMA_FORCE_REFRESH`:是否强制刷新(忽略本地缓存) - `LINGMA_WORK_DIR`:Lingma 工作目录(登录与会话数据) - `LINGMA_SOCKET_PORT`:Lingma 本地 WS 端口 - `LINGMA_STARTUP_TIMEOUT`:Lingma 启动等待秒数 @@ -62,6 +71,10 @@ PORT=8317 API_KEYS=sk-your-api-key LINGMA_USERNAME=your-username LINGMA_PASSWORD=your-password +LINGMA_SOURCE_TYPE=marketplace +LINGMA_MARKETPLACE_PUBLISHER=Alibaba-Cloud +LINGMA_MARKETPLACE_EXTENSION=tongyi-lingma +LINGMA_VSIX_URL=https://tongyi-code.oss-cn-hangzhou.aliyuncs.com/vscode/tongyi-lingma-latest.vsix DEDICATED_DOMAIN_URL= ``` diff --git a/app/bootstrap_lingma.py b/app/bootstrap_lingma.py new file mode 100644 index 0000000..ecc2a86 --- /dev/null +++ b/app/bootstrap_lingma.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import io +import json +import os +import time +import urllib.request +import zipfile +from pathlib import Path + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _pick_nested_zip(vsix_zip: zipfile.ZipFile) -> str: + candidates = [ + n + for n in vsix_zip.namelist() + if n.startswith("extension/dist/bin/") and n.endswith(".zip") and "lingma-" in n + ] + if not candidates: + raise RuntimeError("No lingma-*.zip found in VSIX") + candidates.sort() + return candidates[-1] + + +def _pick_lingma_binary_path(inner_zip: zipfile.ZipFile) -> str: + # Prefer linux amd64 binary path. + names = inner_zip.namelist() + preferred = [n for n in names if n.endswith("x86_64_linux/Lingma")] + if preferred: + return preferred[0] + fallback = [n for n in names if n.endswith("/Lingma") or n == "Lingma"] + if fallback: + return fallback[0] + raise RuntimeError("Lingma binary not found inside nested zip") + + +def _query_marketplace_latest_vsix(publisher: str, extension: str) -> tuple[str, str, dict]: + api = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" + payload = { + "filters": [ + { + "criteria": [ + {"filterType": 7, "value": f"{publisher}.{extension}"}, + {"filterType": 8, "value": "Microsoft.VisualStudio.Code"}, + ], + "pageNumber": 1, + "pageSize": 1, + "sortBy": 0, + "sortOrder": 0, + } + ], + "assetTypes": [], + "flags": 950, + } + req = urllib.request.Request(api, data=json.dumps(payload).encode("utf-8"), method="POST") + req.add_header("accept", "application/json;api-version=3.0-preview.1") + req.add_header("content-type", "application/json") + req.add_header("x-market-client-id", "VSCode 1.115.0") + with urllib.request.urlopen(req, timeout=30) as r: + body = json.loads(r.read().decode("utf-8", errors="ignore")) + + exts = body.get("results", [{}])[0].get("extensions", []) + if not exts: + raise RuntimeError("No extension found from marketplace") + ver_obj = exts[0].get("versions", [{}])[0] + version = ver_obj.get("version", "") + files = ver_obj.get("files", []) + vsix_url = "" + for f in files: + if f.get("assetType") == "Microsoft.VisualStudio.Services.VSIXPackage": + vsix_url = f.get("source", "") + break + if not vsix_url: + if not version: + raise RuntimeError("No version/vsix url found from marketplace") + vsix_url = ( + "https://marketplace.visualstudio.com/_apis/public/gallery/" + f"publishers/{publisher}/vsextensions/{extension}/{version}/vspackage" + ) + return vsix_url, version, {"publisher": publisher, "extension": extension, "version": version} + + +def bootstrap_from_vsix() -> None: + lingma_bin = Path(os.getenv("LINGMA_BIN", "/app/bin/Lingma")) + source_type = os.getenv("LINGMA_SOURCE_TYPE", "marketplace").strip().lower() + vsix_url = os.getenv( + "LINGMA_VSIX_URL", + "https://tongyi-code.oss-cn-hangzhou.aliyuncs.com/vscode/tongyi-lingma-latest.vsix", + ).strip() + mp_publisher = os.getenv("LINGMA_MARKETPLACE_PUBLISHER", "Alibaba-Cloud").strip() + mp_extension = os.getenv("LINGMA_MARKETPLACE_EXTENSION", "tongyi-lingma").strip() + always_refresh = _bool_env("LINGMA_BOOTSTRAP_ALWAYS", True) + force_refresh = _bool_env("LINGMA_FORCE_REFRESH", False) + + if source_type not in {"vsix", "marketplace"}: + print(f"[bootstrap] skip: LINGMA_SOURCE_TYPE={source_type}") + return + + marker_path = lingma_bin.parent / ".lingma-bootstrap.json" + old_marker = {} + if marker_path.exists(): + try: + old_marker = json.loads(marker_path.read_text(encoding="utf-8", errors="ignore")) + except Exception: + old_marker = {} + + resolved_url = vsix_url + resolved_version = "" + source_meta = {"source": source_type} + if source_type == "marketplace": + try: + resolved_url, resolved_version, source_meta = _query_marketplace_latest_vsix( + mp_publisher, mp_extension + ) + print( + f"[bootstrap] marketplace latest: {mp_publisher}.{mp_extension} " + f"version={resolved_version}" + ) + except Exception as exc: + print(f"[bootstrap] marketplace query failed, fallback to LINGMA_VSIX_URL: {exc}") + resolved_url = vsix_url + + if ( + lingma_bin.exists() + and not force_refresh + and ( + (not always_refresh) + or (resolved_version and old_marker.get("version") == resolved_version) + ) + ): + os.chmod(lingma_bin, 0o755) + print(f"[bootstrap] reuse existing Lingma: {lingma_bin}") + return + + tmp_dir = Path("/tmp/lingma-bootstrap") + tmp_dir.mkdir(parents=True, exist_ok=True) + vsix_path = tmp_dir / "tongyi-lingma-latest.vsix" + + print(f"[bootstrap] downloading VSIX: {resolved_url}") + try: + with urllib.request.urlopen(resolved_url, timeout=120) as r: + data = r.read() + vsix_path.write_bytes(data) + except Exception as exc: + if lingma_bin.exists(): + print(f"[bootstrap] download failed, fallback to existing Lingma: {exc}") + os.chmod(lingma_bin, 0o755) + return + raise RuntimeError(f"Failed to download VSIX: {exc}") + + try: + with zipfile.ZipFile(vsix_path, "r") as vsix_zip: + nested_zip_name = _pick_nested_zip(vsix_zip) + nested_zip_bytes = vsix_zip.read(nested_zip_name) + + with zipfile.ZipFile(io.BytesIO(nested_zip_bytes), "r") as inner_zip: + lingma_member = _pick_lingma_binary_path(inner_zip) + lingma_bytes = inner_zip.read(lingma_member) + + lingma_bin.parent.mkdir(parents=True, exist_ok=True) + lingma_bin.write_bytes(lingma_bytes) + os.chmod(lingma_bin, 0o755) + + marker = { + "source": source_type, + "url": resolved_url, + "version": resolved_version, + "downloaded_at": int(time.time()), + "nested_zip": nested_zip_name, + "member": lingma_member, + "size": len(lingma_bytes), + } + marker.update(source_meta) + (lingma_bin.parent / ".lingma-bootstrap.json").write_text( + json.dumps(marker, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"[bootstrap] Lingma ready: {lingma_bin} ({len(lingma_bytes)} bytes)") + except Exception as exc: + if lingma_bin.exists(): + print(f"[bootstrap] extraction failed, fallback to existing Lingma: {exc}") + os.chmod(lingma_bin, 0o755) + return + raise RuntimeError(f"Failed to extract Lingma from VSIX: {exc}") + + +def main() -> int: + bootstrap_from_vsix() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docker-compose.yml b/docker-compose.yml index 7d9f6d3..a92ddb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,6 @@ services: ports: - "${PORT:-8317}:${PORT:-8317}" volumes: - - ./bin:/app/bin:ro + - ./runtime-bin:/app/bin - /root/.lingma:/root/.lingma restart: unless-stopped