This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 0228dad5b chore(studio): 3.0.0 release readiness (#5007)
0228dad5b is described below
commit 0228dad5b9c9460f3e18c5c3e2b56c6525856198
Author: lizhimins <[email protected]>
AuthorDate: Wed Sep 23 11:06:35 2026 +0800
chore(studio): 3.0.0 release readiness (#5007)
License compliance:
- Fix root/server/web NOTICE (copyright holder) and LICENSE (restore the
appendix placeholder), add vendored LobeHub icon attribution
- mysql-connector-j: scope=provided + spring-boot-maven-plugin exclude,
keeping the GPL connector out of the distributed fat JAR
- Dockerfile downloads the connector at image build time and loads it
via LOADER_PATH; only the Dockerfile text is distributed, not the
connector binary
- AI CLI packages (claude-code, qodercli): obtained from npm at user
build time, not redistributed; remove the blocking legal.py npm gate
- server/scripts/legal.py: JAR license gate (GPL detection + completeness)
- rmqctl/scripts/license-binary.go: Go binary license collection
- web/scripts/licenses.mjs: Vite plugin for distribution license manifest
CI & test stability:
- web/src/test/setup.ts: await antd unmount in afterEach to fix leaked
async React root errors; add setup.test.ts
- rmqctl: regenerate catalog_gen.go for catalog-verify gate
- ci.yml: frontend lint/test/license-test + rmqctl catalog gate
Docs & version:
- README: simplify quick start, add AI setup prompt, drop cross-cloud
- Bump version to 3.0.0 (server, web, rmqctl, About page)
- Dockerfile: reformat with stage separators and aligned comments
---
.github/workflows/ci.yml | 38 ++-
.gitignore | 4 +
LICENSE | 15 +-
NOTICE | 11 +-
README.md | 62 ++++-
README_zh.md | 59 +++-
rmqctl/Makefile | 25 +-
rmqctl/cmd/app.go | 2 +-
rmqctl/internal/catalog/catalog_gen.go | 6 +-
rmqctl/scripts/license-binary.go | 281 +++++++++++++++++++
rmqctl/scripts/license-binary_test.go | 129 +++++++++
rmqctl/scripts/package-release.sh | 47 +++-
server/Dockerfile | 154 +++++++---
server/LICENSE | 2 +-
server/NOTICE | 6 +-
server/pom.xml | 83 +++++-
server/scripts/legal.py | 310 +++++++++++++++++++++
server/scripts/legal_test.py | 114 ++++++++
web/Dockerfile | 18 ++
web/LICENSE | 15 +-
web/NOTICE | 11 +-
web/licenses/toggle-selection-1.0.6/LICENSE | 21 ++
web/package-lock.json | 4 +-
web/package.json | 6 +-
web/scripts/licenses.d.mts | 18 ++
web/scripts/licenses.mjs | 247 ++++++++++++++++
web/scripts/licenses.test.mjs | 98 +++++++
web/src/assets/model-logos/LICENSE | 25 +-
.../pages/instance/__tests__/ConsumerPage.test.tsx | 12 +-
web/src/pages/settings/AboutTab.tsx | 4 +-
web/src/test/setup.test.ts | 134 +++++++++
web/src/test/setup.ts | 60 +++-
web/vite.config.ts | 21 +-
33 files changed, 1905 insertions(+), 137 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 29fa34548..21f78d279 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,13 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
name: CI
-# 检测前端与后端是否都能正确编译
+# Verify the build, tests, and generated artifacts for the frontend, backend,
and CLI.
on:
push:
branches:
- master
+ - rocketmq-studio
pull_request:
branches:
- master
+ - rocketmq-studio
concurrency:
group: ci-${{ github.ref }}
@@ -72,8 +89,20 @@ jobs:
cache-dependency-path: web/package-lock.json
- name: Install dependencies
+ # Keep the dependency install scripts, but forbid husky in prepare
from modifying the Git hooks config.
+ env:
+ HUSKY: "0"
run: npm ci
+ - name: Lint frontend
+ run: npm run lint
+
+ - name: Run all frontend tests
+ run: npm test
+
+ - name: Run frontend license tests
+ run: npm run license:test
+
- name: Build frontend
run: npm run build
@@ -115,9 +144,7 @@ jobs:
- name: Check formatting
run: make fmt
- # catalog-verify regenerates internal/catalog/catalog_gen.go from
- # ../server/src/main/resources/tool-catalog/tools and fails on drift, so
the
- # full repository (rmqctl/ + server/) must be checked out.
+ # Check whether the Go catalog is stale against the server-side YAML, so
the full repository must be checked out.
- name: Verify generated tool catalog
run: make catalog-verify
@@ -129,3 +156,6 @@ jobs:
- name: Build rmqctl binary
run: make build
+
+ - name: Build rmqctl for all supported platforms
+ run: make build-all
diff --git a/.gitignore b/.gitignore
index 5bf7c7804..af963fc2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,10 @@ dist/
target/
rmqctl/bin/
+# Python artifacts
+__pycache__/
+*.pyc
+
# Dependencies
node_modules/
diff --git a/LICENSE b/LICENSE
index c8aa782be..438d98cb0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
understanding of the Apache License by reading the FAQ at
http://www.apache.org/foundation/license-faq.html
- Copyright 2026 RocketMQ Studio Contributors
+ Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -199,3 +199,16 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+
+Third-party source materials
+===========================
+This source distribution includes 17 model brand SVG icons from
+@lobehub/icons-static-svg 1.95.0 (https://github.com/lobehub/lobe-icons).
+They are licensed under MIT; the complete upstream license is reproduced in
+web/src/assets/model-logos/LICENSE. Copyright (c) 2023 LobeHub.
+Upstream license: https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE
+This does not grant rights to third-party trademarks.
+
+Dependencies downloaded during builds are not bundled in this source tree.
+Binary distributions must additionally include the licenses and required
+notices of their actually bundled dependencies, generated during packaging.
diff --git a/NOTICE b/NOTICE
index fde8f8e31..7f0bec17a 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,10 @@
-RocketMQ Studio
-Copyright 2026 RocketMQ Studio Contributors
+Apache RocketMQ Studio
+Copyright 2026 The Apache Software Foundation
This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
+The Apache Software Foundation (https://www.apache.org/).
+
+Third-party source materials
+===========================
+Model brand icons from LobeHub (https://github.com/lobehub/lobe-icons):
+Copyright (c) 2023 LobeHub.
diff --git a/README.md b/README.md
index 93c065851..57d2b9087 100644
--- a/README.md
+++ b/README.md
@@ -2,26 +2,47 @@
**English** | [中文](README_zh.md)
-> Cross-cluster · Cross-architecture · Cross-cloud unified RocketMQ management
platform
+> Cross-cluster · Cross-architecture unified RocketMQ management platform
-RocketMQ Studio is a unified management platform for RocketMQ, supporting
multi-cluster, multi-architecture, and multi-cloud environments. It provides
instance management, cluster operations, Topic / Consumer Group CRUD, ACL
permission control, message query and tracing, dead letter queue handling,
monitoring alerts, audit logs, and an AI assistant.
+RocketMQ Studio is a unified management platform for RocketMQ, supporting
multi-cluster and multi-architecture environments. It provides instance
management, cluster operations, Topic / Consumer Group CRUD, ACL permission
control, message query and tracing, dead letter queue handling, monitoring
alerts, audit logs, and an AI assistant.
+
+## Start with AI
+
+You can use an AI coding agent (Claude Code, Qoder, Cursor, etc.) to set up
and launch
+Studio. Open the agent at the repository root and paste the prompt below:
+
+```text
+Set up and run RocketMQ Studio locally from this repository:
+1. Check that Docker and Docker Compose are installed. If not, tell me how to
install them and stop.
+2. Create the shared network: `docker network create rocketmq_net` (ignore the
error if it already exists).
+3. Build and start Studio: `docker compose -f deploy/docker-compose.yml up -d
--build`.
+4. Wait for the backend to become healthy, then open http://127.0.0.1:6789 and
confirm the page loads.
+Report the running containers and any errors. Do not modify source code or
create commits.
+```
+
+To also start the optional bundled RocketMQ demo cluster, insert
+`docker compose -f deploy/rocketmq/docker-compose.yml up -d` before step 3 and
wait until its
+`ps` output is healthy.
## Quick Start
+Run from the repository root:
+
```bash
-(docker network create rocketmq_net 2>/dev/null || true) && docker compose -f
deploy/rocketmq/docker-compose.yml up -d && docker compose -f
deploy/docker-compose.yml up -d --build
+# 1. Create the shared Docker network (the compose files attach to it as
external)
+docker network create rocketmq_net
+
+# 2. Build and start RocketMQ Studio (mysql + rocketmq-server + rocketmq-web)
+docker compose -f deploy/docker-compose.yml up -d --build
```
-Visit **http://127.0.0.1:6789** after startup.
+Then visit **http://127.0.0.1:6789**.
+
+This starts only Studio and its own MySQL database — enough to open the
console, log in, and explore
+the UI. No RocketMQ cluster is bundled here; register an instance pointing at
your own RocketMQ to
+manage real resources, or start the optional demo cluster in
+[Bundled RocketMQ Cluster](#bundled-rocketmq-cluster-optional) below.
-Run from the repository root. The leading `docker network create` is required
-because both compose files declare `rocketmq_net` as `external` and neither
-creates it. The first compose file starts the bundled RocketMQ topology
-(nameserver, broker-0/broker-1, proxy, producer/consumer load rig,
-prometheus); the second builds and starts the Studio stack (mysql,
-rocketmq-server, rocketmq-web). Check the topology is healthy with
-`docker compose -f deploy/rocketmq/docker-compose.yml ps` before starting
-Studio.
The default schema creates only Studio tables. It does not seed instances,
topics, consumer groups, or ACL
records. Development-only sample data can be imported explicitly from
`deploy/mysql/`; it is not part of the
default deployment. Import `upgrade-demo-instance.sql` first and then
`upgrade-demo-acl.sql`; both scripts
@@ -30,8 +51,6 @@ and should never be imported into a production database.
**Studio ports:** Frontend 6789 (Nginx), Backend 8888 (Spring Boot)
-**RocketMQ ports:** NameServer 9876, Broker 10911, Proxy Remoting 8080, Proxy
gRPC 8081
-
To enable login protection for a shared environment, copy
`deploy/.env.example` to
`deploy/.env`, set `STUDIO_AUTH_LOGIN_REQUIRED=true`, and configure
`STUDIO_AUTH_ADMIN_USERNAME` / `STUDIO_AUTH_ADMIN_PASSWORD`. These configured
credentials are a
@@ -42,6 +61,21 @@ user-management page; browsers authenticate with an
`HttpOnly` session cookie, a
request a bearer token explicitly. Disabling login protection only skips API
interception for local
development.
+## Bundled RocketMQ Cluster (Optional)
+
+The Quick Start above runs only Studio and its database. To try it against a
ready-made RocketMQ,
+start the bundled demo cluster — nameserver, two brokers, proxy, a
producer/consumer load generator,
+and Prometheus — on the same `rocketmq_net` network:
+
+```bash
+docker compose -f deploy/rocketmq/docker-compose.yml up -d
+```
+
+Confirm it is healthy with `docker compose -f
deploy/rocketmq/docker-compose.yml ps`. Studio reaches
+the cluster at `nameserver:9876` over the shared network (the backend default).
+
+**RocketMQ ports:** NameServer 9876, Broker 10911, Proxy Remoting 8080, Proxy
gRPC 8081
+
## Screenshots
**Dashboard · AI chat** — a single entry point that switches between AI chat,
cluster diagnosis, resource management, and message query, with model selection
and MCP tool calls.
diff --git a/README_zh.md b/README_zh.md
index 535b7ac00..b08f58662 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -2,28 +2,46 @@
[English](README.md) | **中文**
-> 跨集群 · 跨架构 · 跨云的 RocketMQ 统一管控平台
+> 跨集群 · 跨架构的 RocketMQ 统一管控平台
-RocketMQ Studio 是一个面向多集群、多架构、多云环境的 RocketMQ 管控平台,提供实例管理、集群运维、Topic / 消费组
CRUD、ACL 权限管控、消息查询与轨迹追踪、死信队列处理、监控告警、审计日志以及 AI 智能助手等一站式能力。
+RocketMQ Studio 是一个面向多集群、多架构环境的 RocketMQ 管控平台,提供实例管理、集群运维、Topic / 消费组 CRUD、ACL
权限管控、消息查询与轨迹追踪、死信队列处理、监控告警、审计日志以及 AI 智能助手等一站式能力。
-## 一键构建 & 运行
+## 用 AI 启动
-```bash
-(docker network create rocketmq_net 2>/dev/null || true) && docker compose -f
deploy/rocketmq/docker-compose.yml up -d && docker compose -f
deploy/docker-compose.yml up -d --build
+可以让 AI 编码 agent(Claude Code、Qoder、Cursor 等)帮你把 Studio 装好并跑起来。
+在仓库根目录打开 agent,把下面的提示词粘贴给它:
+
+```text
+从当前仓库在本地拉起并运行 RocketMQ Studio:
+1. 确认已安装 Docker 与 Docker Compose;若未安装,告诉我安装方式并停止。
+2. 创建共享网络:`docker network create rocketmq_net`(若已存在,忽略报错)。
+3. 构建并启动 Studio:`docker compose -f deploy/docker-compose.yml up -d --build`。
+4. 等待后端健康检查通过后,打开 http://127.0.0.1:6789 确认页面正常加载。
+最后报告运行中的容器与任何错误。不要修改源码,也不要提交任何改动。
```
-在仓库根目录执行。开头的 `docker network create` 必不可少:两个 compose 文件都把
-`rocketmq_net` 声明为 `external`,自身都不会创建它。第一个 compose 启动内置
-RocketMQ 拓扑(nameserver、broker-0/broker-1、proxy、producer/consumer 测试挂具、
-prometheus);第二个构建并启动 Studio 三件套(mysql、rocketmq-server、
-rocketmq-web)。启动 Studio 前可用
-`docker compose -f deploy/rocketmq/docker-compose.yml ps` 确认 RocketMQ 已就绪。
+如果还想启动可选的内置 RocketMQ 演示集群,在第 3 步之前插入
+`docker compose -f deploy/rocketmq/docker-compose.yml up -d`,并等待它的 `ps` 输出显示就绪。
+
+## 快速开始
+
+在仓库根目录执行:
+
+```bash
+# 1. 创建共享 Docker 网络(compose 文件以 external 方式引用它)
+docker network create rocketmq_net
+
+# 2. 构建并启动 RocketMQ Studio(mysql + rocketmq-server + rocketmq-web)
+docker compose -f deploy/docker-compose.yml up -d --build
+```
启动后访问 **http://127.0.0.1:6789** 即可使用。
-**Studio 服务端口:** 前端 6789(Nginx)、后端 8888(Spring Boot)
+这一步只启动 Studio 和它自己的 MySQL 数据库,足以打开控制台、登录并浏览界面,不包含
+RocketMQ 集群。要管理真实资源,请注册一个指向你自己 RocketMQ 的实例,或启动下方的
+「内置 RocketMQ 集群(可选)」。
-**RocketMQ 服务端端口:** NameServer 9876、Broker 10911、Proxy Remoting 8080、Proxy
gRPC 8081
+**Studio 服务端口:** 前端 6789(Nginx)、后端 8888(Spring Boot)
共享环境可复制 `deploy/.env.example` 为 `deploy/.env`,设置
`STUDIO_AUTH_LOGIN_REQUIRED=true`,并配置 `STUDIO_AUTH_ADMIN_USERNAME` /
@@ -33,6 +51,21 @@ rocketmq-web)。启动 Studio 前可用
启用/停用、重置密码);浏览器使用 `HttpOnly` 会话 Cookie 认证,API 客户端
可显式换取 bearer token。关闭登录保护只会跳过本地开发场景下的 API 拦截。
+## 内置 RocketMQ 集群(可选)
+
+上面的快速开始只跑了 Studio 和数据库。想连一个现成的 RocketMQ 试用,可在同一
+`rocketmq_net` 网络上启动内置的演示集群,包含 nameserver、两个 broker、proxy、producer/consumer
+压测客户端,以及 Prometheus:
+
+```bash
+docker compose -f deploy/rocketmq/docker-compose.yml up -d
+```
+
+用 `docker compose -f deploy/rocketmq/docker-compose.yml ps` 确认集群就绪。Studio
通过共享网络
+以 `nameserver:9876`(后端默认值)访问它。
+
+**RocketMQ 服务端端口:** NameServer 9876、Broker 10911、Proxy Remoting 8080、Proxy
gRPC 8081
+
## 界面预览
**首页 · AI 对话** — 统一入口,可切换 AI 对话 / 集群诊断 / 资源管理 / 消息查询四类场景,支持多模型选择与 MCP 工具调用。
diff --git a/rmqctl/Makefile b/rmqctl/Makefile
index ec1c8c32f..0f8e1e601 100644
--- a/rmqctl/Makefile
+++ b/rmqctl/Makefile
@@ -13,8 +13,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+.DEFAULT_GOAL := build
GO ?= go
-VERSION ?= 1.0.0
+VERSION ?= 3.0.0
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
BUILD_DIR ?= $(CURDIR)/bin
@@ -32,7 +33,7 @@ CATALOG_GENERATOR := ./internal/catalog/generate
CATALOG_MARKDOWN := ../docs/generated/rmq-tools.md
CATALOG_SDK := ../docs/generated/rmq-tools.json
-.PHONY: build build-all package checksum release clean catalog-generate
catalog-verify test test-race vet lint fmt verify ci completion install
+.PHONY: build build-all package checksum release clean catalog-generate
catalog-verify test test-race vet lint fmt verify ci completion install
license-binary
catalog-generate:
$(GO) run $(CATALOG_GENERATOR) -input-dir $(CATALOG_SOURCE) -output
$(CATALOG_GENERATED) \
@@ -65,6 +66,8 @@ ci: fmt test-race vet build-all
build: catalog-verify
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl
main.go
+ $(GO) run scripts/license-binary.go -go "$(GO)" -binary
$(BUILD_DIR)/rmqctl -output $(LEGAL_DIR)/native
+ $(GO) run scripts/license-binary.go -go "$(GO)" -binary
$(BUILD_DIR)/rmqctl -output $(LEGAL_DIR)/native -check
build-all: catalog-verify
@mkdir -p $(BUILD_DIR)
@@ -74,18 +77,27 @@ build-all: catalog-verify
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -ldflags
"$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl-linux-arm64 main.go
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -ldflags
"$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl-windows-amd64.exe main.go
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 $(GO) build -ldflags
"$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl-windows-arm64.exe main.go
+ # Generate per target; Windows-only dependencies must not reuse the
Linux license manifest.
+ @set -e; for target in $(TARGETS); do \
+ os=$${target%%_*}; arch=$${target##*_}; suffix=; \
+ if [ "$$os" = "windows" ]; then suffix=.exe; fi; \
+ $(GO) run scripts/license-binary.go -go "$(GO)" -binary
$(BUILD_DIR)/rmqctl-$$os-$$arch$$suffix -output $(LEGAL_DIR)/$$target; \
+ $(GO) run scripts/license-binary.go -go "$(GO)" -binary
$(BUILD_DIR)/rmqctl-$$os-$$arch$$suffix -output $(LEGAL_DIR)/$$target -check; \
+ done
+
+license-binary: build-all
-package: build-all
+package: license-binary
@rm -rf $(PACKAGE_DIR)
@mkdir -p $(PACKAGE_DIR)
- @for target in $(TARGETS); do \
+ @set -e; for target in $(TARGETS); do \
os=$${target%%_*}; arch=$${target##*_}; \
if [ "$$os" = "windows" ]; then \
binary=$(BUILD_DIR)/rmqctl-$$os-$$arch.exe; \
else \
binary=$(BUILD_DIR)/rmqctl-$$os-$$arch; \
fi; \
- bash scripts/package-release.sh "$$binary" "$$os" "$$arch"
"$(VERSION)" "$(LEGAL_DIR)" "$(PACKAGE_DIR)"; \
+ GO="$(GO)" bash scripts/package-release.sh "$$binary" "$$os"
"$$arch" "$(VERSION)" "$(LEGAL_DIR)/$$target" "$(PACKAGE_DIR)"; \
done
checksum: package
@@ -103,6 +115,9 @@ completion: build
install: build
@mkdir -p $(DESTDIR)/usr/local/bin
cp $(BUILD_DIR)/rmqctl $(DESTDIR)/usr/local/bin/rmqctl
+ @mkdir -p $(DESTDIR)/usr/local/share/licenses/rmqctl/legal
+ cp $(LEGAL_DIR)/native/LICENSE $(LEGAL_DIR)/native/NOTICE
$(DESTDIR)/usr/local/share/licenses/rmqctl/
+ cp -R $(LEGAL_DIR)/native/.
$(DESTDIR)/usr/local/share/licenses/rmqctl/legal/
clean:
rm -rf $(BUILD_DIR)
diff --git a/rmqctl/cmd/app.go b/rmqctl/cmd/app.go
index fc1521cff..50968a202 100644
--- a/rmqctl/cmd/app.go
+++ b/rmqctl/cmd/app.go
@@ -29,7 +29,7 @@ import (
)
var (
- CLIVersion = "1.0.0"
+ CLIVersion = "3.0.0"
GitCommit = ""
BuildDate = ""
)
diff --git a/rmqctl/internal/catalog/catalog_gen.go
b/rmqctl/internal/catalog/catalog_gen.go
index 622d4a59d..47ffa019c 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
var defaultDocument = Document{
Version: "2.0.0",
MinimumClientVersion: "2.0.0",
- Digest:
"94c2164d0181f55aebc2f32ef1f3db2c0b92185e98ac7824e492b6f0e29014b9",
+ Digest:
"30a907ada71734e87305982bcf29a3ff7c6f3ae050a2211f06e87c11b0424e2d",
Tools: []Tool{
{
Name: "rmq.acl.list",
@@ -582,7 +582,7 @@ var defaultDocument = Document{
Description: "Discover Proxy data endpoints,
optionally filtered by physical cluster name. Discovery does not establish
Broker cluster membership or management capability.",
RiskLevel: "L1",
Permission: "proxy:read",
- RequiredCapabilities: []string{"PROXY_DISCOVERY"},
+ RequiredCapabilities: []string{},
InputSchema: InputSchema{
Fields: []Field{
{Name: "clusterName", Flag:
"cluster-name", Description: "Physical RocketMQ cluster name.", Kind:
StringField, MinLength: 1},
@@ -597,7 +597,7 @@ var defaultDocument = Document{
Description: "Read the Proxy configuration and
reachability snapshot for one physical cluster, with an optional addr filter
(read-only; configuration reload is not exposed through MCP).",
RiskLevel: "L1",
Permission: "proxy:read",
- RequiredCapabilities: []string{"PROXY_DISCOVERY"},
+ RequiredCapabilities: []string{},
InputSchema: InputSchema{
Fields: []Field{
{Name: "clusterName", Flag:
"cluster-name", Description: "Physical RocketMQ cluster name.", Kind:
StringField, Required: true, MinLength: 1},
diff --git a/rmqctl/scripts/license-binary.go b/rmqctl/scripts/license-binary.go
new file mode 100644
index 000000000..dc6beeb56
--- /dev/null
+++ b/rmqctl/scripts/license-binary.go
@@ -0,0 +1,281 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Collect license texts driven by the target binary's Go buildinfo; do not
treat modules in go.mod that are not linked in as distributed dependencies.
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "debug/buildinfo"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+var legalName =
regexp.MustCompile(`(?i)^(licen[sc]e|notice|copying|copyright|patents|authors)([._-].*)?$`)
+
+type component struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Sum string `json:"sum,omitempty"`
+ Files []string `json:"files"`
+}
+
+type inventory struct {
+ BinarySHA256 string `json:"binarySha256"`
+ GOOS string `json:"goos"`
+ GOARCH string `json:"goarch"`
+ Components []component `json:"components"`
+ Files map[string]string `json:"files"`
+}
+
+func must(err error) {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "license gate:", err)
+ os.Exit(1)
+ }
+}
+
+func read(path string) []byte {
+ data, err := os.ReadFile(path)
+ must(err)
+ if len(bytes.TrimSpace(data)) == 0 {
+ must(fmt.Errorf("empty license material: %s", path))
+ }
+ return data
+}
+
+func digest(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func command(goCommand, cwd string, args ...string) []byte {
+ cmd := exec.Command(goCommand, args...)
+ cmd.Dir = cwd
+ cmd.Stderr = os.Stderr
+ out, err := cmd.Output()
+ must(err)
+ return out
+}
+
+func main() {
+ binary := flag.String("binary", "", "target Go binary")
+ output := flag.String("output", "", "license output directory")
+ root := flag.String("root", "..", "repository root")
+ goCommand := flag.String("go", "go", "Go command used for the build")
+ check := flag.Bool("check", false, "recompute and verify existing
materials byte by byte without writing files")
+ expectedOS := flag.String("os", "", "expected target operating system")
+ expectedArch := flag.String("arch", "", "expected target architecture")
+ flag.Parse()
+ if *binary == "" || *output == "" {
+ flag.Usage()
+ os.Exit(2)
+ }
+ info, err := buildinfo.ReadFile(*binary)
+ must(err)
+ const ownModule = "github.com/apache/rocketmq-dashboard/rmqctl"
+ isOwn := info.Main.Path == ownModule
+ for _, dep := range info.Deps {
+ if dep.Path == ownModule && dep.Version == "(devel)" &&
dep.Replace == nil {
+ isOwn = true
+ }
+ }
+ if !isOwn || len(info.Deps) == 0 {
+ must(fmt.Errorf("not an rmqctl binary with module info: %s",
*binary))
+ }
+ cwd := filepath.Join(*root, "rmqctl")
+ var env struct{ GOROOT, GOVERSION string }
+ must(json.Unmarshal(command(*goCommand, cwd, "env", "-json", "GOROOT",
"GOVERSION"), &env))
+ if info.GoVersion != env.GOVERSION {
+ must(fmt.Errorf("Go license version mismatch: binary=%s
toolchain=%s", info.GoVersion, env.GOVERSION))
+ }
+ inv := inventory{BinarySHA256: digest(read(*binary)), Files:
map[string]string{}}
+ for _, s := range info.Settings {
+ switch s.Key {
+ case "GOOS":
+ inv.GOOS = s.Value
+ case "GOARCH":
+ inv.GOARCH = s.Value
+ case "CGO_ENABLED":
+ if s.Value != "0" {
+ must(fmt.Errorf("binaries with C dynamic
dependencies are not supported yet; their licenses need separate review"))
+ }
+ }
+ }
+ if inv.GOOS == "" || inv.GOARCH == "" {
+ must(fmt.Errorf("binary is missing target platform info"))
+ }
+ if (*expectedOS != "" && inv.GOOS != *expectedOS) || (*expectedArch !=
"" && inv.GOARCH != *expectedArch) {
+ must(fmt.Errorf("binary target does not match the package name:
%s/%s", inv.GOOS, inv.GOARCH))
+ }
+ // The standard library also contains vendored code; keep only the
ancestor licenses of packages actually used on the target platform.
+ stdDirs := map[string]bool{env.GOROOT: true}
+ args := []string{"list", "-deps", "-json", "-mod=readonly"}
+ for _, setting := range info.Settings {
+ if setting.Key == "-tags" {
+ args = append(args, "-tags="+setting.Value)
+ }
+ }
+ args = append(args, "main.go")
+ cmd := exec.Command(*goCommand, args...)
+ cmd.Dir = cwd
+ cmd.Env = append(os.Environ(), "GOOS="+inv.GOOS, "GOARCH="+inv.GOARCH,
"CGO_ENABLED=0", "GOFLAGS=")
+ cmd.Stderr = os.Stderr
+ packages, err := cmd.Output()
+ must(err)
+ decoder := json.NewDecoder(bytes.NewReader(packages))
+ for {
+ var pkg struct {
+ Standard bool
+ Dir string
+ }
+ err := decoder.Decode(&pkg)
+ if err == io.EOF {
+ break
+ }
+ must(err)
+ if pkg.Standard {
+ for dir := pkg.Dir; dir != env.GOROOT &&
strings.HasPrefix(dir, env.GOROOT+string(filepath.Separator)); dir =
filepath.Dir(dir) {
+ stdDirs[dir] = true
+ }
+ }
+ }
+ files := map[string][]byte{}
+ base := func(name string) string {
+ text := string(read(filepath.Join(*root, name)))
+ return strings.Split(text, "\nThird-party source
materials\n")[0]
+ }
+ license, notice := base("LICENSE"), base("NOTICE")
+ collect := func(name, version, sum, dir string, toolchain bool) {
+ c := component{Name: name, Version: version, Sum: sum}
+ prefix := "licenses/" + name + "@" + version + "/"
+ foundLicense := false
+ must(filepath.WalkDir(dir, func(path string, entry fs.DirEntry,
walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ rel, err := filepath.Rel(dir, path)
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ if entry.Name() == ".git" || entry.Name() ==
"node_modules" || entry.Name() == "testdata" {
+ return filepath.SkipDir
+ }
+ if toolchain && !stdDirs[path] {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !legalName.MatchString(entry.Name()) ||
strings.HasSuffix(entry.Name(), ".go") {
+ return nil
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("symlinked license not
accepted: %s", path)
+ }
+ data := read(path)
+ lower := strings.ToLower(entry.Name())
+ if strings.HasPrefix(lower, "licen") ||
strings.HasPrefix(lower, "copying") {
+ if len(data) < 300 {
+ return fmt.Errorf("license too short;
the complete text must be verified: %s", path)
+ }
+ foundLicense = true
+ }
+ key := prefix + filepath.ToSlash(rel) + ".txt"
+ files[key] = data
+ c.Files = append(c.Files, key)
+ if strings.HasPrefix(lower, "notice") {
+ notice += "\n--- " + name + " " + version + " /
" + filepath.ToSlash(rel) + " ---\n" + string(data) + "\n"
+ }
+ return nil
+ }))
+ if !foundLicense {
+ must(fmt.Errorf("%s@%s is missing the complete upstream
LICENSE/COPYING", name, version))
+ }
+ sort.Strings(c.Files)
+ inv.Components = append(inv.Components, c)
+ license += "\n" + name + " " + version + ": complete upstream
license, copyright and additional terms under legal/" + prefix + "\n"
+ }
+ collect("go", info.GoVersion, "", env.GOROOT, true)
+ sort.Slice(info.Deps, func(i, j int) bool { return info.Deps[i].Path <
info.Deps[j].Path })
+ for _, dep := range info.Deps {
+ if dep.Path == ownModule && dep.Version == "(devel)" &&
dep.Replace == nil {
+ continue
+ }
+ if dep.Replace != nil {
+ must(fmt.Errorf("module replacement requires manual
source review: %s", dep.Path))
+ }
+ var module struct {
+ Path, Version, Dir, Sum string
+ Error *struct{ Err string }
+ }
+ must(json.Unmarshal(command(*goCommand, cwd, "mod", "download",
"-json", dep.Path+"@"+dep.Version), &module))
+ if module.Dir == "" || module.Path != dep.Path ||
module.Version != dep.Version || module.Sum != dep.Sum {
+ must(fmt.Errorf("module source/checksum mismatch:
%s@%s", dep.Path, dep.Version))
+ }
+ collect(dep.Path, dep.Version, dep.Sum, module.Dir, false)
+ }
+ files["LICENSE"] = []byte(license)
+ files["NOTICE"] = []byte(notice)
+ for name, data := range files {
+ inv.Files[name] = digest(data)
+ }
+ manifest, err := json.MarshalIndent(inv, "", " ")
+ must(err)
+ files["manifest.json"] = append(manifest, '\n')
+ if *check {
+ seen := 0
+ must(filepath.WalkDir(*output, func(path string, entry
fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ rel, err := filepath.Rel(*output, path)
+ if err != nil {
+ return err
+ }
+ want, ok := files[filepath.ToSlash(rel)]
+ if !ok || entry.Type()&os.ModeSymlink != 0 ||
!bytes.Equal(read(path), want) {
+ return fmt.Errorf("material missing, stale or
modified: %s", path)
+ }
+ seen++
+ return nil
+ }))
+ if seen != len(files) {
+ must(fmt.Errorf("license file count mismatch: got %d,
expected %d", seen, len(files)))
+ }
+ } else {
+ // Do not delete unknown files; a later check rejects leftover
materials from a previous build.
+ for name, data := range files {
+ path := filepath.Join(*output, filepath.FromSlash(name))
+ must(os.MkdirAll(filepath.Dir(path), 0755))
+ must(os.WriteFile(path, data, 0644))
+ }
+ }
+ fmt.Printf("license materials: %s/%s, %d actual components, %d
files\n", inv.GOOS, inv.GOARCH, len(inv.Components), len(files))
+}
diff --git a/rmqctl/scripts/license-binary_test.go
b/rmqctl/scripts/license-binary_test.go
new file mode 100644
index 000000000..f56a6e0e5
--- /dev/null
+++ b/rmqctl/scripts/license-binary_test.go
@@ -0,0 +1,129 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestBinaryLicensePackagingTest(t *testing.T) {
+ _, source, _, _ := runtime.Caller(0)
+ root := filepath.Clean(filepath.Join(filepath.Dir(source), "../.."))
+ goCommand := filepath.Join(runtime.GOROOT(), "bin/go")
+ for _, target := range []string{"linux", "windows"} {
+ t.Run(target, func(t *testing.T) {
+ dir := t.TempDir()
+ binary := filepath.Join(dir, "rmqctl")
+ build := exec.Command(goCommand, "build",
"-mod=readonly", "-o", binary, "main.go")
+ build.Dir = filepath.Join(root, "rmqctl")
+ build.Env = append(os.Environ(), "CGO_ENABLED=0",
"GOOS="+target, "GOARCH=amd64")
+ if output, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("targeted packaging build failed:
%v\n%s", err, output)
+ }
+ legal := filepath.Join(dir, "legal")
+ generate := exec.Command(goCommand, "run",
filepath.Join(root, "rmqctl/scripts/license-binary.go"),
+ "-go", goCommand, "-root", root, "-binary",
binary, "-output", legal)
+ if output, err := generate.CombinedOutput(); err != nil
{
+ t.Fatalf("license generation failed: %v\n%s",
err, output)
+ }
+ pack := func(osName, material string, success bool) {
+ t.Helper()
+ cmd := exec.Command("bash", filepath.Join(root,
"rmqctl/scripts/package-release.sh"),
+ binary, osName, "amd64", "fixture",
material, filepath.Join(dir, "packages"))
+ cmd.Env = append(os.Environ(), "GO="+goCommand)
+ output, err := cmd.CombinedOutput()
+ if (err == nil) != success {
+ t.Fatalf("packaging gate returned the
wrong result success=%v err=%v\n%s", success, err, output)
+ }
+ }
+ pack(target, legal, true)
+ contents := map[string]string{}
+ archive := filepath.Join(dir, "packages",
"rmqctl-"+target+"-amd64-fixture")
+ if target == "windows" {
+ z, err := zip.OpenReader(archive + ".zip")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer z.Close()
+ for _, file := range z.File {
+ r, err := file.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := io.ReadAll(r)
+ r.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ contents[strings.TrimPrefix(file.Name,
"./")] = string(data)
+ }
+ } else {
+ f, err := os.Open(archive + ".tar.gz")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ gz, err := gzip.NewReader(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer gz.Close()
+ tr := tar.NewReader(gz)
+ for {
+ header, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := io.ReadAll(tr)
+ if err != nil {
+ t.Fatal(err)
+ }
+
contents[strings.TrimPrefix(header.Name, "./")] = string(data)
+ }
+ }
+ if
!strings.Contains(contents["legal/licenses/github.com/spf13/[email protected]/LICENSE.txt"],
"Alex Ogier") {
+ t.Fatal("package is missing the original pflag
BSD license")
+ }
+ if
!strings.Contains(contents["legal/licenses/github.com/mark3labs/[email protected]/LICENSE.txt"],
"2024 Anthropic, PBC") {
+ t.Fatal("package is missing the original mcp-go
MIT license")
+ }
+ if !strings.Contains(contents["NOTICE"], "The Apache
Software Foundation") || strings.Contains(contents["LICENSE"], "LobeHub") {
+ t.Fatal("the binary must carry the ASF NOTICE
and must not mix in the web source-package attribution")
+ }
+ if target == "windows" &&
!strings.Contains(contents["LICENSE"], "mousetrap") {
+ t.Fatal("the Windows package is missing a
platform-specific dependency license")
+ }
+ pack("darwin", legal, false)
+ pack(target, "", false)
+ if err := os.WriteFile(filepath.Join(legal, "NOTICE"),
[]byte("tampered"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ pack(target, legal, false)
+ })
+ }
+}
diff --git a/rmqctl/scripts/package-release.sh
b/rmqctl/scripts/package-release.sh
index 35508579d..6978b2f6b 100755
--- a/rmqctl/scripts/package-release.sh
+++ b/rmqctl/scripts/package-release.sh
@@ -17,7 +17,7 @@
#
# Package a single rmqctl binary into a release archive (tar.gz or zip)
-# with LICENSE, NOTICE, completion scripts, and optional legal bundle.
+# Bundles LICENSE, NOTICE, the completion script, and the third-party legal
materials that must pass the check.
#
# Usage: package-release.sh <binary> <os> <arch> <version> <legal_dir>
<package_dir>
#
@@ -45,6 +45,21 @@ package_dir="$6"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
project_root="$(cd "${script_dir}/../.." && pwd)"
+# Verify the materials for the actual binary byte by byte before packaging; an
empty directory, a stale manifest, or a silent omission is not allowed.
+if [ -z "$legal_dir" ] || [ ! -d "$legal_dir" ]; then
+ echo "Missing third-party legal materials; run make license-binary first"
>&2
+ exit 1
+fi
+for value in "$os" "$arch" "$version"; do
+ [[ "$value" =~ ^[a-zA-Z0-9._+-]+$ ]] || { echo "Invalid packaging
argument" >&2; exit 1; }
+done
+binary="$(cd "$(dirname "$binary")" && pwd)/$(basename "$binary")"
+legal_dir="$(cd "$legal_dir" && pwd)"
+mkdir -p "$package_dir"
+package_dir="$(cd "$package_dir" && pwd)"
+"${GO:-go}" run "${script_dir}/license-binary.go" -root "$project_root" \
+ -go "${GO:-go}" -binary "$binary" -output "$legal_dir" -os "$os" -arch
"$arch" -check
+
# Staging directory for archive contents
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
@@ -59,11 +74,9 @@ fi
# Copy binary
cp "$binary" "${stage}/${bin_name}"
-# Copy LICENSE and NOTICE from project root
-cp "${project_root}/LICENSE" "${stage}/LICENSE"
-if [ -f "${project_root}/NOTICE" ]; then
- cp "${project_root}/NOTICE" "${stage}/NOTICE"
-fi
+# Use the binary-specific LICENSE/NOTICE; do not mix in the web icon
attribution from the source package.
+cp "${legal_dir}/LICENSE" "${stage}/LICENSE"
+cp "${legal_dir}/NOTICE" "${stage}/NOTICE"
# Copy completion scripts if they exist (produced by `make completion`)
completion_dir="${script_dir}/../bin/completion"
@@ -72,10 +85,8 @@ if [ -d "$completion_dir" ]; then
cp "$completion_dir"/* "${stage}/completion/" 2>/dev/null || true
fi
-# Copy legal bundle if it exists (produced by license-binary, currently unused)
-if [ -n "$legal_dir" ] && [ -d "$legal_dir" ]; then
- cp -r "$legal_dir" "${stage}/legal"
-fi
+# The license manifest and the full license texts must ship with the package.
+cp -r "$legal_dir" "${stage}/legal"
# Build archive
archive_name="rmqctl-${os}-${arch}-${version}"
@@ -83,18 +94,20 @@ cd "$stage"
if [ "$os" = "windows" ]; then
archive="${package_dir}/${archive_name}.zip"
+ # Updating an existing zip archive keeps the old files, so refuse to reuse
an existing target package.
+ [ ! -e "$archive" ] || { echo "Target archive already exists: $archive"
>&2; exit 1; }
# Use zip if available; fall back to python zipfile for portability
if command -v zip >/dev/null 2>&1; then
zip -r -q "$archive" .
else
- python3 -c "
-import zipfile, os
-with zipfile.ZipFile('${archive}', 'w', zipfile.ZIP_DEFLATED) as z:
+ python3 - "$archive" <<'PY'
+import zipfile, os, sys
+with zipfile.ZipFile(sys.argv[1], 'w', zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk('.'):
for f in files:
path = os.path.join(root, f)
z.write(path, os.path.relpath(path, '.'))
-"
+PY
fi
else
archive="${package_dir}/${archive_name}.tar.gz"
@@ -103,6 +116,10 @@ fi
# Generate SHA-256 checksum
cd "$package_dir"
-shasum -a 256 "$(basename "$archive")" > "$(basename "$archive").sha256"
+if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$(basename "$archive")" > "$(basename "$archive").sha256"
+else
+ shasum -a 256 "$(basename "$archive")" > "$(basename "$archive").sha256"
+fi
echo "Created $(basename "$archive") + $(basename "$archive").sha256"
diff --git a/server/Dockerfile b/server/Dockerfile
index 5b8983820..4851ec150 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,71 +1,145 @@
-# NOTE: the build context for this Dockerfile is the REPOSITORY ROOT, not
server/.
-# It was widened so the rmqctl Go stage below can `COPY rmqctl/ ...` and ship
-# /usr/local/bin/rmqctl in the server image (the AI backend spawns the agent
CLI
-# with an MCP config that runs `rmqctl mcp stdio`). Every COPY/ADD path in this
-# file is therefore rooted at the repository. Callers must pass the root as the
-# context: `docker build -f server/Dockerfile .` / compose `context: ..`.
-# A root-level .dockerignore keeps that wider context lean.
-
-# rmqctl CLI + MCP server. Built here rather than shipped as a host artifact so
-# the image is self-contained and reproducible.
-# - golang:1.27.1 EXACTLY: rmqctl/go.mod declares `go 1.27.1`, and an older
-# toolchain refuses to build it unless GOTOOLCHAIN may download a newer
one,
-# which the China network makes unreliable. GOTOOLCHAIN=local pins the
-# in-image toolchain so a version mismatch fails loudly instead of hanging.
-# - GOPROXY=goproxy.cn mirrors the other China-network choices in this file
-# (npmmirror for the Node.js tarball, registry.npmmirror.com for npm).
-# - CGO_ENABLED=0: this stage is Debian-based while the runtime is
-# dragonwell:21 (Anolis), so a static binary runs regardless of glibc.
-# - go.mod/go.sum are copied BEFORE the sources so the module download layer
-# stays cached across source-only changes.
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
==============================================================================
+# Build context: REPOSITORY ROOT (not server/).
+# Callers: docker build -f server/Dockerfile . / compose context: ..
+#
==============================================================================
+
+#
------------------------------------------------------------------------------
+# Stage 1: rmqctl CLI + MCP server (static Go binary)
+#
------------------------------------------------------------------------------
+# golang:1.27.1 pinned to match rmqctl/go.mod; GOTOOLCHAIN=local prevents
+# silent downloads. CGO_ENABLED=0 produces a static binary portable across
+# Debian (this stage) and Anolis (runtime stage).
FROM golang:1.27.1 AS rmqctl-build
-WORKDIR /src
+WORKDIR /src/rmqctl
+
ENV GOPROXY=https://goproxy.cn,direct \
GOTOOLCHAIN=local \
CGO_ENABLED=0
+
+# Cache module downloads separately from source changes.
COPY rmqctl/go.mod rmqctl/go.sum ./
RUN go mod download
+
COPY rmqctl/ ./
-RUN mkdir -p /out && go build -trimpath -ldflags "-s -w" -o /out/rmqctl main.go
+COPY LICENSE NOTICE /src/
-# Shared runtime dependencies
-# Note: a pinned dragonwell patch tag (e.g. 21.0.10-anolis) would be
preferable for
-# reproducibility, but such tags are not reachable through the deployment
registry
-# mirrors in use, so keep the rolling :21 tag until a verifiable pin is
available.
+RUN mkdir -p /out \
+ && go build -trimpath -ldflags "-s -w" -o /out/rmqctl main.go \
+ && go run scripts/license-binary.go -root /src -binary /out/rmqctl -output
/out/legal \
+ && go run scripts/license-binary.go -root /src -binary /out/rmqctl -output
/out/legal -check
+
+#
------------------------------------------------------------------------------
+# Stage 2: runtime-base — shared runtime layer (JDK + Node.js + rmqctl)
+#
------------------------------------------------------------------------------
+# Rolling :21 tag until a pinned dragonwell patch tag is reachable through
+# deployment registry mirrors.
FROM alibabadragonwell/dragonwell:21 AS runtime-base
WORKDIR /app
-# Node.js + agent CLIs for the claude-code / qoder agent providers.
-RUN curl -fsSL
https://npmmirror.com/mirrors/node/v20.19.2/node-v20.19.2-linux-x64.tar.gz |
tar xz -C /opt \
- && ln -s /opt/node-v20.19.2-linux-x64/bin/node /usr/local/bin/node \
- && ln -s /opt/node-v20.19.2-linux-x64/bin/npm /usr/local/bin/npm \
- && ln -s /opt/node-v20.19.2-linux-x64/bin/npx /usr/local/bin/npx \
+
+# Python3 interpreter for the license-check script (standard library only).
+RUN if ! command -v python3 >/dev/null 2>&1; then \
+ if command -v yum >/dev/null 2>&1; then \
+ yum install -y python3 && yum clean all; \
+ else \
+ apt-get update && apt-get install -y --no-install-recommends python3; \
+ fi; \
+ fi
+
+COPY server/scripts/legal.py /opt/studio-legal/scripts/legal.py
+COPY server/LICENSE server/NOTICE /opt/studio-legal/
+COPY server/LICENSE server/NOTICE /usr/share/licenses/rocketmq-studio/
+
+# Node.js + AI agent CLIs (claude-code, qodercli).
+# These packages are obtained from npm at build time by the user; they are NOT
+# distributed as part of the ASF release artifact (only this Dockerfile is).
+RUN curl -fsSL
https://npmmirror.com/mirrors/node/v20.19.2/node-v20.19.2-linux-x64.tar.gz \
+ | tar xz -C /opt \
+ && ln -s /opt/node-v20.19.2-linux-x64/bin/node /usr/local/bin/node \
+ && ln -s /opt/node-v20.19.2-linux-x64/bin/npm /usr/local/bin/npm \
+ && ln -s /opt/node-v20.19.2-linux-x64/bin/npx /usr/local/bin/npx \
&& npm config set registry https://registry.npmmirror.com \
&& npm install -g @anthropic-ai/claude-code @qoder-ai/qodercli \
- && ln -s /opt/node-v20.19.2-linux-x64/bin/claude /usr/local/bin/claude \
+ && ln -s /opt/node-v20.19.2-linux-x64/bin/claude /usr/local/bin/claude \
&& ln -s /opt/node-v20.19.2-linux-x64/bin/qodercli /usr/local/bin/qodercli
-# rmqctl: RocketMQ MCP server / CLI used by the agent providers.
+
+# rmqctl binary + legal materials from stage 1.
COPY --from=rmqctl-build /out/rmqctl /usr/local/bin/rmqctl
+COPY --from=rmqctl-build /out/legal/LICENSE /out/legal/NOTICE
/usr/share/licenses/rmqctl/
+COPY --from=rmqctl-build /out/legal /usr/share/licenses/rmqctl/legal
RUN chmod 0755 /usr/local/bin/rmqctl
+
+# MySQL Connector/J (GPL-2.0 + Universal FOSS Exception, ASF Category X).
+# Not bundled in the fat JAR (pom.xml scope=provided); downloaded here at image
+# build time so the container has a working MySQL driver out of the box.
+# ASF distributes only this Dockerfile text, not the connector binary.
+RUN mkdir -p /app/lib \
+ && curl -fsSL -o /app/lib/mysql-connector-j-9.7.0.jar \
+
https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/9.7.0/mysql-connector-j-9.7.0.jar
+ENV LOADER_PATH=/app/lib
+
EXPOSE 8888
ENTRYPOINT ["java", "-jar", "app.jar"]
-# deploy.sh target: package with the JAR prebuilt by a Maven container that
-# mounts the host ~/.m2 cache, avoiding dependency downloads on every build.
-# Keep this before the build stage so Docker's legacy builder can stop here.
+#
------------------------------------------------------------------------------
+# Stage 3a: runtime-prebuilt — deploy.sh target (JAR built on host)
+#
------------------------------------------------------------------------------
+# Expects server/target/*.jar to exist before docker build.
FROM runtime-base AS runtime-prebuilt
COPY server/target/*.jar app.jar
+RUN python3 /opt/studio-legal/scripts/legal.py check-jar /app/app.jar \
+ --output /usr/share/licenses/rocketmq-studio
-# Maven build stage used by the default CI / docker compose target.
+#
------------------------------------------------------------------------------
+# Stage 3b: build — Maven compile inside Docker (CI / docker compose default)
+#
------------------------------------------------------------------------------
+# mysql-connector-j is scope=provided, so `mvn package` produces a fat JAR
+# WITHOUT the GPL connector. The connector is downloaded separately in the
+# runtime-base stage above and loaded via LOADER_PATH at startup.
FROM alibabadragonwell/dragonwell:21 AS build
-RUN curl -fsSL
https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz
| tar xz -C /opt \
- && ln -s /opt/apache-maven-3.9.9/bin/mvn /usr/local/bin/mvn
WORKDIR /app
+
+RUN curl -fsSL
https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz
\
+ | tar xz -C /opt \
+ && ln -s /opt/apache-maven-3.9.9/bin/mvn /usr/local/bin/mvn
+
+RUN if ! command -v python3 >/dev/null 2>&1; then \
+ if command -v yum >/dev/null 2>&1; then \
+ yum install -y python3 && yum clean all; \
+ else \
+ apt-get update && apt-get install -y --no-install-recommends python3; \
+ fi; \
+ fi
+
+# Cache dependencies separately from source changes.
COPY server/pom.xml .
RUN mvn dependency:go-offline
+
COPY server/src ./src
COPY server/style ./style
+COPY server/scripts/legal.py ./scripts/legal.py
+COPY server/LICENSE server/NOTICE ./
+
RUN mvn package -DskipTests
-# Default (last) target: build the JAR inside Docker for CI / docker compose.
+#
------------------------------------------------------------------------------
+# Stage 4: runtime — default target (JAR built inside Docker)
+#
------------------------------------------------------------------------------
FROM runtime-base AS runtime
COPY --from=build /app/target/*.jar app.jar
+RUN python3 /opt/studio-legal/scripts/legal.py check-jar /app/app.jar \
+ --output /usr/share/licenses/rocketmq-studio
diff --git a/server/LICENSE b/server/LICENSE
index c8aa782be..65e3e026b 100644
--- a/server/LICENSE
+++ b/server/LICENSE
@@ -186,7 +186,7 @@
understanding of the Apache License by reading the FAQ at
http://www.apache.org/foundation/license-faq.html
- Copyright 2026 RocketMQ Studio Contributors
+ Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/server/NOTICE b/server/NOTICE
index fde8f8e31..b0e660925 100644
--- a/server/NOTICE
+++ b/server/NOTICE
@@ -1,5 +1,5 @@
-RocketMQ Studio
-Copyright 2026 RocketMQ Studio Contributors
+Apache RocketMQ Studio
+Copyright 2026 The Apache Software Foundation
This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
+The Apache Software Foundation (https://www.apache.org/).
diff --git a/server/pom.xml b/server/pom.xml
index b057278f8..98e209b38 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -1,4 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -13,7 +29,7 @@
<groupId>com.rocketmq</groupId>
<artifactId>rocketmq-studio</artifactId>
- <version>1.0.0</version>
+ <version>3.0.0</version>
<name>RocketMQ Studio</name>
<properties>
@@ -21,6 +37,7 @@
<spring-ai.version>2.0.0</spring-ai.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<rocketmq.version>5.5.0</rocketmq.version>
+ <license.python>python3</license.python>
</properties>
<dependencyManagement>
@@ -107,10 +124,13 @@
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.17</version>
</dependency>
+ <!-- MySQL Connector/J: GPL-2.0 + Universal FOSS Exception (ASF
Category X).
+ Scope=provided keeps it out of the distributed fat JAR; the
Dockerfile
+ downloads it at image build time. See server/Dockerfile for
details. -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
- <scope>runtime</scope>
+ <scope>provided</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
@@ -171,6 +191,65 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
+ <configuration>
+ <!-- MySQL Connector/J is GPL (ASF Category X); keep it
out of the
+ distributed fat JAR. The Dockerfile downloads it
separately and
+ Spring Boot loads it via LOADER_PATH at runtime. -->
+ <excludes>
+ <exclude>
+ <groupId>com.mysql</groupId>
+ <artifactId>mysql-connector-j</artifactId>
+ </exclude>
+ </excludes>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>project-legal-resources</id>
+ <phase>process-resources</phase>
+ <goals><goal>copy-resources</goal></goals>
+ <configuration>
+
<outputDirectory>${project.build.outputDirectory}/META-INF</outputDirectory>
+ <resources>
+ <resource>
+ <directory>${project.basedir}</directory>
+ <filtering>false</filtering>
+ <includes>
+ <include>LICENSE</include>
+ <include>NOTICE</include>
+ </includes>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <!-- Check the actually embedded dependencies after Boot
repackage; does not affect the test-only flow. -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-antrun-plugin</artifactId>
+ <version>3.2.0</version>
+ <executions>
+ <execution>
+ <id>binary-license-gate</id>
+ <phase>package</phase>
+ <goals><goal>run</goal></goals>
+ <configuration>
+ <target>
+ <exec executable="${license.python}"
failonerror="true">
+ <arg
value="${project.basedir}/scripts/legal.py"/>
+ <arg value="jar"/>
+ <arg
value="${project.build.directory}/${project.build.finalName}.jar"/>
+ <arg value="--output"/>
+ <arg
value="${project.build.directory}/legal"/>
+ </exec>
+ </target>
+ </configuration>
+ </execution>
+ </executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
diff --git a/server/scripts/legal.py b/server/scripts/legal.py
new file mode 100644
index 000000000..76a343e3a
--- /dev/null
+++ b/server/scripts/legal.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Preserve the license texts of the actually distributed files using only the
standard library; complete materials do not imply redistribution rights have
been granted."""
+import argparse
+import hashlib
+import io
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import subprocess
+import sys
+import tempfile
+import zipfile
+
+SERVER = Path(__file__).resolve().parents[1]
+LEGAL_NAME =
re.compile(r"^(licen[sc]e|notice|copying|copyright|patents|authors|dependencies|third[-_]party)([._-].*)?$",
re.I)
+LICENSE_NAME = re.compile(r"^(licen[sc]e|copying)([._-].*)?$", re.I)
+NOTICE_NAME = re.compile(r"^notice([._-].*)?$", re.I)
+PERMISSIVE = {"MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC",
"0BSD"}
+
+
+def sha(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def read(path):
+ data = Path(path).read_bytes()
+ if not data.strip():
+ raise ValueError(f"empty license material: {path}")
+ return data
+
+
+def safe(name):
+ p = PurePosixPath(name)
+ if p.is_absolute() or ".." in p.parts or "\\" in name:
+ raise ValueError(f"unsafe material path: {name}")
+ return name
+
+
+def is_legal(name):
+ p = PurePosixPath(name)
+ if p.suffix.lower() in {".class", ".java", ".js", ".ts", ".go", ".py",
".so", ".dll", ".exe"}:
+ return False
+ return bool(LEGAL_NAME.match(p.name) or p.name.lower() == "about.html"
+ or any(part.lower() in {"licenses", "legal", "license"} for
part in p.parts[:-1]))
+
+
+def texts_from_zip(data):
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
+ if len(archive.namelist()) != len(set(archive.namelist())):
+ raise ValueError("dependency JAR contains duplicate ZIP entries")
+ return {safe(name): archive.read(name) for name in
sorted(archive.namelist())
+ if not name.endswith("/") and is_legal(name)}
+
+
+def add_component(files, components, errors, name, payload, texts, notice,
source):
+ prefix = f"licenses/{safe(name)}/"
+ component = {"name": name, "sha256": sha(payload), "source": source,
"files": []}
+ license_texts = [data for filename, data in texts.items() if
LICENSE_NAME.match(PurePosixPath(filename).name)]
+ if any(b"GNU GENERAL PUBLIC LICENSE" in data or b"GNU LESSER GENERAL
PUBLIC LICENSE" in data
+ or b"GNU AFFERO GENERAL PUBLIC LICENSE" in data for data in
license_texts):
+ errors.append(f"{name}: contains GPL-family terms; dual-license
choice/exception and redistribution obligations require manual review")
+ if not license_texts or not any(len(data.strip()) >= 300 for data in
license_texts):
+ errors.append(f"{name}: missing complete upstream LICENSE/COPYING; a
POM name or link is not a substitute")
+ for filename, data in sorted(texts.items()):
+ if not data.strip():
+ errors.append(f"{name}: empty license file {filename}")
+ continue
+ target = prefix + safe(filename) + ".txt"
+ files[target] = data
+ component["files"].append({"path": target, "source": filename,
"sha256": sha(data)})
+ if NOTICE_NAME.match(PurePosixPath(filename).name):
+ notice += f"\n--- {name} / {filename} ---\n".encode() + data +
b"\n"
+ components.append(component)
+ return notice
+
+
+def finish(files, components, errors, license_text, notice, payload):
+ for component in components:
+ license_text += f"\n{component['name']}: complete upstream license,
copyright and additional terms under
META-INF/legal/licenses/{component['name']}/\n".encode()
+ files["LICENSE"] = license_text
+ files["NOTICE"] = notice
+ manifest = {"components": components, "payload": payload, "manualReview":
sorted(set(errors)),
+ "files": {name: sha(data) for name, data in
sorted(files.items())}}
+ files["manifest.json"] = (json.dumps(manifest, ensure_ascii=False,
indent=2) + "\n").encode()
+ return files, manifest
+
+
+def generated_entry(name):
+ return name in {"META-INF/LICENSE", "META-INF/NOTICE"} or
name.startswith("META-INF/legal/")
+
+
+def collect_jar(jar, base=SERVER):
+ files, components, errors, payload = {}, [], [], {}
+ license_text, notice = read(base / "LICENSE"), read(base / "NOTICE")
+ with zipfile.ZipFile(jar) as archive:
+ if len(archive.namelist()) != len(set(archive.namelist())):
+ raise ValueError("distributed JAR contains duplicate ZIP entries")
+ dependencies = sorted(name for name in archive.namelist() if
name.startswith("BOOT-INF/lib/") and name.endswith(".jar"))
+ if not dependencies:
+ raise ValueError("not a Spring Boot JAR containing actual
dependencies; refuse to generate an empty license manifest")
+ for name in archive.namelist():
+ if not name.endswith("/") and not generated_entry(name):
+ payload[safe(name)] = sha(archive.read(name))
+ for name in dependencies:
+ data = archive.read(name)
+ filename = PurePosixPath(name).name
+ texts = texts_from_zip(data)
+ notice = add_component(files, components, errors, filename, data,
texts, notice, name)
+ if filename.startswith("mysql-connector-j-"):
+ errors.append(f"{filename}: applicability of GPLv2 + Universal
FOSS Exception and ASF redistribution licensing pending PMC/legal review; this
is not the Classpath Exception")
+ # The Spring Boot loader itself is also copied into the JAR, not only
the dependencies under BOOT-INF/lib.
+ if any(name.startswith("org/springframework/boot/loader/") and
name.endswith(".class") for name in archive.namelist()):
+ spring_license = "META-INF/LICENSE.txt"
+ spring_notice = "META-INF/NOTICE.txt"
+ if spring_license not in archive.namelist() or spring_notice not
in archive.namelist():
+ errors.append("Spring Boot loader: missing the original
META-INF/LICENSE.txt or NOTICE.txt")
+ else:
+ texts = {name: archive.read(name) for name in [spring_license,
spring_notice]}
+ version = re.search(rb"(?m)^Spring-Boot-Version: ([^\r\n]+)",
archive.read("META-INF/MANIFEST.MF"))
+ if not version:
+ errors.append("Spring Boot loader: missing version source")
+ loader = "spring-boot-loader@" + (version.group(1).decode() if
version else "unknown")
+ notice = add_component(files, components, errors, loader,
b"".join(texts.values()), texts, notice, "JAR root META-INF")
+ return finish(files, components, errors, license_text, notice, payload)
+
+
+def write_bundle(directory, files):
+ directory = Path(directory)
+ for name, data in files.items():
+ target = directory / safe(name)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(data)
+
+
+def embedded_path(name):
+ return f"META-INF/{name}" if name in {"LICENSE", "NOTICE"} else
f"META-INF/legal/{name}"
+
+
+def require_complete(manifest):
+ if manifest["manualReview"]:
+ raise ValueError("release license gate failed:\n" +
"\n".join(manifest["manualReview"]))
+
+
+def check_jar(jar, base=SERVER):
+ files, manifest = collect_jar(jar, base)
+ with zipfile.ZipFile(jar) as archive:
+ for name, expected in files.items():
+ target = embedded_path(name)
+ if target not in archive.namelist() or archive.read(target) !=
expected:
+ raise ValueError(f"JAR is missing legal materials or they do
not match the actual dependencies: {target}")
+ actual = {name for name in archive.namelist() if generated_entry(name)
and not name.endswith("/")}
+ if actual != {embedded_path(name) for name in files}:
+ raise ValueError("JAR has stale or unregistered legal materials")
+ require_complete(manifest)
+ print(f"JAR license check passed: {len(manifest['components'])} actual
components")
+
+
+def package_jar(jar, output, base=SERVER):
+ jar = Path(jar)
+ files, manifest = collect_jar(jar, base)
+ write_bundle(output, files)
+ # Emit the review materials first for inspection, but do not produce a
seemingly compliant distribution package when materials are missing or
licensing is unresolved.
+ require_complete(manifest)
+ with tempfile.NamedTemporaryFile(dir=jar.parent, suffix=".legal.jar",
delete=False) as temporary:
+ temporary_path = Path(temporary.name)
+ try:
+ with zipfile.ZipFile(jar) as source, zipfile.ZipFile(temporary_path,
"w") as target:
+ target.comment = source.comment
+ for entry in source.infolist():
+ if not generated_entry(entry.filename):
+ # Preserve attributes such as ZIP_STORED for nested JARs;
Boot's random-access loading must not break.
+ target.writestr(entry, source.read(entry.filename))
+ for name, data in sorted(files.items()):
+ entry = zipfile.ZipInfo(embedded_path(name), (1980, 1, 1, 0,
0, 0))
+ entry.compress_type = zipfile.ZIP_DEFLATED
+ entry.external_attr = 0o100644 << 16
+ target.writestr(entry, data)
+ check_jar(temporary_path, base)
+ os.replace(temporary_path, jar)
+ finally:
+ if temporary_path.exists():
+ temporary_path.unlink()
+
+
+def collect_npm(directory, node_license, base=SERVER):
+ files, components, errors = {}, [], []
+ license_text, notice = read(base / "LICENSE"), read(base / "NOTICE")
+ directory = Path(directory)
+ package_files = sorted(directory.rglob("package.json"))
+ if not package_files:
+ raise ValueError("global npm directory is empty; cannot verify the
default AI CLI")
+ seen_agents = set()
+ for package_file in package_files:
+ pkg = json.loads(read(package_file))
+ if not pkg.get("name") or not pkg.get("version"):
+ continue
+ name = pkg["name"] + "@" + pkg["version"]
+ package_root = package_file.parent
+ texts = {}
+ for current, dirs, names in os.walk(package_root):
+ # Handle each actually installed sub-package separately; a parent
package's license must not stand in for a sub-package's.
+ dirs[:] = sorted(d for d in dirs if d != "node_modules" and not
(Path(current) / d / "package.json").exists())
+ for filename in names:
+ relative = (Path(current) /
filename).relative_to(package_root).as_posix()
+ if is_legal(relative):
+ texts[relative] = read(Path(current) / filename)
+ # A package name may be installed more than once; the manifest path
keeps the physical install location to avoid overwriting each other.
+ identity = package_file.parent.relative_to(directory).as_posix() + "@"
+ pkg["version"]
+ notice = add_component(files, components, errors, identity,
read(package_file), texts, notice,
+ package_file.relative_to(directory).as_posix())
+ components[-1]["package"] = name
+ components[-1]["declaredLicense"] = pkg.get("license")
+ if not isinstance(pkg.get("license"), str) or pkg["license"] not in
PERMISSIVE:
+ errors.append(f"{name}: declared license {pkg.get('license')!r}
requires manual review")
+ if pkg["name"] in {"@anthropic-ai/claude-code", "@qoder-ai/qodercli"}:
+ seen_agents.add(pkg["name"])
+ errors.append(f"{name}: redistribution licensing for the default
in-image AI CLI and its native payload pending manual review; being installable
via npm is not treated as licensed")
+ expected_agents = {"@anthropic-ai/claude-code", "@qoder-ai/qodercli"}
+ if seen_agents != expected_agents:
+ errors.append("the default AI CLI is not fully installed; the license
check must not be bypassed by omitting components")
+ files["licenses/node/LICENSE.txt"] = read(node_license)
+ license_text += b"\nNode.js and bundled components:
legal/licenses/node/LICENSE.txt\n"
+ return finish(files, components, errors, license_text, notice, {})
+
+
+def scan_headers(root):
+ # Only report pre-existing missing headers in other scopes; do not modify
Java/SQL, tests, or workflow files owned by concurrent agents.
+ names = subprocess.check_output(["git", "-C", str(root), "ls-files",
"-z"]).decode().split("\0")
+ extensions = {".sh", ".py", ".go", ".ts", ".tsx", ".mjs", ".cjs", ".js",
".css", ".html", ".xml", ".yml", ".yaml"}
+ missing = []
+ for name in names:
+ file = root / name
+ if not name or not file.is_file() or (file.suffix not in extensions
and file.name not in {"Dockerfile", "Makefile"}):
+ continue
+ if name.startswith("server/src/") or name.startswith("web/src/test/")
or name == "rmqctl/internal/catalog/catalog_gen.go":
+ continue
+ text = file.read_text(errors="replace")
+ if "Licensed to the Apache Software Foundation" not in text[:5000]:
+ missing.append(name)
+ print(json.dumps({"scope": "tracked non-Java/SQL files outside
concurrent-agent resources/tests", "missing": missing}, ensure_ascii=False,
indent=2))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest="command")
+ headers = sub.add_parser("headers")
+ headers.add_argument("root", type=Path)
+ package = sub.add_parser("jar")
+ package.add_argument("jar", type=Path)
+ package.add_argument("--output", type=Path, required=True)
+ check = sub.add_parser("check-jar")
+ check.add_argument("jar", type=Path)
+ check.add_argument("--output", type=Path)
+ inspect = sub.add_parser("inspect")
+ inspect.add_argument("jar", type=Path)
+ inspect.add_argument("--summary", action="store_true")
+ npm = sub.add_parser("npm")
+ npm.add_argument("directory", type=Path)
+ npm.add_argument("--node-license", type=Path, required=True)
+ npm.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ if args.command is None:
+ parser.error("missing subcommand")
+ if args.command == "headers":
+ scan_headers(args.root)
+ elif args.command == "jar":
+ package_jar(args.jar, args.output)
+ elif args.command == "check-jar":
+ check_jar(args.jar)
+ if args.output:
+ files, _ = collect_jar(args.jar)
+ write_bundle(args.output / "META-INF/legal", files)
+ write_bundle(args.output, {name: files[name] for name in
["LICENSE", "NOTICE"]})
+ elif args.command == "inspect":
+ texts = texts_from_zip(read(args.jar))
+ print(json.dumps({"jar": args.jar.name, "sha256": sha(read(args.jar)),
+ "legalFiles": {name: {"sha256": sha(data), "bytes":
len(data), "text": data[:1200].decode('utf-8', errors='replace') if
args.summary else data.decode('utf-8', errors='replace')}
+ for name, data in texts.items()}},
ensure_ascii=False, indent=2))
+ elif args.command == "npm":
+ files, manifest = collect_npm(args.directory, args.node_license)
+ # Relative paths of in-image materials do not use the JAR's META-INF
prefix.
+ files["LICENSE"] = files["LICENSE"].replace(b"META-INF/legal/",
b"legal/")
+ manifest["files"]["LICENSE"] = sha(files["LICENSE"])
+ files["manifest.json"] = (json.dumps(manifest, ensure_ascii=False,
indent=2) + "\n").encode()
+ write_bundle(args.output, files)
+ require_complete(manifest)
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (ValueError, OSError, zipfile.BadZipFile, KeyError) as error:
+ print(f"license gate: {error}", file=sys.stderr)
+ sys.exit(1)
diff --git a/server/scripts/legal_test.py b/server/scripts/legal_test.py
new file mode 100644
index 000000000..5a52e0608
--- /dev/null
+++ b/server/scripts/legal_test.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import io
+import json
+from pathlib import Path
+import tempfile
+import unittest
+import zipfile
+import xml.etree.ElementTree as ET
+
+import legal
+
+
+class LegalTest(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory(dir=legal.SERVER /
"scripts")
+ self.addCleanup(self.temporary.cleanup)
+ self.directory = Path(self.temporary.name)
+
+ def jar(self, name="fixture-1.0.jar", license_present=True):
+ dependency = io.BytesIO()
+ with zipfile.ZipFile(dependency, "w") as archive:
+ if license_present:
+ archive.writestr("META-INF/LICENSE", legal.read(legal.SERVER /
"LICENSE"))
+ archive.writestr("META-INF/NOTICE", b"fixture required notice\n")
+ archive.writestr("META-INF/COPYRIGHT", b"fixture copyright
statement\n")
+ archive.writestr("fixture/Notice.class", b"not a legal text")
+ jar = self.directory / "app.jar"
+ with zipfile.ZipFile(jar, "w") as archive:
+ archive.writestr("BOOT-INF/lib/" + name, dependency.getvalue(),
compress_type=zipfile.ZIP_STORED)
+ archive.writestr("BOOT-INF/classes/fixture.txt", b"application")
+ return jar
+
+ def jarRoundTripAndCopyrightTest(self):
+ jar = self.jar()
+ legal.package_jar(jar, self.directory / "legal")
+ legal.check_jar(jar)
+ first = jar.read_bytes()
+ legal.package_jar(jar, self.directory / "legal")
+ self.assertEqual(first, jar.read_bytes())
+ with zipfile.ZipFile(jar) as archive:
+ self.assertIn(b"fixture required notice",
archive.read("META-INF/NOTICE"))
+ self.assertEqual(b"fixture copyright statement\n",
archive.read("META-INF/legal/licenses/fixture-1.0.jar/META-INF/COPYRIGHT.txt"))
+ self.assertEqual(zipfile.ZIP_STORED,
archive.getinfo("BOOT-INF/lib/fixture-1.0.jar").compress_type)
+
self.assertNotIn("META-INF/legal/licenses/fixture-1.0.jar/fixture/Notice.class.txt",
archive.namelist())
+
+ def missingLicenseFailsWithoutReplacingJarTest(self):
+ jar = self.jar(license_present=False)
+ original = jar.read_bytes()
+ with self.assertRaisesRegex(ValueError, "missing complete upstream"):
+ legal.package_jar(jar, self.directory / "legal")
+ self.assertEqual(original, jar.read_bytes())
+ self.assertTrue((self.directory / "legal/manifest.json").is_file())
+
+ def mysqlExceptionRequiresReviewTest(self):
+ jar = self.jar("mysql-connector-j-9.7.0.jar")
+ with self.assertRaisesRegex(ValueError, "Universal FOSS Exception"):
+ legal.package_jar(jar, self.directory / "legal")
+
+ def changedPayloadFailsTest(self):
+ jar = self.jar()
+ legal.package_jar(jar, self.directory / "legal")
+ with zipfile.ZipFile(jar, "a") as archive:
+ archive.writestr("BOOT-INF/classes/extra.txt", b"new payload")
+ with self.assertRaisesRegex(ValueError, "do not match the actual
dependencies"):
+ legal.check_jar(jar)
+
+ def defaultAiCliRequiresReviewTest(self):
+ for name in ["@anthropic-ai/claude-code", "@qoder-ai/qodercli"]:
+ directory = self.directory / "npm" / name
+ directory.mkdir(parents=True)
+ # A test fixture must not be treated as having obtained
redistribution rights for a real product just because it declares MIT.
+ (directory / "package.json").write_text(json.dumps({"name": name,
"version": "fixture", "license": "MIT"}))
+ (directory / "LICENSE").write_bytes(legal.read(legal.SERVER /
"LICENSE"))
+ files, manifest = legal.collect_npm(self.directory / "npm",
legal.SERVER / "LICENSE")
+ self.assertIn("licenses/node/LICENSE.txt", files)
+ with self.assertRaisesRegex(ValueError, "AI CLI"):
+ legal.require_complete(manifest)
+
+ def lifecycleAndDockerStaticContractTest(self):
+ pom = ET.parse(legal.SERVER / "pom.xml")
+ ns = {"m": "http://maven.apache.org/POM/4.0.0"}
+ plugins = pom.findall("m:build/m:plugins/m:plugin", ns)
+ names = [plugin.findtext("m:artifactId", namespaces=ns) for plugin in
plugins]
+ self.assertLess(names.index("spring-boot-maven-plugin"),
names.index("maven-antrun-plugin"))
+ gate = plugins[names.index("maven-antrun-plugin")]
+ self.assertEqual("package",
gate.findtext("m:executions/m:execution/m:phase", namespaces=ns))
+ self.assertEqual("true",
gate.find("m:executions/m:execution/m:configuration/m:target/m:exec",
ns).get("failonerror"))
+ docker = (legal.SERVER / "Dockerfile").read_text()
+ self.assertEqual(2, docker.count("legal.py check-jar /app/app.jar"))
+ self.assertIn("npm install -g @anthropic-ai/claude-code
@qoder-ai/qodercli", docker)
+ self.assertIn("COPY --from=rmqctl-build /out/legal", docker)
+
+
+def load_tests(_loader, _tests, _pattern):
+ return unittest.TestSuite(LegalTest(name) for name in LegalTest.__dict__
if name.endswith("Test"))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/web/Dockerfile b/web/Dockerfile
index f5beaefce..a0f2efe6b 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,3 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
# Stage 1: Build
# Pin Node and nginx base images to a specific minor so CI builds are
# reproducible (the bare :20-alpine and :alpine tags are rolling).
@@ -14,6 +29,9 @@ RUN npm run build
FROM nginx:1.27.2-alpine
ENV NGINX_ENVSUBST_FILTER=^RESOLVER$
COPY --from=build /app/dist /usr/share/nginx/html
+# A separate path carries the same build-checked materials; COPY must fail
when they are missing.
+COPY --from=build /app/dist/LICENSE /app/dist/NOTICE
/usr/share/licenses/rocketmq-studio/
+COPY --from=build /app/dist/legal /usr/share/licenses/rocketmq-studio/legal
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY 15-resolver.envsh /docker-entrypoint.d/15-resolver.envsh
RUN chmod +x /docker-entrypoint.d/15-resolver.envsh
diff --git a/web/LICENSE b/web/LICENSE
index c8aa782be..cd86ab26c 100644
--- a/web/LICENSE
+++ b/web/LICENSE
@@ -186,7 +186,7 @@
understanding of the Apache License by reading the FAQ at
http://www.apache.org/foundation/license-faq.html
- Copyright 2026 RocketMQ Studio Contributors
+ Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -199,3 +199,16 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+
+Third-party source materials
+===========================
+This source distribution includes 17 model brand SVG icons from
+@lobehub/icons-static-svg 1.95.0 (https://github.com/lobehub/lobe-icons).
+They are licensed under MIT; the complete upstream license is reproduced in
+src/assets/model-logos/LICENSE. Copyright (c) 2023 LobeHub.
+Upstream license: https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE
+This does not grant rights to third-party trademarks.
+
+Production builds generate distribution-specific LICENSE, NOTICE and legal/
+from the actual bundled modules and these vendored icons. Build-only packages
+are not included unless their code or assets are actually redistributed.
diff --git a/web/NOTICE b/web/NOTICE
index fde8f8e31..7f0bec17a 100644
--- a/web/NOTICE
+++ b/web/NOTICE
@@ -1,5 +1,10 @@
-RocketMQ Studio
-Copyright 2026 RocketMQ Studio Contributors
+Apache RocketMQ Studio
+Copyright 2026 The Apache Software Foundation
This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
+The Apache Software Foundation (https://www.apache.org/).
+
+Third-party source materials
+===========================
+Model brand icons from LobeHub (https://github.com/lobehub/lobe-icons):
+Copyright (c) 2023 LobeHub.
diff --git a/web/licenses/toggle-selection-1.0.6/LICENSE
b/web/licenses/toggle-selection-1.0.6/LICENSE
new file mode 100644
index 000000000..ccca75602
--- /dev/null
+++ b/web/licenses/toggle-selection-1.0.6/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 sudodoki <[email protected]>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/web/package-lock.json b/web/package-lock.json
index f725054c2..61d84f781 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rocketmq-studio-web",
- "version": "0.1.0",
+ "version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rocketmq-studio-web",
- "version": "0.1.0",
+ "version": "3.0.0",
"dependencies": {
"@ant-design/icons": "^5.5.0",
"@phosphor-icons/react": "^2.1.10",
diff --git a/web/package.json b/web/package.json
index 6b375d523..79d31461e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,14 +1,16 @@
{
"name": "rocketmq-studio-web",
"private": true,
- "version": "0.1.0",
+ "version": "3.0.0",
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "build": "tsc -b && vite build && npm run license:check",
+ "license:check": "node scripts/licenses.mjs check",
+ "license:test": "node --test scripts/licenses.test.mjs",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
diff --git a/web/scripts/licenses.d.mts b/web/scripts/licenses.d.mts
new file mode 100644
index 000000000..483c19ce9
--- /dev/null
+++ b/web/scripts/licenses.d.mts
@@ -0,0 +1,18 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import type { Plugin } from 'vite';
+export function distributionLicenses(): Plugin;
diff --git a/web/scripts/licenses.mjs b/web/scripts/licenses.mjs
new file mode 100644
index 000000000..d48d68857
--- /dev/null
+++ b/web/scripts/licenses.mjs
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { createHash } from 'node:crypto';
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const legalName =
/^(licen[sc]e|notice|copying|copyright|patents|authors)([._-].*)?$/i;
+const approved = new Set(['MIT', 'Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause',
'ISC', '0BSD']);
+const sha = (data) => createHash('sha256').update(data).digest('hex');
+const read = (name) => {
+ const data = readFileSync(name);
+ if (!data.toString().trim()) throw new Error(`empty license material:
${name}`);
+ return data;
+};
+const json = (name) => JSON.parse(read(name));
+const slash = (name) => name.split(path.sep).join('/');
+
+function packageRoot(id, base) {
+ let dir = path.dirname(id);
+ while (dir !== base && dir !== path.dirname(dir)) {
+ const file = path.join(dir, 'package.json');
+ if (existsSync(file) && json(file).name) return dir;
+ dir = path.dirname(dir);
+ }
+ throw new Error(`cannot find the package.json of an actually bundled module:
${id}`);
+}
+
+function legalFiles(dir) {
+ const result = [];
+ function walk(current) {
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
+ if (['node_modules', '.git', 'test', 'tests',
'__tests__'].includes(entry.name)) continue;
+ const file = path.join(current, entry.name);
+ if (entry.isDirectory()) walk(file);
+ else if (legalName.test(entry.name) &&
!/\.(js|cjs|mjs|ts|tsx|jsx|class|map|svg|png|jpg|gif|woff2?)$/i.test(entry.name))
{
+ if (!entry.isFile()) throw new Error(`license file must be a regular
file: ${file}`);
+ result.push(file);
+ }
+ }
+ }
+ walk(dir);
+ return result.sort();
+}
+
+export function collectLicenses(moduleIds, base = root) {
+ const packages = new Map();
+ const files = new Map();
+ const components = [];
+ const baseText = (name) => read(path.join(base,
name)).toString().split('\nThird-party source materials\n')[0];
+ let license = baseText('LICENSE');
+ let notice = baseText('NOTICE');
+ const ids = [...new Set(moduleIds)].sort();
+ let hasIcons = false;
+ for (const original of ids) {
+ const id = original.replace(/^\0/, '').split('?')[0];
+ if (id.includes('src/assets/model-logos/') && id.endsWith('.svg'))
hasIcons = true;
+ if (id.includes('node_modules/')) {
+ const dir = packageRoot(path.resolve(base, id), base);
+ packages.set(dir, 'bundled-module');
+ } else if (id.includes('vite/') || id.includes('commonjsHelpers')) {
+ packages.set(path.join(base, 'node_modules/vite'),
'bundled-runtime-helper');
+ }
+ }
+ // Tailwind's preflight CSS ends up in the bundle; do not omit it just
because it is listed under devDependencies.
+ for (const id of ids.filter((name) => name.endsWith('.css') &&
!name.includes('node_modules/'))) {
+ if (/@tailwind\s+base\s*;/.test(read(path.resolve(base, id)).toString())) {
+ packages.set(path.join(base, 'node_modules/tailwindcss'),
'bundled-preflight-css');
+ }
+ }
+ if (hasIcons) packages.set(path.join(base,
'node_modules/@lobehub/icons-static-svg'), 'vendored-svg');
+ if (packages.size === 0) throw new Error('no verifiable third-party
component in the build; refuse to generate an empty manifest');
+
+ for (const [dir, reason] of [...packages].sort(([a], [b]) =>
a.localeCompare(b, 'en'))) {
+ const pkg = json(path.join(dir, 'package.json'));
+ if (!pkg.version || !approved.has(pkg.license)) {
+ throw new Error(`redistribution license requires manual review:
${pkg.name}@${pkg.version}: ${JSON.stringify(pkg.license)}`);
+ }
+ let sources = legalFiles(dir);
+ const sourceURLs = {};
+ const sourceNames = {};
+ const sourceContent = {};
+ const identity = `${pkg.name}@${pkg.version}`;
+ if (identity === '@ant-design/[email protected]') {
+ // Verified against the repository-level LICENSE at the npm gitHead;
identical to the installed icons 5.6.1 text.
+ const fallback = path.join(base,
'node_modules/@ant-design/icons/LICENSE');
+ if (json(path.join(base,
'node_modules/@ant-design/icons/package.json')).version !== '5.6.1') {
+ throw new Error('the icons license source version changed;
re-verification required');
+ }
+ if (sha(read(fallback)) !==
'5d367fb0a07340571542eb4ee8eb1add62d37b71bad6786a57c2e9a86bf76c70') {
+ throw new Error('the icons upstream license text changed;
re-verification required');
+ }
+ sources.push(fallback);
+ sourceURLs[fallback] =
'https://github.com/ant-design/ant-design-icons/blob/e6d33f4ba94ebe9f4b8648373342505b88e51a57/LICENSE';
+ }
+ if (['[email protected]', '[email protected]'].includes(identity)) {
+ // These two installed modules put the full MIT text in their README;
extract only the complete license section, do not infer copyright.
+ const source = path.join(dir, 'README.md');
+ const match = read(source).toString().match(/\(The MIT
License\)\r?\n[\s\S]*?SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE\.\r?\n/);
+ if (!match) throw new Error(`the complete upstream license section is
missing from the README: ${identity}`);
+ sources.push(source);
+ sourceNames[source] = 'LICENSE-from-README';
+ sourceContent[source] = Buffer.from(match[0]);
+ }
+ if (identity === '[email protected]') {
+ // The 1.0.6 npm package omits LICENSE; the index.js from the immutable
upstream commit below is identical to the package, and the MIT text is supplied.
+ const fallback = path.join(base,
'licenses/toggle-selection-1.0.6/LICENSE');
+ if (sha(read(path.join(dir, 'index.js'))) !==
'd1a1caf366f8ae5ed3cf4a87c42c46e73bb5536acbcbc5ca479c180d4d3e7756'
+ || sha(read(fallback)) !==
'5149051aed807f78acfbf9a43ac66368374a8fa1f9dfc092b73de5a67d42673a') {
+ throw new Error('toggle-selection code or upstream license text does
not match the verified source');
+ }
+ sources.push(fallback, path.join(dir, 'README.md'));
+ sourceURLs[fallback] =
'https://github.com/sudodoki/toggle-selection/blob/888650b271ee4937e2baff6a43bee744635601e3/LICENSE';
+ sourceNames[path.join(dir, 'README.md')] = 'ATTRIBUTION';
+ }
+ if (reason === 'vendored-svg') {
+ if (pkg.version !== '1.95.0') throw new Error('the LobeHub version
changed; the vendored SVG source must be re-approved');
+ const vendoredDir = path.join(base, 'src/assets/model-logos');
+ const icons = readdirSync(vendoredDir).filter((name) =>
name.endsWith('.svg')).sort();
+ if (icons.length !== 17) throw new Error('the vendored SVG list changed;
the source must be re-approved');
+ for (const name of icons) {
+ if (!read(path.join(vendoredDir, name)).equals(read(path.join(dir,
'icons', name)))) {
+ throw new Error(`a vendored icon does not match the locked upstream
text: ${name}`);
+ }
+ }
+ const fallback = path.join(vendoredDir, 'LICENSE');
+ sources = [...new Set([...sources, fallback])];
+ sourceURLs[fallback] =
'https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE';
+ notice += '\nModel brand icons from LobeHub
(https://github.com/lobehub/lobe-icons):\nCopyright (c) 2023 LobeHub.\n';
+ }
+ if (!sources.some((file) =>
/^(licen[sc]e|copying)([._-].*)?$/i.test(sourceNames[file] ||
path.basename(file)))) {
+ throw new Error(`missing the complete upstream license text:
${pkg.name}@${pkg.version}`);
+ }
+ const component = { name: pkg.name, version: pkg.version, license:
pkg.license, reason, files: [] };
+ if (identity === '[email protected]') {
+ component.payloadSource = { sha256: sha(read(path.join(dir,
'index.js'))),
+ upstream:
'https://github.com/sudodoki/toggle-selection/blob/888650b271ee4937e2baff6a43bee744635601e3/index.js'
};
+ }
+ for (const source of sources) {
+ const data = sourceContent[source] || read(source);
+ if (/^(licen[sc]e|copying)/i.test(sourceNames[source] ||
path.basename(source)) && data.length < 300) {
+ throw new Error(`license text too short; a link or SPDX id cannot
replace the full text: ${source}`);
+ }
+ const relative = sourceNames[source] || (sourceURLs[source] ? 'LICENSE'
: slash(path.relative(dir, source)));
+ const target =
`legal/licenses/${pkg.name}@${pkg.version}/${relative}.txt`;
+ if (files.has(target) && !files.get(target).equals(data)) throw new
Error(`conflicting license file: ${target}`);
+ files.set(target, data);
+ component.files.push({ path: target, source: sourceURLs[source] ||
slash(path.relative(base, source)), sha256: sha(data) });
+ if (/^notice([._-]|$)/i.test(path.basename(source))) {
+ notice += `\n--- ${pkg.name} ${pkg.version} / ${relative}
---\n${data}\n`;
+ }
+ }
+ components.push(component);
+ license += `\n${pkg.name} ${pkg.version} (${pkg.license}):
legal/licenses/${pkg.name}@${pkg.version}/\n`;
+ }
+ files.set('LICENSE', Buffer.from(license));
+ files.set('NOTICE', Buffer.from(notice));
+ return { files, components, modules: ids };
+}
+
+export function distributionLicenses() {
+ let base;
+ return {
+ name: 'distribution-licenses',
+ apply: 'build',
+ enforce: 'post',
+ configResolved(config) { base = config.root; },
+ generateBundle(_options, bundle) {
+ const ids = new Set();
+ for (const item of Object.values(bundle)) {
+ if (item.type !== 'chunk') continue;
+ for (const [id, module] of Object.entries(item.modules)) {
+ if (module.renderedLength > 0 || /\.(css|svg)(\?|$)/.test(id)) {
+ ids.add(id.startsWith('\0') ? id : slash(path.relative(base, id)));
+ }
+ }
+ }
+ const result = collectLicenses([...ids], base);
+ const outputFiles = {};
+ for (const [name, item] of Object.entries(bundle)) {
+ outputFiles[name] = sha(item.type === 'chunk' ? item.code :
item.source);
+ }
+ const manifest = { modules: result.modules, components:
result.components, outputFiles, files: {} };
+ for (const [name, data] of result.files) {
+ manifest.files[name] = sha(data);
+ this.emitFile({ type: 'asset', fileName: name, source: data });
+ }
+ this.emitFile({ type: 'asset', fileName: 'legal/manifest.json', source:
`${JSON.stringify(manifest, null, 2)}\n` });
+ },
+ };
+}
+
+export function checkDistribution(directory = path.join(root, 'dist'), base =
root) {
+ const manifest = json(path.join(directory, 'legal/manifest.json'));
+ const result = collectLicenses(manifest.modules, base);
+ if (JSON.stringify(manifest.components) !==
JSON.stringify(result.components)) throw new Error('dependency versions/license
texts changed; a rebuild is required');
+ if (Object.keys(manifest.files).length !== result.files.size) throw new
Error('the license manifest is incomplete');
+ for (const [name, data] of result.files) {
+ if (manifest.files[name] !== sha(data) || !read(path.join(directory,
name)).equals(data)) throw new Error(`legal material modified or missing:
${name}`);
+ }
+ for (const [name, checksum] of Object.entries(manifest.outputFiles)) {
+ const file = path.resolve(directory, name);
+ if (!file.startsWith(`${path.resolve(directory)}${path.sep}`) ||
sha(read(file)) !== checksum) throw new Error(`build artifact verification
failed: ${name}`);
+ }
+ if (!statSync(path.join(directory, 'LICENSE')).isFile()) throw new
Error('missing LICENSE');
+ console.log(`web license check passed: ${result.components.length} actual
components, ${result.files.size} license files`);
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) ===
fileURLToPath(import.meta.url)) {
+ try {
+ if (process.argv[2] === 'audit') {
+ // Only pre-check the materials of installed candidate dependencies;
this set is not used for the binary attribution manifest.
+ const lock = json(path.join(root, 'package-lock.json'));
+ const candidates = Object.entries(lock.packages)
+ .filter(([name, pkg]) => name.includes('node_modules/') && !pkg.dev &&
existsSync(path.join(root, name, 'package.json')))
+ .map(([name]) => `${name}/index.js`);
+ candidates.push('src/assets/model-logos/openai.svg',
'node_modules/vite/index.js', 'node_modules/tailwindcss/index.js');
+ const errors = [];
+ for (const id of candidates) {
+ try { collectLicenses([id]); } catch (error) {
errors.push(error.message); }
+ }
+ console.log(`license pre-check: ${candidates.length} candidate
components, ${errors.length} gaps; the final distribution manifest is still
based on the actually bundled modules`);
+ if (errors.length) throw new Error(errors.join('\n'));
+ } else {
+ if (process.argv[2] !== 'check') throw new Error('usage: node
scripts/licenses.mjs check [dist] | audit');
+ checkDistribution(process.argv[3]);
+ }
+ } catch (error) {
+ console.error(`license gate: ${error.message}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/web/scripts/licenses.test.mjs b/web/scripts/licenses.test.mjs
new file mode 100644
index 000000000..75bb3188d
--- /dev/null
+++ b/web/scripts/licenses.test.mjs
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from
'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import test from 'node:test';
+import { build } from 'vite';
+import { checkDistribution, collectLicenses, distributionLicenses } from
'./licenses.mjs';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+function temporary(t) {
+ const directory = mkdtempSync(path.join(root,
'node_modules/.license-test-'));
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
+ return directory;
+}
+function write(directory, name, content) {
+ const target = path.join(directory, name);
+ mkdirSync(path.dirname(target), { recursive: true });
+ writeFileSync(target, content);
+}
+
+test('vendoredSvgAndRuntimeOnlyTest', () => {
+ const result = collectLicenses(['src/assets/model-logos/openai.svg',
'node_modules/react/index.js']);
+ assert.deepEqual(result.components.map((c) => c.name),
['@lobehub/icons-static-svg', 'react']);
+ assert.match(result.files.get('NOTICE').toString(), /Copyright \(c\) 2023
LobeHub/);
+
assert.match(result.files.get('legal/licenses/@lobehub/[email protected]/LICENSE.txt').toString(),
/THE SOFTWARE IS PROVIDED "AS IS"/);
+ assert.doesNotMatch(result.files.get('LICENSE').toString(),
/src\/assets\/model-logos/);
+});
+
+test('upstreamFallbacksKeepCompleteTextTest', () => {
+ const result =
collectLicenses(['node_modules/@ant-design/icons-svg/index.js',
'node_modules/toggle-selection/index.js',
+ 'node_modules/agent-base/index.js',
'node_modules/https-proxy-agent/index.js']);
+ assert([...result.files.keys()].every((name) => !name.endsWith('.svg.txt')));
+
assert.match(result.files.get('legal/licenses/[email protected]/LICENSE-from-README.txt').toString(),
/Copyright \(c\) 2013 Nathan Rajlich/);
+
assert.match(result.files.get('legal/licenses/[email protected]/ATTRIBUTION.txt').toString(),
/shvaikalesh/);
+
assert.match(result.files.get('legal/licenses/@ant-design/[email protected]/LICENSE.txt').toString(),
/2018-present Ant UED/);
+});
+
+test('missingOrUnknownLicenseFailsTest', (t) => {
+ const directory = temporary(t);
+ for (const name of ['LICENSE', 'NOTICE']) write(directory, name,
readFileSync(path.join(root, name)));
+ write(directory, 'node_modules/example/package.json', JSON.stringify({ name:
'example', version: '1', license: 'MIT' }));
+ assert.throws(() => collectLicenses(['node_modules/example/index.js'],
directory), /missing the complete upstream license/);
+ write(directory, 'node_modules/example/LICENSE', 'MIT');
+ assert.throws(() => collectLicenses(['node_modules/example/index.js'],
directory), /license text too short/);
+ write(directory, 'node_modules/example/package.json', JSON.stringify({ name:
'example', version: '1', license: 'UNLICENSED' }));
+ assert.throws(() => collectLicenses(['node_modules/example/index.js'],
directory), /manual review/);
+});
+
+test('vitePackagingAndTamperGateTest', async (t) => {
+ const directory = temporary(t);
+ // Build only fixtures for React, CSS, SVG and small dependencies; do not
build the app or run app tests.
+ const result = await build({
+ root,
+ configFile: false,
+ logLevel: 'error',
+ plugins: [
+ {
+ name: 'license-fixture',
+ resolveId(id) { if (id === 'license-fixture') return
'\0license-fixture'; },
+ load(id) {
+ if (id === '\0license-fixture') return `import React from
'${root}node_modules/react/index.js'; import logo from
'${root}src/assets/model-logos/openai.svg'; import '${root}src/index.css';
import toggle from '${root}node_modules/toggle-selection/index.js';
console.log(React, logo, toggle);`;
+ },
+ },
+ distributionLicenses(),
+ ],
+ build: { write: false, minify: false, rollupOptions: { input:
'license-fixture' } },
+ });
+ for (const output of result.output) write(directory, output.fileName,
output.type === 'chunk' ? output.code : output.source);
+ checkDistribution(directory);
+ const manifest = JSON.parse(readFileSync(path.join(directory,
'legal/manifest.json')));
+ assert(manifest.components.some((c) => c.name === 'react'));
+ assert(manifest.components.some((c) => c.name ===
'@lobehub/icons-static-svg'));
+ assert(manifest.components.some((c) => c.name === 'tailwindcss' && c.reason
=== 'bundled-preflight-css'));
+ assert(manifest.components.some((c) => c.name === 'toggle-selection'));
+ assert(!manifest.components.some((c) => c.name === 'vitest' || c.name ===
'eslint'));
+ write(directory, 'NOTICE', 'tampered');
+ assert.throws(() => checkDistribution(directory), /modified or missing/);
+ write(directory, 'NOTICE', result.output.find((item) => item.fileName ===
'NOTICE').source);
+ const output = Object.keys(manifest.outputFiles)[0];
+ write(directory, output, 'tampered');
+ assert.throws(() => checkDistribution(directory), /build artifact
verification failed/);
+});
diff --git a/web/src/assets/model-logos/LICENSE
b/web/src/assets/model-logos/LICENSE
index 758e68d9a..7db4f140a 100644
--- a/web/src/assets/model-logos/LICENSE
+++ b/web/src/assets/model-logos/LICENSE
@@ -1,4 +1,21 @@
-Model brand icons in this directory are from lobehub/lobe-icons
-(https://github.com/lobehub/lobe-icons), licensed under the MIT License.
-Copyright (c) 2023-2026 LobeHub. All brand marks remain the property of
-their respective owners and are used here as small status badges only.
+MIT License
+
+Copyright (c) 2023 LobeHub
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 6cf189487..09bcb39fc 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { App, Modal, message } from 'antd';
+import { App, Modal } from 'antd';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
@@ -325,10 +325,12 @@ describe('Consumer page', () => {
expect(screen.queryByRole('button', { name: /删除 \(1\)$/
})).not.toBeInTheDocument();
});
- afterEach(() => {
- cleanup();
- Modal.destroyAll();
- message.destroy();
+ afterEach(async () => {
+ await act(async () => {
+ cleanup();
+ Modal.destroyAll();
+ });
+ // 静态提示由全局清理卸载,避免冷调用 destroy 再创建异步 root。
});
it('clamps back to a valid page when the current page becomes empty after a
delete', async () => {
diff --git a/web/src/pages/settings/AboutTab.tsx
b/web/src/pages/settings/AboutTab.tsx
index f08ab7473..3042044fc 100644
--- a/web/src/pages/settings/AboutTab.tsx
+++ b/web/src/pages/settings/AboutTab.tsx
@@ -23,12 +23,12 @@ const { Title, Text, Link: TypoLink } = Typography;
export const AboutTab = () => (
<div style={{ maxWidth: 800 }}>
<Descriptions column={1} bordered size="small">
- <Descriptions.Item label="版本">0.1.0</Descriptions.Item>
+ <Descriptions.Item label="版本">3.0.0</Descriptions.Item>
<Descriptions.Item label="构建提交">{__BUILD_COMMIT__}</Descriptions.Item>
<Descriptions.Item label="构建时间">{__BUILD_TIME__}</Descriptions.Item>
<Descriptions.Item label="RocketMQ 支持版本">4.x / 5.x</Descriptions.Item>
<Descriptions.Item label="前端框架">React 18 + Ant Design
5</Descriptions.Item>
- <Descriptions.Item label="后端框架">Spring Boot 3 + RocketMQ MCP
Server</Descriptions.Item>
+ <Descriptions.Item label="后端框架">Spring Boot 4.1 + RocketMQ MCP
Server</Descriptions.Item>
<Descriptions.Item label="License">Apache 2.0</Descriptions.Item>
</Descriptions>
diff --git a/web/src/test/setup.test.ts b/web/src/test/setup.test.ts
new file mode 100644
index 000000000..2aa92aa34
--- /dev/null
+++ b/web/src/test/setup.test.ts
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act, renderHook } from '@testing-library/react';
+import { message, notification } from 'antd';
+import { createElement, useEffect } from 'react';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+
+const realSetTimeout = globalThis.setTimeout;
+
+// 故意跨用例检查全局 afterEach 的结果,不能在断言前主动清理或用 act 冲掉遗留任务。
+describe.sequential('全局提示清理生命周期', () => {
+ const errors = vi.spyOn(console, 'error');
+ const warnings = vi.spyOn(console, 'warn');
+ const fragments = vi.spyOn(document, 'createDocumentFragment');
+ const onClose = vi.fn();
+ const onUnmount = vi.fn();
+
+ function Notice({ text }: { text: string }) {
+ useEffect(() => () => onUnmount(), []);
+ return text;
+ }
+
+ beforeAll(() => {
+ // 保留真实微任务与 React 调度器,仅记录提示定时器和动画帧。
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'setInterval',
+ 'clearInterval',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ });
+
+ function expectClean() {
+ expect(document.querySelector('.ant-message,
.ant-notification')).toBeNull();
+ expect(vi.getTimerCount()).toBe(0);
+ // spy 默认透传日志;任何警告仍会打印并导致回归失败。
+ expect(errors).not.toHaveBeenCalled();
+ expect(warnings).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ }
+
+ async function expectSettled() {
+ expectClean();
+ // 给微任务和真实事件循环一次运行机会,确认不是仅在清理返回瞬间为空。
+ await new Promise<void>((resolve) => realSetTimeout(resolve, 0));
+ await vi.advanceTimersByTimeAsync(10_000);
+ expectClean();
+ }
+
+ afterAll(async () => {
+ try {
+ await expectSettled();
+ } finally {
+ vi.useRealTimers();
+ errors.mockRestore();
+ warnings.mockRestore();
+ fragments.mockRestore();
+ }
+ });
+
+ it('coldCleanupWithoutOpeningNoticesTest', () => {
+ expectClean();
+ });
+
+ it('coldCleanupLeavesNoAsyncWorkTest', async () => {
+ await expectSettled();
+ expect(fragments).not.toHaveBeenCalled();
+ });
+
+ it('opensTimedNoticesForGlobalCleanupTest', async () => {
+ await act(async () => {
+ message.info({
+ content: createElement(Notice, { text: '定时消息' }),
+ duration: 3,
+ onClose,
+ });
+ notification.open({
+ message: createElement(Notice, { text: '定时通知' }),
+ duration: 3,
+ onClose,
+ });
+ });
+ expect(document.querySelector('.ant-message')).not.toBeNull();
+ expect(document.querySelector('.ant-notification')).not.toBeNull();
+ expect(vi.getTimerCount()).toBeGreaterThan(0);
+ });
+
+ it('timedCleanupLeavesNoAsyncWorkAndSupportsReuseTest', async () => {
+ await expectSettled();
+ expect(onUnmount).toHaveBeenCalledTimes(2);
+ await act(async () => {
+ message.loading({ content: '常驻消息', duration: 0, onClose });
+ notification.open({ message: '常驻通知', duration: 0, onClose });
+ });
+ expect(document.body).toHaveTextContent('常驻消息');
+ expect(document.body).toHaveTextContent('常驻通知');
+ });
+
+ it('cleansNoticesCreatedDuringComponentUnmountTest', async () => {
+ await expectSettled();
+ renderHook(() =>
+ useEffect(
+ () => () => {
+ message.info({ content: '卸载消息', duration: 3, onClose });
+ notification.open({ message: '卸载通知', duration: 3, onClose });
+ },
+ [],
+ ),
+ );
+ });
+
+ it('unmountCleanupLeavesNoAsyncWorkTest', async () => {
+ await expectSettled();
+ });
+});
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
index 02fa8a0ef..4617a8cf9 100644
--- a/web/src/test/setup.ts
+++ b/web/src/test/setup.ts
@@ -1,22 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
/// <reference types="vitest/globals" />
import '@testing-library/jest-dom/vitest';
-import { configure } from '@testing-library/react';
-import { message, notification } from 'antd';
+import { act, cleanup, configure } from '@testing-library/react';
+import { unstableSetRender } from 'antd';
+import { actDestroy as resetMessage } from 'antd/lib/message';
+import { actDestroy as resetNotification } from 'antd/lib/notification';
+
+// 透传 antd 原始渲染,仅保存它返回的卸载函数,让静态 holder 也遵循测试生命周期。
+const render = unstableSetRender();
+const unmounts = new Set<() => Promise<void>>();
+unstableSetRender((node, container) => {
+ const unmount = render(node, container);
+ const dispose = async () => {
+ await unmount();
+ unmounts.delete(dispose);
+ };
+ unmounts.add(dispose);
+ return dispose;
+});
-// findBy*/waitFor default to 1s, which antd's async rendering exceeds once
the whole
-// suite runs in parallel.
+// 全量并行时 antd 异步渲染可能超过 findBy*/waitFor 默认的 1 秒。
configure({ asyncUtilTimeout: 5000 });
-// Clean up localStorage between tests
+// 测试之间清理本地存储。
beforeEach(() => {
localStorage.clear();
});
-// The static `message`/`notification` APIs render into body-level holders
that RTL's cleanup
-// does not own: their notices (3s duration plus a leave animation) survive
into later tests,
-// and their rc-notification timers fire mid-test on an orphaned React root —
the source of
-// cross-test DOM pollution and "window is not defined" teardown errors under
parallel load.
-afterEach(() => {
- message.destroy();
- notification.destroy();
+// destroy 冷调用也会创建 root,热调用仅开始退场动画;不能用它代替卸载。
+// 先等待组件卸载及其提示队列初始化,再卸载静态 root,取消动画与自动关闭定时器。
+afterEach(async () => {
+ await act(async () => {
+ cleanup();
+ });
+ await act(async () => {
+ await Promise.all([...unmounts].map((unmount) => unmount()));
+ });
+ // 使用 antd 的测试专用重置入口;lib 与 antd 主入口共用同一组静态实例。
+ resetMessage();
+ resetNotification();
});
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 8cc69c618..70944843f 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,6 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
+import { distributionLicenses } from './scripts/licenses.mjs';
function formatBuildTime(date: Date): string {
// Build runs in a UTC container; render the timestamp in UTC+8.
@@ -17,7 +34,7 @@ export default defineConfig(({ mode }) => {
const buildCommit = env.VITE_GIT_COMMIT || 'dev';
const buildTime = formatBuildTime(new Date());
return {
- plugins: [react()],
+ plugins: [react(), distributionLicenses()],
define: {
__BUILD_COMMIT__: JSON.stringify(buildCommit),
__BUILD_TIME__: JSON.stringify(buildTime),
@@ -53,6 +70,8 @@ export default defineConfig(({ mode }) => {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
+ // Scope vitest to src/; scripts/*.test.mjs are node:test files run by
`npm run license:test`.
+ include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
css: true,
// antd interactions driven through userEvent are slow in jsdom, and the
default
// 5s budget is exceeded once the whole suite runs in parallel.