refactor: unify into single muyue binary with embedded desktop mode
All checks were successful
Beta Release / beta (push) Successful in 37s

- Merge muyue + muyue-desktop into one binary (13MB)
- `muyue` starts TUI, `muyue desktop` launches web UI in browser
- Move frontend from cmd/muyue-desktop/frontend/ to web/ (standard Go layout)
- Add web/embed.go with //go:embed all:dist for frontend assets
- Add internal/desktop/ package (server, browser open, SPA routing, signals)
- Split internal/api/api.go into server.go + handlers.go
- Add internal/desktop/desktop.go with SPA fallback and --port/--no-open flags
- Clean package.json: remove unused @xterm/xterm, switch to ESM
- Fix vite.config.js proxy to use port 8095 for dev mode
- Add Makefile targets: frontend, desktop, dev-desktop
- Update all CI workflows: single binary build, web/ paths
- Remove cmd/muyue-desktop/ entirely

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-21 21:04:47 +02:00
parent 097cf40ccd
commit 34636056da
27 changed files with 317 additions and 330 deletions

View File

@@ -35,8 +35,8 @@ jobs:
- name: Cache Node modules - name: Cache Node modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: cmd/muyue-desktop/frontend/node_modules path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('cmd/muyue-desktop/frontend/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
@@ -45,7 +45,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: | run: |
cd cmd/muyue-desktop/frontend cd web
npm ci npm ci
npm run build npm run build
@@ -68,7 +68,7 @@ jobs:
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
echo "Building beta release: ${VERSION}" echo "Building beta release: ${VERSION}"
- name: Build CLI (all platforms) - name: Build (all platforms)
run: | run: |
mkdir -p dist mkdir -p dist
VERSION=${{ steps.version.outputs.version }} VERSION=${{ steps.version.outputs.version }}
@@ -80,12 +80,6 @@ jobs:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Build Desktop (linux amd64)
run: |
VERSION=${{ steps.version.outputs.version }}
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-linux-amd64 ./cmd/muyue-desktop/
- name: Package archives - name: Package archives
run: | run: |
cd dist cd dist
@@ -94,10 +88,9 @@ jobs:
tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64 tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64
tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64 tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64
tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64 tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64
tar czf muyue-desktop-linux-amd64.tar.gz muyue-desktop-linux-amd64
zip muyue-windows-amd64.zip muyue-windows-amd64.exe zip muyue-windows-amd64.zip muyue-windows-amd64.exe
zip muyue-windows-arm64.zip muyue-windows-arm64.exe zip muyue-windows-arm64.zip muyue-windows-arm64.exe
rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe muyue-desktop-linux-amd64 rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe
- name: Generate changelog - name: Generate changelog
id: changelog id: changelog

View File

@@ -35,8 +35,8 @@ jobs:
- name: Cache Node modules - name: Cache Node modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: cmd/muyue-desktop/frontend/node_modules path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('cmd/muyue-desktop/frontend/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
@@ -45,7 +45,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: | run: |
cd cmd/muyue-desktop/frontend cd web
npm ci npm ci
npm run build npm run build
@@ -64,7 +64,7 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}" echo "Building stable release: ${VERSION}"
- name: Build CLI (all platforms) - name: Build (all platforms)
run: | run: |
mkdir -p dist mkdir -p dist
LDFLAGS="-s -w" LDFLAGS="-s -w"
@@ -75,16 +75,6 @@ jobs:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Build Desktop (all platforms)
run: |
LDFLAGS="-s -w"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-linux-amd64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-linux-arm64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-darwin-amd64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-darwin-arm64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-windows-amd64.exe ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-windows-arm64.exe ./cmd/muyue-desktop/
- name: Package archives - name: Package archives
run: | run: |
cd dist cd dist
@@ -93,15 +83,9 @@ jobs:
tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64 tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64
tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64 tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64
tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64 tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64
tar czf muyue-desktop-linux-amd64.tar.gz muyue-desktop-linux-amd64
tar czf muyue-desktop-linux-arm64.tar.gz muyue-desktop-linux-arm64
tar czf muyue-desktop-darwin-amd64.tar.gz muyue-desktop-darwin-amd64
tar czf muyue-desktop-darwin-arm64.tar.gz muyue-desktop-darwin-arm64
zip muyue-windows-amd64.zip muyue-windows-amd64.exe zip muyue-windows-amd64.zip muyue-windows-amd64.exe
zip muyue-windows-arm64.zip muyue-windows-arm64.exe zip muyue-windows-arm64.zip muyue-windows-arm64.exe
zip muyue-desktop-windows-amd64.zip muyue-desktop-windows-amd64.exe rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe
zip muyue-desktop-windows-arm64.zip muyue-desktop-windows-arm64.exe
rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe muyue-desktop-linux-amd64 muyue-desktop-linux-arm64 muyue-desktop-darwin-amd64 muyue-desktop-darwin-arm64 muyue-desktop-windows-amd64.exe muyue-desktop-windows-arm64.exe
- name: Generate changelog - name: Generate changelog
id: changelog id: changelog
@@ -135,16 +119,8 @@ jobs:
echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |" echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |"
echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |" echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |"
echo "" echo ""
echo "### Downloads (Desktop)" echo "The binary includes both CLI and Desktop modes."
echo "" echo "Run \`muyue\` for TUI, \`muyue desktop\` for web UI."
echo "| Platform | File |"
echo "|----------|------|"
echo "| Linux x86_64 | [muyue-desktop-linux-amd64.tar.gz](${DL_URL}/muyue-desktop-linux-amd64.tar.gz) |"
echo "| Linux ARM64 | [muyue-desktop-linux-arm64.tar.gz](${DL_URL}/muyue-desktop-linux-arm64.tar.gz) |"
echo "| macOS Intel | [muyue-desktop-darwin-amd64.tar.gz](${DL_URL}/muyue-desktop-darwin-amd64.tar.gz) |"
echo "| macOS Apple Silicon | [muyue-desktop-darwin-arm64.tar.gz](${DL_URL}/muyue-desktop-darwin-arm64.tar.gz) |"
echo "| Windows x86_64 | [muyue-desktop-windows-amd64.zip](${DL_URL}/muyue-desktop-windows-amd64.zip) |"
echo "| Windows ARM64 | [muyue-desktop-windows-arm64.zip](${DL_URL}/muyue-desktop-windows-arm64.zip) |"
echo "" echo ""
echo "### Install" echo "### Install"
echo "" echo ""

View File

@@ -33,8 +33,8 @@ jobs:
- name: Cache Node modules - name: Cache Node modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: cmd/muyue-desktop/frontend/node_modules path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('cmd/muyue-desktop/frontend/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
@@ -43,7 +43,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: | run: |
cd cmd/muyue-desktop/frontend cd web
npm ci npm ci
npm run build npm run build
@@ -56,5 +56,4 @@ jobs:
- name: Build - name: Build
run: | run: |
go build -o muyue ./cmd/muyue/ go build -o muyue ./cmd/muyue/
go build -o muyue-desktop ./cmd/muyue-desktop/
./muyue version ./muyue version

4
.gitignore vendored
View File

@@ -28,4 +28,6 @@ vendor/
# Config with secrets # Config with secrets
.muyue/ .muyue/
frontend/node_modules/
# Frontend (web/.gitignore handles specifics)
web/node_modules/

View File

@@ -3,10 +3,16 @@ GOBIN ?= $(GOPATH)/bin
BINARY = muyue BINARY = muyue
BUILD_DIR = . BUILD_DIR = .
GO = go GO = go
NODE ?= node
NPM ?= npm
WEB_DIR = web
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet .PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
build: frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
build: frontend
$(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/ $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/
install: build install: build
@@ -18,6 +24,8 @@ install-local: build
clean: clean:
rm -f $(BUILD_DIR)/$(BINARY) rm -f $(BUILD_DIR)/$(BINARY)
rm -rf $(WEB_DIR)/dist
rm -rf $(WEB_DIR)/node_modules
test: test:
$(GO) test ./... -v -count=1 $(GO) test ./... -v -count=1
@@ -31,6 +39,12 @@ vet:
run: build run: build
./$(BINARY) ./$(BINARY)
desktop: build
./$(BINARY) desktop
dev-desktop:
cd $(WEB_DIR) && $(NPM) run dev
scan: build scan: build
./$(BINARY) scan ./$(BINARY) scan
@@ -41,7 +55,7 @@ fmt:
lint: lint:
which golangci-lint > /dev/null 2>&1 && golangci-lint run || true which golangci-lint > /dev/null 2>&1 && golangci-lint run || true
build-all: build-all: frontend
GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/ GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/
GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/ GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/ GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/

View File

@@ -1,26 +0,0 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"xterm": "^5.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
}

View File

@@ -1,31 +0,0 @@
const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
export function useAPI() {
return {
getInfo: () => request('/info'),
getSystem: () => request('/system'),
getTools: () => request('/tools'),
getConfig: () => request('/config'),
getProviders: () => request('/providers'),
getSkills: () => request('/skills'),
getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
}
}

View File

@@ -1,116 +0,0 @@
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/muyue/muyue/internal/api"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/version"
)
//go:embed frontend/dist/*
var frontendFS embed.FS
func main() {
if len(os.Args) > 1 && os.Args[1] == "--help" {
fmt.Printf("%s Desktop v%s\n", version.Name, version.Version)
fmt.Println("Usage: muyue-desktop [options]")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" --help Show this help")
fmt.Println(" --port Specify port (default: auto)")
fmt.Println(" --no-open Don't open browser")
return
}
cfg := loadConfig()
srv := api.NewServer(cfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("Failed to bind: %v", err)
}
addr := listener.Addr().(*net.TCPAddr)
port := addr.Port
frontendDist, err := fs.Sub(frontendFS, "frontend/dist")
if err != nil {
log.Fatalf("Failed to load frontend: %v", err)
}
fileServer := http.FileServer(http.FS(frontendDist))
mux := http.NewServeMux()
mux.Handle("/api/", srv)
mux.Handle("/", fileServer)
go func() {
log.Printf("%s Desktop v%s", version.Name, version.Version)
log.Printf("Listening on http://127.0.0.1:%d", port)
if err := http.Serve(listener, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
noOpen := false
for _, arg := range os.Args[1:] {
if arg == "--no-open" {
noOpen = true
}
}
if !noOpen {
url := fmt.Sprintf("http://127.0.0.1:%d", port)
openBrowser(url)
log.Printf("Opened %s in browser", url)
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
}
func loadConfig() *config.MuyueConfig {
if !config.Exists() {
fmt.Println("No config found. Run `muyue setup` first.")
os.Exit(1)
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1)
}
return cfg
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch {
case commandExists("xdg-open"):
cmd = exec.Command("xdg-open", url)
case commandExists("open"):
cmd = exec.Command("open", url)
case commandExists("cmd"):
cmd = exec.Command("cmd", "/c", "start", url)
default:
fmt.Printf("Open manually: %s\n", url)
return
}
cmd.Start()
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
@@ -53,6 +54,8 @@ func handleCommand(args []string) {
runLSP(args[1:]) runLSP(args[1:])
case "mcp": case "mcp":
runMCP(args[1:]) runMCP(args[1:])
case "desktop":
runDesktop(args[1:])
case "skills": case "skills":
runSkills(args[1:]) runSkills(args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
@@ -79,6 +82,7 @@ Commands:
setup Run first-time setup wizard setup Run first-time setup wizard
config Show current configuration config Show current configuration
doctor Check that everything is properly configured doctor Check that everything is properly configured
desktop Launch desktop web UI (opens browser)
lsp [scan|install] Scan or install LSP servers lsp [scan|install] Scan or install LSP servers
mcp [config|scan] Configure MCP servers for Crush and Claude Code mcp [config|scan] Configure MCP servers for Crush and Claude Code
skills [list|generate|deploy|init|delete] Manage AI coding skills skills [list|generate|deploy|init|delete] Manage AI coding skills
@@ -118,6 +122,14 @@ func runTUI() {
} }
} }
func runDesktop(args []string) {
cfg := loadOrSetupConfig()
if err := desktop.Run(cfg, args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func loadOrSetupConfig() *config.MuyueConfig { func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() { if !config.Exists() {
fmt.Println("First time setup detected!") fmt.Println("First time setup detected!")

View File

@@ -4,9 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os/exec" "os/exec"
"strings"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
@@ -15,50 +13,6 @@ import (
"github.com/muyue/muyue/internal/version" "github.com/muyue/muyue/internal/version"
) )
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}
func writeJSON(w http.ResponseWriter, data interface{}) { func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
@@ -85,20 +39,19 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
}) })
} }
type ToolInfo struct { func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
type toolInfo struct {
Name string `json:"name"` Name string `json:"name"`
Installed bool `json:"installed"` Installed bool `json:"installed"`
Version string `json:"version"` Version string `json:"version"`
Path string `json:"path"` Path string `json:"path"`
} }
tools := make([]toolInfo, len(s.scanResult.Tools))
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
tools := make([]ToolInfo, len(s.scanResult.Tools))
for i, t := range s.scanResult.Tools { for i, t := range s.scanResult.Tools {
tools[i] = ToolInfo{ tools[i] = toolInfo{
Name: t.Name, Name: t.Name,
Installed: t.Installed, Installed: t.Installed,
Version: t.Version, Version: t.Version,
@@ -165,8 +118,7 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
writeError(w, "POST only", http.StatusMethodNotAllowed) writeError(w, "POST only", http.StatusMethodNotAllowed)
return return
} }
err := mcp.ConfigureAll(s.config) if err := mcp.ConfigureAll(s.config); err != nil {
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -222,11 +174,6 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
type TermResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed) writeError(w, "POST only", http.StatusMethodNotAllowed)
@@ -240,25 +187,27 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
return return
} }
if body.Command == "" { if body.Command == "" {
writeError(w, "no command", http.StatusBadRequest) writeError(w, "no command", http.StatusBadRequest)
return return
} }
shell := "/bin/sh" shell := "/bin/sh"
if sh := strings.TrimSpace(body.Command); sh != "" {
if s, err := exec.LookPath("bash"); err == nil { if s, err := exec.LookPath("bash"); err == nil {
shell = s shell = s
} }
}
cmd := exec.Command(shell, "-c", body.Command) cmd := exec.Command(shell, "-c", body.Command)
if body.Cwd != "" { if body.Cwd != "" {
cmd.Dir = body.Cwd cmd.Dir = body.Cwd
} }
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
result := TermResult{Output: string(out)}
type termResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
result := termResult{Output: string(out)}
if err != nil { if err != nil {
result.Error = err.Error() result.Error = err.Error()
} }

52
internal/api/server.go Normal file
View File

@@ -0,0 +1,52 @@
package api
import (
"net/http"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}

131
internal/desktop/desktop.go Normal file
View File

@@ -0,0 +1,131 @@
package desktop
import (
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/muyue/muyue/internal/api"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/web"
)
type options struct {
port int
noOpen bool
}
type option func(*options)
func WithPort(port int) option {
return func(o *options) { o.port = port }
}
func WithNoOpen(noOpen bool) option {
return func(o *options) { o.noOpen = noOpen }
}
func parseFlags(args []string) []option {
var opts []option
for _, arg := range args {
switch {
case arg == "--no-open":
opts = append(opts, WithNoOpen(true))
case strings.HasPrefix(arg, "--port="):
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
opts = append(opts, WithPort(p))
}
case arg == "--port":
// handled as prefix case
}
}
return opts
}
func Run(cfg *config.MuyueConfig, args []string) error {
o := options{}
for _, opt := range parseFlags(args) {
opt(&o)
}
log.Printf("%s Desktop v%s", version.Name, version.Version)
srv := api.NewServer(cfg)
frontendFS, err := fs.Sub(web.Assets, "dist")
if err != nil {
return fmt.Errorf("frontend assets: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/api/", srv)
mux.Handle("/", spaHandler(http.FileServer(http.FS(frontendFS))))
addr := fmt.Sprintf("127.0.0.1:%d", o.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind %s: %w", addr, err)
}
port := listener.Addr().(*net.TCPAddr).Port
go func() {
if err := http.Serve(listener, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
log.Printf("Listening on %s", url)
if !o.noOpen {
openBrowser(url)
log.Printf("Opened browser")
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
return nil
}
func spaHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path != "/" && !strings.Contains(path, ".") {
r.URL.Path = "/"
}
next.ServeHTTP(w, r)
})
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch {
case exists("xdg-open"):
cmd = exec.Command("xdg-open", url)
case exists("open"):
cmd = exec.Command("open", url)
case exists("cmd"):
cmd = exec.Command("cmd", "/c", "start", url)
default:
fmt.Printf("Open manually: %s\n", url)
return
}
_ = cmd.Start()
}
func exists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
!dist/.gitkeep
.vite/ .vite/

6
web/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed all:dist
var Assets embed.FS

View File

@@ -1,19 +1,13 @@
{ {
"name": "frontend", "name": "muyue-web",
"version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "muyue-web",
"version": "1.0.0",
"license": "ISC",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5"
"xterm": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
@@ -402,21 +396,6 @@
} }
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -981,13 +960,6 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
} }
} }
} }

18
web/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "muyue-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
}

31
web/src/api/client.js Normal file
View File

@@ -0,0 +1,31 @@
const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
const api = {
getInfo: () => request('/info'),
getSystem: () => request('/system'),
getTools: () => request('/tools'),
getConfig: () => request('/config'),
getProviders: () => request('/providers'),
getSkills: () => request('/skills'),
getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
}
export default api

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useAPI } from '../hooks/useAPI' import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes' import { getTheme, getThemeNames, applyTheme } from '../themes'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import Studio from './Studio' import Studio from './Studio'
@@ -21,7 +21,7 @@ export default function App() {
const [tools, setTools] = useState([]) const [tools, setTools] = useState([])
const [transition, setTransition] = useState(false) const [transition, setTransition] = useState(false)
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red') const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
const api = useAPI() // api is imported directly
useEffect(() => { useEffect(() => {
api.getInfo().then(setInfo).catch(() => {}) api.getInfo().then(setInfo).catch(() => {})

View File

@@ -8,8 +8,12 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
}, },
server: { server: {
port: 5173,
proxy: { proxy: {
'/api': 'http://127.0.0.1:0', '/api': {
target: 'http://127.0.0.1:8095',
changeOrigin: true,
},
}, },
}, },
}) })