Files
lingma-proxy-compose/internal/bootstrap/lingma.go
GitHub Actions a4cedecca6 Add Docker Compose Lingma bootstrap support
Package the proxy for Docker Compose deployments and add Lingma bootstrap, session restore, and runtime status support for containerized remote usage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:56:05 +08:00

477 lines
13 KiB
Go

package bootstrap
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
type Config struct {
Enabled bool
SourceType string
VSIXURL string
MarketplacePublisher string
MarketplaceExtension string
OutputDir string
BinaryPath string
AlwaysRefresh bool
ForceRefresh bool
HTTPTimeout time.Duration
}
type Result struct {
Enabled bool `json:"enabled"`
Source string `json:"source,omitempty"`
Version string `json:"version,omitempty"`
URL string `json:"url,omitempty"`
BinaryPath string `json:"binary_path,omitempty"`
ReleaseDir string `json:"release_dir,omitempty"`
MarkerPath string `json:"marker_path,omitempty"`
Downloaded bool `json:"downloaded"`
ReusedExisting bool `json:"reused_existing"`
Message string `json:"message,omitempty"`
}
type marker struct {
Source string `json:"source"`
URL string `json:"url"`
Version string `json:"version"`
DownloadedAt int64 `json:"downloaded_at"`
NestedZip string `json:"nested_zip"`
Member string `json:"member"`
ReleaseRoot string `json:"release_root"`
Size int `json:"size"`
Publisher string `json:"publisher,omitempty"`
Extension string `json:"extension,omitempty"`
}
func Ensure(cfg Config) (Result, error) {
result := Result{Enabled: cfg.Enabled}
if !cfg.Enabled {
result.Message = "bootstrap disabled"
return result, nil
}
cfg = normalize(cfg)
if cfg.BinaryPath == "" {
return result, fmt.Errorf("bootstrap binary path is required")
}
if err := os.MkdirAll(filepath.Dir(cfg.BinaryPath), 0o755); err != nil {
return result, err
}
if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil {
return result, err
}
resolvedURL := strings.TrimSpace(cfg.VSIXURL)
version := ""
if cfg.SourceType == "marketplace" {
url, ver, err := queryMarketplaceLatestVSIX(cfg)
if err == nil {
resolvedURL = url
version = ver
} else if resolvedURL == "" {
return result, err
}
}
if resolvedURL == "" {
return result, fmt.Errorf("bootstrap VSIX URL is empty")
}
result.Source = cfg.SourceType
result.URL = resolvedURL
result.Version = version
result.BinaryPath = cfg.BinaryPath
result.MarkerPath = filepath.Join(filepath.Dir(cfg.BinaryPath), ".lingma-bootstrap.json")
oldMarker := readMarker(result.MarkerPath)
releaseDir := releaseDirForOutput(cfg.OutputDir, oldMarker.ReleaseRoot)
result.ReleaseDir = releaseDir
ready := hasRequiredAssets(releaseDir)
if fileExists(cfg.BinaryPath) && ready && !cfg.ForceRefresh {
if !cfg.AlwaysRefresh || (version != "" && oldMarker.Version == version) {
if err := os.Chmod(cfg.BinaryPath, 0o755); err != nil {
return result, err
}
result.ReusedExisting = true
result.Message = "reused existing Lingma binary"
return result, nil
}
}
vsixBytes, err := downloadVSIX(resolvedURL, cfg.HTTPTimeout)
if err != nil {
if fileExists(cfg.BinaryPath) {
result.ReusedExisting = true
result.Message = fmt.Sprintf("download failed, reused existing Lingma: %v", err)
return result, os.Chmod(cfg.BinaryPath, 0o755)
}
return result, err
}
result.Downloaded = true
nestedName, nestedBytes, err := extractNestedZip(vsixBytes)
if err != nil {
return result, err
}
memberName, binaryBytes, releaseRoot, releaseFiles, err := extractRelease(nestedBytes)
if err != nil {
return result, err
}
releaseDir = releaseDirForOutput(cfg.OutputDir, releaseRoot)
result.ReleaseDir = releaseDir
if err := os.RemoveAll(releaseDir); err != nil {
return result, err
}
if err := writeReleaseFiles(releaseDir, releaseRoot, releaseFiles); err != nil {
return result, err
}
if err := os.WriteFile(cfg.BinaryPath, binaryBytes, 0o755); err != nil {
return result, err
}
if !hasRequiredAssets(releaseDir) {
return result, fmt.Errorf("extension assets missing after extraction under %s", releaseDir)
}
mk := marker{
Source: cfg.SourceType,
URL: resolvedURL,
Version: version,
DownloadedAt: time.Now().Unix(),
NestedZip: nestedName,
Member: memberName,
ReleaseRoot: releaseRoot,
Size: len(binaryBytes),
Publisher: cfg.MarketplacePublisher,
Extension: cfg.MarketplaceExtension,
}
if err := writeMarker(result.MarkerPath, mk); err != nil {
return result, err
}
result.Message = fmt.Sprintf("downloaded Lingma %s", versionOrUnknown(version))
return result, nil
}
type marketplaceResponse struct {
Results []struct {
Extensions []struct {
Versions []struct {
Version string `json:"version"`
Files []struct {
AssetType string `json:"assetType"`
Source string `json:"source"`
} `json:"files"`
} `json:"versions"`
} `json:"extensions"`
} `json:"results"`
}
type zipEntry struct {
Name string
Data []byte
Mode os.FileMode
}
func normalize(cfg Config) Config {
cfg.SourceType = strings.ToLower(strings.TrimSpace(cfg.SourceType))
if cfg.SourceType == "" {
cfg.SourceType = "marketplace"
}
if cfg.OutputDir == "" {
cfg.OutputDir = filepath.Join(filepath.Dir(cfg.BinaryPath), "release")
}
if cfg.HTTPTimeout <= 0 {
cfg.HTTPTimeout = 30 * time.Second
}
if strings.TrimSpace(cfg.MarketplacePublisher) == "" {
cfg.MarketplacePublisher = "Alibaba-Cloud"
}
if strings.TrimSpace(cfg.MarketplaceExtension) == "" {
cfg.MarketplaceExtension = "tongyi-lingma"
}
if strings.TrimSpace(cfg.VSIXURL) == "" {
cfg.VSIXURL = "https://tongyi-code.oss-cn-hangzhou.aliyuncs.com/vscode/tongyi-lingma-latest.vsix"
}
return cfg
}
func queryMarketplaceLatestVSIX(cfg Config) (string, string, error) {
payload := map[string]any{
"filters": []any{map[string]any{
"criteria": []any{
map[string]any{"filterType": 7, "value": cfg.MarketplacePublisher + "." + cfg.MarketplaceExtension},
map[string]any{"filterType": 8, "value": "Microsoft.VisualStudio.Code"},
},
"pageNumber": 1,
"pageSize": 1,
"sortBy": 0,
"sortOrder": 0,
}},
"assetTypes": []any{},
"flags": 950,
}
body, err := json.Marshal(payload)
if err != nil {
return "", "", err
}
req, err := http.NewRequest(http.MethodPost, "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("accept", "application/json;api-version=3.0-preview.1")
req.Header.Set("content-type", "application/json")
req.Header.Set("x-market-client-id", "VSCode 1.115.0")
client := &http.Client{Timeout: cfg.HTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", "", fmt.Errorf("marketplace query status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var parsed marketplaceResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", "", err
}
if len(parsed.Results) == 0 || len(parsed.Results[0].Extensions) == 0 || len(parsed.Results[0].Extensions[0].Versions) == 0 {
return "", "", fmt.Errorf("no extension found from marketplace")
}
version := parsed.Results[0].Extensions[0].Versions[0].Version
for _, file := range parsed.Results[0].Extensions[0].Versions[0].Files {
if file.AssetType == "Microsoft.VisualStudio.Services.VSIXPackage" && strings.TrimSpace(file.Source) != "" {
return file.Source, version, nil
}
}
if version == "" {
return "", "", fmt.Errorf("no version/vsix url found from marketplace")
}
fallback := fmt.Sprintf("https://marketplace.visualstudio.com/_apis/public/gallery/publishers/%s/vsextensions/%s/%s/vspackage", cfg.MarketplacePublisher, cfg.MarketplaceExtension, version)
return fallback, version, nil
}
func downloadVSIX(url string, timeout time.Duration) ([]byte, error) {
client := &http.Client{Timeout: timeout}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("download VSIX: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("download VSIX status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return data, nil
}
func extractNestedZip(vsix []byte) (string, []byte, error) {
reader, err := zip.NewReader(bytes.NewReader(vsix), int64(len(vsix)))
if err != nil {
return "", nil, err
}
candidates := make([]*zip.File, 0)
for _, file := range reader.File {
name := file.Name
if strings.HasPrefix(name, "extension/dist/bin/") && strings.HasSuffix(name, ".zip") && strings.Contains(name, "lingma-") {
candidates = append(candidates, file)
}
}
if len(candidates) == 0 {
return "", nil, fmt.Errorf("no lingma-*.zip found in VSIX")
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name })
picked := candidates[len(candidates)-1]
rc, err := picked.Open()
if err != nil {
return "", nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return "", nil, err
}
return picked.Name, data, nil
}
func extractRelease(inner []byte) (string, []byte, string, []zipEntry, error) {
reader, err := zip.NewReader(bytes.NewReader(inner), int64(len(inner)))
if err != nil {
return "", nil, "", nil, err
}
binaryPath := pickLingmaBinaryPath(reader.File)
if binaryPath == "" {
return "", nil, "", nil, fmt.Errorf("Lingma binary not found inside nested zip")
}
releaseRoot := inferReleaseRoot(binaryPath)
entries := make([]zipEntry, 0, len(reader.File))
var binaryBytes []byte
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
if releaseRoot != "" && !strings.HasPrefix(file.Name, releaseRoot+"/") {
continue
}
rc, err := file.Open()
if err != nil {
return "", nil, "", nil, err
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return "", nil, "", nil, err
}
entries = append(entries, zipEntry{Name: file.Name, Data: data, Mode: file.Mode()})
if file.Name == binaryPath {
binaryBytes = data
}
}
if len(binaryBytes) == 0 {
return "", nil, "", nil, fmt.Errorf("Lingma binary bytes missing from nested zip")
}
return binaryPath, binaryBytes, releaseRoot, entries, nil
}
func pickLingmaBinaryPath(files []*zip.File) string {
preferredSuffix := platformBinarySuffix()
for _, file := range files {
if strings.HasSuffix(file.Name, preferredSuffix) {
return file.Name
}
}
for _, file := range files {
if strings.HasSuffix(file.Name, "/Lingma") || file.Name == "Lingma" {
return file.Name
}
}
return ""
}
func platformBinarySuffix() string {
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
return "x86_64_linux/Lingma"
}
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
return "arm64_linux/Lingma"
}
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
return "arm64_darwin/Lingma"
}
if runtime.GOOS == "darwin" {
return "x86_64_darwin/Lingma"
}
if runtime.GOOS == "windows" && runtime.GOARCH == "arm64" {
return "arm64_windows/Lingma"
}
if runtime.GOOS == "windows" {
return "x86_64_windows/Lingma"
}
return "/Lingma"
}
func inferReleaseRoot(memberPath string) string {
parts := strings.Split(memberPath, "/")
platforms := []string{"x86_64_linux", "arm64_linux", "x86_64_darwin", "arm64_darwin", "x86_64_windows", "arm64_windows"}
for _, platform := range platforms {
for i, part := range parts {
if part == platform && i > 0 {
return strings.Join(parts[:i], "/")
}
}
}
if len(parts) > 1 {
return parts[0]
}
return ""
}
func writeReleaseFiles(releaseDir string, releaseRoot string, files []zipEntry) error {
prefix := ""
if releaseRoot != "" {
prefix = releaseRoot + "/"
}
for _, entry := range files {
rel := entry.Name
if prefix != "" {
rel = strings.TrimPrefix(rel, prefix)
}
if rel == "" {
continue
}
dest := filepath.Join(releaseDir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
mode := entry.Mode
if mode == 0 {
mode = 0o644
}
if strings.HasSuffix(rel, "/Lingma") || filepath.Base(rel) == "Lingma" {
mode = 0o755
}
if err := os.WriteFile(dest, entry.Data, mode); err != nil {
return err
}
}
return nil
}
func releaseDirForOutput(outputDir string, releaseRoot string) string {
name := strings.TrimSpace(releaseRoot)
if name == "" {
return outputDir
}
return filepath.Join(outputDir, filepath.FromSlash(name))
}
func hasRequiredAssets(releaseDir string) bool {
info, err := os.Stat(filepath.Join(releaseDir, "extension", "main.js"))
return err == nil && !info.IsDir()
}
func readMarker(path string) marker {
body, err := os.ReadFile(path)
if err != nil {
return marker{}
}
var mk marker
if err := json.Unmarshal(body, &mk); err != nil {
return marker{}
}
return mk
}
func writeMarker(path string, mk marker) error {
body, err := json.MarshalIndent(mk, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, body, 0o644)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func versionOrUnknown(version string) string {
if strings.TrimSpace(version) == "" {
return "unknown version"
}
return version
}