From 433e7d63fc6945c28787c19c212487608956f9a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 08:33:20 +0800 Subject: [PATCH] refactor(web): embed frontend assets into binary via go:embed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 16 +++- cmd/cc-connect/main.go | 39 +++------- cmd/cc-connect/plugin_web.go | 5 ++ core/engine.go | 103 +++++++----------------- core/i18n.go | 117 +++++++--------------------- core/management.go | 41 +++++----- core/web_assets.go | 21 +++++ core/web_manager.go | 146 +---------------------------------- web/.npmignore | 20 ----- web/embed.go | 21 +++++ web/embed_stub.go | 3 + web/package.json | 29 +------ web/tsconfig.tsbuildinfo | 2 +- 13 files changed, 150 insertions(+), 413 deletions(-) create mode 100644 cmd/cc-connect/plugin_web.go create mode 100644 core/web_assets.go delete mode 100644 web/.npmignore create mode 100644 web/embed.go create mode 100644 web/embed_stub.go diff --git a/Makefile b/Makefile index b1a6444..b6694a2 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index afcf881..9cfbbfc 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -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() } diff --git a/cmd/cc-connect/plugin_web.go b/cmd/cc-connect/plugin_web.go new file mode 100644 index 0000000..0f2a279 --- /dev/null +++ b/cmd/cc-connect/plugin_web.go @@ -0,0 +1,5 @@ +//go:build !no_web + +package main + +import _ "github.com/chenhg5/cc-connect/web" diff --git a/core/engine.go b/core/engine.go index 474d914..e5c4846 100644 --- a/core/engine.go +++ b/core/engine.go @@ -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)) } diff --git a/core/i18n.go b/core/i18n.go index 00227c6..db1492c 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -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 ` to create one.", diff --git a/core/management.go b/core/management.go index bc03446..1d39e1a 100644 --- a/core/management.go +++ b/core/management.go @@ -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) }) } diff --git a/core/web_assets.go b/core/web_assets.go new file mode 100644 index 0000000..fd021e2 --- /dev/null +++ b/core/web_assets.go @@ -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 +} diff --git a/core/web_manager.go b/core/web_manager.go index 2ea29bd..e8bd6ca 100644 --- a/core/web_manager.go +++ b/core/web_manager.go @@ -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 "" -} diff --git a/web/.npmignore b/web/.npmignore deleted file mode 100644 index a2721f5..0000000 --- a/web/.npmignore +++ /dev/null @@ -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 diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..4f14a0a --- /dev/null +++ b/web/embed.go @@ -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) +} diff --git a/web/embed_stub.go b/web/embed_stub.go new file mode 100644 index 0000000..6c2cdb4 --- /dev/null +++ b/web/embed_stub.go @@ -0,0 +1,3 @@ +//go:build no_web + +package web diff --git a/web/package.json b/web/package.json index 929a49f..4330acd 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 2f9a527..60ec59f 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file