Files
cc-connect/core/web_manager.go
T
Claude 7461a64c60 feat: add /web command for one-click web admin install and npm publish config
- Add /web install|upgrade|status|uninstall slash command (privileged)
- Auto-install cc-connect-web via npm into ~/.cc-connect/web/
- Auto-configure [bridge] and [management] in config.toml with random tokens
- Management server serves SPA static files from dist/ with fallback to index.html
- Add config.EnableWebAdmin() for atomic bridge+management setup
- Add core/web_manager.go with npm registry integration
- Prepare web/package.json for npm publish as cc-connect-web (files: dist/)
- Add i18n translations for all /web messages (en, zh, zh-TW, ja, es)

Made-with: Cursor
2026-03-29 09:59:06 +08:00

167 lines
4.5 KiB
Go

package core
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
npmPackageName = "cc-connect-web"
npmRegistryURL = "https://registry.npmjs.org"
defaultMgmtPort = 9820
defaultBridgePort = 9810
)
// WebInstallDir returns the directory where cc-connect-web is installed.
// Typically ~/.cc-connect/web/
func WebInstallDir(dataDir string) string {
return filepath.Join(dataDir, "web")
}
// WebDistDir returns the path to the built static files.
func WebDistDir(dataDir string) string {
return filepath.Join(WebInstallDir(dataDir), "node_modules", npmPackageName, "dist")
}
// WebInstalledVersion reads the installed version from node_modules.
func WebInstalledVersion(dataDir string) string {
pkgJSON := filepath.Join(WebInstallDir(dataDir), "node_modules", npmPackageName, "package.json")
data, err := os.ReadFile(pkgJSON)
if err != nil {
return ""
}
var pkg struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &pkg) != nil {
return ""
}
return pkg.Version
}
// WebLatestVersion fetches the latest version from npm registry.
func WebLatestVersion() (string, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(npmRegistryURL + "/" + npmPackageName + "/latest")
if err != nil {
return "", fmt.Errorf("fetch npm registry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("npm registry returned %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("read npm response: %w", err)
}
var pkg struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &pkg); err != nil {
return "", fmt.Errorf("parse npm response: %w", err)
}
return pkg.Version, nil
}
// WebInstall installs cc-connect-web via npm into the data directory.
// Returns the installed version and any error.
func WebInstall(dataDir string) (string, error) {
dir := WebInstallDir(dataDir)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create web dir: %w", err)
}
pkgJSON := filepath.Join(dir, "package.json")
if _, err := os.Stat(pkgJSON); os.IsNotExist(err) {
if err := os.WriteFile(pkgJSON, []byte(`{"private":true}`), 0644); err != nil {
return "", fmt.Errorf("create package.json: %w", err)
}
}
npmBin := findNpmBin()
if npmBin == "" {
return "", fmt.Errorf("npm not found in PATH — please install Node.js (https://nodejs.org)")
}
cmd := exec.Command(npmBin, "install", npmPackageName+"@latest", "--save")
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("npm install failed: %w\n%s", err, strings.TrimSpace(string(output)))
}
version := WebInstalledVersion(dataDir)
if version == "" {
return "", fmt.Errorf("install succeeded but version not found")
}
slog.Info("web admin installed", "version", version, "dir", dir)
return version, nil
}
// WebUpgrade upgrades cc-connect-web to the latest version.
// Returns (oldVersion, newVersion, error).
func WebUpgrade(dataDir string) (string, string, error) {
oldVersion := WebInstalledVersion(dataDir)
if oldVersion == "" {
v, err := WebInstall(dataDir)
return "", v, err
}
dir := WebInstallDir(dataDir)
npmBin := findNpmBin()
if npmBin == "" {
return oldVersion, "", fmt.Errorf("npm not found in PATH")
}
cmd := exec.Command(npmBin, "install", npmPackageName+"@latest", "--save")
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return oldVersion, "", fmt.Errorf("npm update failed: %w\n%s", err, strings.TrimSpace(string(output)))
}
newVersion := WebInstalledVersion(dataDir)
slog.Info("web admin upgraded", "from", oldVersion, "to", newVersion)
return oldVersion, newVersion, nil
}
// WebIsInstalled checks if cc-connect-web is installed.
func WebIsInstalled(dataDir string) bool {
return WebInstalledVersion(dataDir) != ""
}
// GenerateToken creates a random hex token.
func GenerateToken(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("cc-connect-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func findNpmBin() string {
if path, err := exec.LookPath("npm"); err == nil {
return path
}
for _, candidate := range []string{
"/usr/local/bin/npm",
"/usr/bin/npm",
filepath.Join(os.Getenv("HOME"), ".nvm/current/bin/npm"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}