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:
1 parent
6255fd5127
commit
433e7d63fc
13 files changed
+150
-413
No files matched your search
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !no_web
|
||||
|
||||
package main
|
||||
|
||||
import _ "github.com/chenhg5/cc-connect/web"
|
||||
+26
-77
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//go:build no_web
|
||||
|
||||
package web
|
||||
+2
-27
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in new issue
Block a user