Compare commits

...
15 Commits
Author SHA1 Message Date
cheney 8ff4917d0c feat(kit): 实现 tcpc tcp 报文发收命令
TDevOpsCICD / build-kit (push) Successful in 6m11s
- tcpc 支持 hex/文本发送、--headlen 大端长度头自动填充与收包校验、超时控制

refactor(killport): 支持 --dry 模式,Linux 下通过 docker-proxy 定位容器内真实进程(拆分至 linux/portpid.js)
2026-10-16 17:29:40 +08:00
cheney 40d5dea9cc feat(glance): --report 传端口号时导出报告到临时目录并开启 http 服务在线查看
TDevOpsCICD / build-kit (push) Successful in 5m50s
2026-09-18 22:32:46 +08:00
cheney 00aaf282bb fix(glance): 端口监控透过 docker-proxy 定位容器内真实进程
TDevOpsCICD / build-kit (push) Successful in 6m3s
2026-09-18 12:00:16 +08:00
cheney 072c9bfa50 新增 glance 进程监控命令:采集 cpu/内存/句柄数并生成报告
TDevOpsCICD / build-kit (push) Successful in 5m54s
2026-09-18 10:00:15 +08:00
jif2.zhang 593ac96c8c feat: kit 增加 registry 命令对接配置中心
TDevOpsCICD / build-kit (push) Successful in 5m26s
2026-09-11 14:14:18 +08:00
jif2.zhang 31b50f9f43 docs: registry 模块设计文档 + 网站使用介绍
TDevOpsCICD / build-kit (push) Successful in 5m25s
2026-09-11 11:26:57 +08:00
jif2.zhang e5e38a16eb website: 首页增加下载卡片,整理文档内容
TDevOpsCICD / build-kit (push) Successful in 5m33s
2026-09-11 11:06:39 +08:00
jif2.zhang 297c2366f2 ci: 修复 GitHub 仓库为空时 clone 失败的问题
TDevOpsCICD / build-kit (push) Successful in 5m27s
2026-09-11 10:00:14 +08:00
jif2.zhang 97c3b439fc ci: 移除 Gitea packages 上传步骤,仅保留 R2 存档
TDevOpsCICD / build-kit (push) Failing after 5m29s
2026-09-11 09:39:03 +08:00
jif2.zhang 348486fd38 fix(build): Linux bun 构建同样排除 ssh2 的可选原生模块
TDevOpsCICD / build-kit (push) Failing after 3m1s
2026-09-11 09:32:33 +08:00
jif2.zhang b6bd70611d fix(build): 交叉编译 Windows 时排除 ssh2 的可选原生模块
TDevOpsCICD / build-kit (push) Failing after 1m19s
2026-09-11 09:25:09 +08:00
cheney bfc29ea262 更新 cicd:构建后发布 R2 + website 到 GitHub,密钥走配置中心
TDevOpsCICD / build-kit (push) Failing after 55s
2026-09-08 10:58:16 +08:00
cheney 1a929761d5 执行成功,但是目标代码没有保存下来。
TDevOPsCICD / build-image (push) Successful in 4m25s
2026-07-21 14:49:59 +08:00
cheney 52b8e57fc7 修改 cicd 运行环境
TDevOPsCICD / build-image (push) Successful in 4m58s
2026-07-21 14:41:27 +08:00
cheney f01eb56097 save
TDevOPsCICD / build-image (push) Waiting to run
2026-07-21 14:24:13 +08:00
36 changed files with 2387 additions and 282 deletions
+134 -22
View File
@@ -1,34 +1,146 @@
name: TDevOPsCICD
name: TDevOpsCICD
on: [push]
jobs:
build-image:
runs-on: workbench
build-kit:
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Build Binary
- name: 从配置中心加载密钥
env:
OBJECT_TOKEN: ${{ secrets.OBJECT_TOKEN }} # 需要在Gitea项目设置中配置的密钥
GITEA_BASE_URL: http://git.honor3.com # 你的Gitea实例地址
PACKAGE_OWNER: Fullstack # 仓库所有者用户名
REPO_NAME: kit.program # 仓库名
PACKAGE_NAME: kit
PACKAGE_VERSION: ${{ github.ref_name }} # 使用分支名或标签作为版本
CONFIG_CENTER_URL: ${{ secrets.CONFIG_CENTER_URL }}
CONFIG_CENTER_TOKEN: ${{ secrets.CONFIG_CENTER_TOKEN }}
run: |
set -e
BASE_URL="${CONFIG_CENTER_URL:-http://bh.vps.honor3.com:8008}"
AUTH_ARGS=()
if [ -n "$CONFIG_CENTER_TOKEN" ]; then
AUTH_ARGS=(-H "Authorization: Bearer ${CONFIG_CENTER_TOKEN}")
fi
curl -fsSL "${AUTH_ARGS[@]}" "$BASE_URL/api/projects" -o /tmp/cc-projects.json
node -e '
const fs = require("fs");
const projects = JSON.parse(fs.readFileSync("/tmp/cc-projects.json", "utf8")).data;
const proj = projects.find(p => p.name === "CommonExternalService");
if (!proj) { console.error("project CommonExternalService not found"); process.exit(1); }
fs.writeFileSync("/tmp/cc-project-id", proj.id);
'
PROJECT_ID=$(cat /tmp/cc-project-id)
curl -fsSL "${AUTH_ARGS[@]}" "$BASE_URL/api/projects/$PROJECT_ID/configs" -o /tmp/cc-configs.json
node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync("/tmp/cc-configs.json", "utf8")).data;
const allow = ["CF_API_TOKEN","CF_R2_PUBLIC_URL","CF_R2_BUCKET","GH_TOKEN","GH_OWNER","GH_REPO","GH_BRANCH"];
const loaded = [];
for (const c of data) {
const key = String(c.key).split("/").pop();
if (allow.includes(key) && c.value) {
fs.appendFileSync(process.env.GITHUB_ENV, `${key}=${c.value}\n`);
loaded.push(key);
}
}
console.log("loaded config keys:", loaded.join(", "));
'
- name: Setup Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
echo "BUN_INSTALL=$HOME/.bun" >> $GITHUB_ENV
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- name: Install pkg
run: npm i -g pkg
- name: Install deps
run: |
echo ----------------------------------------
echo ${{ github.ref_name }} ${{ github.sha }}
bun -v
ping -c 3 git.honor3.com
cd kit
bun i
- name: Build Windows (bun 交叉编译)
run: |
cd kit
bun run windows
curl -X POST \
-H "Authorization: token $OBJECT_TOKEN" \
-F "data=@./dist/windows/kit.exe" \
"$GITEA_BASE_URL/api/packages/$PACKAGE_OWNER/generic/$REPO_NAME/$PACKAGE_VERSION/kit.exe"
ls -la ./dist/windows/
- name: Build Linux (bun, 现代 glibc)
run: |
cd kit
bun run linux
curl -X POST \
-H "Authorization: token $OBJECT_TOKEN" \
-F "data=@./dist/linux/kit" \
"$GITEA_BASE_URL/api/packages/$PACKAGE_OWNER/generic/$REPO_NAME/$PACKAGE_VERSION/kit"
ls -la ./dist/linux/
- name: Build Linux (pkg, 低版本 glibc / Kylin V10) + 打包 zip
run: |
set -e
cd kit
# pkg 首次构建需要从 GitHub 下载 node14 基础运行时,网络不稳时重试
for i in 1 2 3; do npm run pkg-linux && break || sleep 5; done
test -f ./dist/kylin/kit || { echo "pkg build failed"; exit 1; }
if ! command -v zip >/dev/null 2>&1; then
(apt-get update -qq && apt-get install -y -qq zip) >/dev/null 2>&1 || true
fi
command -v zip >/dev/null 2>&1 || { echo "zip not available"; exit 1; }
cd ./dist/kylin
zip -9 -j kit.zip kit
rm -f kit
ls -la .
- name: 上传二进制到 Cloudflare R2(公开桶)
run: |
set -e
test -n "$CF_API_TOKEN" || { echo "CF_API_TOKEN 未从配置中心加载"; exit 1; }
npm i -g wrangler
export CLOUDFLARE_API_TOKEN="$CF_API_TOKEN"
# 仅需 token,wrangler 会自动从 token 解析账号 ID,无需 CF_ACCOUNT_ID
BUCKET="${CF_R2_BUCKET:-store}"
wrangler r2 object put "$BUCKET/data/windows/kit.exe" --file ./kit/dist/windows/kit.exe --content-type application/octet-stream --remote
wrangler r2 object put "$BUCKET/data/linux/kit" --file ./kit/dist/linux/kit --content-type application/octet-stream --remote
wrangler r2 object put "$BUCKET/data/kylin/kit.zip" --file ./kit/dist/kylin/kit.zip --content-type application/zip --remote
- name: 发布 website 到 GitHub(触发 Cloudflare Pages 自动部署)
env:
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
set -e
test -n "$GH_TOKEN" || { echo "GH_TOKEN 未从配置中心加载"; exit 1; }
mkdir -p website/static/data
cat > website/static/data/kit.json <<EOF
{
"version": "${REF_NAME}",
"commit": "${SHA}",
"updatedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"binaries": {
"windows": { "url": "${CF_R2_PUBLIC_URL}/data/windows/kit.exe" },
"linux": { "url": "${CF_R2_PUBLIC_URL}/data/linux/kit" },
"kylin": { "url": "${CF_R2_PUBLIC_URL}/data/kylin/kit.zip" }
}
}
EOF
cat website/static/data/kit.json
URL="https://x-access-token:${GH_TOKEN}@github.com/${GH_OWNER}/${GH_REPO}.git"
BRANCH="${GH_BRANCH:-main}"
rm -rf /tmp/website-pub
if git clone --depth 1 --branch "$BRANCH" "${URL}" /tmp/website-pub 2>/dev/null; then
cp -a website/. /tmp/website-pub/
else
rm -rf /tmp/website-pub
mkdir -p /tmp/website-pub
cd /tmp/website-pub
git init -b "$BRANCH"
git remote add origin "${URL}"
cp -a "$GITHUB_WORKSPACE/website/." .
fi
cd /tmp/website-pub
git config user.name "kit-ci"
git config user.email "kit-ci@honor3.com"
git add -A
if git diff --cached --quiet; then
echo "No website changes to publish"
else
git commit -m "release(website): publish kit ${REF_NAME}"
git push origin "$BRANCH"
fi
+1
View File
@@ -3,3 +3,4 @@ dist
build.json
kit/.kit/modules/*
kit/publish/*
.wrangler
+1 -1
View File
@@ -19,7 +19,7 @@ module.exports = class CliCmdLine {
let param = cmdLine.params[pname];
if ( param.isFlag ) {
if ( param.needValue ){
if ( params[pname] ) {
if ( params[pname] !== undefined ) {
this.params[pname] = params[pname]
} else {
if ( ! param.isOptional ) {
+10 -2
View File
@@ -85,12 +85,21 @@ module.exports = class CliCmdParser {
// 位置参数:值
const position = []
let line = this.cmdLines[ argv[0] ];
for (let i = 1; i < argv.length; i++ ) {
if ( argv[i].startsWith("--") ) {
const name = argv[i].substring(2);
if ( i + 1 == argv.length || argv[ i + 1 ].startsWith("-") ) {
const param = line.params[name];
if ( ! param || ! param.isOptional ) {
throw "flag 格式错误" + argv[i];
}
params[ argv[i].substring(2) ] = argv[++i];
// 可选命名参数允许不带值,值为空串,由命令自行做默认值
params[ name ] = "";
} else {
params[ name ] = argv[++i];
}
} else if ( argv[i].startsWith("-") ) {
flags.add( argv[i].substring(1) );
@@ -99,7 +108,6 @@ module.exports = class CliCmdParser {
}
}
let line = this.cmdLines[ argv[0] ];
// 整理
// line 是藐视
// flags, params, position 是值
+10 -7
View File
@@ -1,14 +1,17 @@
FROM node:latest
FROM node:20-bookworm
LABEL authors="hq@gpio.me"
WORKDIR /app
ADD index.js /app
RUN npm config set registry http://registry.npmmirror.com
RUN apt-get update \
&& apt-get install -y --no-install-recommends git curl zip python3 \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://bun.sh/install | bash \
&& ln -s /root/.bun/bin/bun /usr/local/bin/bun
RUN npm i pkg -g
RUN pkg -t node14-alpine-arm64 index.js
RUN pkg -t node14-linux-arm64 index.js
ENV PATH="/root/.bun/bin:${PATH}"
+175
View File
@@ -0,0 +1,175 @@
# Registry 模块设计文档
## 概述
kit 的 registry 模块用于对接 Registry 配置管理中心(`D:\workbench\registry`),提供命令行方式管理微服务配置。
Registry 是一个基于 REST API 的配置管理系统,支持项目隔离、三维配置(机房/环境/分组)、规划/编辑/只读三种模式。
## 对接方式
kit 通过 HTTP 请求调用 Registry REST API,使用 Node 内置 `http`/`https` 模块,不引入新依赖。
## 配置存储
registry 的连接信息复用 kit 已有的 `kit config` 机制:
```shell
kit config registryUrl http://bh.vps.honor3.com:8008
kit config registryToken abc123
```
## 命令设计
### 配置管理
复用 `kit config`,无需新增命令。
### 项目与配置操作
| 命令 | 说明 | 示例 |
|------|------|------|
| `kit registry list` | 列出所有项目(含详情) | `kit registry list` |
| `kit registry config <project>[:path]` | 查看/设置配置项 | 见下方 |
#### `kit registry list`
列出 Registry 上所有项目,输出项目名称、ID、维度启用状态、配置数量。
```shell
$ kit registry list
ID 名称 维度
proj-abc-123 CommonExternalService IDC=✓ Env=✓ Group=✓
proj-def-456 OrderService IDC=✗ Env=✓ Group=✓
共 2 个项目
```
#### `kit registry config <project>[:path]`
三种用法:
**1. 查看配置列表**(不指定 key)
```shell
# 查看整个项目
kit registry config CommonExternalService
# 查看路径下的配置
kit registry config CommonExternalService:data/windows
```
输出:
```
路径 值 类型
CF_API_TOKEN **** string
CF_R2_BUCKET store string
CF_R2_PUBLIC_URL https://pub-xxx.r2.dev string
```
**2. 获取单个值**(指定 key)
```shell
kit registry config CommonExternalService:CF_R2_BUCKET
# 输出: store
```
**3. 设置值**(指定 key + value)
```shell
kit registry config CommonExternalService:CF_R2_BUCKET my-new-bucket
```
### 导入导出
| 命令 | 说明 | 示例 |
|------|------|------|
| `kit registry export <project>[:path] [file]` | 导出为 JSON | `kit registry export CommonExternalService:data ./backup.json` |
| `kit registry import <project>[:path] <file>` | 从 JSON 导入 | `kit registry import CommonExternalService:data ./backup.json` |
#### `project:path` 语法
`project:path` 中的 `path` 对应 Registry 中配置的路径前缀(由维度映射 + key 构成)。
| 语法 | 含义 |
|------|------|
| `proj` | 整个项目的所有配置 |
| `proj:data` | 路径以 `data` 开头的配置 |
| `proj:data/windows` | 路径以 `data/windows` 开头的配置 |
| `proj:data/windows/kit.exe` | 精确匹配单个配置 |
#### export 示例
```shell
# 导出整个项目
kit registry export CommonExternalService ./backup.json
# 仅导出 data/windows 下的配置
kit registry export CommonExternalService:data/windows ./backup-windows.json
```
导出文件格式:
```json
{
"project": "CommonExternalService",
"path": "data/windows",
"exportedAt": "2026-09-11T02:00:00Z",
"configs": [
{
"key": "kit.exe",
"value": "...",
"type": "string",
"description": "Windows 二进制下载地址",
"idc": "",
"environment": "",
"group": ""
}
]
}
```
#### import 示例
```shell
# 从备份文件导入整个项目
kit registry import CommonExternalService ./backup.json
# 将备份导入到指定路径下
kit registry import CommonExternalService:data/windows ./backup-windows.json
```
import 行为:
- 已存在的 key(按路径匹配)→ 更新 value
- 不存在的 key → 新增
- 远程有但本地没有的 → 保留不动(不删除)
## API 映射
| kit 命令 | Registry API |
|----------|-------------|
| `kit registry list` | `GET /api/projects` |
| `kit registry config <project>` | `GET /api/projects/:id/configs` |
| `kit registry config <project>:<key>` | `GET /api/projects/:id/configs` → 按 path 匹配 |
| `kit registry config <project>:<key> <value>` | `GET` → 匹配后 `PUT` 或 `POST` |
| `kit registry export` | `GET /api/projects/:id/configs` → 写文件 |
| `kit registry import` | 读文件 → 逐项 `PUT`/`POST` |
## 文件结构
```
kit/src/registry/
├── index.js # 命令注册入口
├── client.js # HTTP 客户端(调用 Registry API)
└── sync.js # export/import 逻辑
```
## 错误处理
- 未配置 `registryUrl` → 提示 `请先运行 kit config registryUrl <url>`
- 连接失败 → 输出 `Registry 不可达: <url>`
- 认证失败 → 输出 `认证失败,请检查 registryToken`
- 项目不存在 → 输出 `项目不存在: <name>`
- 只读模式下写入 → 输出 `Registry 当前为只读模式`
+33
View File
@@ -1,3 +1,36 @@
- ## 编码前思考
- 明确假设,不确定时询问而非猜测。
- 存在歧义时,列出多种解释,不默默选定单一方案。
- 如果任务有明显更简单的做法,直接指出优化思路。
- 发现代码矛盾、逻辑不一致时及时暂停,请求信息澄清。
## 简洁优先
- 用最少的代码解决问题,拒绝冗余实现。
- 不为一次性需求创建抽象层、复杂架构。
- 不盲目增加扩展性、可配置性,应对“未来可能用到”的场景。
- 若代码可大幅精简,主动重写优化。
- 校验标准:以资深工程师视角判断,代码若过于复杂,立即简化。
## 精准修改
- 仅修改与当前任务直接相关的代码内容。
- 不顺手优化相邻代码、注释、排版格式。
- 不重构原本可以正常运行的代码模块。
- 严格匹配项目现有代码风格,保留原有编码习惯。
- 因本次修改产生的无效导入、废弃变量,可直接删除。
- 发现项目中原有的死代码、冗余内容,仅做文字提醒,不擅自删除。
## 目标驱动执行
- 执行任务前,定义清晰、可落地的成功标准。
- 将“修复Bug”转化为:编写用例复现问题,再调试至用例正常通过。
- 将“新增校验功能”转化为:针对异常输入编写测试用例,保证全部通过。
- 将“代码重构”转化为:完成重构后,确保原有所有测试用例正常运行。
- 多步骤复杂任务,先输出简短执行计划,同时标注每一步的验证方式。
## 编程规范
- 每个函数都要有中文注释, 包括功能说明、参数说明、返回值说明等,如果有注意事项,也要在注释中特别说明。
- 每次需求修改都要同时更新文档、代码、测试代码。
- 每条测试用例都要有一个唯一编号。例如 1-1、2-20 等。
+4 -3
View File
@@ -5,12 +5,13 @@
"scripts": {
"test": "bun src/index.js",
"publish": "node publish.js",
"linux": "bun build.js && bun build ./src/index.js --compile --bytecode --minify --target=bun-linux-x64-baseline --outfile=./dist/linux/kit",
"windows": "bun build.js && bun build ./src/index.js --compile --bytecode --minify --target=bun-windows-x64-baseline --outfile=./dist/windows/kit.exe",
"linux": "bun build.js && bun build ./src/index.js --compile --bytecode --minify --target=bun-linux-x64-baseline --external cpu-features --external \"*.node\" --outfile=./dist/linux/kit",
"windows": "bun build.js && bun build ./src/index.js --compile --bytecode --minify --target=bun-windows-x64-baseline --external cpu-features --external \"*.node\" --outfile=./dist/windows/kit.exe",
"pkg-alpine": "bun build.js && pkg --compress GZip . -t node14-alpine-arm64 -o ./dist/alpine/kit",
"pkg-windows": "bun build.js && pkg --compress GZip . -t node14-windows-x64 -o ./dist/windows/kit.exe",
"pkg-arm64": "bun build.js && pkg --compress GZip . -t node14-linux-arm64 -o ./dist/linux/kit",
"pkg-linux": "bun build.js && pkg --compress GZip . -t node14-linux-x64 -o ./dist/linux/kit"
"pkg-linux": "bun build.js && pkg --compress GZip . -t node14-linux-x64 -o ./dist/kylin/kit",
"kylin": "npm run pkg-linux"
},
"dependencies": {
"auto-launch": "^5.0.6",
+217
View File
@@ -0,0 +1,217 @@
/**
* kit glance 命令
* - 记录模式:--pid <pid> 或 --port <port>,采集一次写入 sqlite(~/.kit/glance.db)
* - 报告文件:--report [文件](默认 report.html),需配合 --pid/--port
* - 报告服务:--report <端口号>,报告导出到系统临时目录 kit-glance/<端口>/index.html,
* 并在该端口开启简易 http 服务在线查看
* - --startTime/--endTime 格式 yyyyMMddHHmmss,指定报告时间范围
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const http = require("http");
const store = require("./store");
const report = require("./report");
const monitor = require("./monitor");
const HOUR = 60 * 60 * 1000;
const HOURS_24 = 24 * HOUR;
function parseTime(text) {
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(text);
if (!m) {
return null;
}
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
return d.getTime();
}
function resolveRange(startText, endText) {
if (startText && endText) {
const s = parseTime(startText);
const e = parseTime(endText);
if (s == null || e == null) {
return { error: "--startTime/--endTime 格式应为 yyyyMMddHHmmss" };
}
if (e <= s) {
return { error: "--endTime 必须晚于 --startTime" };
}
return { start: s, end: e };
}
const now = Date.now();
if (startText) {
const s = parseTime(startText);
if (s == null) {
return { error: "--startTime 格式应为 yyyyMMddHHmmss" };
}
return { start: s, end: s + HOURS_24 };
}
if (endText) {
const e = parseTime(endText);
if (e == null) {
return { error: "--endTime 格式应为 yyyyMMddHHmmss" };
}
return { start: e - HOURS_24, end: e };
}
return { start: now - HOURS_24, end: now };
}
async function record(cli) {
const db = store.open();
let record = null;
const pidText = cli.getParamValue("pid");
if (pidText && pidText !== "") {
const pid = Number(pidText);
if (!Number.isInteger(pid) || pid <= 0) {
$logger.error(`pid 参数无效: ${pidText}`);
process.exit(-1);
}
const data = await monitor.collectPid(pid);
if (!data) {
$logger.warn(`进程 ${pid} 不存在或已退出,本次不记录`);
return;
}
record = { ts: Date.now(), target: "pid", pid, port: null, cpu: data.cpu, mem: data.mem, handle: data.handle };
} else {
const portText = cli.getParamValue("port");
const port = Number(portText);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
$logger.error(`port 参数无效: ${portText}`);
process.exit(-1);
}
const found = monitor.findPortPid(port);
if (!found) {
$logger.warn(`端口 ${port} 无监听进程,本次不记录`);
return;
}
const data = await monitor.collectPid(found);
if (!data) {
$logger.warn(`进程 ${found} 已退出,本次不记录`);
return;
}
record = { ts: Date.now(), target: "port", pid: found, port, cpu: data.cpu, mem: data.mem, handle: data.handle };
}
store.insert(db, record);
$logger.info(`已记录 target=${record.target} pid=${record.pid} cpu=${record.cpu}% mem=${record.mem}MB handle=${record.handle}`);
}
function buildReport(cli, reportFile) {
const isPid = cli.getParamValue("pid") != null;
const { start, end, error } = resolveRange(cli.getParamValue("startTime"), cli.getParamValue("endTime"));
if (error) {
$logger.error(error);
process.exit(-1);
}
const db = store.open();
const rows = isPid
? store.queryRange(db, { pid: Number(cli.getParamValue("pid")), start, end })
: store.queryRange(db, { port: Number(cli.getParamValue("port")), start, end });
const html = report.render({
mode: isPid ? "pid" : "port",
value: isPid ? Number(cli.getParamValue("pid")) : Number(cli.getParamValue("port")),
rows,
start,
end
});
fs.writeFileSync(reportFile, html, "utf8");
$logger.info(`报告已生成: ${path.resolve(reportFile)}`);
}
/**
* 报告在线服务:把报告导出为系统临时目录 kit-glance/<端口>/index.html,
* 并在该端口启动一个简易 http 服务供在线查看(监听 0.0.0.0)。
* 报告数据按 --pid/--port 与 --startTime/--endTime 从当前库生成。
* @param {object} cli 命令行
* @param {number} serverPort 报告服务端口
*/
function serveOnline(cli, serverPort) {
const serveDir = path.join(os.tmpdir(), "kit-glance", String(serverPort));
fs.mkdirSync(serveDir, { recursive: true });
const indexPath = path.join(serveDir, "index.html");
buildReport(cli, indexPath);
const server = http.createServer((req, res) => {
const urlPath = (req.url || "/").split("?")[0];
if (urlPath === "/" || urlPath === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
fs.createReadStream(indexPath).pipe(res);
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
});
server.on("error", (e) => {
if (e.code === "EADDRINUSE") {
$logger.warn(`端口 ${serverPort} 已被占用:报告已更新到 ${indexPath},已有报告服务运行中可直接刷新查看`);
process.exit(0);
} else {
$logger.error(`启动 http 服务失败: ${e.message}`);
process.exit(-1);
}
});
server.listen(serverPort, "0.0.0.0", () => {
$logger.info(`在线查看: http://localhost:${serverPort}/ (监听 0.0.0.0:${serverPort},可通过本机任一 IP 访问)`);
});
}
async function main(cli) {
const pidText = cli.getParamValue("pid");
const portText = cli.getParamValue("port");
const reportRaw = cli.getParamValue("report");
const isPid = pidText != null && pidText !== "";
const isPort = portText != null && portText !== "";
const isReport = reportRaw !== undefined;
if (isPid && isPort) {
$logger.error("--pid 和 --port 只能传递一个");
process.exit(-1);
}
if (!isPid && !isPort) {
$logger.error("--pid 和 --port 必须传递一个");
process.exit(-1);
}
if (isPid) {
const pid = Number(pidText);
if (!Number.isInteger(pid) || pid <= 0) {
$logger.error(`pid 参数无效: ${pidText}`);
process.exit(-1);
}
} else if (isPort) {
const port = Number(portText);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
$logger.error(`port 参数无效: ${portText}`);
process.exit(-1);
}
}
if (isReport) {
const reportValue = reportRaw || "";
const servePort = /^\d+$/.test(reportValue) ? Number(reportValue) : 0;
if (servePort >= 1 && servePort <= 65535) {
serveOnline(cli, servePort);
return;
}
buildReport(cli, reportValue || "report.html");
return;
}
if (!monitor.isLinux()) {
$logger.error("glance 仅支持 Linux 操作系统");
process.exit(-1);
}
await record(cli);
}
module.exports = { main };
+80
View File
@@ -0,0 +1,80 @@
/**
* 进程监控指标采集(仅支持 Linux)
* - CPU 占用率:两次采样 /proc/<pid>/stat 与 /proc/stat 差值计算,内部休眠约 1 秒
* - 内存占用:/proc/<pid>/status 的 VmRSS,单位 MB
* - 句柄数:/proc/<pid>/fd 目录条目数
* - 端口 -> pid:端口解析逻辑见 ../linux/portpid.js(含 docker 端口发布)
*/
const fs = require("fs");
const portpid = require("../linux/portpid");
const os = require("os");
function isLinux() {
return os.type() === "Linux";
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function readPidTicks(pid) {
const txt = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
// 字段 14=utime 15=stime,comm 可能含空格,按最后一个 ) 截断
const rest = txt.slice(txt.lastIndexOf(")") + 1).trim().split(/\s+/);
return Number(rest[11]) + Number(rest[12]);
}
function readCpuTotal() {
const line = fs.readFileSync("/proc/stat", "utf8").split("\n")[0];
// user nice system idle iowait irq softirq steal(guest 已计入 user)
const parts = line.trim().split(/\s+/).slice(1, 9).map(Number);
let total = 0;
for (const v of parts) {
total += Number.isFinite(v) ? v : 0;
}
return total;
}
function readMemMB(pid) {
const status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
const m = status.match(/^VmRSS:\s+(\d+)\s+kB/m);
if (!m) {
return null;
}
return Math.round(Number(m[1]) / 1024 * 100) / 100;
}
function readHandleCount(pid) {
return fs.readdirSync(`/proc/${pid}/fd`, { withFileTypes: true }).length;
}
/**
* 采集单个进程的监控数据
* @param {number} pid
* @returns {Promise<{pid:number,cpu:number,mem:number,handle:number}|null>} 进程不存在/不可读返回 null
*/
async function collectPid(pid) {
try {
const beforePid = readPidTicks(pid);
const beforeTotal = readCpuTotal();
await sleep(1000);
const afterPid = readPidTicks(pid);
const afterTotal = readCpuTotal();
const totalDelta = afterTotal - beforeTotal;
const pidDelta = afterPid - beforePid;
const cpu = totalDelta > 0 ? Math.round(pidDelta / totalDelta * 10000) / 100 : 0;
const mem = readMemMB(pid);
const handle = readHandleCount(pid);
return { pid, cpu, mem, handle };
} catch (e) {
return null;
}
}
module.exports = {
isLinux,
collectPid,
findPortPid: portpid.findPortPid
};
+312
View File
@@ -0,0 +1,312 @@
/**
* glance 报告生成:生成一个自包含 HTML 文件(内联 SVG 图表,无外部依赖)。
* 三张图表:CPU 占用率、内存占用、句柄数。
* - 纵轴/横轴根据数据量自动缩放
* - 时间段内无数据或进程中停(时间缺口)时,图表会显现
* - pid 变化(进程重启)以橙色虚线 + 标注标出
*/
const HOUR = 60 * 60 * 1000;
function esc(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pad2(n) {
return String(n).padStart(2, "0");
}
function fmtTime(ts) {
const d = new Date(ts);
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
function fmtFull(ts) {
const d = new Date(ts);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
function niceStep(raw) {
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
let step;
if (norm <= 1) step = 1;
else if (norm <= 2) step = 2;
else if (norm <= 5) step = 5;
else step = 10;
return step * mag;
}
function computeYAxis(values) {
const vs = values.filter(v => Number.isFinite(v));
if (!vs.length) {
return { min: 0, max: 1, ticks: [0, 0.2, 0.4, 0.6, 0.8, 1] };
}
let min = Math.min(...vs);
let max = Math.max(...vs);
if (min === max) {
min -= 1;
max += 1;
}
const step = niceStep((max - min) / 5);
const lo = Math.floor(min / step) * step;
const hi = Math.ceil(max / step) * step;
const ticks = [];
for (let v = lo; v <= hi + step / 2; v += step) {
ticks.push(Math.round(v * 1e6) / 1e6);
}
return { min: lo, max: hi, ticks };
}
/**
* 生成折线内容:按时间缺口切成多段 polyline,缺口处绘制淡色竖带
*/
function buildLine(entries, X, Y, gapThreshold, color) {
let svg = "";
let segment = [];
const gaps = [];
function flush() {
if (segment.length > 0) {
const points = segment.map(p => `${X(p.ts).toFixed(2)},${Y(p.value).toFixed(2)}`).join(" ");
svg += `\n <polyline fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" points="${points}"/>`;
for (const p of segment) {
svg += `\n <circle cx="${X(p.ts).toFixed(2)}" cy="${Y(p.value).toFixed(2)}" r="2.5" fill="${color}"/>`;
}
segment = [];
}
}
for (let i = 0; i < entries.length; i++) {
if (i > 0 && gapThreshold > 0) {
const dt = entries[i].ts - entries[i - 1].ts;
if (dt > gapThreshold) {
gaps.push({ x0: X(entries[i - 1].ts), x1: X(entries[i].ts) });
flush();
}
}
segment.push(entries[i]);
}
flush();
return { svg, gaps };
}
function buildChart(title, unit, color, rows, start, end, gapThreshold, pidChanges) {
const W = 960, H = 300, mL = 80, mR = 40, mT = 36, mB = 52;
const pw = W - mL - mR;
const ph = H - mT - mB;
const span = (end - start) || 1;
const entries = rows
.filter(r => r.value != null && Number.isFinite(r.value))
.map(r => ({ ts: r.ts, value: r.value, pid: r.pid }));
const X = ts => mL + Math.max(0, Math.min(1, (ts - start) / span)) * pw;
const axis = computeYAxis(entries.map(e => e.value));
const Y = v => mT + ph - ((v - axis.min) / ((axis.max - axis.min) || 1)) * ph;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img">`;
svg += `\n <text x="${mL}" y="22" font-size="13" font-weight="600" fill="#333">${esc(title)}${unit ? `(${esc(unit)})` : ""}</text>`;
// 网格与纵轴
for (const t of axis.ticks) {
const y = Y(t);
svg += `\n <line x1="${mL}" y1="${y.toFixed(2)}" x2="${W - mR}" y2="${y.toFixed(2)}" stroke="#e5e7eb" stroke-width="1"/>`;
svg += `\n <text x="${mL - 8}" y="${(y + 4).toFixed(2)}" font-size="11" fill="#6b7280" text-anchor="end">${t}</text>`;
}
// 横轴(6 个刻度)
for (let k = 0; k <= 5; k++) {
const ts = start + (end - start) * k / 5;
const x = X(ts);
svg += `\n <line x1="${x.toFixed(2)}" y1="${mT + ph}" x2="${x.toFixed(2)}" y2="${mT + ph + 4}" stroke="#9ca3af" stroke-width="1"/>`;
svg += `\n <text x="${x.toFixed(2)}" y="${mT + ph + 18}" font-size="11" fill="#6b7280" text-anchor="middle">${fmtTime(ts)}</text>`;
}
// 坐标轴
svg += `\n <line x1="${mL}" y1="${mT}" x2="${mL}" y2="${mT + ph}" stroke="#9ca3af" stroke-width="1"/>`;
svg += `\n <line x1="${mL}" y1="${mT + ph}" x2="${W - mR}" y2="${mT + ph}" stroke="#9ca3af" stroke-width="1"/>`;
if (entries.length === 0) {
svg += `\n <text x="${mL + pw / 2}" y="${mT + ph / 2}" font-size="14" fill="#9ca3af" text-anchor="middle">该时间范围无数据</text>`;
} else {
const { svg: lineSvg, gaps } = buildLine(entries, X, Y, gapThreshold, color);
svg += lineSvg;
// 时间缺口高亮(一段时间没数据)
for (const g of gaps) {
const x = Math.min(g.x0, g.x1);
const w = Math.max(1, Math.abs(g.x1 - g.x0));
svg += `\n <rect x="${x.toFixed(2)}" y="${(mT + 6).toFixed(2)}" width="${w.toFixed(2)}" height="${(ph - 12).toFixed(2)}" fill="#f3f4f6" stroke="#d1d5db" stroke-width="0.5"/>`;
}
// pid 变化(进程重启)竖线
if (pidChanges && pidChanges.length) {
for (const ch of pidChanges) {
const x = X(ch.ts);
svg += `\n <line x1="${x.toFixed(2)}" y1="${mT}" x2="${x.toFixed(2)}" y2="${mT + ph}" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>`;
const label = `pid ${ch.from}→${ch.to}`;
const lx = Math.min(x + 5, W - mR - 110);
const ly = mT + 14;
svg += `\n <text x="${lx.toFixed(2)}" y="${ly}" font-size="11" fill="#b45309" stroke="#ffffff" stroke-width="3" paint-order="stroke">↻ ${esc(label)}</text>`;
}
}
}
svg += "\n</svg>";
return svg;
}
function computeGapThreshold(rows) {
const tsList = rows
.map(r => r.ts)
.filter(ts => Number.isFinite(ts))
.sort((a, b) => a - b);
if (tsList.length < 2) {
return 0;
}
const deltas = [];
for (let i = 1; i < tsList.length; i++) {
const d = tsList[i] - tsList[i - 1];
if (d > 0) {
deltas.push(d);
}
}
if (!deltas.length) {
return 0;
}
deltas.sort((a, b) => a - b);
const typical = deltas[Math.floor(deltas.length / 2)];
return Math.max(typical * 3, 30 * 1000);
}
function collectPidChanges(rows) {
const changes = [];
for (let i = 1; i < rows.length; i++) {
const prev = rows[i - 1].pid;
const cur = rows[i].pid;
if (prev != null && cur != null && prev !== cur) {
changes.push({ ts: rows[i].ts, from: prev, to: cur });
}
}
return changes;
}
function summary(rows, key) {
const vs = rows.map(r => r[key]).filter(v => v != null && Number.isFinite(v));
if (!vs.length) {
return null;
}
const min = Math.min(...vs);
const max = Math.max(...vs);
const avg = vs.reduce((a, b) => a + b, 0) / vs.length;
return { min, max, avg };
}
function fmtNum(v, digits) {
if (v == null || !Number.isFinite(v)) {
return "-";
}
return Number(v.toFixed(digits));
}
/**
* 生成报告 HTML
* @param opts {mode:'pid'|'port', value, rows, start, end}
*/
function render(opts) {
const { mode, value, rows, start, end } = opts;
const spanHours = Math.round((end - start) / HOUR * 10) / 10;
const targetText = mode === "pid" ? `--pid ${value}` : `--port ${value}`;
const gapThreshold = computeGapThreshold(rows);
const pidChanges = collectPidChanges(rows);
const restarts = pidChanges.length;
const generatedAt = fmtFull(Date.now());
const series = [
{ key: "cpu", title: "CPU 占用率", unit: "%", color: "#2563eb" },
{ key: "mem", title: "内存占用", unit: "MB", color: "#16a34a" },
{ key: "handle", title: "句柄数", unit: "", color: "#dc2626" }
];
const summaryRows = series.map(s => {
const st = summary(rows, s.key);
return {
title: s.title,
unit: s.unit,
min: st ? fmtNum(st.min, 2) : "-",
max: st ? fmtNum(st.max, 2) : "-",
avg: st ? fmtNum(st.avg, 2) : "-"
};
});
let chartsHtml = "";
for (const s of series) {
chartsHtml += `
<section class="chart">
<h2>${esc(s.title)}</h2>
${buildChart(s.title, s.unit, s.color, rows.map(r => ({ ts: r.ts, value: r[s.key], pid: r.pid })), start, end, gapThreshold, pidChanges)}
</section>`;
}
const summaryHtml = summaryRows.map(r => `
<tr>
<td>${esc(r.title)}${r.unit ? `(${esc(r.unit)})` : ""}</td>
<td>${r.min}</td>
<td>${r.max}</td>
<td>${r.avg}</td>
</tr>`).join("");
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>进程监控报告 ${esc(targetText)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f9fafb; margin: 0; padding: 24px; color: #1f2937; }
.report { max-width: 1040px; margin: 0 auto; }
header { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px 24px; margin-bottom: 16px; }
header h1 { margin: 0 0 8px; font-size: 22px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px 32px; font-size: 13px; color: #4b5563; }
.meta b { color: #111827; }
.charts { display: flex; flex-direction: column; gap: 16px; }
.chart { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px 8px; }
.chart h2 { margin: 0 0 4px; font-size: 15px; color: #374151; }
.legend { color: #6b7280; font-size: 12px; margin-top: 4px; }
table { border-collapse: collapse; margin-top: 12px; font-size: 13px; width: 100%; }
th, td { text-align: right; padding: 6px 12px; border-bottom: 1px solid #e5e7eb; }
th:first-child, td:first-child { text-align: left; }
th { color: #6b7280; font-weight: 500; }
footer { margin-top: 16px; font-size: 12px; color: #9ca3af; text-align: center; }
</style>
</head>
<body>
<div class="report">
<header>
<h1>进程监控报告</h1>
<div class="meta">
<span>监控目标:<b>${esc(targetText)}</b></span>
<span>时间范围:<b>${fmtFull(start)} ~ ${fmtFull(end)}</b>(约 ${spanHours} 小时)</span>
<span>样本数:<b>${rows.length}</b></span>
<span>重启次数:<b>${restarts}</b></span>
<span>生成时间:<b>${generatedAt}</b></span>
</div>
<table>
<thead><tr><th>指标</th><th>最小值</th><th>最大值</th><th>平均值</th></tr></thead>
<tbody>${summaryHtml}</tbody>
</table>
<div class="legend">说明:时间轴覆盖请求范围;灰色竖带表示其间无采样数据(进程中断/采集缺失);橙色虚线表示 pid 变化(进程重启)。</div>
</header>
<div class="charts">
${chartsHtml}
</div>
<footer>generated by kit glance</footer>
</div>
</body>
</html>`;
}
module.exports = { render };
+68
View File
@@ -0,0 +1,68 @@
/**
* glance 数据存储:默认 sqlite 位于 ~/.kit/glance.db(不存在则创建)
* 依赖 bun:sqlite,仅在调用时加载,避免影响 node 环境下的其他命令。
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
function defaultDbPath() {
return path.join(os.homedir(), ".kit", "glance.db");
}
function open(dbPath) {
dbPath = dbPath || defaultDbPath();
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const { Database } = require("bun:sqlite");
const db = new Database(dbPath);
db.exec(`CREATE TABLE IF NOT EXISTS glance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
target TEXT NOT NULL,
pid INTEGER,
port INTEGER,
cpu REAL,
mem REAL,
handle INTEGER
)`);
db.exec("CREATE INDEX IF NOT EXISTS idx_glance_lookup ON glance(pid, port, ts)");
return db;
}
/**
* 插入一条监控记录
* @param db database
* @param record {ts, target, pid, port, cpu, mem, handle}
*/
function insert(db, record) {
db.query("INSERT INTO glance (ts, target, pid, port, cpu, mem, handle) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(record.ts, record.target, record.pid ?? null, record.port ?? null, record.cpu ?? null, record.mem ?? null, record.handle ?? null);
}
/**
* 按时间范围查询监控数据(忽略 target,pid/port 二者取其一)
* @param db database
* @param opts {pid?, port?, start, end}
* @returns rows
*/
function queryRange(db, opts) {
let where;
const params = [];
if (opts.pid != null) {
where = "pid = ?";
params.push(opts.pid);
} else {
where = "port = ?";
params.push(opts.port);
}
params.push(opts.start, opts.end);
return db.query(`SELECT ts, target, pid, port, cpu, mem, handle FROM glance WHERE ${where} AND ts >= ? AND ts < ? ORDER BY ts ASC`).all(...params);
}
module.exports = {
defaultDbPath,
open,
insert,
queryRange
};
+30 -4
View File
@@ -15,9 +15,14 @@ const zlib = require("zlib");
const mem = require("./mem")
const { getAllDeviceInfo } = require("./platform")
const kitcommand = require("./kitcommand")
const glance = require("./glance")
kitcommand.init()
kitcommand.regTo( parser )
const registry = require("./registry")
registry.init()
registry.regTo( parser )
// parser.addCmdLine("man", "打开在线帮助;", async function (){
// const uConfig = await $context.get("uConfig")
@@ -179,9 +184,14 @@ parser.addCmdLine("dockersh <container> [--file]", "反向获取 docker 容器
});
parser.addCmdLine("killport <port>", "释放端口", function (cli) {
parser.addCmdLine("killport <port> [-dry]", "释放端口;-dry 仅显示端口与进程信息,不执行清理", async function (cli) {
let port = cli.getParamValue("port")
net.killport( parseInt(port, 10) );
let dry = cli.hasFlag("dry")
await net.killport( parseInt(port, 10), dry );
});
parser.addCmdLine("glance [--pid] [--port] [--report] [--startTime] [--endTime]", "进程监控:--pid/--port 采集监控数据(仅 Linux);--report 生成报告 HTML,传端口号时导出到临时目录并开启 http 服务在线查看;", async function (cli) {
await glance.main(cli);
});
@@ -417,8 +427,24 @@ parser.addCmdLine("crlf [-d] [-r] [--path] [--filter-name] [--filter-path] <crlf
$logger.info( "暂未实现" );
});
parser.addCmdLine("tcpc [-d] [-x] [--host] [--ip] [--port] [--headlen] [--timeout] [--filepath]", "tcp 报文发收", function (cli) {
$logger.info( "暂未实现" );
parser.addCmdLine("tcpc [-d] [-x] [--ip] [--port] [--headlen] [--timeout] [--data]", "tcp 报文发收", async function (cli) {
const hex = cli.hasFlag("x");
try {
const recv = await net.tcpc({
ip: cli.getParamValue("ip"),
port: cli.getParamValue("port"),
data: cli.getParamValue("data"),
hex,
headlen: cli.getParamValue("headlen"),
timeout: cli.getParamValue("timeout"),
debug: cli.hasFlag("d")
});
$logger.info( hex ? recv.toString("hex") : recv.toString("utf8") );
process.exit(0)
} catch ( e ) {
$logger.error( e )
process.exit(-1)
}
});
async function main(){
+30 -20
View File
@@ -1,29 +1,39 @@
const { findPortPid, cmdlineOf } = require("./portpid");
async function killport(port) {
async function killport(port, dry) {
$logger.info(`kill process on port ${port}`);
const util = require('util');
const exec = util.promisify(require('child_process').exec);
try {
// 查 PID
let { stdout : stdout1 } = exec(`lsof -t -sTCP:LISTEN -i:${port}`)
let pid = stdout1.trim();
$logger.debug(`绑定端口号 ${port} 的进程 PID=${pid}`)
// 查进程信息
let { stdout : stdout2 } = exec(`ps -p ${pid} -o comm=`)
let processName = stdout2.trim();
$logger.info(`绑定端口号=${port}; 进程 PID=${pid}; 程序=${processName};`);
// Kill 进程
exec(`kill -9 ${pid}`)
} catch (e) {
if ( 1 === e.code ) {
$logger.error("此端口未被使用,或清理进程失败。")
return
// 解析监听进程:若由 docker-proxy 发布端口,会优先返回容器内真实应用进程
const pid = findPortPid(port);
if (pid == null) {
$logger.error("此端口未被使用,或清理进程失败。");
return;
}
const cmd = cmdlineOf(pid);
const program = cmd.length > 0 && cmd[0] ? cmd[0] : `pid=${pid}`;
if (program.includes("docker-proxy")) {
// 容器内应用未就绪时 findPortPid 会退回 docker-proxy 自身;
// 杀掉它既停不掉容器内的服务,也会被 docker 重新拉起,只清理真实进程。
$logger.error(`端口=${port} 由 docker-proxy 发布,但未定位到容器内的真实应用进程,已跳过清理(避免误杀 docker 进程)。`);
return;
}
$logger.info(`绑定端口号=${port}; 进程 PID=${pid}; 程序=${program};`);
if (dry) {
$logger.info(`dry 模式,未执行清理: pid=${pid}`);
return;
}
try {
await exec(`kill -9 ${pid}`)
$logger.info(`已结束进程 PID=${pid}`);
} catch (e) {
$logger.error("此端口未被使用,或清理进程失败。")
}
}
+278
View File
@@ -0,0 +1,278 @@
/**
* 端口 -> 进程 pid 解析(仅支持 Linux,基于 /proc)
*
* 优先命中容器内真实应用进程而非 docker-proxy;端口映射不一致
* (如 0.0.0.0:6380->6379)时按 docker-proxy 解析出的容器 IP/端口定位:
* 1. 宿主网络命名空间中监听该端口的 fd 属主(普通端口服务,如 sshd)
* 2. 若属主是 docker-proxy,则解析其 -container-ip/-container-port,
* 在容器网络命名空间内按容器内端口查找持有监听 fd 的真实应用进程
* (处理 0.0.0.0:6380->6379 这类端口映射不一致,并用 fib_trie 的 /32
* 本地地址匹配容器 IP 以区分布局在网段相同、内网端口相同的多个容器)
* 3. 宿主无监听时(userland-proxy 关闭),直接扫描非宿主 netns 的监听进程
*/
const fs = require("fs");
const path = require("path");
/**
* 读取进程所在网络命名空间标识
* @param {number} pid
* @returns {string|null}
*/
function readNetNs(pid) {
try {
return fs.readlinkSync(`/proc/${pid}/ns/net`);
} catch (e) {
return null;
}
}
/**
* 读取一个网络命名空间内监听指定端口的 socket inode 集合
* @param {string} netNs
* @param {number} port
* @returns {Set<string>}
*/
function listenInodesInNetNs(netNs, port) {
const out = new Set();
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) {
continue;
}
if (readNetNs(Number(entry)) !== netNs) {
continue;
}
for (const file of ["tcp", "tcp6"]) {
let text;
try {
text = fs.readFileSync(`/proc/${entry}/net/${file}`, "utf8");
} catch (e) {
continue;
}
for (const line of text.split("\n").slice(1)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 10 || parts[3] !== "0A") {
continue;
}
const local = parts[1];
const idx = local.lastIndexOf(":");
if (idx < 0) {
continue;
}
if (parseInt(local.slice(idx + 1), 16) === Number(port)) {
out.add(parts[9]);
}
}
}
}
return out;
}
/**
* 在指定网络命名空间内查找持有这些 socket inode 的进程 pid(归一化为 Tgid)
* @param {string} netNs
* @param {Set<string>} inodes
* @returns {number|null}
*/
function ownerInNetNs(netNs, inodes) {
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) {
continue;
}
if (readNetNs(Number(entry)) !== netNs) {
continue;
}
const fdDir = `/proc/${entry}/fd`;
let names;
try {
names = fs.readdirSync(fdDir);
} catch (e) {
continue;
}
for (const fd of names) {
try {
const target = fs.readlinkSync(path.join(fdDir, fd));
const m = target.match(/^socket:\[(\d+)\]$/);
if (m && inodes.has(m[1])) {
return tgidOf(Number(entry));
}
} catch (e) {
// 忽略无法读取的 fd
}
}
}
return null;
}
/**
* 归一化线程 pid 为进程 Tgid
* @param {number} pid
* @returns {number}
*/
function tgidOf(pid) {
try {
const m = fs.readFileSync(`/proc/${pid}/status`, "utf8").match(/^Tgid:\s+(\d+)/m);
return m ? Number(m[1]) : pid;
} catch (e) {
return pid;
}
}
/**
* 读取进程启动参数
* @param {number} pid
* @returns {string[]}
*/
function cmdlineOf(pid) {
try {
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
} catch (e) {
return [];
}
}
/**
* 读取网络命名空间内声明的本地 IPv4 地址集合(/proc/<pid>/net/fib_trie)
* @param {string} netNs
* @returns {Set<string>}
*/
function netNsIpv4s(netNs) {
const ips = new Set();
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) {
continue;
}
if (readNetNs(Number(entry)) !== netNs) {
continue;
}
try {
const text = fs.readFileSync(`/proc/${entry}/net/fib_trie`, "utf8");
const re = /\b(\d+\.\d+\.\d+\.\d+)\b/g;
let m;
while ((m = re.exec(text)) !== null) {
ips.add(m[1]);
}
break;
} catch (e) {
// 继续尝试下一个候选
}
}
return ips;
}
/**
* 根据端口查找监听进程 pid
*
* 优先命中容器内真实应用进程而非 docker-proxy;端口映射不一致
* (如 0.0.0.0:6380->6379)时按 docker-proxy 解析出的容器 IP/端口定位。
* 容器应用未就绪时仍可能退回 docker-proxy 自身的 pid,调用方需自行判断。
* @param {number} port
* @returns {number|null}
*/
function findPortPid(port) {
const hostNs = readNetNs(1);
if (hostNs === null) {
return null;
}
const hostInodes = listenInodesInNetNs(hostNs, port);
if (hostInodes.size > 0) {
const owner = ownerInNetNs(hostNs, hostInodes);
if (owner !== null) {
const cmd = cmdlineOf(owner);
if (cmd.length > 0 && cmd[0].includes("docker-proxy")) {
const args = {};
for (let i = 1; i < cmd.length - 1; i++) {
if (cmd[i].startsWith("-")) {
args[cmd[i]] = cmd[i + 1];
}
}
const cip = args["-container-ip"];
const cport = Number(args["-container-port"]);
if (Number.isFinite(cport) && cport > 0) {
// 所有可能持有该容器内端口的网络命名空间
const groups = new Map(); // netNs -> Set<inode>
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) {
continue;
}
const ns = readNetNs(Number(entry));
if (ns === null || ns === hostNs) {
continue;
}
const inodes = new Set();
for (const file of ["tcp", "tcp6"]) {
let text;
try {
text = fs.readFileSync(`/proc/${entry}/net/${file}`, "utf8");
} catch (e) {
continue;
}
for (const line of text.split("\n").slice(1)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 10 || parts[3] !== "0A") {
continue;
}
const idx = parts[1].lastIndexOf(":");
if (idx >= 0 && parseInt(parts[1].slice(idx + 1), 16) === cport) {
inodes.add(parts[9]);
}
}
}
if (inodes.size > 0) {
groups.set(ns, inodes);
}
}
// 优先选 fib_trie 本地地址与容器 IP 一致的命名空间
if (cip) {
for (const [ns, inodes] of groups) {
if (netNsIpv4s(ns).has(cip)) {
const pid = ownerInNetNs(ns, inodes);
if (pid !== null) {
return pid;
}
}
}
}
// 退而求其次:任意持有该容器内端口监听的命名空间的属主
for (const [ns, inodes] of groups) {
const pid = ownerInNetNs(ns, inodes);
if (pid !== null) {
return pid;
}
}
}
// 容器应用未就绪:退回 docker-proxy,保留可见性
return owner;
}
return owner;
}
}
// 宿主无监听(userland-proxy 关闭):扫描非宿主命名空间内监听该端口的进程
const groups = new Map();
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) {
continue;
}
const ns = readNetNs(Number(entry));
if (ns === null || ns === hostNs) {
continue;
}
const inodes = listenInodesInNetNs(ns, port);
if (inodes.size > 0) {
groups.set(ns, inodes);
}
}
for (const [ns, inodes] of groups) {
const pid = ownerInNetNs(ns, inodes);
if (pid !== null) {
return pid;
}
}
return null;
}
module.exports = {
findPortPid,
cmdlineOf
};
+108 -8
View File
@@ -1,5 +1,4 @@
require("./init")
const osutil = require("./util/osutil")
const killer = require('cross-port-killer');
const net = require('net');
const dns = require('dns').promises;
@@ -35,13 +34,17 @@ async function telnet(port, host, timeout=3000){
}
async function killport(port) {
async function killport(port, dry) {
// Linux:先解析监听进程。若端口由 docker-proxy 发布,会定位容器内
// 真实应用进程再清理,避免误杀 docker 进程(见 ./linux/net.js)。
if (process.platform === "linux") {
return require("./linux/net").killport(port, dry);
}
// dry 模式:Windows 走 netstat 解析 pid,仅展示不清理
if (dry) {
return require("./windows/net").killport(port, dry);
}
return killer.kill( port );
// let func = osutil.choise([
// require("./windows/net").killport,
// require("./linux/net").killport,
// ])
// return func && func(port);
}
/**
@@ -371,7 +374,104 @@ function getServiceName(port) {
return commonServices[port] || `Unknown (${port})`;
}
/**
* tcp 报文发收:以 hex 或文本发送一包数据,再接收一包并返回。
*
* @param {Object} opts
* @param {string} opts.ip 目标 ip
* @param {string|number} opts.port 目标端口,必填
* @param {string} opts.data 待发送数据
* @param {boolean} [opts.hex=false] true 时 data 为 hex 格式,发送前转回 bytes
* @param {number|string} [opts.headlen] 长度头字节数(1-6):发送前按大端填入 data 长度,接收时按其声明长度读满一包
* @param {number|string} [opts.timeout=3000] 超时毫秒
* @param {boolean} [opts.debug=false] true 时打印过程日志
* @returns {Promise<Buffer>} 接收到的一包数据
*/
async function tcpc({ ip, port, data, hex=false, headlen=null, timeout=3000, debug=false } = {}) {
if ( null == ip || "" === String(ip).trim() ) throw "缺少 --ip";
if ( null == port || "" === String(port).trim() ) throw "缺少 --port";
if ( null == data ) throw "缺少 --data";
const log = function(){ if ( debug ) $logger.info( ...arguments ) };
const dport = parseInt(port, 10);
if ( ! Number.isInteger(dport) || dport < 1 || dport > 65535 ) throw "非法端口: " + port;
const tmo = timeout ? parseInt(timeout, 10) : 3000;
if ( ! Number.isInteger(tmo) || tmo <= 0 ) throw "非法 timeout: " + timeout;
// 组装发送载荷
let payload;
if ( hex ) {
const h = String(data).replace(/\s+/g, "");
if ( h.length % 2 !== 0 ) throw "hex 数据长度必须为偶数";
if ( h.length > 0 && ! /^[0-9a-fA-F]+$/.test(h) ) throw "hex 数据包含非法字符";
payload = Buffer.from(h, "hex");
} else {
payload = Buffer.from(String(data), "utf8");
}
// 长度头:大端填入 data 字节数(不含头自身)
let hl = null;
if ( null != headlen && "" !== String(headlen) ) {
hl = parseInt(headlen, 10);
if ( ! Number.isInteger(hl) || hl < 1 || hl > 6 ) throw "--headlen 必须为 1-6 的整数";
if ( payload.length >= Math.pow(2, hl * 8) ) throw `数据长度 ${payload.length} 超出 ${hl} 字节长度头表示范围`;
}
let packet = payload;
if ( hl ) {
const hdr = Buffer.alloc(hl);
hdr.writeUIntBE(payload.length, 0, hl);
packet = Buffer.concat([hdr, payload]);
}
log(`连接 ${ip}:${dport},超时 ${tmo}ms`);
log(`发送 ${packet.length} 字节: ${packet.toString("hex")}`);
return new Promise(function (resolve, reject){
const socket = new net.Socket();
let timer = null;
let buf = Buffer.alloc(0);
let got = Buffer.alloc(0);
let done = false;
const finish = function(err, val){
if ( done ) return;
done = true;
if ( timer ) clearTimeout(timer);
socket.destroy();
if ( err ) reject(err); else resolve(val);
};
timer = setTimeout(() => finish("收发超时"), tmo);
socket.setTimeout(tmo, () => finish("链接超时"));
socket.on("error", e => finish(e));
socket.connect(dport, ip, () => socket.write(packet));
socket.on("data", chunk => {
if ( hl ) {
buf = Buffer.concat([buf, chunk]);
if ( buf.length < hl ) return;
const declared = buf.readUIntBE(0, hl);
const total = hl + declared;
if ( buf.length < total ) return;
got = buf.slice(0, total);
log(`收到 ${got.length} 字节(头声明 data ${declared} 字节)`);
finish(null, got);
} else {
got = Buffer.concat([got, chunk]);
log(`收到 ${got.length} 字节`);
finish(null, got);
}
});
socket.on("end", () => finish("连接关闭,收包不完整"));
});
}
module.exports = {
killport, getport, telnet, checkip, scan
killport, getport, telnet, checkip, scan, tcpc
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Registry HTTP 客户端
* 调用 Registry 配置中心 REST API
*/
const http = require("http");
const https = require("https");
class RegistryClient {
constructor(baseUrl, token) {
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
this.token = token || "";
}
/**
* 发起 HTTP 请求
* @param {string} method - GET/POST/PUT/DELETE
* @param {string} path - API 路径
* @param {object} body - 请求体
* @returns {Promise<object>} 响应 data
*/
request(method, path, body) {
return new Promise((resolve, reject) => {
let url;
try {
url = new URL(this.baseUrl + path);
} catch (e) {
reject(new Error("Registry 地址无效: " + this.baseUrl));
return;
}
const lib = url.protocol === "https:" ? https : http;
const options = {
method,
hostname: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
path: url.pathname + url.search,
headers: { "Content-Type": "application/json" },
};
if (this.token) {
options.headers.Authorization = "Bearer " + this.token;
}
const req = lib.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch (e) {
/* 非 JSON 响应 */
}
if (parsed && parsed.success === true) {
resolve(parsed.data);
} else {
const msg = parsed && parsed.message
? parsed.message
: "HTTP " + res.statusCode;
const err = new Error(msg);
err.status = res.statusCode;
reject(err);
}
});
});
req.on("error", (e) => {
reject(new Error("Registry 不可达: " + e.message));
});
req.setTimeout(10000, () => {
req.destroy(new Error("请求超时"));
});
if (body !== undefined) {
req.write(JSON.stringify(body));
}
req.end();
});
}
listProjects() {
return this.request("GET", "/api/projects");
}
listConfigs(projectId) {
return this.request("GET", `/api/projects/${encodeURIComponent(projectId)}/configs`);
}
createConfig(projectId, data) {
return this.request("POST", `/api/projects/${encodeURIComponent(projectId)}/configs`, data);
}
updateConfig(projectId, configId, data) {
return this.request("PUT", `/api/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configId)}`, data);
}
}
module.exports = RegistryClient;
+404
View File
@@ -0,0 +1,404 @@
/**
* Registry 模块
* kit 命令行对接 Registry 配置管理中心
*
* 配置项(通过 kit config 设置):
* registryUrl Registry 服务地址
* registryToken Registry 认证 Token
*
* 命令:
* kit registry 查看用法
* kit registry list 列出项目
* kit registry config <project[:path]> 查看配置
* kit registry config <project[:path]> <value> 设置配置
* kit registry export <project[:path]> [file] 导出配置
* kit registry import <project[:path]> <file> 导入配置
*/
const RegistryClient = require("./client");
const USAGE = [
"registry 用法:",
" kit registry list 列出 Registry 项目",
" kit registry config <project[:path]> 查看配置",
" kit registry config <project[:path]> <value> 设置配置",
" kit registry export <project[:path]> [file] 导出配置为 JSON",
" kit registry import <project[:path]> <file> 从 JSON 导入配置",
"配置项: kit config registryUrl <url>, kit config registryToken <token>",
].join("\n");
/**
* 解析 project:path 语法
* @param {string} spec - 例如 CommonExternalService 或 CommonExternalService:data/windows
*/
function parseProjectPath(spec) {
spec = String(spec || "");
const idx = spec.indexOf(":");
if (idx === -1) {
return { project: spec, path: "" };
}
return {
project: spec.substring(0, idx),
path: spec.substring(idx + 1).replace(/^\/+|\/+$/g, ""),
};
}
/**
* 规整 key:去空白、压缩连续斜杠、去首尾斜杠
*/
function normalizeKey(key) {
return String(key || "")
.trim()
.split("/")
.map((s) => s.trim())
.filter((s) => s !== "")
.join("/");
}
/**
* 获取配置并用 path 过滤
* @returns {Promise<{all: Array, matched: Array}>}
*/
async function getConfigsFiltered(client, projectId, prefix) {
const configs = await client.listConfigs(projectId);
if (!prefix) {
return { all: configs, matched: configs };
}
const matched = configs.filter((c) => {
const p = c.path || "";
return p === prefix || p.startsWith(prefix + "/");
});
return { all: configs, matched };
}
/**
* 格式化配置值用于展示
*/
function formatValue(value, type) {
if (value === undefined || value === null) {
return "";
}
if (type === "json" && typeof value !== "string") {
return JSON.stringify(value);
}
return String(value);
}
/**
* 解析项目(支持名称或 ID)
*/
async function resolveProject(client, name) {
const projects = await client.listProjects();
const project = projects.find((p) => p.id === name || p.name === name);
if (!project) {
throw new Error("项目不存在: " + name);
}
return project;
}
/**
* 打印配置表格
*/
function printConfigTable(configs) {
if (!configs || configs.length === 0) {
$logger.info("无匹配的配置");
return;
}
const table = configs.map((c) => ({
路径: c.path,
值: formatValue(c.value, c.type),
类型: c.type,
必填: c.required ? "✓" : "",
存在: c.exists === false ? "✗" : "✓",
}));
console.table(table);
$logger.info("共 " + configs.length + " 条配置");
}
/**
* 构建导出文件内容
*/
function buildExport(project, prefix, configs) {
return {
project: project.name,
path: prefix,
exportedAt: new Date().toISOString(),
configs: configs.map((c) => {
const fullPath = c.path || "";
let key = c.key || "";
if (prefix && fullPath === prefix) {
key = "";
} else if (prefix && fullPath.startsWith(prefix + "/")) {
key = fullPath.substring(prefix.length + 1);
}
return {
key,
path: fullPath,
value: c.value,
type: c.type,
description: c.description,
required: c.required === true,
exists: c.exists !== false,
defaultValue: c.defaultValue,
purpose: c.purpose,
valueRange: c.valueRange,
idc: c.idc || "",
environment: c.environment || "",
group: c.group || "",
};
}),
};
}
/**
* 按完整路径查找已存在的配置
*/
function findExistingByPath(configs, fullPath) {
const target = normalizeKey(fullPath);
return configs.find((c) => normalizeKey(c.path || "") === target);
}
/**
* 将完整路径分解为 key + 已启用 path 维度的值
* 通过前端剥离维度前缀还原存储 key
*/
function decomposeByDims(project, fullPath, dims) {
let key = normalizeKey(fullPath);
let idc = project.enableIdc ? String(dims.idc || "") : "";
let environment = project.enableEnvironment ? String(dims.environment || "") : "";
let group = project.enableGroup ? String(dims.group || "") : "";
if (idc && key.startsWith(idc + "/")) {
key = key.substring(idc.length + 1);
}
if (environment && key.startsWith(environment + "/")) {
key = key.substring(environment.length + 1);
}
if (group && key.startsWith(group + "/")) {
key = key.substring(group.length + 1);
}
return { key: normalizeKey(key), idc, environment, group };
}
/**
* 尝试解析 JSON 字符串
* 对于 json 类型值自动从字符串转为对象
*/
function maybeParseJson(value) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
return JSON.parse(trimmed);
} catch (e) {
// 解析失败,保持原字符串
}
}
return value;
}
/**
* 获取 Registry 客户端
*/
async function getClient() {
const uConfig = await $context.get("uConfig");
const baseUrl = uConfig.get("registryUrl");
if (!baseUrl) {
throw new Error("未配置 Registry 地址,请先运行: kit config registryUrl <url>");
}
const token = uConfig.get("registryToken") || "";
return new RegistryClient(baseUrl, token);
}
/**
* 默认导出文件名
*/
function defaultExportFile(projectName, prefix) {
const safe = String(projectName).replace(/[^a-zA-Z0-9_-]/g, "_");
const suffix = prefix ? "-" + String(prefix).replace(/[^a-zA-Z0-9_-]/g, "_") : "";
return safe + suffix + "-" + new Date().toISOString().slice(0, 10) + ".json";
}
async function cmdList(client) {
const projects = await client.listProjects();
if (!projects || projects.length === 0) {
$logger.info("Registry 上没有项目");
return;
}
const table = [];
for (const p of projects) {
let configs = [];
try {
configs = await client.listConfigs(p.id);
} catch (e) {
/* 忽略单个项目配置数获取失败 */
}
table.push({
ID: p.id,
名称: p.name,
说明: p.description,
机房: p.enableIdc ? "✓" : "✗",
环境: p.enableEnvironment ? "✓" : "✗",
分组: p.enableGroup ? "✓" : "✗",
配置数: configs.length,
});
}
console.table(table);
$logger.info("共 " + projects.length + " 个项目");
}
async function cmdConfig(client, spec, value) {
const { project: projectSpec, path: prefix } = parseProjectPath(spec);
if (!projectSpec) {
throw new Error("缺少 project,用法: kit registry config <project[:path]> [value]");
}
const project = await resolveProject(client, projectSpec);
const { matched } = await getConfigsFiltered(client, project.id, prefix);
if (value === undefined || value === null) {
if (matched.length === 1 && matched[0].path === prefix) {
const c = matched[0];
$logger.info(formatValue(c.value, c.type));
} else {
printConfigTable(matched);
}
return;
}
if (matched.length === 0) {
throw new Error("配置不存在: " + prefix + ",请先用 kit registry import 导入或在 Registry 界面创建");
}
if (matched.length > 1) {
throw new Error("路径匹配到多条配置,请指定完整 key: " + prefix);
}
const target = matched[0];
const newValue = maybeParseJson(value);
const updated = await client.updateConfig(project.id, target.id, { value: newValue });
$logger.info("设置成功: " + updated.path + " = " + formatValue(updated.value, updated.type));
}
async function cmdExport(client, spec, file) {
const { project: projectSpec, path: prefix } = parseProjectPath(spec);
if (!projectSpec) {
throw new Error("缺少 project,用法: kit registry export <project[:path]> [file]");
}
const project = await resolveProject(client, projectSpec);
const { matched } = await getConfigsFiltered(client, project.id, prefix);
const data = buildExport(project, prefix, matched);
const outFile = file || defaultExportFile(project.name, prefix);
const fs = require("fs");
fs.writeFileSync(outFile, JSON.stringify(data, null, 4), "utf-8");
$logger.info("已导出 " + matched.length + " 条配置到 " + outFile);
}
async function cmdImport(client, spec, file) {
const fs = require("fs");
if (!file) {
throw new Error("缺少 file,用法: kit registry import <project[:path]> <file>");
}
if (!fs.existsSync(file)) {
throw new Error("文件不存在: " + file);
}
let data;
try {
const raw = fs.readFileSync(file, "utf-8").replace(/^\uFEFF/, "");
data = JSON.parse(raw);
} catch (e) {
throw new Error("JSON 解析失败: " + e.message);
}
if (!data || !Array.isArray(data.configs)) {
throw new Error("文件格式不正确,缺少 configs 数组");
}
const { project: projectSpec, path: prefix } = parseProjectPath(spec);
if (!projectSpec) {
throw new Error("缺少 project,用法: kit registry import <project[:path]> <file>");
}
const project = await resolveProject(client, projectSpec);
const { all } = await getConfigsFiltered(client, project.id, "");
let created = 0;
let updated = 0;
let skipped = 0;
for (const item of data.configs) {
const baseKey = normalizeKey(item.key);
// 指定前缀时按前缀+相对key 重定位;未指定前缀时沿用导出时的完整路径,保证幂等
const targetPath = normalizeKey(prefix ? prefix + "/" + baseKey : item.path || baseKey);
if (!targetPath) {
skipped++;
continue;
}
const existing = findExistingByPath(all, targetPath);
if (existing) {
const patch = { value: item.value };
if (item.exists !== undefined) {
patch.exists = item.exists !== false;
}
await client.updateConfig(project.id, existing.id, patch);
updated++;
} else {
const { key, idc, environment, group } = decomposeByDims(project, targetPath, item);
await client.createConfig(project.id, {
key,
value: item.value,
type: item.type || "string",
description: item.description || "",
required: item.required === true,
defaultValue: item.defaultValue,
purpose: item.purpose,
valueRange: item.valueRange,
idc,
environment,
group,
});
created++;
}
}
$logger.info("导入完成: 新增 " + created + ",更新 " + updated + (skipped ? ",跳过 " + skipped : "") + " 条");
}
module.exports = {
init: function () {
return this;
},
regTo: function (parser) {
parser.addCmdLine("registry [sub] [project-path] [value]", "Registry 配置管理; sub: list/config/export/import", async function (cli) {
const sub = cli.getParamValue("sub");
const spec = cli.getParamValue("project-path");
const value = cli.getParamValue("value");
switch (sub) {
case "list": {
const client = await getClient();
await cmdList(client);
break;
}
case "config": {
const client = await getClient();
await cmdConfig(client, spec, value);
break;
}
case "export": {
const client = await getClient();
await cmdExport(client, spec, value);
break;
}
case "import": {
const client = await getClient();
await cmdImport(client, spec, value);
break;
}
default:
$logger.info(USAGE);
}
});
},
};
+6 -1
View File
@@ -1,6 +1,6 @@
async function killport(port) {
async function killport(port, dry) {
$logger.info(`kill process on port ${port}`);
const util = require('util');
const exec = util.promisify(require('child_process').exec);
@@ -23,6 +23,11 @@ async function killport(port) {
$logger.info(`绑定端口号=${port}; 进程 PID=${pid}; 程序=${processName};`);
if (dry) {
$logger.info(`dry 模式,未执行清理: pid=${pid}`);
return
}
// Kill 进程
await exec(`taskkill /PID ${pid} /F`)
+1 -1
View File
@@ -15,7 +15,7 @@
"filename" : "kit.exe"
},
"x64|linux" : {
"path": "data/20250314003525/kit",
"path": "data/linux/kit",
"filename" : "kit"
},
"x64|linux|kylin V10" : {
@@ -1,12 +0,0 @@
---
slug: first-blog-post
title: First Blog Post
authors:
name: Gao Wei
title: Docusaurus Core Team
url: https://github.com/wgao19
image_url: https://github.com/wgao19.png
tags: [hola, docusaurus]
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-44
View File
@@ -1,44 +0,0 @@
---
slug: long-blog-post
title: Long Blog Post
authors: endi
tags: [hello, docusaurus]
---
This is the summary of a very long blog post,
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
<!--truncate-->
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-20
View File
@@ -1,20 +0,0 @@
---
slug: mdx-blog-post
title: MDX Blog Post
authors: [slorber]
tags: [docusaurus]
---
Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
:::tip
Use the power of React to create interactive blog posts.
```js
<button onClick={() => alert('button clicked!')}>Click me!</button>
```
<button onClick={() => alert('button clicked!')}>Click me!</button>
:::
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

-25
View File
@@ -1,25 +0,0 @@
---
slug: welcome
title: Welcome
authors: [slorber, yangshun]
tags: [facebook, hello, docusaurus]
---
[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
Simply add Markdown files (or folders) to the `blog` directory.
Regular blog authors can be added to `authors.yml`.
The blog post date can be extracted from filenames, such as:
- `2019-05-30-welcome.md`
- `2019-05-30-welcome/index.md`
A blog post folder can be convenient to co-locate blog post images:
![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg)
The blog supports tags as well!
**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.
-17
View File
@@ -1,17 +0,0 @@
endi:
name: Endilie Yacop Sucipto
title: Maintainer of Docusaurus
url: https://github.com/endiliey
image_url: https://github.com/endiliey.png
yangshun:
name: Yangshun Tay
title: Front End Engineer @ Facebook
url: https://github.com/yangshun
image_url: https://github.com/yangshun.png
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
+75
View File
@@ -0,0 +1,75 @@
---
sidebar_position: 2
---
# 功能概览
kit 提供命令行工具、脚本组装、运维管理三大核心能力。
## 命令行工具
kit 内置跨平台的常用命令,解决 Windows/Linux 脚本兼容性问题:
| 命令 | 说明 |
|------|------|
| `info` | 查看系统信息 |
| `mkdir` | 创建目录 |
| `rm` | 删除文件/目录 |
| `cp` | 复制文件 |
| `mv` | 移动文件 |
| `zip` | 压缩/解压 |
| `md5` | 计算 MD5 |
| `readJson` | 读取 JSON 文件 |
| `writeJson` | 写入 JSON 文件 |
## 远程操作
| 命令 | 说明 |
|------|------|
| `scp` | 跨主机文件传输 |
| `remoteShell` | SSH 远程执行命令 |
## 模块管理
kit 可作为模块管理器,从远程仓库安装、升级、卸载工具:
```shell
# 安装模块
kit install myapp
# 指定版本安装
kit install myapp@1.0
# 查看已安装版本
kit list myapp
# 切换版本
kit use myapp@1.0
# 回滚到上一个版本
kit rollback myapp
# 删除版本
kit remove myapp@1.0
```
## 脚本组装
kit 支持通过脚本文件(`kitx.js`)组合各种能力,实现工作流自动化:
```js
// kitx.js
async function main() {
console.log("开始部署...");
mkdir("./dist");
cp("./src/*", "./dist/");
scp("./dist/", "server:/app/");
remoteShell({host: "server"}, "cd /app && restart.sh");
}
```
运行脚本:
```shell
kit run
```
+28 -3
View File
@@ -4,19 +4,44 @@ sidebar_position: 1
# 安装
kit 支持 Windows(x86_64)、Linux(x86_64)、 Linux(arm64)、 Alpine 等多个执行平台。
kit 是一个跨平台命令行工具,支持以下平台:
下载地址:http://baishe.fullstack.club:5244/
| 平台 | 架构 | 说明 |
|------|------|------|
| Windows | x86_64 | `kit.exe` |
| Linux | x86_64 | 现代 glibc(Ubuntu 20.04+) |
| Linux | x86_64 | Kylin V10 低版本 glibc |
## 下载
前往首页下载对应平台的二进制文件:
- **Windows**: [kit.exe](https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/windows/kit.exe)
- **Linux (现代 glibc)**: [kit](https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/linux/kit)
- **Linux (Kylin V10)**: [kit.zip](https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/kylin/kit.zip)
下载后将可执行文件添加到系统 `PATH` 中即可使用。
## 远程仓库
kit 支持从远程仓库安装和管理模块。配置仓库地址:
```shell
kit config remoteServer http://baishe.fullstack.club:5244/dav
kit config remoteAuth cheney@722V587
```
## 信雅达内部仓库
### 信雅达内部仓库
```shell
kit config remoteServer http://172.16.17.230:5244/dav
kit config remoteAuth kit@kitkit
```
## 验证
配置完成后,运行以下命令验证仓库连接:
```shell
kit remote
```
+7 -3
View File
@@ -1,6 +1,10 @@
---
sidebar_position: 1
sidebar_position: 3
---
# 更多
等多文档请移步 -> **[Kit 辅助开发工具](https://flowus.cn/fullstack/share/9e90b5b7-cea7-44d7-9231-e5d8a6c807f9)**
# 更多文档
详细的 API 文档和使用示例,请参考:
- **在线文档**: [Kit 辅助开发工具](https://flowus.cn/fullstack/share/9e90b5b7-cea7-44d7-9231-e5d8a6c807f9)
- **代码仓库**: [git.honor3.com/Fullstack/kit.program](http://git.honor3.com/Fullstack/kit.program)
+116
View File
@@ -0,0 +1,116 @@
---
sidebar_position: 3
---
# Registry 配置管理
kit 可对接 [Registry](https://git.honor3.com/Fullstack/registry) 配置管理中心,通过命令行管理微服务配置。
## 配置连接
使用 `kit config` 设置 Registry 地址和认证信息:
```shell
# 设置 Registry 服务地址
kit config registryUrl http://your-registry-host:8008
# 设置认证 Token(Registry 设置中获取)
kit config registryToken your-token-here
```
验证连接:
```shell
kit registry list
```
## 查看项目
```shell
# 列出所有项目
kit registry list
# 输出示例:
# ID 名称 维度
# proj-abc-123 CommonExternalService IDC=✓ Env=✓ Group=✓
# proj-def-456 OrderService IDC=✗ Env=✓ Group=✓
```
## 管理配置
`kit registry config` 支持查看和修改配置项。
### 查看配置
```shell
# 查看整个项目的所有配置
kit registry config CommonExternalService
# 查看指定路径下的配置
kit registry config CommonExternalService:data/windows
# 获取单个配置值
kit registry config CommonExternalService:CF_R2_BUCKET
```
### 设置配置值
```shell
kit registry config CommonExternalService:CF_R2_BUCKET new-value
```
### 路径语法
`project:path` 中的 `path` 对应配置的路径前缀:
| 语法 | 含义 |
|------|------|
| `project` | 整个项目 |
| `project:data` | 路径以 `data` 开头的配置 |
| `project:data/windows` | 路径以 `data/windows` 开头的配置 |
| `project:data/windows/kit.exe` | 精确匹配单个配置 |
## 导入导出
### 导出
```shell
# 导出整个项目到文件
kit registry export CommonExternalService ./backup.json
# 仅导出指定路径下的配置
kit registry export CommonExternalService:data/windows ./backup-windows.json
```
导出文件包含项目名、路径、时间戳和配置列表:
```json
{
"project": "CommonExternalService",
"path": "data/windows",
"exportedAt": "2026-09-11T02:00:00Z",
"configs": [
{
"key": "kit.exe",
"value": "https://pub-xxx.r2.dev/data/windows/kit.exe",
"type": "string",
"description": "Windows 二进制下载地址"
}
]
}
```
### 导入
```shell
# 从备份文件导入
kit registry import CommonExternalService ./backup.json
# 导入到指定路径下
kit registry import CommonExternalService:data ./backup.json
```
导入规则:
- 已存在的 key → 更新值
- 不存在的 key → 新增
- 远程有但本地没有的 → 保留不动
+31 -40
View File
@@ -1,8 +1,4 @@
// @ts-check
// `@type` JSDoc annotations allow editor autocompletion and type checking
// (when paired with `@ts-check`).
// There are various equivalent ways to declare your Docusaurus config.
// See: https://docusaurus.io/docs/api/docusaurus-config
import {themes as prismThemes} from 'prism-react-renderer';
@@ -12,23 +8,15 @@ const config = {
tagline: '手边的开发运维工具',
favicon: 'img/favicon.ico',
// Set the production url of your site here
url: 'http://kit.fullstack.club',
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: '/',
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: 'facebook', // Usually your GitHub org/user name.
projectName: 'docusaurus', // Usually your repo name.
organizationName: 'Fullstack',
projectName: 'kit.program',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: 'zh',
locales: ['zh'],
@@ -41,18 +29,8 @@ const config = {
({
docs: {
sidebarPath: './sidebars.js',
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
},
blog: {
showReadingTime: true,
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
},
blog: false,
theme: {
customCss: './src/css/custom.css',
},
@@ -63,45 +41,57 @@ const config = {
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
// Replace with your project's social card
image: 'img/docusaurus-social-card.jpg',
navbar: {
title: 'Kit 项目主页',
title: 'Kit',
logo: {
alt: 'kit logo is still in production',
alt: 'Kit logo',
src: 'img/logo.svg',
},
items: [
{ href: 'https://flowus.cn/fullstack/share/9e90b5b7-cea7-44d7-9231-e5d8a6c807f9', label: '文档', position: 'right'}
{
type: 'docSidebar',
sidebarId: 'tutorialSidebar',
position: 'left',
label: '文档',
},
{
href: 'http://git.honor3.com/Fullstack/kit.program',
label: '仓库',
position: 'right',
},
],
},
footer: {
style: 'dark',
links: [
{
title: 'More',
title: 'Kit',
items: [
{
label: '代码仓库',
href: 'https://gitee.com/fullstackclub/kit.git',
label: '下载',
to: '/',
},
{
label: '开发者博客',
to: 'http://gpio.me',
label: '文档',
to: '/docs/intro',
},
{
label: '反馈',
to: 'mailto:hq@gpio.me',
label: '功能概览',
to: '/docs/features',
},
],
},
{
title: 'Docs',
title: '社区',
items: [
{
label: '文档',
to: 'https://flowus.cn/fullstack/share/9e90b5b7-cea7-44d7-9231-e5d8a6c807f9',
label: '代码仓库',
href: 'http://git.honor3.com/Fullstack/kit.program',
},
{
label: '反馈',
href: 'mailto:hq@gpio.me',
},
],
},
@@ -111,6 +101,7 @@ const config = {
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ['bash'],
},
}),
};
+3 -25
View File
@@ -1,33 +1,11 @@
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
{
type: 'category',
label: 'Tutorial',
items: ['tutorial-basics/create-a-document'],
},
'features',
'registry',
'more',
],
*/
};
export default sidebars;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
.downloads {
display: flex;
align-items: center;
padding: 2rem 0 4rem;
width: 100%;
}
.downloadCard {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
transition: box-shadow 0.2s;
}
.downloadCard:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
color: var(--ifm-color-primary);
}
.filename {
font-family: var(--ifm-font-family-monospace);
font-size: 0.85rem;
color: var(--ifm-color-emphasis-600);
margin: 0;
}
+3 -1
View File
@@ -3,6 +3,7 @@ import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import DownloadSection from '@site/src/components/DownloadSection';
import Heading from '@theme/Heading';
import styles from './index.module.css';
@@ -33,10 +34,11 @@ export default function Home() {
return (
<Layout
title={`${siteConfig.title}`}
description="Description will go into a meta tag in <head />">
description="手边的开发运维工具">
<HomepageHeader />
<main>
<HomepageFeatures />
<DownloadSection />
</main>
</Layout>
);
-7
View File
@@ -1,7 +0,0 @@
---
title: Markdown page example
---
# Markdown page example
You don't need React to write simple standalone pages.