refactor(web): embed frontend assets into binary via go:embed

Replace the npm-based install/upgrade workflow with compile-time
embedding. The web/dist/ directory is now embedded directly into the
binary using go:embed and registered through core's plugin registry
pattern (web/embed.go → core.RegisterWebAssets).

- Add web/embed.go (go:build !no_web) and web/embed_stub.go
- Add core/web_assets.go as the embedded FS registry
- Add cmd/cc-connect/plugin_web.go to import the web package
- Rewrite management.go static serving to use io/fs instead of disk
- Simplify /web command to setup + status (remove install/upgrade/uninstall)
- Clean up web_manager.go (remove npm logic, keep GenerateToken)
- Update Makefile with web build target and NO_WEB/build-noweb support
- Remove .npmignore and npm publishing fields from package.json

Binary size impact: ~1MB (16M → 17M). Use `no_web` tag to exclude.

Made-with: Cursor
This commit is contained in:
Claude committed 2026-03-31 08:33:20 +08:00
1 parent 6255fd5127
commit 433e7d63fc
13 files changed
+150 -413

No files matched your search

+14 -2
View File
@@ -35,6 +35,7 @@ PLATFORMS := \
ALL_AGENTS := acp claudecode codex cursor gemini iflow opencode pi qoder
ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line
ALL_EXTRAS := web
COMMA := ,
@@ -57,14 +58,25 @@ ifdef EXCLUDE
_EXCLUDE_TAGS += $(addprefix no_,$(subst $(COMMA), ,$(EXCLUDE)))
endif
ifdef NO_WEB
_EXCLUDE_TAGS += no_web
endif
_BUILD_TAGS := $(strip $(_EXCLUDE_TAGS))
_TAGS_FLAG := $(if $(_BUILD_TAGS),-tags '$(_BUILD_TAGS)',)
.PHONY: build run clean test test-fast test-full test-smoke test-e2e test-release test-performance pre-test lint release release-all
.PHONY: build run clean test test-fast test-full test-smoke test-e2e test-release test-performance pre-test lint release release-all web
build:
web:
@if [ ! -d web/node_modules ]; then cd web && npm install; fi
cd web && npm run build
build: web
go build $(_TAGS_FLAG) -ldflags "$(LDFLAGS)" -o $(APP) $(CMD)
build-noweb:
go build $(_TAGS_FLAG) -tags 'no_web' -ldflags "$(LDFLAGS)" -o $(APP) $(CMD)
run: build
./$(APP)
+10 -29
View File
@@ -541,27 +541,7 @@ func main() {
})
// Wire /web command callbacks
capturedDataDir := cfg.DataDir
engine.SetWebInstallFunc(func() (string, error) {
return core.WebInstall(capturedDataDir)
})
engine.SetWebUpgradeFunc(func() (string, string, error) {
return core.WebUpgrade(capturedDataDir)
})
engine.SetWebStatusFunc(func() (bool, string, string) {
installed := core.WebIsInstalled(capturedDataDir)
version := core.WebInstalledVersion(capturedDataDir)
url := ""
if cfg.Management.Enabled != nil && *cfg.Management.Enabled {
port := cfg.Management.Port
if port == 0 {
port = 9820
}
url = fmt.Sprintf("http://localhost:%d", port)
}
return installed, version, url
})
engine.SetWebEnableFunc(func() (int, string, bool, error) {
engine.SetWebSetupFunc(func() (int, string, bool, error) {
mgmtToken := core.GenerateToken(16)
bridgeToken := core.GenerateToken(16)
result, err := config.EnableWebAdmin(mgmtToken, bridgeToken)
@@ -570,9 +550,15 @@ func main() {
}
return result.ManagementPort, result.ManagementToken, !result.AlreadyEnabled, nil
})
engine.SetWebUninstallFunc(func() error {
dir := core.WebInstallDir(capturedDataDir)
return os.RemoveAll(dir)
engine.SetWebStatusFunc(func() string {
if cfg.Management.Enabled == nil || !*cfg.Management.Enabled {
return ""
}
port := cfg.Management.Port
if port == 0 {
port = 9820
}
return fmt.Sprintf("http://localhost:%d", port)
})
engines = append(engines, engine)
@@ -791,11 +777,6 @@ func main() {
}
return config.SaveGlobalSettings(u)
})
webDist := core.WebDistDir(cfg.DataDir)
if info, err := os.Stat(webDist); err == nil && info.IsDir() {
mgmtSrv.SetWebDistDir(webDist)
slog.Info("web admin serving static files", "dir", webDist)
}
mgmtSrv.Start()
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !no_web
package main
import _ "github.com/chenhg5/cc-connect/web"
+26 -77
View File
@@ -216,11 +216,8 @@ type Engine struct {
stopping bool
// /web command callbacks
webInstallFunc func() (version string, err error)
webUpgradeFunc func() (oldVer, newVer string, err error)
webStatusFunc func() (installed bool, version, url string)
webEnableFunc func() (mgmtPort int, token string, needRestart bool, err error)
webUninstallFunc func() error
webSetupFunc func() (port int, token string, needRestart bool, err error)
webStatusFunc func() (url string)
}
// workspaceInitFlow tracks a channel that is being onboarded to a workspace.
@@ -461,11 +458,8 @@ func (e *Engine) SetShowContextIndicator(show bool) {
e.showContextIndicator = show
}
func (e *Engine) SetWebInstallFunc(fn func() (string, error)) { e.webInstallFunc = fn }
func (e *Engine) SetWebUpgradeFunc(fn func() (string, string, error)) { e.webUpgradeFunc = fn }
func (e *Engine) SetWebStatusFunc(fn func() (bool, string, string)) { e.webStatusFunc = fn }
func (e *Engine) SetWebEnableFunc(fn func() (int, string, bool, error)) { e.webEnableFunc = fn }
func (e *Engine) SetWebUninstallFunc(fn func() error) { e.webUninstallFunc = fn }
func (e *Engine) SetWebSetupFunc(fn func() (int, string, bool, error)) { e.webSetupFunc = fn }
func (e *Engine) SetWebStatusFunc(fn func() string) { e.webStatusFunc = fn }
// SetInjectSender controls whether sender identity (platform and user ID) is
// prepended to each message before forwarding it to the agent. When enabled,
@@ -9657,98 +9651,53 @@ func (e *Engine) cmdWeb(p Platform, msg *Message, args []string) {
subCmd := ""
if len(args) > 0 {
subCmd = matchSubCommand(strings.ToLower(args[0]),
[]string{"install", "upgrade", "status", "open", "uninstall"})
[]string{"setup", "status"})
}
switch subCmd {
case "install":
e.cmdWebInstall(p, msg)
case "upgrade":
e.cmdWebUpgrade(p, msg)
case "uninstall":
e.cmdWebUninstall(p, msg)
case "open", "status":
e.cmdWebStatus(p, msg)
case "setup":
e.cmdWebSetup(p, msg)
default:
e.cmdWebStatus(p, msg)
}
}
func (e *Engine) cmdWebInstall(p Platform, msg *Message) {
if e.webInstallFunc == nil {
func (e *Engine) cmdWebSetup(p Platform, msg *Message) {
if !WebAssetsAvailable() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotSupported))
return
}
if e.webSetupFunc == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotSupported))
return
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebInstalling))
version, err := e.webInstallFunc()
port, token, needRestart, err := e.webSetupFunc()
if err != nil {
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgError, err))
return
}
// Auto-enable bridge & management
if e.webEnableFunc != nil {
port, token, needRestart, err := e.webEnableFunc()
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebInstallDone), version)+"\n\n⚠️ "+err.Error())
return
}
url := fmt.Sprintf("http://localhost:%d", port)
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebInstallSuccess), version, url, token))
if needRestart {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNeedRestart))
}
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebInstallDone), version))
}
func (e *Engine) cmdWebUpgrade(p Platform, msg *Message) {
if e.webUpgradeFunc == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotSupported))
return
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebUpgrading))
oldVer, newVer, err := e.webUpgradeFunc()
if err != nil {
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgError, err))
return
}
if oldVer == newVer {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebAlreadyLatest), newVer))
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebUpgradeSuccess), oldVer, newVer))
url := fmt.Sprintf("http://localhost:%d", port)
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebSetupSuccess), url, token))
if needRestart {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNeedRestart))
}
}
func (e *Engine) cmdWebStatus(p Platform, msg *Message) {
if !WebAssetsAvailable() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotSupported))
return
}
if e.webStatusFunc == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotSupported))
return
}
installed, version, url := e.webStatusFunc()
if !installed {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotInstalled))
url := e.webStatusFunc()
if url == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotEnabled))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebStatus), version, url))
}
func (e *Engine) cmdWebUninstall(p Platform, msg *Message) {
if e.webUninstallFunc == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebNotSupported))
return
}
if err := e.webUninstallFunc(); err != nil {
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgError, err))
return
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWebUninstalled))
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgWebStatus), url))
}
+27 -90
View File
@@ -378,17 +378,11 @@ const (
MsgUpgradeSuccess MsgKey = "upgrade_success"
MsgUpgradeDevBuild MsgKey = "upgrade_dev_build"
MsgWebNotSupported MsgKey = "web_not_supported"
MsgWebInstalling MsgKey = "web_installing"
MsgWebInstallDone MsgKey = "web_install_done"
MsgWebInstallSuccess MsgKey = "web_install_success"
MsgWebNeedRestart MsgKey = "web_need_restart"
MsgWebUpgrading MsgKey = "web_upgrading"
MsgWebAlreadyLatest MsgKey = "web_already_latest"
MsgWebUpgradeSuccess MsgKey = "web_upgrade_success"
MsgWebNotInstalled MsgKey = "web_not_installed"
MsgWebStatus MsgKey = "web_status"
MsgWebUninstalled MsgKey = "web_uninstalled"
MsgWebNotSupported MsgKey = "web_not_supported"
MsgWebNotEnabled MsgKey = "web_not_enabled"
MsgWebSetupSuccess MsgKey = "web_setup_success"
MsgWebNeedRestart MsgKey = "web_need_restart"
MsgWebStatus MsgKey = "web_status"
MsgAliasEmpty MsgKey = "alias_empty"
MsgAliasListHeader MsgKey = "alias_list_header"
@@ -2604,40 +2598,33 @@ var messages = map[MsgKey]map[Language]string{
LangSpanish: "⚠️ Compilación de desarrollo — la verificación de versión no está disponible. Compile desde el código fuente o instale una versión publicada.",
},
MsgWebNotSupported: {
LangEnglish: "⚠️ Web admin management is not available in this build.",
LangChinese: "⚠️ 当前版本不支持 Web 管理后台。",
LangTraditionalChinese: "⚠️ 目前版本不支援 Web 管理後台。",
LangJapanese: "⚠️ このビルドではWeb管理画面は利用できません。",
LangSpanish: "⚠️ La administración web no está disponible en esta versión.",
LangEnglish: "⚠️ Web admin is not available in this build. Rebuild without the `no_web` tag to enable it.",
LangChinese: "⚠️ 当前版本未包含 Web 管理后台。请去掉 `no_web` 标签重新编译以启用。",
LangTraditionalChinese: "⚠️ 目前版本未包含 Web 管理後台。請移除 `no_web` 標籤重新編譯以啟用。",
LangJapanese: "⚠️ このビルドにはWeb管理画面が含まれていません。`no_web` タグなしで再ビルドしてください。",
LangSpanish: "⚠️ La administración web no está incluida en esta compilación. Recompile sin la etiqueta `no_web`.",
},
MsgWebInstalling: {
LangEnglish: "📦 Installing cc-connect-web ...",
LangChinese: "📦 正在安装 cc-connect-web ...",
LangTraditionalChinese: "📦 正在安裝 cc-connect-web ...",
LangJapanese: "📦 cc-connect-web をインストールしています...",
LangSpanish: "📦 Instalando cc-connect-web ...",
MsgWebNotEnabled: {
LangEnglish: "ℹ️ Web admin is not enabled.\n\nUse `/web setup` to configure and enable it.",
LangChinese: "ℹ️ Web 管理后台未启用。\n\n使用 `/web setup` 配置并启用。",
LangTraditionalChinese: "ℹ️ Web 管理後台未啟用。\n\n使用 `/web setup` 設定並啟用。",
LangJapanese: "ℹ️ Web管理画面は有効になっていません。\n\n`/web setup` で設定して有効にしてください。",
LangSpanish: "ℹ️ La administración web no está habilitada.\n\nUsa `/web setup` para configurarla.",
},
MsgWebInstallDone: {
LangEnglish: "✅ cc-connect-web **%s** installed.",
LangChinese: "✅ cc-connect-web **%s** 安装完成。",
LangTraditionalChinese: "✅ cc-connect-web **%s** 安裝完成。",
LangJapanese: "✅ cc-connect-web **%s** をインストールしました。",
LangSpanish: "✅ cc-connect-web **%s** instalado.",
},
MsgWebInstallSuccess: {
LangEnglish: "✅ cc-connect-web **%s** installed!\n\n" +
MsgWebSetupSuccess: {
LangEnglish: "✅ Web admin configured!\n\n" +
"🌐 URL: %s\n🔑 Token: `%s`\n\n" +
"Open the URL in your browser and use the token to log in.",
LangChinese: "✅ cc-connect-web **%s** 安装完成!\n\n" +
LangChinese: "✅ Web 管理后台配置完成!\n\n" +
"🌐 地址:%s\n🔑 令牌:`%s`\n\n" +
"在浏览器打开地址,使用令牌登录。",
LangTraditionalChinese: "✅ cc-connect-web **%s** 安裝完成!\n\n" +
LangTraditionalChinese: "✅ Web 管理後台設定完成!\n\n" +
"🌐 網址:%s\n🔑 權杖:`%s`\n\n" +
"在瀏覽器開啟網址,使用權杖登入。",
LangJapanese: "✅ cc-connect-web **%s** インストール完了!\n\n" +
LangJapanese: "✅ Web管理画面の設定が完了しました!\n\n" +
"🌐 URL: %s\n🔑 トークン: `%s`\n\n" +
"ブラウザでURLを開き、トークンでログインしてください。",
LangSpanish: "✅ cc-connect-web **%s** instalado!\n\n" +
LangSpanish: "✅ Administración web configurada!\n\n" +
"🌐 URL: %s\n🔑 Token: `%s`\n\n" +
"Abre la URL en tu navegador y usa el token para iniciar sesión.",
},
@@ -2648,62 +2635,12 @@ var messages = map[MsgKey]map[Language]string{
LangJapanese: "🔄 `/restart` でサービスを再起動して、Web管理画面を有効にしてください。",
LangSpanish: "🔄 Reinicia el servicio con `/restart` para activar la administración web.",
},
MsgWebUpgrading: {
LangEnglish: "⬆️ Upgrading cc-connect-web ...",
LangChinese: "⬆️ 正在升级 cc-connect-web ...",
LangTraditionalChinese: "⬆️ 正在升級 cc-connect-web ...",
LangJapanese: "⬆️ cc-connect-web をアップグレードしています...",
LangSpanish: "⬆️ Actualizando cc-connect-web ...",
},
MsgWebAlreadyLatest: {
LangEnglish: "✅ Already up to date (**%s**).",
LangChinese: "✅ 已是最新版本(**%s**)。",
LangTraditionalChinese: "✅ 已是最新版本(**%s**)。",
LangJapanese: "✅ すでに最新版です(**%s**)。",
LangSpanish: "✅ Ya está actualizado (**%s**).",
},
MsgWebUpgradeSuccess: {
LangEnglish: "✅ Upgraded cc-connect-web: **%s** → **%s**\n\nRestart with `/restart` to apply.",
LangChinese: "✅ cc-connect-web 已升级:**%s** → **%s**\n\n使用 `/restart` 重启以生效。",
LangTraditionalChinese: "✅ cc-connect-web 已升級:**%s** → **%s**\n\n使用 `/restart` 重新啟動以生效。",
LangJapanese: "✅ cc-connect-web をアップグレードしました: **%s** → **%s**\n\n`/restart` で再起動して反映してください。",
LangSpanish: "✅ cc-connect-web actualizado: **%s** → **%s**\n\nReinicia con `/restart` para aplicar.",
},
MsgWebNotInstalled: {
LangEnglish: "ℹ️ Web admin is not installed.\n\nUse `/web install` to install it.",
LangChinese: "ℹ️ Web 管理后台尚未安装。\n\n使用 `/web install` 进行安装。",
LangTraditionalChinese: "ℹ️ Web 管理後台尚未安裝。\n\n使用 `/web install` 進行安裝。",
LangJapanese: "ℹ️ Web管理画面はインストールされていません。\n\n`/web install` でインストールしてください。",
LangSpanish: "ℹ️ La administración web no está instalada.\n\nUsa `/web install` para instalarla.",
},
MsgWebStatus: {
LangEnglish: "🌐 **Web Admin**\n\n" +
"Version: **%s**\n" +
"URL: %s\n\n" +
"Commands: `/web upgrade` · `/web uninstall`",
LangChinese: "🌐 **Web 管理后台**\n\n" +
"版本:**%s**\n" +
"地址:%s\n\n" +
"可用命令:`/web upgrade` · `/web uninstall`",
LangTraditionalChinese: "🌐 **Web 管理後台**\n\n" +
"版本:**%s**\n" +
"網址:%s\n\n" +
"可用命令:`/web upgrade` · `/web uninstall`",
LangJapanese: "🌐 **Web管理画面**\n\n" +
"バージョン: **%s**\n" +
"URL: %s\n\n" +
"コマンド: `/web upgrade` · `/web uninstall`",
LangSpanish: "🌐 **Administración Web**\n\n" +
"Versión: **%s**\n" +
"URL: %s\n\n" +
"Comandos: `/web upgrade` · `/web uninstall`",
},
MsgWebUninstalled: {
LangEnglish: "✅ Web admin has been uninstalled.",
LangChinese: "✅ Web 管理后台已卸载。",
LangTraditionalChinese: "✅ Web 管理後台已移除。",
LangJapanese: "✅ Web管理画面をアンインストールしました。",
LangSpanish: "✅ La administración web ha sido desinstalada.",
LangEnglish: "🌐 **Web Admin**\n\nURL: %s",
LangChinese: "🌐 **Web 管理后台**\n\n地址:%s",
LangTraditionalChinese: "🌐 **Web 管理後台**\n\n網址:%s",
LangJapanese: "🌐 **Web管理画面**\n\nURL: %s",
LangSpanish: "🌐 **Administración Web**\n\nURL: %s",
},
MsgAliasEmpty: {
LangEnglish: "No aliases configured. Use `/alias add <trigger> <command>` to create one.",
+19 -22
View File
@@ -4,10 +4,10 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -52,7 +52,6 @@ type ManagementServer struct {
configFilePath string
getGlobalSettings func() map[string]any
saveGlobalSettings func(map[string]any) error
webDistDir string // path to cc-connect-web dist/ for static serving
}
// NewManagementServer creates a new management API server.
@@ -110,9 +109,6 @@ func (m *ManagementServer) SetSaveGlobalSettings(fn func(map[string]any) error)
m.saveGlobalSettings = fn
}
func (m *ManagementServer) SetWebDistDir(dir string) {
m.webDistDir = dir
}
func (m *ManagementServer) Start() {
mux := http.NewServeMux()
@@ -166,41 +162,42 @@ func (m *ManagementServer) Stop() {
}
// withStaticFallback wraps the API mux with a file server for the web UI.
// API requests (/api/) go to the mux; everything else tries static files
// from webDistDir, falling back to index.html for SPA routing.
// API requests (/api/) go to the mux; everything else tries embedded static
// files, falling back to index.html for SPA routing.
func (m *ManagementServer) withStaticFallback(apiMux *http.ServeMux) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
apiMux.ServeHTTP(w, r)
return
}
if m.webDistDir == "" {
assets := GetWebAssets()
if assets == nil {
apiMux.ServeHTTP(w, r)
return
}
// CORS for static files too
m.setCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Try to serve the exact file; guard against path traversal.
filePath := filepath.Join(m.webDistDir, filepath.Clean(r.URL.Path))
if !strings.HasPrefix(filePath, m.webDistDir) {
http.NotFound(w, r)
// Try to serve the exact file from the embedded FS.
urlPath := strings.TrimPrefix(r.URL.Path, "/")
if urlPath == "" {
urlPath = "index.html"
}
if f, err := assets.Open(urlPath); err == nil {
f.Close()
http.FileServer(http.FS(assets)).ServeHTTP(w, r)
return
}
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
http.ServeFile(w, r, filePath)
// SPA fallback: serve index.html for any non-file route.
indexData, err := fs.ReadFile(assets, "index.html")
if err != nil {
apiMux.ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html for any non-file route
indexPath := filepath.Join(m.webDistDir, "index.html")
if _, err := os.Stat(indexPath); err == nil {
http.ServeFile(w, r, indexPath)
return
}
apiMux.ServeHTTP(w, r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexData)
})
}
+21
View File
@@ -0,0 +1,21 @@
package core
import "io/fs"
var webAssetsFS fs.FS
// RegisterWebAssets registers the embedded web frontend assets.
// Called from web/embed.go's init() function.
func RegisterWebAssets(fsys fs.FS) {
webAssetsFS = fsys
}
// GetWebAssets returns the registered web assets filesystem, or nil.
func GetWebAssets() fs.FS {
return webAssetsFS
}
// WebAssetsAvailable reports whether web frontend assets are embedded.
func WebAssetsAvailable() bool {
return webAssetsFS != nil
}
+1 -145
View File
@@ -3,143 +3,15 @@ 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
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)
@@ -148,19 +20,3 @@ func GenerateToken(n int) string {
}
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 ""
}
-20
View File
@@ -1,20 +0,0 @@
# Source & dev files — only dist/ is published
src/
public/
node_modules/
.vite/
*.ts
!*.d.ts
*.tsx
postcss.config.js
tailwind.config.ts
tsconfig.json
tsconfig.tsbuildinfo
vite.config.ts
vite-env.d.ts
index.html
preview.html
pnpm-lock.yaml
pnpm-workspace.yaml
.pnpmrc.json
.gitignore
+21
View File
@@ -0,0 +1,21 @@
//go:build !no_web
package web
import (
"embed"
"io/fs"
"github.com/chenhg5/cc-connect/core"
)
//go:embed all:dist
var distFS embed.FS
func init() {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic("web: embed dist: " + err.Error())
}
core.RegisterWebAssets(sub)
}
+3
View File
@@ -0,0 +1,3 @@
//go:build no_web
package web
+2 -27
View File
@@ -1,37 +1,12 @@
{
"name": "cc-connect-web",
"version": "0.1.0",
"description": "Web admin dashboard for CC-Connect — bridge AI coding agents with messaging platforms",
"private": true,
"type": "module",
"keywords": [
"cc-connect",
"admin",
"dashboard",
"ai-agent",
"claude",
"codex",
"feishu",
"telegram",
"discord",
"slack"
],
"homepage": "https://github.com/chenhg5/cc-connect",
"repository": {
"type": "git",
"url": "https://github.com/chenhg5/cc-connect.git",
"directory": "web"
},
"license": "MIT",
"author": "chenhg5",
"files": [
"dist"
],
"main": "dist/index.html",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"prepublishOnly": "npm run build"
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/api/client.ts","./src/api/cron.ts","./src/api/heartbeat.ts","./src/api/index.ts","./src/api/projects.ts","./src/api/providers.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/setup.ts","./src/api/status.ts","./src/components/Layout/Footer.tsx","./src/components/Layout/Header.tsx","./src/components/Layout/Layout.tsx","./src/components/Layout/Sidebar.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/index.ts","./src/hooks/useBridgeSocket.ts","./src/i18n/index.ts","./src/lib/platformMeta.ts","./src/lib/utils.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/Chat/ChatList.tsx","./src/pages/Chat/ChatView.tsx","./src/pages/Chat/CommandPalette.tsx","./src/pages/Chat/CommandResultPanel.tsx","./src/pages/Chat/SessionDrawer.tsx","./src/pages/Cron/CronList.tsx","./src/pages/Projects/PlatformManualForm.tsx","./src/pages/Projects/PlatformSetupQR.tsx","./src/pages/Projects/ProjectDetail.tsx","./src/pages/Projects/ProjectList.tsx","./src/pages/Sessions/SessionChat.tsx","./src/pages/Sessions/SessionList.tsx","./src/pages/System/Config.tsx","./src/pages/System/GlobalSettings.tsx","./src/store/auth.ts","./src/store/theme.ts","./vite-env.d.ts"],"version":"5.8.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/api/bridge.ts","./src/api/client.ts","./src/api/cron.ts","./src/api/heartbeat.ts","./src/api/index.ts","./src/api/projects.ts","./src/api/providers.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/setup.ts","./src/api/status.ts","./src/components/Layout/Footer.tsx","./src/components/Layout/Header.tsx","./src/components/Layout/Layout.tsx","./src/components/Layout/Sidebar.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/index.ts","./src/hooks/useBridgeSocket.ts","./src/i18n/index.ts","./src/lib/platformMeta.ts","./src/lib/utils.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/Bridge/BridgeAdapters.tsx","./src/pages/Chat/ChatList.tsx","./src/pages/Chat/ChatView.tsx","./src/pages/Chat/CommandPalette.tsx","./src/pages/Chat/CommandResultPanel.tsx","./src/pages/Chat/SessionDrawer.tsx","./src/pages/Cron/CronList.tsx","./src/pages/Projects/PlatformManualForm.tsx","./src/pages/Projects/PlatformSetupQR.tsx","./src/pages/Projects/ProjectDetail.tsx","./src/pages/Projects/ProjectList.tsx","./src/pages/Sessions/SessionChat.tsx","./src/pages/Sessions/SessionList.tsx","./src/pages/System/Config.tsx","./src/pages/System/GlobalSettings.tsx","./src/store/auth.ts","./src/store/theme.ts","./vite-env.d.ts"],"version":"5.8.3"}