Compare commits

...
23 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
cheney d93316be15 publish
TDevOPsCICD / build-image (push) Has been cancelled
2026-06-17 11:13:02 +08:00
cheney 97614a9079 remoteshell 2026-06-17 10:52:30 +08:00
cheney 37fa4cf020 save 2026-06-17 10:46:31 +08:00
cheney 4207d6e467 更新 cli 命令
TDevOPsCICD / build-image (push) Waiting to run
2026-06-17 10:31:26 +08:00
cheney a7f596aa0a 调试通过 2026-06-17 10:25:27 +08:00
cheney 364efbc1c1 修改 meta.json 规则, 涉及version 、group 字段. 2026-06-17 10:03:51 +08:00
cheney fc81584878 上传成功,准备修改路径 2026-06-17 09:30:22 +08:00
cheney 0132be7586 添加 docker runsh 命令 2026-06-17 09:13:30 +08:00
53 changed files with 4181 additions and 458 deletions
+146 -34
View File
@@ -1,34 +1,146 @@
name: TDevOPsCICD
on: [push]
jobs:
build-image:
runs-on: workbench
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Build Binary
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 }} # 使用分支名或标签作为版本
run: |
echo ----------------------------------------
echo ${{ github.ref_name }} ${{ github.sha }}
bun -v
ping -c 3 git.honor3.com
cd kit
bun i
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"
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"
name: TDevOpsCICD
on: [push]
jobs:
build-kit:
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: 从配置中心加载密钥
env:
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: |
cd kit
bun i
- name: Build Windows (bun 交叉编译)
run: |
cd kit
bun run windows
ls -la ./dist/windows/
- name: Build Linux (bun, 现代 glibc)
run: |
cd kit
bun run linux
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
+4 -1
View File
@@ -1,3 +1,6 @@
node_modules
dist
build.json
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 ) {
+11 -3
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("-") ) {
throw "flag 格式错误" + argv[i];
const param = line.params[name];
if ( ! param || ! param.isOptional ) {
throw "flag 格式错误" + argv[i];
}
// 可选命名参数允许不带值,值为空串,由命令自行做默认值
params[ name ] = "";
} else {
params[ name ] = argv[++i];
}
params[ argv[i].substring(2) ] = 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 当前为只读模式`
+21 -20
View File
@@ -1,4 +1,4 @@
- 基于 webdav 协议访问。包括**远程仓库存储方案**(基于 WebDAV 协议)与**本地客户端存储方案**。
- 基于 webdav 协议访问。包括**远程仓库存储方案**(基于 WebDAV 协议)与**本地客户端存储方案**。
- 支持存储多种操作系统(Linux、Windows)、多种架构(X86、aarch64)
- 支持记录官网地址,方便定期从官网拉取新版本存储。
@@ -20,21 +20,23 @@
```
/repo/ # WebDAV 服务暴露的根路径
└─ {project-id}/ # 项目目录(唯一标识)
├─ metadata.json # 项目级元数据文件
└─ {semver}/ # 版本目录(如 1.2.3 或 v1.2.3)
├─ version.json # 版本级元数据文件(推荐)
├─ any/any/ # 平台无关文件目录(保留字)
│ └─ ... # 单文件或压缩包
├─ {os}/ # 操作系统(如 linux, windows, darwin)
│ └─ {arch}/ # 架构(如 x86_64, aarch64)
│ └─ ... # 具体二进制文件或压缩包
└─ ...
└─ {group}/ # 分组目录,默认 /modules;空值等同 /
└─ {project-id}/ # 项目目录(唯一标识),实际路径为 group/id
├─ metadata.json # 项目级元数据文件
└─ {version}/ # 版本目录(如 1.0、1.1、2.0)
├─ version.json # 版本级元数据文件(推荐)
├─ any/any/ # 平台无关文件目录(保留字)
│ └─ ... # 单文件或压缩包
├─ {os}/ # 操作系统(如 linux, windows, darwin)
│ └─ {arch}/ # 架构(如 x86_64, aarch64)
│ └─ ... # 具体二进制文件或压缩包
└─ ...
```
#### 2.1 命名规则
- `project-id`:小写字母、数字、连字符(-),长度 ≤ 64。
- `semver`:符合 [Semantic Versioning 2.0]( 的版本号,推荐不含 `v` 前缀。
- `project-id`:来自项目 `meta.json` 中的唯一 `id`,小写字母、数字、连字符(-),长度 ≤ 64。
- `group`:来自项目 `meta.json`,默认 `/modules`;空字符串等同 `/`;支持 `/docker`、`/tmpls`、`/docker/net` 等子路径。
- `version`:来自项目 `meta.json` 的 `version`;若为空,发布时根据远程 `metadata.json.version` 尾数 +1,远程不存在时初始为 `1.0`。
- `os` 与 `arch` 使用标准化名称(见下表),`any` 为保留字,表示任意平台。
| 操作系统 | 标准名称 | 架构 | 标准名称 |
@@ -58,12 +60,11 @@
**字段说明**:
| 字段名 | 类型 | 必选 | 说明 |
| ------------------ | ----------------- | --- | ----------------- |
| `project_id` | string | 是 | 项目唯一标识,与目录名相同 |
| `group` | string | 否 | 仓库分组路径,默认 `/modules`,空值等同 `/` |
| `official_url` | string | 是 | 项目官网地址(用于定期拉取新版本) |
| `description` | string | 否 | 项目简述 |
| `latest_version` | string | 是 | 仓库中已存储的最新版本号 |
| `versions` | array of strings | 是 | 仓库中已存储的所有版本号列表 |
| `version` | string | 是 | 当前远程最新版本号;不再使用 `versions` 数组 |
| `last_fetch_check` | string (ISO 8601) | 否 | 上次检查官网更新的时间戳 |
| `fetch_rules` | array of objects | 否 | 自动拉取时的匹配规则(见 3.3) |
| `extra` | object | 否 | 扩展字段 |
@@ -72,10 +73,10 @@
```json
{
"project_id": "myapp",
"group": "/modules",
"official_url": "",
"description": "An example tool",
"latest_version": "1.2.1",
"versions": ["1.0.0", "1.2.0", "1.2.1"],
"version": "1.2",
"last_fetch_check": "2026-06-15T12:00:00Z",
"fetch_rules": [...],
"extra": { "license": "Apache-2.0" }
@@ -207,9 +208,9 @@
2. 访问 `official_url`,获取所有可用版本列表及最新版本。
3. 对比已存储版本,找出缺失的新版本。
4. 对于每个新版本,根据 `fetch_rules`(或内置逻辑)匹配下载 URL,下载对应的二进制/压缩包。
5. 按 `os` / `arch` / `any` 规则存放到 `{project-id}/{version}/{os}/{arch}/` 下。
5. 按 `os` / `arch` / `any` 规则存放到 `{group}/{project-id}/{version}/{os}/{arch}/` 下。
6. 计算文件哈希,生成或更新该版本的 `version.json`。
7. 更新项目级 `metadata.json`(`versions`、`latest_version`、`last_fetch_check`)。
7. 更新项目级 `metadata.json`(`version`、`group`、`last_fetch_check`),不再写入 `versions`。
---
+36
View File
@@ -0,0 +1,36 @@
- ## 编码前思考
- 明确假设,不确定时询问而非猜测。
- 存在歧义时,列出多种解释,不默默选定单一方案。
- 如果任务有明显更简单的做法,直接指出优化思路。
- 发现代码矛盾、逻辑不一致时及时暂停,请求信息澄清。
## 简洁优先
- 用最少的代码解决问题,拒绝冗余实现。
- 不为一次性需求创建抽象层、复杂架构。
- 不盲目增加扩展性、可配置性,应对“未来可能用到”的场景。
- 若代码可大幅精简,主动重写优化。
- 校验标准:以资深工程师视角判断,代码若过于复杂,立即简化。
## 精准修改
- 仅修改与当前任务直接相关的代码内容。
- 不顺手优化相邻代码、注释、排版格式。
- 不重构原本可以正常运行的代码模块。
- 严格匹配项目现有代码风格,保留原有编码习惯。
- 因本次修改产生的无效导入、废弃变量,可直接删除。
- 发现项目中原有的死代码、冗余内容,仅做文字提醒,不擅自删除。
## 目标驱动执行
- 执行任务前,定义清晰、可落地的成功标准。
- 将“修复Bug”转化为:编写用例复现问题,再调试至用例正常通过。
- 将“新增校验功能”转化为:针对异常输入编写测试用例,保证全部通过。
- 将“代码重构”转化为:完成重构后,确保原有所有测试用例正常运行。
- 多步骤复杂任务,先输出简短执行计划,同时标注每一步的验证方式。
## 编程规范
- 每个函数都要有中文注释, 包括功能说明、参数说明、返回值说明等,如果有注意事项,也要在注释中特别说明。
- 每次需求修改都要同时更新文档、代码、测试代码。
- 每条测试用例都要有一个唯一编号。例如 1-1、2-20 等。
+15
View File
@@ -25,3 +25,18 @@ rollback myapp
# 删除模块
remove myapp
```
## 发布规则
项目 `meta.json` 支持 `group` 字段:
- 默认值为 `/modules`。
- 空字符串等同于 `/`。
- 可配置为 `/docker`、`/tmpls` 等一级路径,也可配置为 `/docker/net` 等子路径。
- 远程仓库整体上传路径为 `group/id`,其中 `id` 是项目唯一标识。
项目 `meta.json` 只使用 `version` 字段,不再使用 `versions`:
- `version` 有值时,按该版本上传;上传前检查远程 `group/id/version` 是否存在,已存在则报错。
- `version` 为空时,先读取远程 `group/id/metadata.json` 中的 `version`,将尾数 +1 后上传。
- 远程没有版本信息时,初始版本号为 `1.0`。
+78
View File
@@ -18,6 +18,7 @@
"node-forge": "^1.3.1",
"physical-cpu-count": "^2.0.0",
"sm-crypto": "^0.3.13",
"ssh2": "^1.16.0",
"systeminformation": "^5.30.6",
"webdav": "^5.3.1"
},
@@ -56,6 +57,15 @@
"resolved": "https://registry.npmmirror.com/applescript/-/applescript-1.0.0.tgz",
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/auto-launch": {
"version": "5.0.6",
"resolved": "https://registry.npmmirror.com/auto-launch/-/auto-launch-5.0.6.tgz",
@@ -81,6 +91,15 @@
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -97,6 +116,15 @@
"node": "*"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz",
@@ -110,6 +138,20 @@
"node": "*"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cross-port-killer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/cross-port-killer/-/cross-port-killer-1.4.0.tgz",
@@ -417,6 +459,13 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nan": {
"version": "2.27.0",
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.27.0.tgz",
"integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==",
"license": "MIT",
"optional": true
},
"node_modules/nested-property": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz",
@@ -523,6 +572,12 @@
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sm-crypto": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/sm-crypto/-/sm-crypto-0.3.13.tgz",
@@ -531,6 +586,23 @@
"jsbn": "^1.1.0"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/streamroller": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
@@ -575,6 +647,12 @@
"url": "https://www.buymeacoffee.com/systeminfo"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+7 -3
View File
@@ -4,12 +4,14 @@
"bin": "src/index.js",
"scripts": {
"test": "bun src/index.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",
"publish": "node publish.js",
"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",
@@ -22,7 +24,9 @@
"node-forge": "^1.3.1",
"physical-cpu-count": "^2.0.0",
"sm-crypto": "^0.3.13",
"ssh2": "^1.16.0",
"systeminformation": "^5.30.6",
"webdav": "^5.3.1"
}
}
+116
View File
@@ -0,0 +1,116 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const JSON5 = require('json5');
const root = __dirname;
const publishDir = path.resolve(root, 'publish');
const artifacts = [
{
script: 'pkg-windows',
source: path.resolve(root, 'dist/windows/kit.exe'),
target: path.resolve(publishDir, 'windows/x86_64/kit.exe'),
localPath: 'publish/windows/x86_64/kit.exe',
os: 'windows',
arch: 'x86_64',
filename: 'kit.exe'
},
{
script: 'pkg-linux',
source: path.resolve(root, 'dist/linux/kit'),
target: path.resolve(publishDir, 'linux/x86_64/kit'),
localPath: 'publish/linux/x86_64/kit',
os: 'linux',
arch: 'x86_64',
filename: 'kit'
}
];
function run(command, args) {
console.log(`> ${command} ${args.join(' ')}`);
const result = spawnSync(command, args, {
cwd: root,
stdio: 'inherit',
shell: process.platform === 'win32'
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status || 1);
}
}
function readJson(file) {
return JSON5.parse(fs.readFileSync(file, 'utf-8'));
}
function readPackage() {
return readJson(path.resolve(root, 'package.json'));
}
function readSourceMeta() {
const candidates = [
path.resolve(root, 'meta.json'),
path.resolve(root, '..', 'meta.json')
];
for (const file of candidates) {
if (fs.existsSync(file)) {
return readJson(file);
}
}
return {};
}
function copyFile(source, target) {
if (!fs.existsSync(source)) {
throw new Error(`Build artifact not found: ${source}`);
}
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
}
function writeMeta() {
const pkg = readPackage();
const sourceMeta = readSourceMeta();
const meta = {
id: sourceMeta.id || pkg.name || 'kit',
name: sourceMeta.name || pkg.name || 'kit',
desc: sourceMeta.desc || sourceMeta.description || '',
group: sourceMeta.group || '/modules',
dist: artifacts.map((artifact) => ({
path: artifact.localPath,
os: artifact.os,
arch: artifact.arch,
filename: artifact.filename,
type: 'executable'
}))
};
fs.mkdirSync(publishDir, { recursive: true });
fs.writeFileSync(path.resolve(publishDir, 'meta.json'), JSON.stringify(meta, null, 4), 'utf-8');
}
function preparePublishDir() {
fs.rmSync(publishDir, { recursive: true, force: true });
for (const artifact of artifacts) {
copyFile(artifact.source, artifact.target);
}
writeMeta();
}
for (const artifact of artifacts) {
run('npm', ['run', artifact.script]);
}
preparePublishDir();
run('kit3', ['publish', './publish']);
console.log('Publish completed.');
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env node
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
function exitError(message, code = 1) {
console.error("ERROR: " + message);
process.exit(code);
}
function run(command, args) {
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function ensureDocker() {
try {
run("docker", ["version", "--format", "{{.Server.Version}}"]);
} catch (e) {
exitError("docker command is not executable, or Docker daemon is unavailable");
}
}
function inspectContainer(container) {
try {
const text = run("docker", ["inspect", container]);
const data = JSON.parse(text);
if (!Array.isArray(data) || data.length === 0) {
exitError("container not found: " + container);
}
return data[0];
} catch (e) {
if (e instanceof SyntaxError) {
exitError("docker inspect output is not valid JSON");
}
exitError("docker inspect failed: " + container);
}
}
function quoteSh(value) {
if (value === undefined || value === null) {
return "''";
}
const text = String(value);
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) {
return text;
}
return "'" + text.replace(/'/g, "'\\''") + "'";
}
function quoteMountSpec(value) {
const text = String(value);
if (text.startsWith("$(pwd)")) {
const rest = text.slice("$(pwd)".length).replace(/(["\\`$])/g, "\\$1");
return '"$(pwd)' + rest + '"';
}
return quoteSh(text);
}
function normalizeContainerName(name) {
return (name || "container").replace(/^\/+/, "") || "container";
}
function isLikelyProjectPath(source) {
if (!source) return false;
const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
if (normalized === "/") return false;
if (/^[A-Za-z]:$/i.test(normalized)) return false;
const specialPrefixes = [
"/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
"/opt/docker", "/var/lib/docker", "/var/run/docker.sock",
"C:/Program Files", "C:/Program Files (x86)", "C:/ProgramData", "C:/Windows"
];
return !specialPrefixes.some(prefix => normalized === prefix || normalized.startsWith(prefix + "/"));
}
function relativeToPwdIfProject(source) {
if (!isLikelyProjectPath(source)) {
return source;
}
const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
const base = path.posix.basename(normalized);
if (!base || base === "." || base === "/") {
return source;
}
return "$(pwd)/" + base;
}
function collectEnv(config) {
const env = Array.isArray(config.Env) ? config.Env : [];
return env
.filter(item => typeof item === "string" && item.length > 0)
.map(item => " -e " + quoteSh(item) + " \\");
}
function collectPorts(networkSettings, hostConfig) {
const lines = [];
const seen = new Set();
const ports = Object.assign({}, networkSettings && networkSettings.Ports ? networkSettings.Ports : {});
const bindings = hostConfig && hostConfig.PortBindings ? hostConfig.PortBindings : {};
for (const containerPort of Object.keys(bindings)) {
if (!ports[containerPort]) ports[containerPort] = bindings[containerPort];
}
for (const containerPort of Object.keys(ports).sort()) {
const entries = ports[containerPort];
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || !entry.HostPort) continue;
const hostIp = entry.HostIp && entry.HostIp !== "0.0.0.0" && entry.HostIp !== "::" ? entry.HostIp + ":" : "";
const spec = hostIp + entry.HostPort + ":" + containerPort;
if (seen.has(spec)) continue;
seen.add(spec);
lines.push(" -p " + quoteSh(spec) + " \\");
}
}
return lines;
}
function collectMounts(info) {
const mounts = Array.isArray(info.Mounts) ? info.Mounts : [];
const lines = [];
for (const mount of mounts) {
if (!mount || !mount.Destination) continue;
if (mount.Type === "volume" && mount.Name) {
let spec = mount.Name + ":" + mount.Destination;
if (mount.RW === false) spec += ":ro";
lines.push(" -v " + quoteMountSpec(spec) + " \\");
continue;
}
if (mount.Source) {
let spec = relativeToPwdIfProject(mount.Source) + ":" + mount.Destination;
if (mount.RW === false) spec += ":ro";
lines.push(" -v " + quoteMountSpec(spec) + " \\");
}
}
return lines;
}
function collectNetworks(info) {
const hostConfig = info.HostConfig || {};
const networkMode = hostConfig.NetworkMode;
const lines = [];
if (networkMode && networkMode !== "default" && networkMode !== "bridge") {
lines.push(" --network " + quoteSh(networkMode) + " \\");
return lines;
}
const networkSettings = info.NetworkSettings || {};
const networks = networkSettings.Networks ? Object.keys(networkSettings.Networks) : [];
if (networks.length === 1 && networks[0] !== "bridge") {
lines.push(" --network " + quoteSh(networks[0]) + " \\");
}
return lines;
}
function collectSimpleOptions(info) {
const config = info.Config || {};
const hostConfig = info.HostConfig || {};
const lines = [];
if (hostConfig.RestartPolicy && hostConfig.RestartPolicy.Name && hostConfig.RestartPolicy.Name !== "no") {
let restart = hostConfig.RestartPolicy.Name;
if (restart === "on-failure" && hostConfig.RestartPolicy.MaximumRetryCount) {
restart += ":" + hostConfig.RestartPolicy.MaximumRetryCount;
}
lines.push(" --restart " + quoteSh(restart) + " \\");
}
if (config.WorkingDir) lines.push(" -w " + quoteSh(config.WorkingDir) + " \\");
if (config.User) lines.push(" -u " + quoteSh(config.User) + " \\");
if (hostConfig.Privileged) lines.push(" --privileged \\");
if (config.Tty) lines.push(" -t \\");
if (config.OpenStdin) lines.push(" -i \\");
return lines;
}
function commandTail(config) {
let entrypoint = [];
if (Array.isArray(config.Entrypoint)) {
entrypoint = config.Entrypoint.filter(item => item !== null && item !== undefined).map(String);
} else if (typeof config.Entrypoint === "string" && config.Entrypoint) {
entrypoint = [config.Entrypoint];
}
const cmd = Array.isArray(config.Cmd) ? config.Cmd : (typeof config.Cmd === "string" && config.Cmd ? [config.Cmd] : []);
return {
entrypoint: entrypoint.length > 0 ? entrypoint[0] : null,
args: entrypoint.slice(1).concat(cmd)
};
}
function buildScript(info) {
const config = info.Config || {};
const hostConfig = info.HostConfig || {};
const name = normalizeContainerName(info.Name);
const image = config.Image || info.Image || "";
const tail = commandTail(config);
const lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"",
"name=" + quoteSh(name),
"img=" + quoteSh(image),
"",
"docker run -d \\",
" --name \"$name\" \\",
];
lines.push(...collectSimpleOptions(info));
lines.push(...collectEnv(config));
lines.push(...collectPorts(info.NetworkSettings || {}, hostConfig));
lines.push(...collectMounts(info));
lines.push(...collectNetworks(info));
if (tail.entrypoint) {
lines.push(" --entrypoint " + quoteSh(tail.entrypoint) + " \\");
}
const cmd = tail.args.map(quoteSh).join(" ");
if (cmd) {
lines.push(" \"$img\" " + cmd);
} else {
lines.push(" \"$img\"");
}
lines.push("");
return lines.join("\n");
}
function outputFile(container, explicitOutput) {
if (explicitOutput) return explicitOutput;
const safe = container.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "container";
return path.resolve(process.cwd(), "docker-run-" + safe + ".sh");
}
function main() {
const args = process.argv.slice(2);
if (args.length < 1 || args[0] === "-h" || args[0] === "--help") {
console.log("Usage: node restore-run.js <container-id-or-name> [output-file]");
process.exit(args.length < 1 ? 1 : 0);
}
ensureDocker();
const container = args[0];
const info = inspectContainer(container);
const script = buildScript(info);
const file = outputFile(container, args[1]);
fs.writeFileSync(file, script, { mode: 0o755 });
console.log(file);
}
if (require.main === module) {
main();
}
module.exports = {
buildScript,
main
};
+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
};
+37 -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")
@@ -172,9 +177,21 @@ parser.addCmdLine("freem [level]", "释放内存", function (cli) {
});
parser.addCmdLine("killport <port>", "释放端口", function (cli) {
parser.addCmdLine("dockersh <container> [--file]", "反向获取 docker 容器启动脚本", function (cli) {
let container = cli.getParamValue("container")
let file = cli.getParamValue("file")
import("./docker/index.js").then(psl => psl.main(container, file))
});
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);
});
@@ -410,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(){
+88 -37
View File
@@ -2,6 +2,15 @@
const os = require("os");
const kitfunction = require("./kitfunction");
async function getAvailableRemote() {
const remote = await $context.get("remote");
if (!(await remote.check())) {
$logger.error("远程仓库不可用");
return null;
}
return remote;
}
module.exports = {
init : function () {
return this;
@@ -57,8 +66,6 @@ async function main(){
const vm = require('vm');
const script = new vm.Script(projectScript + ";" + step + "()");
// 注入全局变量
const kitfunction = require("./kitfunction")
const context = kitfunction;
script.runInNewContext(context, {
@@ -83,53 +90,97 @@ async function main(){
});
parser.addCmdLine("remote", "验证远程仓库可达;", async function () {
let remote = await $context.get("remote")
if (!(await remote.check())) {
$logger.error("远程仓库不可用")
return
} else {
$logger.info("远程仓库可用")
const remote = await getAvailableRemote();
if (remote) {
$logger.info("远程仓库可用");
}
});
parser.addCmdLine("publish [localFile] [remotePath]", "发布到远程仓库", async function (cli) {
parser.addCmdLine("check [module]", "验证远程仓库可达;指定 module 时查看远程 meta 信息;", async function (cli) {
const remote = await getAvailableRemote();
if (!remote) {
return;
}
const module = cli.getParamValue("module");
if (!module) {
$logger.info("远程仓库可用");
return;
}
try {
const parsed = remote.parseProjectVersion(module);
const meta = await remote.getProjectMetadata(parsed.projectId, parsed.group);
$logger.info(JSON.stringify(meta, null, 4));
} catch (e) {
$logger.info(`远程 module 不存在: ${module}`);
}
});
parser.addCmdLine("publish [localFile] [remotePath]", "发布到远程仓库; localFile 为空时使用当前目录 meta.json", async function (cli) {
const fs = require("fs");
let localFile = cli.getParamValue("localFile");
if (!fs.existsSync(localFile)) {
$logger.error("本地文件不存在")
return
let remotePath = cli.getParamValue("remotePath");
if (localFile && !fs.existsSync(localFile)) {
$logger.error("本地文件不存在");
return;
}
let remotePath = cli.getParamValue("remotePath");
if (!remotePath) {
$logger.info("使用本地配置路径")
if (!fs.existsSync("meta.json")) {
$logger.error("没有找到本地配置")
return
}
} else {
$logger.info("发布到远程路径 " + remotePath)
if (!localFile && !fs.existsSync("meta.json")) {
$logger.error("没有找到本地配置 meta.json");
return;
}
let remote = await $context.get("remote")
if (!(await remote.check())) {
$logger.error("远程仓库不可用")
return
} else {
$logger.info("远程仓库可用")
const remote = await getAvailableRemote();
if (!remote) {
return;
}
await remote.publish(localFile, remotePath)
$logger.info("发布成功")
if (remotePath) {
$logger.info("发布到远程路径 " + remotePath);
} else {
$logger.info("使用 meta.json 中的 group/id/version 规则发布");
}
await remote.publish(localFile, remotePath);
$logger.info("发布成功");
process.exit(0);
});
parser.addCmdLine("install [module]", "安装/更新模块;", async function (cli) {
let remote = await $context.get("remote")
if (!(await remote.check())) {
$logger.error("远程仓库不可用")
return
} else {
$logger.info("远程仓库可用")
parser.addCmdLine("install [module]", "安装/更新模块; 支持 myapp、myapp@1.0、/docker/net/myapp", async function (cli) {
const remote = await getAvailableRemote();
if (!remote) {
return;
}
remote.install(cli.getParamValue("module"))
await remote.install(cli.getParamValue("module"));
});
parser.addCmdLine("list [module]", "列出本地已安装版本;", async function (cli) {
let module = cli.getParamValue("module");
if (!module) {
module = "kit";
}
const remote = await $context.get("remote");
await remote.list(module);
});
parser.addCmdLine("use <module-version>", "切换到已安装版本,例如 myapp@1.0;", async function (cli) {
const remote = await $context.get("remote");
const version = await remote.use(cli.getParamValue("module-version"));
$logger.info("当前版本 " + version);
});
parser.addCmdLine("rollback <module>", "回滚到上一个已安装版本;", async function (cli) {
const remote = await $context.get("remote");
const version = await remote.rollback(cli.getParamValue("module"));
$logger.info("当前版本 " + version);
});
parser.addCmdLine("remove <module-version>", "删除本地已安装版本,例如 myapp@1.0;", async function (cli) {
const remote = await $context.get("remote");
await remote.remove(cli.getParamValue("module-version"));
$logger.info("删除成功");
});
}
}
+195 -3
View File
@@ -1,4 +1,4 @@
const crypto = require("crypto");
const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
@@ -298,17 +298,208 @@ function writeJson(filePath, key, value) {
return nextData;
}
function resolvePlainPassword(params) {
if (params.plainPwd !== undefined) {
return params.plainPwd;
}
if (params.plainPassword !== undefined) {
return params.plainPassword;
}
if (params.plaintextPwd !== undefined) {
return params.plaintextPwd;
}
if (params.plaintextPassword !== undefined) {
return params.plaintextPassword;
}
return undefined;
}
function resolveScpPassword(params) {
const plainPassword = resolvePlainPassword(params);
if (plainPassword !== undefined) {
return plainPassword;
}
return resolveValue(params.pwd || params.password);
}
function scp(from, to, params = {}) {
if (!from || !to) {
throw new Error("scp from and to are required");
}
platform.scp(from, to, Object.assign({}, params, { pwd: resolveValue(params.pwd) }));
const pwd = resolveScpPassword(params);
platform.scp(from, to, Object.assign({}, params, {
pwd,
password: pwd,
key: resolveValue(params.key || params.identityFile),
identityFile: resolveValue(params.identityFile || params.key)
}));
}
function loadSsh2Client() {
try {
return require("ssh2").Client;
} catch (e) {
throw new Error("remoteShell requires pure JS dependency ssh2. Please run npm install ssh2 in kit project.");
}
}
function normalizeRemoteShellConfig(config = {}) {
const host = config.host || config.hostname;
if (!host) {
throw new Error("remoteShell host is required");
}
const username = config.username || config.user;
if (!username) {
throw new Error("remoteShell username is required");
}
const password = resolveScpPassword(config);
const privateKey = config.privateKey || config.keyText || (config.keyFile ? fs.readFileSync(path.resolve(config.keyFile), "utf-8") : undefined);
const connectConfig = Object.assign({}, config, {
host,
username,
port: config.port || 22,
password,
privateKey,
passphrase: resolveValue(config.passphrase)
});
delete connectConfig.user;
delete connectConfig.hostname;
delete connectConfig.pwd;
delete connectConfig.plainPwd;
delete connectConfig.plainPassword;
delete connectConfig.plaintextPwd;
delete connectConfig.plaintextPassword;
delete connectConfig.keyText;
delete connectConfig.keyFile;
return connectConfig;
}
function remoteShell(config = {}) {
const Client = loadSsh2Client();
const connectConfig = normalizeRemoteShellConfig(config);
let client = null;
let connectPromise = null;
function connect() {
if (client) {
return Promise.resolve(client);
}
if (connectPromise) {
return connectPromise;
}
connectPromise = new Promise((resolve, reject) => {
const conn = new Client();
conn.once("ready", () => {
client = conn;
resolve(conn);
});
conn.once("error", (e) => {
connectPromise = null;
reject(e);
});
conn.once("close", () => {
client = null;
connectPromise = null;
});
conn.connect(connectConfig);
});
return connectPromise;
}
async function exec(cmd, options = {}) {
if (!cmd) {
throw new Error("remoteShell exec cmd is required");
}
const conn = await connect();
return new Promise((resolve, reject) => {
const execOptions = Object.assign({}, options);
delete execOptions.stdio;
delete execOptions.rejectOnError;
conn.exec(cmd, execOptions, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = "";
let stderr = "";
stream.on("error", reject);
stream.on("close", (code, signal) => {
const result = { code, signal, stdout, stderr };
if (options.rejectOnError === false || code === 0) {
resolve(result);
} else {
const error = new Error(`Remote command failed with code ${code}: ${cmd}`);
error.result = result;
reject(error);
}
});
stream.on("data", (data) => {
const text = data.toString();
stdout += text;
if (options.stdio === "inherit") {
process.stdout.write(text);
}
});
stream.stderr.on("data", (data) => {
const text = data.toString();
stderr += text;
if (options.stdio === "inherit") {
process.stderr.write(text);
}
});
});
});
}
async function execText(cmd, options = {}) {
const result = await exec(cmd, options);
return result.stdout;
}
async function call(cmd, options = {}) {
return exec(cmd, Object.assign({ stdio: "inherit" }, options));
}
function close() {
if (client) {
client.end();
}
client = null;
connectPromise = null;
}
return { connect, exec, execText, call, close };
}
function exit(code = 0) {
process.exit(code);
}
function isSecretLogKey(key) {
return /^(pwd|password|plainPwd|plainPassword|plaintextPwd|plaintextPassword|privateKey|passphrase|keyText)$/i.test(key);
}
function redactLogValue(value) {
if (Array.isArray(value)) {
return value.map(redactLogValue);
}
if (value && typeof value === "object") {
const result = {};
for (const key of Object.keys(value)) {
result[key] = isSecretLogKey(key) ? "******" : redactLogValue(value[key]);
}
return result;
}
return value;
}
function stringifyLogValue(value) {
if (typeof value === "string") {
return value;
@@ -320,7 +511,7 @@ function stringifyLogValue(value) {
return `[Function ${value.name || "anonymous"}]`;
}
try {
return JSON.stringify(value);
return JSON.stringify(redactLogValue(value));
} catch (e) {
return String(value);
}
@@ -355,6 +546,7 @@ module.exports = {
readJson: withCallLog("readJson", readJson),
writeJson: withCallLog("writeJson", writeJson),
scp: withCallLog("scp", scp),
remoteShell: withCallLog("remoteShell", remoteShell),
exit: withCallLog("exit", exit),
setVar: withCallLog("setVar", setVar),
getVar: withCallLog("getVar", getVar),
+51 -4
View File
@@ -50,21 +50,68 @@ function zip(sourceDir, to, params = {}) {
}
}
function isRemotePath(value) {
if (typeof value !== "string") {
return false;
}
if (/^[A-Za-z]:[\\/]/.test(value)) {
return false;
}
return /^[^\s:]+:.+/.test(value);
}
function resolveScpPath(value) {
return isRemotePath(value) ? value : path.resolve(value);
}
function appendScpParams(args, params) {
if (params.recursive !== false) {
args.push("-r");
}
if (params.port) {
args.push("-P", String(params.port));
}
if (params.key) {
args.push("-i", params.key);
}
if (params.preserve) {
args.push("-p");
}
if (params.compress) {
args.push("-C");
}
if (params.strictHostKeyChecking !== undefined) {
args.push("-o", `StrictHostKeyChecking=${params.strictHostKeyChecking ? "yes" : "no"}`);
}
if (params.options) {
for (const name of Object.keys(params.options)) {
args.push("-o", `${name}=${params.options[name]}`);
}
}
if (params.args) {
args.push(...(Array.isArray(params.args) ? params.args : [params.args]));
}
}
function scp(from, to, params = {}) {
const source = path.resolve(from);
if (!fs.existsSync(source)) {
const source = resolveScpPath(from);
if (!isRemotePath(from) && !fs.existsSync(source)) {
return;
}
const args = [];
appendScpParams(args, params);
args.push(source, resolveScpPath(to));
if (params.pwd) {
if (!commandExists("sshpass")) {
throw new Error("Linux scp with pwd requires sshpass in PATH");
}
run("sshpass", ["-p", params.pwd, "scp", "-r", source, to]);
run("sshpass", ["-p", params.pwd, "scp", ...args]);
return;
}
run("scp", ["-r", source, to]);
run("scp", args);
}
module.exports = { zip, scp };
+29 -19
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);
// 解析监听进程:若由 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 {
// 查 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}`)
await exec(`kill -9 ${pid}`)
$logger.info(`已结束进程 PID=${pid}`);
} catch (e) {
if ( 1 === e.code ) {
$logger.error("此端口未被使用,或清理进程失败。")
return
}
$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);
}
});
},
};
+43 -48
View File
@@ -12,7 +12,15 @@ function normalizeRemotePath(value) {
if (!value || value === "/") {
return "/";
}
return path.posix.normalize("/" + String(value).replace(/\\/g, "/")).replace(/\/+$|^$/g, "") || "/";
return path.posix.normalize("/" + String(value).replace(/\\/g, "/")).replace(/\/+$/g, "") || "/";
}
function normalizeGroup(group) {
return normalizeRemotePath(group == null ? "/modules" : group);
}
function joinRemote(...items) {
return normalizeRemotePath(items.filter((item) => item !== undefined && item !== null && item !== "").join("/"));
}
function normalizeOs(value) {
@@ -136,7 +144,8 @@ module.exports = class Remote {
}
async list(projectId) {
const state = this.getInstallState(projectId);
const parsed = this.parseProjectVersion(projectId);
const state = this.getInstallState(parsed.projectId);
for (const version of state.installed_versions) {
const current = version === state.current_version ? "*" : " ";
$logger.info(`${current} ${version}${current === "*" ? " (current)" : ""}`);
@@ -150,14 +159,15 @@ module.exports = class Remote {
}
async rollback(projectId) {
const state = this.getInstallState(projectId);
const parsed = this.parseProjectVersion(projectId);
const state = this.getInstallState(parsed.projectId);
const history = [...state.history].reverse();
const current = state.current_version;
const previous = history.find((item) => item.version && item.version !== current && state.installed_versions.includes(item.version));
if (!previous) {
throw new Error(`没有可回滚版本 ${projectId}`);
throw new Error(`没有可回滚版本 ${parsed.projectId}`);
}
return this.activate(projectId, previous.version, "rollback");
return this.activate(parsed.projectId, previous.version, "rollback");
}
async remove(projectVersion) {
@@ -183,10 +193,15 @@ module.exports = class Remote {
parseProjectVersion(value) {
const text = String(value || "kit");
const index = text.lastIndexOf("@");
if (index <= 0) {
return { projectId: text, version: undefined };
const target = index <= 0 ? text : text.substring(0, index);
const version = index <= 0 ? undefined : text.substring(index + 1);
if (target.startsWith("/")) {
const normalized = normalizeRemotePath(target);
const projectId = path.posix.basename(normalized);
const group = normalizeGroup(path.posix.dirname(normalized));
return { projectId, version, group };
}
return { projectId: text.substring(0, index), version: text.substring(index + 1) };
return { projectId: target, version, group: "/modules" };
}
getLocalModuleRoot(projectId) {
@@ -255,6 +270,10 @@ module.exports = class Remote {
];
}
getRemoteProjectRoot(projectId, group = "/modules") {
return joinRemote(normalizeGroup(group), projectId);
}
async ensureDir(remotePath) {
const target = normalizeRemotePath(remotePath);
if (target === "/") {
@@ -299,51 +318,29 @@ module.exports = class Remote {
return this.ensureDir(folder);
}
async wGetModuleInfo(projectId) {
return this.getProjectMetadata(projectId);
async wGetModuleInfo(projectId, group = "/modules") {
return this.getProjectMetadata(projectId, group);
}
async getProjectMetadata(projectId) {
const metadata = await this.getJson(`/${projectId}/metadata.json`, undefined);
async getProjectMetadata(projectId, group = "/modules") {
const metadata = await this.getJson(joinRemote(this.getRemoteProjectRoot(projectId, group), "metadata.json"), undefined);
if (metadata) {
metadata.group = normalizeGroup(metadata.group == null ? group : metadata.group);
return metadata;
}
throw new Error(`远程模块不存在 ${normalizeGroup(group)}/${projectId}`);
}
async getVersionMetadata(projectId, version, group = "/modules") {
const metadata = await this.getJson(joinRemote(this.getRemoteProjectRoot(projectId, group), version, "version.json"), undefined);
if (metadata) {
return metadata;
}
const legacy = await this.getJson(`/modules/${projectId}/meta.json`, undefined);
if (legacy) {
return {
project_id: legacy.id || projectId,
description: legacy.desc || legacy.name || "",
latest_version: legacy.latest && legacy.latest.version,
versions: (legacy.versions || []).map((item) => item.version || item),
extra: legacy
};
}
throw new Error(`远程模块不存在 ${projectId}`);
throw new Error(`远程版本不存在 ${normalizeGroup(group)}/${projectId}@${version}`);
}
async getVersionMetadata(projectId, version) {
const metadata = await this.getJson(`/${projectId}/${version}/version.json`, undefined);
if (metadata) {
return metadata;
}
const project = await this.getProjectMetadata(projectId);
const legacyVersion = project.extra && project.extra.versions && project.extra.versions.find((item) => (item.version || item) === version);
if (legacyVersion && legacyVersion.dist) {
return {
version,
artifacts: legacyVersion.dist.map((dist) => ({
path: `${normalizeOs(dist.os)}/${normalizeArch(dist.platform)}/${dist.filename}`,
type: "executable"
}))
};
}
throw new Error(`远程版本不存在 ${projectId}@${version}`);
}
async downloadArtifact(projectId, version, artifact, versionDir) {
const remotePath = `/${projectId}/${version}/${artifact.path}`;
async downloadArtifact(projectId, version, artifact, versionDir, group = "/modules") {
const remotePath = joinRemote(this.getRemoteProjectRoot(projectId, group), version, artifact.path);
const localPath = path.resolve(versionDir, artifact.path);
await this.wDownload(remotePath, localPath);
return localPath;
@@ -362,5 +359,3 @@ module.exports = class Remote {
return [];
}
}
+5 -4
View File
@@ -50,13 +50,14 @@ function unpackArtifact(artifact, localPath, versionDir) {
module.exports = async function install(remote, id) {
const parsed = remote.parseProjectVersion(id || "kit");
const projectId = parsed.projectId;
const project = await remote.getProjectMetadata(projectId);
const version = parsed.version || project.latest_version;
const group = parsed.group || "/modules";
const project = await remote.getProjectMetadata(projectId, group);
const version = parsed.version || project.version;
if (!version) {
throw new Error(`远程模块没有可安装版本 ${projectId}`);
}
const versionMeta = await remote.getVersionMetadata(projectId, version);
const versionMeta = await remote.getVersionMetadata(projectId, version, project.group || group);
const artifacts = remote.getMatchingArtifacts(versionMeta);
if (artifacts.length < 1) {
throw new Error(`没有匹配当前平台的文件 ${projectId}@${version}`);
@@ -67,7 +68,7 @@ module.exports = async function install(remote, id) {
fs.mkdirSync(versionDir, { recursive: true });
for (const artifact of artifacts) {
const localPath = await remote.downloadArtifact(projectId, version, artifact, versionDir);
const localPath = await remote.downloadArtifact(projectId, version, artifact, versionDir, project.group || group);
verifyChecksum(localPath, artifact.checksum || (versionMeta.checksums && versionMeta.checksums[artifact.path]));
unpackArtifact(artifact, localPath, versionDir);
}
+60 -22
View File
@@ -7,6 +7,21 @@ function now() {
return new Date().toISOString();
}
function normalizeRemotePath(value) {
if (!value || value === "/") {
return "/";
}
return path.posix.normalize("/" + String(value).replace(/\\/g, "/")).replace(/\/+$/g, "") || "/";
}
function normalizeGroup(group) {
return normalizeRemotePath(group == null ? "/modules" : group);
}
function joinRemote(...items) {
return normalizeRemotePath(items.filter((item) => item !== undefined && item !== null && item !== "").join("/"));
}
function normalizeOs(value) {
const name = String(value || "any").toLowerCase();
if (["windows_nt", "win32", "windows"].includes(name)) {
@@ -70,16 +85,6 @@ function archiveFormat(filePath) {
return undefined;
}
function buildVersion(localMeta) {
if (localMeta.release_version) {
return localMeta.release_version;
}
if (localMeta.version) {
return localMeta.version;
}
return new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
}
function readLocalMeta(localFile) {
const base = localFile ? path.resolve(localFile) : process.cwd();
const metaFile = fs.statSync(base).isDirectory() ? path.resolve(base, "meta.json") : path.resolve(process.cwd(), "meta.json");
@@ -93,7 +98,8 @@ function readLocalMeta(localFile) {
return {
id: path.basename(localFile, path.extname(localFile)).toLowerCase(),
version: buildVersion({}),
group: "/modules",
version: "",
desc: "",
dist: [{ path: localFile, os: "any", platform: "any", filename: path.basename(localFile) }]
};
@@ -123,6 +129,34 @@ function buildArtifacts(localMeta) {
});
}
function bumpTailVersion(version) {
const text = String(version || "").trim();
if (!text) {
return "1.0";
}
const match = text.match(/^(.*?)(\d+)$/);
if (!match) {
return `${text}.1`;
}
return `${match[1]}${Number(match[2]) + 1}`;
}
async function resolveVersion(remote, projectRoot, localMeta) {
const explicitVersion = String(localMeta.version || "").trim();
if (explicitVersion) {
if (await remote.client.exists(joinRemote(projectRoot, explicitVersion))) {
throw new Error(`远程版本已存在 ${projectRoot}@${explicitVersion}`);
}
return explicitVersion;
}
const metadata = await remote.getJson(joinRemote(projectRoot, "metadata.json"), undefined);
if (metadata && metadata.version) {
return bumpTailVersion(metadata.version);
}
return "1.0";
}
module.exports = async function publish(remote, localFile, remotePath) {
if (remotePath) {
const target = path.posix.resolve("/", remotePath.replace(/\\/g, "/"));
@@ -137,9 +171,11 @@ module.exports = async function publish(remote, localFile, remotePath) {
throw new Error("meta.json 缺少 project_id/id/name");
}
const version = buildVersion(localMeta);
const group = normalizeGroup(localMeta.group);
const projectRoot = remote.getRemoteProjectRoot ? remote.getRemoteProjectRoot(projectId, group) : joinRemote(group, projectId);
const version = await resolveVersion(remote, projectRoot, localMeta);
const artifacts = buildArtifacts(localMeta);
await remote.ensureDir(`/${projectId}/${version}`);
await remote.ensureDir(joinRemote(projectRoot, version));
const versionMeta = {
version,
@@ -150,27 +186,29 @@ module.exports = async function publish(remote, localFile, remotePath) {
};
for (const artifact of artifacts) {
await remote.wUpload(artifact.localPath, `/${projectId}/${version}/${artifact.path}`);
await remote.wUpload(artifact.localPath, joinRemote(projectRoot, version, artifact.path));
}
await remote.putJson(`/${projectId}/${version}/version.json`, versionMeta);
await remote.putJson(joinRemote(projectRoot, version, "version.json"), versionMeta);
const metadata = await remote.getJson(`/${projectId}/metadata.json`, {
const metadata = await remote.getJson(joinRemote(projectRoot, "metadata.json"), {
project_id: projectId,
group,
official_url: localMeta.official_url || localMeta.vcs || "",
description: localMeta.description || localMeta.desc || "",
latest_version: version,
versions: [],
version,
fetch_rules: localMeta.fetch_rules || [],
extra: {}
});
metadata.project_id = projectId;
metadata.group = group;
metadata.official_url = metadata.official_url || localMeta.official_url || localMeta.vcs || "";
metadata.description = localMeta.description || localMeta.desc || metadata.description || "";
metadata.latest_version = version;
metadata.versions = Array.from(new Set([...(metadata.versions || []), version]));
metadata.version = version;
delete metadata.versions;
delete metadata.latest_version;
metadata.extra = Object.assign({}, metadata.extra || {}, localMeta.extra || {});
await remote.putJson(`/${projectId}/metadata.json`, metadata);
$logger.info(`文件上传成功 ${projectId}@${version}`);
await remote.putJson(joinRemote(projectRoot, "metadata.json"), metadata);
$logger.info(`文件上传成功 ${group}/${projectId}@${version}`);
};
+56 -4
View File
@@ -48,9 +48,52 @@ function zip(sourceDir, to, params = {}) {
}
}
function isRemotePath(value) {
if (typeof value !== "string") {
return false;
}
if (/^[A-Za-z]:[\\/]/.test(value)) {
return false;
}
return /^[^\s:]+:.+/.test(value);
}
function resolveScpPath(value) {
return isRemotePath(value) ? value : path.resolve(value);
}
function appendScpParams(args, params, supportOpenSshOptions) {
if (params.recursive !== false) {
args.push("-r");
}
if (params.port) {
args.push("-P", String(params.port));
}
if (params.key) {
args.push("-i", params.key);
}
if (params.preserve) {
args.push("-p");
}
if (params.compress) {
args.push("-C");
}
if (supportOpenSshOptions && params.strictHostKeyChecking !== undefined) {
args.push("-o", `StrictHostKeyChecking=${params.strictHostKeyChecking ? "yes" : "no"}`);
}
if (supportOpenSshOptions && params.options) {
for (const name of Object.keys(params.options)) {
args.push("-o", `${name}=${params.options[name]}`);
}
}
if (params.args) {
args.push(...(Array.isArray(params.args) ? params.args : [params.args]));
}
}
function scp(from, to, params = {}) {
const source = path.resolve(from);
if (!fs.existsSync(source)) {
const source = resolveScpPath(from);
if (!isRemotePath(from) && !fs.existsSync(source)) {
return;
}
@@ -59,11 +102,20 @@ function scp(from, to, params = {}) {
throw new Error("Windows scp with pwd requires pscp in PATH");
}
const bin = commandExists("pscp.exe") ? "pscp.exe" : "pscp";
run(bin, ["-pw", params.pwd, "-r", source, to]);
const args = ["-pw", params.pwd];
appendScpParams(args, params, false);
args.push(source, resolveScpPath(to));
run(bin, args);
return;
}
run("scp.exe", ["-r", source, to]);
if (!commandExists("scp.exe")) {
throw new Error("Windows scp requires scp.exe in PATH");
}
const args = [];
appendScpParams(args, params, true);
args.push(source, resolveScpPath(to));
run("scp.exe", args);
}
module.exports = { zip, scp };
+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`)
+21
View File
@@ -0,0 +1,21 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const { buildScript } = require("./restore-run");
const inspectPath = path.join(__dirname, "testcase-inspect.json");
const expectedPath = path.join(__dirname, "testcase-run.sh");
const inspectData = JSON.parse(fs.readFileSync(inspectPath, "utf8"));
const info = Array.isArray(inspectData) ? inspectData[0] : inspectData;
const actual = buildScript(info);
const expected = fs.readFileSync(expectedPath, "utf8").replace(/\r\n/g, "\n");
assert.strictEqual(actual, expected);
assert.ok(actual.includes("name=freshrss"));
assert.ok(actual.includes("img=freshrss/freshrss:latest"));
assert.ok(actual.includes('-v "$(pwd)/data:/var/www/FreshRSS/data"'));
assert.ok(actual.includes('-v "$(pwd)/extensions:/var/www/FreshRSS/extensions"'));
assert.strictEqual((actual.match(/-p 8082:80\/tcp/g) || []).length, 1);
console.log("restore-run testcase passed");
+272
View File
@@ -0,0 +1,272 @@
[
{
"Id": "71ba6f6111c86b9cc72fd144717357a40a3c472794bae99cc17b7fd887648338",
"Created": "2026-06-09T08:40:16.386743045Z",
"Path": "./Docker/entrypoint.sh",
"Args": [
"/bin/bash",
"-o",
"pipefail",
"-c",
"([ -z \"$CRON_MIN\" ] || cron) && \t. /etc/apache2/envvars && \texec apache2 -D FOREGROUND $([ -n \"$OIDC_ENABLED\" ] && [ \"$OIDC_ENABLED\" -ne 0 ] && echo \"-D OIDC_ENABLED\")"
],
"State": {
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 2178,
"ExitCode": 0,
"Error": "",
"StartedAt": "2026-06-11T03:36:17.818299057Z",
"FinishedAt": "2026-06-11T03:36:11.0220238Z"
},
"Image": "sha256:fc6e4cf934a2d62c5da897c9fd011ef29dc3c83948ef3581d2a526fc90789622",
"ResolvConfPath": "/var/lib/docker/containers/71ba6f6111c86b9cc72fd144717357a40a3c472794bae99cc17b7fd887648338/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/71ba6f6111c86b9cc72fd144717357a40a3c472794bae99cc17b7fd887648338/hostname",
"HostsPath": "/var/lib/docker/containers/71ba6f6111c86b9cc72fd144717357a40a3c472794bae99cc17b7fd887648338/hosts",
"LogPath": "/var/lib/docker/containers/71ba6f6111c86b9cc72fd144717357a40a3c472794bae99cc17b7fd887648338/71ba6f6111c86b9cc72fd144717357a40a3c472794bae99cc17b7fd887648338-json.log",
"Name": "/freshrss",
"RestartCount": 0,
"Driver": "overlay2",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "docker-default",
"ExecIDs": null,
"HostConfig": {
"Binds": [
"/app/freshrss/extensions:/var/www/FreshRSS/extensions",
"/app/freshrss/data:/var/www/FreshRSS/data"
],
"ContainerIDFile": "",
"LogConfig": {
"Type": "json-file",
"Config": {
"max-file": "3",
"max-size": "10m"
}
},
"NetworkMode": "bridge",
"PortBindings": {
"80/tcp": [
{
"HostIp": "",
"HostPort": "8082"
}
]
},
"RestartPolicy": {
"Name": "unless-stopped",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": null,
"ConsoleSize": [
30,
118
],
"CapAdd": null,
"CapDrop": null,
"CgroupnsMode": "private",
"Dns": [],
"DnsOptions": [],
"DnsSearch": [],
"ExtraHosts": null,
"GroupAdd": null,
"IpcMode": "private",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": null,
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": [],
"BlkioDeviceReadBps": [],
"BlkioDeviceWriteBps": [],
"BlkioDeviceReadIOps": [],
"BlkioDeviceWriteIOps": [],
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": [],
"DeviceCgroupRules": null,
"DeviceRequests": null,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"OomKillDisable": null,
"PidsLimit": null,
"Ulimits": [],
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"MaskedPaths": [
"/proc/asound",
"/proc/acpi",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/proc/scsi",
"/sys/firmware",
"/sys/devices/virtual/powercap"
],
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
]
},
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/d9852f484fa657d12db8ed58e4f61fa9d937a63b2c02326bc859aa03f3c6a6f9-init/diff:/var/lib/docker/overlay2/e16b5110bbde48cd9070a8bea52ca1b49a6c7a6ae17abc23bc2f2152aa851dbb/diff:/var/lib/docker/overlay2/6d104177f8014ee6b291525597ac93c48cce1d6e3f45f8390330dbee8f536d89/diff:/var/lib/docker/overlay2/640758ad6ad59ac4f5825afc463879a4da93264dcbe96cfd06653d494689b1c4/diff:/var/lib/docker/overlay2/51e3461fd7fed56b0595c7c83e8c0edd6a67ae72ef9a59d2a29d6bb808847204/diff:/var/lib/docker/overlay2/b7de361f0da3dea53c4bcc25a9bbde40efaaf7933147da3b95ed8e7d4d718ad4/diff:/var/lib/docker/overlay2/1ee479d5e4ed919312b4792bec9fd9a2425ee199b4b7ff911572f272b2ba2449/diff:/var/lib/docker/overlay2/4d45f37889ffe243f232f86582a4c3b8125b13b397cacfc0193a79af39158190/diff",
"MergedDir": "/var/lib/docker/overlay2/d9852f484fa657d12db8ed58e4f61fa9d937a63b2c02326bc859aa03f3c6a6f9/merged",
"UpperDir": "/var/lib/docker/overlay2/d9852f484fa657d12db8ed58e4f61fa9d937a63b2c02326bc859aa03f3c6a6f9/diff",
"WorkDir": "/var/lib/docker/overlay2/d9852f484fa657d12db8ed58e4f61fa9d937a63b2c02326bc859aa03f3c6a6f9/work"
},
"Name": "overlay2"
},
"Mounts": [
{
"Type": "bind",
"Source": "/app/freshrss/data",
"Destination": "/var/www/FreshRSS/data",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
},
{
"Type": "bind",
"Source": "/app/freshrss/extensions",
"Destination": "/var/www/FreshRSS/extensions",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "71ba6f6111c8",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": {
"80/tcp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"TZ=Asia/Shanghai",
"CRON_MIN=*/30",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"COPY_LOG_TO_SYSLOG=On",
"COPY_SYSLOG_TO_STDERR=On",
"DATA_PATH=",
"FRESHRSS_ENV=",
"LISTEN=",
"OIDC_ENABLED=",
"TRUSTED_PROXY="
],
"Cmd": [
"/bin/bash",
"-o",
"pipefail",
"-c",
"([ -z \"$CRON_MIN\" ] || cron) && \t. /etc/apache2/envvars && \texec apache2 -D FOREGROUND $([ -n \"$OIDC_ENABLED\" ] && [ \"$OIDC_ENABLED\" -ne 0 ] && echo \"-D OIDC_ENABLED\")"
],
"Image": "freshrss/freshrss:latest",
"Volumes": null,
"WorkingDir": "/var/www/FreshRSS",
"Entrypoint": [
"./Docker/entrypoint.sh"
],
"OnBuild": null,
"Labels": {
"org.opencontainers.image.created": "2026-05-20T17:58:29.110Z",
"org.opencontainers.image.description": "A free, self-hostable news aggregator…",
"org.opencontainers.image.documentation": "https://freshrss.github.io/FreshRSS/",
"org.opencontainers.image.licenses": "AGPL-3.0",
"org.opencontainers.image.revision": "b2c50115baa36c217e939ee3ea8ecfae52f91abd",
"org.opencontainers.image.source": "https://github.com/FreshRSS/FreshRSS",
"org.opencontainers.image.title": "FreshRSS",
"org.opencontainers.image.url": "https://freshrss.org/",
"org.opencontainers.image.vendor": "FreshRSS",
"org.opencontainers.image.version": "1.29.1"
}
},
"NetworkSettings": {
"Bridge": "",
"SandboxID": "78f7f8cccc0810fcdadb3d0e7bff0fe16156a46ab4b0feb7a4ebd6e80e5e330d",
"SandboxKey": "/var/run/docker/netns/78f7f8cccc08",
"Ports": {
"80/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "8082"
},
{
"HostIp": "::",
"HostPort": "8082"
}
]
},
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null,
"EndpointID": "14c4ae2de08b7c68dfe8a757933088247f4c4ffd8c3d1236ff919da0422a655c",
"Gateway": "172.17.0.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "172.17.0.3",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"MacAddress": "02:42:ac:11:00:03",
"Networks": {
"bridge": {
"IPAMConfig": null,
"Links": null,
"Aliases": null,
"MacAddress": "02:42:ac:11:00:03",
"DriverOpts": null,
"NetworkID": "2e5dc7af2729566328476edc3fe4594e9f6b39adabbdcda5b7cc26fcbe073224",
"EndpointID": "14c4ae2de08b7c68dfe8a757933088247f4c4ffd8c3d1236ff919da0422a655c",
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.3",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"DNSNames": null
}
}
}
}
]
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
name=freshrss
img=freshrss/freshrss:latest
docker run -d \
--name "$name" \
--restart unless-stopped \
-w /var/www/FreshRSS \
-e TZ=Asia/Shanghai \
-e 'CRON_MIN=*/30' \
-e PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
-e COPY_LOG_TO_SYSLOG=On \
-e COPY_SYSLOG_TO_STDERR=On \
-e DATA_PATH= \
-e FRESHRSS_ENV= \
-e LISTEN= \
-e OIDC_ENABLED= \
-e TRUSTED_PROXY= \
-p 8082:80/tcp \
-v "$(pwd)/data:/var/www/FreshRSS/data" \
-v "$(pwd)/extensions:/var/www/FreshRSS/extensions" \
--entrypoint ./Docker/entrypoint.sh \
"$img" /bin/bash -o pipefail -c '([ -z "$CRON_MIN" ] || cron) && . /etc/apache2/envvars && exec apache2 -D FOREGROUND $([ -n "$OIDC_ENABLED" ] && [ "$OIDC_ENABLED" -ne 0 ] && echo "-D OIDC_ENABLED")'
+260
View File
@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
const RUN_MANUAL = process.env.KIT_REMOTE_MANU_TEST === "1";
const manualDescribe = RUN_MANUAL ? describe : describe.skip;
const manualTest = RUN_MANUAL ? test : test.skip;
let Remote;
if (RUN_MANUAL) {
require("../src/init");
Remote = require("../src/remote");
}
async function createRemote() {
const remote = new Remote();
await remote.init();
return remote;
}
function writeFixtureProject(tempDir, version, content, options = {}) {
const projectId = options.id || "myapp";
const group = options.group === undefined ? "/modules" : options.group;
const projectDir = path.resolve(tempDir, `fixture-${projectId}-${version || "auto"}-${Math.random().toString(16).slice(2)}`);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.resolve(projectDir, "myapp.txt");
fs.writeFileSync(filePath, content, "utf-8");
fs.writeFileSync(path.resolve(projectDir, "meta.json"), JSON.stringify({
id: projectId,
group,
version,
desc: "test app",
official_url: "https://example.test/myapp",
dist: [
{
path: filePath,
os: "any",
platform: "any",
filename: "myapp.txt",
type: "data"
}
]
}, null, 4), "utf-8");
return projectDir;
}
function normalizeRemotePath(value) {
if (!value || value === "/") {
return "/";
}
return path.posix.normalize("/" + String(value).replace(/\\/g, "/")).replace(/\/+$/g, "") || "/";
}
function remoteProjectPath(group, projectId) {
const normalizedGroup = group === undefined ? "/modules" : normalizeRemotePath(group);
return normalizeRemotePath(path.posix.join(normalizedGroup, projectId));
}
async function deleteRemotePath(remote, remotePath) {
try {
if (remote.client && remote.client.deleteFile && await remote.client.exists(remotePath)) {
await remote.client.deleteFile(remotePath);
}
} catch (e) {
console.warn(`cleanup remote failed ${remotePath}: ${e.message}`);
}
}
let tempDir;
let localStore;
let remote;
let runId;
let counter;
let projectIds;
let remoteRoots;
beforeEach(async () => {
if (!RUN_MANUAL) {
return;
}
tempDir = fs.mkdtempSync(path.resolve(os.tmpdir(), "kit-remote-manu-test-"));
localStore = $sConfig.getLocalStorePath();
remote = await createRemote();
runId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
counter = 0;
projectIds = [];
remoteRoots = [];
});
afterEach(async () => {
if (!RUN_MANUAL) {
return;
}
for (const remoteRoot of remoteRoots.reverse()) {
await deleteRemotePath(remote, remoteRoot);
}
for (const projectId of projectIds) {
fs.rmSync(path.resolve(localStore, "modules", projectId), { recursive: true, force: true });
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
function nextProjectId() {
const projectId = `myapp-${runId}-${++counter}`.toLowerCase();
projectIds.push(projectId);
return projectId;
}
function trackRemote(group, projectId) {
const root = remoteProjectPath(group, projectId);
remoteRoots.push(root);
return root;
}
manualDescribe("remote module public API manual webdav", () => {
manualTest("1-1 exposes documented public methods", async () => {
expect(typeof remote.check).toBe("function");
expect(typeof remote.publish).toBe("function");
expect(typeof remote.install).toBe("function");
expect(typeof remote.list).toBe("function");
expect(typeof remote.use).toBe("function");
expect(typeof remote.rollback).toBe("function");
expect(typeof remote.remove).toBe("function");
});
manualTest("1-2 check validates an available webdav client", async () => {
expect(await remote.check()).toBe(true);
});
manualTest("1-3 publish stores files under default group /modules/id", async () => {
const projectId = nextProjectId();
trackRemote("/modules", projectId);
const projectDir = writeFixtureProject(tempDir, "1.0", "hello v1", { id: projectId });
await remote.publish(projectDir);
const metadata = JSON.parse(await remote.client.getFileContents(`/modules/${projectId}/metadata.json`, { format: "text" }));
const version = JSON.parse(await remote.client.getFileContents(`/modules/${projectId}/1.0/version.json`, { format: "text" }));
const artifact = await remote.client.getFileContents(`/modules/${projectId}/1.0/any/any/myapp.txt`, { format: "text" });
expect(metadata.project_id).toBe(projectId);
expect(metadata.group).toBe("/modules");
expect(metadata.version).toBe("1.0");
expect(metadata.versions).toBeUndefined();
expect(metadata.latest_version).toBeUndefined();
expect(version.artifacts[0].path).toBe("any/any/myapp.txt");
expect(artifact).toBe("hello v1");
}, 30000);
manualTest("1-4 publish supports custom nested group", async () => {
const projectId = nextProjectId();
const group = `/kit-manual-test/${runId}/docker/net`;
trackRemote(group, projectId);
await remote.publish(writeFixtureProject(tempDir, "2.0", "docker net", { id: projectId, group }));
const metadata = JSON.parse(await remote.client.getFileContents(`${group}/${projectId}/metadata.json`, { format: "text" }));
const artifact = await remote.client.getFileContents(`${group}/${projectId}/2.0/any/any/myapp.txt`, { format: "text" });
expect(metadata.group).toBe(group);
expect(metadata.version).toBe("2.0");
expect(artifact).toBe("docker net");
}, 30000);
manualTest("1-5 empty group stores project at repository root", async () => {
const projectId = nextProjectId();
trackRemote("", projectId);
await remote.publish(writeFixtureProject(tempDir, "1.0", "root group", { id: projectId, group: "" }));
const metadata = JSON.parse(await remote.client.getFileContents(`/${projectId}/metadata.json`, { format: "text" }));
expect(metadata.group).toBe("/");
expect(await remote.client.getFileContents(`/${projectId}/1.0/any/any/myapp.txt`, { format: "text" })).toBe("root group");
}, 30000);
manualTest("1-6 explicit version fails when remote version already exists", async () => {
const projectId = nextProjectId();
trackRemote("/modules", projectId);
await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1", { id: projectId }));
await expect(remote.publish(writeFixtureProject(tempDir, "1.0", "hello again", { id: projectId }))).rejects.toThrow("远程版本已存在");
}, 30000);
manualTest("1-7 blank version increments remote version tail or starts at 1.0", async () => {
const projectId = nextProjectId();
trackRemote("/modules", projectId);
await remote.publish(writeFixtureProject(tempDir, "", "first auto", { id: projectId }));
let metadata = JSON.parse(await remote.client.getFileContents(`/modules/${projectId}/metadata.json`, { format: "text" }));
expect(metadata.version).toBe("1.0");
await remote.publish(writeFixtureProject(tempDir, "", "second auto", { id: projectId }));
metadata = JSON.parse(await remote.client.getFileContents(`/modules/${projectId}/metadata.json`, { format: "text" }));
expect(metadata.version).toBe("1.1");
expect(await remote.client.getFileContents(`/modules/${projectId}/1.1/any/any/myapp.txt`, { format: "text" })).toBe("second auto");
}, 30000);
manualTest("1-8 install downloads latest version from default group", async () => {
const projectId = nextProjectId();
trackRemote("/modules", projectId);
await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1", { id: projectId }));
const installed = await remote.install(projectId);
const moduleRoot = path.resolve(localStore, "modules", projectId);
const installJson = JSON.parse(fs.readFileSync(path.resolve(moduleRoot, ".meta", "install.json"), "utf-8"));
const installedFile = fs.readFileSync(path.resolve(moduleRoot, "versions", "1.0", "any", "any", "myapp.txt"), "utf-8");
expect(installed).toBe("1.0");
expect(installedFile).toBe("hello v1");
expect(installJson.current_version).toBe("1.0");
expect(installJson.installed_versions).toContain("1.0");
expect(fs.existsSync(path.resolve(moduleRoot, "current"))).toBe(true);
}, 30000);
manualTest("1-9 install can target a custom group path", async () => {
const projectId = nextProjectId();
const group = `/kit-manual-test/${runId}/docker/net`;
trackRemote(group, projectId);
await remote.publish(writeFixtureProject(tempDir, "1.0", "docker install", { id: projectId, group }));
const installed = await remote.install(`${group}/${projectId}`);
const installedFile = fs.readFileSync(path.resolve(localStore, "modules", projectId, "versions", "1.0", "any", "any", "myapp.txt"), "utf-8");
expect(installed).toBe("1.0");
expect(installedFile).toBe("docker install");
}, 30000);
manualTest("1-10 list use rollback and remove manage installed versions", async () => {
const projectId = nextProjectId();
trackRemote("/modules", projectId);
await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1", { id: projectId }));
await remote.install(projectId);
await remote.publish(writeFixtureProject(tempDir, "2.0", "hello v2", { id: projectId }));
await remote.install(projectId);
expect(await remote.list(projectId)).toEqual(["1.0", "2.0"]);
await remote.rollback(projectId);
let installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", projectId, ".meta", "install.json"), "utf-8"));
expect(installJson.current_version).toBe("1.0");
await remote.use(`${projectId}@2.0`);
installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", projectId, ".meta", "install.json"), "utf-8"));
expect(installJson.current_version).toBe("2.0");
await remote.remove(`${projectId}@1.0`);
installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", projectId, ".meta", "install.json"), "utf-8"));
expect(installJson.installed_versions).toEqual(["2.0"]);
expect(fs.existsSync(path.resolve(localStore, "modules", projectId, "versions", "1.0"))).toBe(false);
}, 30000);
});
if (!RUN_MANUAL) {
describe("remote manual webdav tests", () => {
test.skip("set KIT_REMOTE_MANU_TEST=1 and run bun test test/remote_manu_test.js", () => {});
});
}
+214 -36
View File
@@ -2,30 +2,155 @@
import fs from "fs";
import os from "os";
import path from "path";
import { Readable, Writable } from "stream";
import Remote from "../src/remote";
require("../src/init")
async function createRemote() {
const Remote = require("../src/remote")
class MemoryWebdavClient {
constructor() {
this.files = new Map();
this.dirs = new Set(["/"]);
}
normalize(remotePath) {
const normalized = path.posix.normalize("/" + String(remotePath || "").replace(/\\/g, "/"));
return normalized === "/" ? "/" : normalized.replace(/\/$/, "");
}
async exists(remotePath) {
const key = this.normalize(remotePath);
return this.dirs.has(key) || this.files.has(key);
}
async createDirectory(remotePath) {
const key = this.normalize(remotePath);
const parts = key.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current += "/" + part;
this.dirs.add(current);
}
}
async getDirectoryContents(remotePath) {
const dir = this.normalize(remotePath);
const prefix = dir === "/" ? "/" : dir + "/";
const names = new Map();
for (const item of [...this.dirs, ...this.files.keys()]) {
if (item === dir || !item.startsWith(prefix)) {
continue;
}
const rest = item.slice(prefix.length);
const name = rest.split("/")[0];
names.set(name, prefix + name);
}
return [...names].map(([basename, filename]) => ({ basename, filename, type: this.dirs.has(this.normalize(filename)) ? "directory" : "file" }));
}
async putFileContents(remotePath, content) {
const key = this.normalize(remotePath);
await this.createDirectory(path.posix.dirname(key));
this.files.set(key, Buffer.isBuffer(content) ? content : Buffer.from(String(content)));
}
async getFileContents(remotePath, options = {}) {
const key = this.normalize(remotePath);
if (!this.files.has(key)) {
throw new Error(`missing ${key}`);
}
const data = this.files.get(key);
return options.format === "text" ? data.toString("utf-8") : data;
}
createWriteStream(remotePath) {
const key = this.normalize(remotePath);
const chunks = [];
const stream = new Writable({
write(chunk, encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
stream.on("finish", () => {
this.createDirectory(path.posix.dirname(key));
this.files.set(key, Buffer.concat(chunks));
});
return stream;
}
createReadStream(remotePath) {
const key = this.normalize(remotePath);
if (!this.files.has(key)) {
throw new Error(`missing ${key}`);
}
return Readable.from(this.files.get(key));
}
async stat(remotePath) {
const key = this.normalize(remotePath);
const data = this.files.get(key);
if (!data) {
throw new Error(`missing ${key}`);
}
return { size: data.length };
}
}
function writeFixtureProject(tempDir, version, content, group = "/modules") {
const projectDir = path.resolve(tempDir, `fixture-${version || "auto"}-${Math.random().toString(16).slice(2)}`);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.resolve(projectDir, "myapp.txt");
fs.writeFileSync(filePath, content, "utf-8");
fs.writeFileSync(path.resolve(projectDir, "meta.json"), JSON.stringify({
id: "myapp",
group,
version,
desc: "test app",
official_url: "https://example.test/myapp",
dist: [
{
path: filePath,
os: "any",
platform: "any",
filename: "myapp.txt",
type: "data"
}
]
}, null, 4), "utf-8");
return projectDir;
}
function createRemote() {
const remote = new Remote();
await remote.init()
return remote
remote.client = new MemoryWebdavClient();
return remote;
}
let tempDir;
let localStore;
let oldLogger;
let oldConfig;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.resolve(os.tmpdir(), "kit-remote-test"));
localStore = $sConfig.getLocalStorePath();
tempDir = fs.mkdtempSync(path.resolve(os.tmpdir(), "kit-remote-test-"));
localStore = path.resolve(tempDir, "local-store");
oldLogger = globalThis.$logger;
oldConfig = globalThis.$sConfig;
globalThis.$logger = { info() {}, warn() {}, error() {}, debug() {} };
globalThis.$sConfig = { getLocalStorePath: () => localStore };
});
afterEach(() => {
globalThis.$logger = oldLogger;
globalThis.$sConfig = oldConfig;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe("remote module public API", () => {
test("exposes documented public methods", async() => {
const remote = await createRemote();
test("exposes documented public methods", () => {
const remote = createRemote();
expect(typeof remote.check).toBe("function");
expect(typeof remote.publish).toBe("function");
expect(typeof remote.install).toBe("function");
@@ -36,65 +161,118 @@ describe("remote module public API", () => {
});
test("check validates an available webdav client", async () => {
const remote = await createRemote();
const remote = createRemote();
expect(await remote.check()).toBe(true);
});
test("publish stores metadata and version artifacts in webdav layout", async () => {
const remote = await createRemote();
const projectDir = writeFixtureProject(tempDir, "1.0.0", "hello v1");
test("publish stores files under default group /modules/id", async () => {
const remote = createRemote();
const projectDir = writeFixtureProject(tempDir, "1.0", "hello v1");
await remote.publish(projectDir);
const metadata = JSON.parse(await remote.client.getFileContents("/myapp/metadata.json", { format: "text" }));
const version = JSON.parse(await remote.client.getFileContents("/myapp/1.0.0/version.json", { format: "text" }));
const artifact = await remote.client.getFileContents("/myapp/1.0.0/any/any/myapp.txt", { format: "text" });
const metadata = JSON.parse(await remote.client.getFileContents("/modules/myapp/metadata.json", { format: "text" }));
const version = JSON.parse(await remote.client.getFileContents("/modules/myapp/1.0/version.json", { format: "text" }));
const artifact = await remote.client.getFileContents("/modules/myapp/1.0/any/any/myapp.txt", { format: "text" });
expect(metadata.project_id).toBe("myapp");
expect(metadata.latest_version).toBe("1.0.0");
expect(metadata.versions).toContain("1.0.0");
expect(metadata.group).toBe("/modules");
expect(metadata.version).toBe("1.0");
expect(metadata.versions).toBeUndefined();
expect(metadata.latest_version).toBeUndefined();
expect(version.artifacts[0].path).toBe("any/any/myapp.txt");
expect(artifact).toBe("hello v1");
});
test("install downloads latest matching artifact and records local state", async () => {
const remote = await createRemote();
const projectDir = writeFixtureProject(tempDir, "1.0.0", "hello v1");
await remote.publish(projectDir);
test("publish supports custom nested group", async () => {
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "2.0", "docker net", "/docker/net"));
const metadata = JSON.parse(await remote.client.getFileContents("/docker/net/myapp/metadata.json", { format: "text" }));
const artifact = await remote.client.getFileContents("/docker/net/myapp/2.0/any/any/myapp.txt", { format: "text" });
expect(metadata.group).toBe("/docker/net");
expect(metadata.version).toBe("2.0");
expect(artifact).toBe("docker net");
});
test("empty group stores project at repository root", async () => {
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "1.0", "root group", ""));
const metadata = JSON.parse(await remote.client.getFileContents("/myapp/metadata.json", { format: "text" }));
expect(metadata.group).toBe("/");
expect(await remote.client.getFileContents("/myapp/1.0/any/any/myapp.txt", { format: "text" })).toBe("root group");
});
test("explicit version fails when remote version already exists", async () => {
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1"));
await expect(remote.publish(writeFixtureProject(tempDir, "1.0", "hello again"))).rejects.toThrow("远程版本已存在");
});
test("blank version increments remote version tail or starts at 1.0", async () => {
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "", "first auto"));
let metadata = JSON.parse(await remote.client.getFileContents("/modules/myapp/metadata.json", { format: "text" }));
expect(metadata.version).toBe("1.0");
await remote.publish(writeFixtureProject(tempDir, "", "second auto"));
metadata = JSON.parse(await remote.client.getFileContents("/modules/myapp/metadata.json", { format: "text" }));
expect(metadata.version).toBe("1.1");
expect(await remote.client.getFileContents("/modules/myapp/1.1/any/any/myapp.txt", { format: "text" })).toBe("second auto");
});
test("install downloads latest version from default group", async () => {
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1"));
const installed = await remote.install("myapp");
const moduleRoot = path.resolve(localStore, "modules", "myapp");
const installJson = JSON.parse(fs.readFileSync(path.resolve(moduleRoot, ".meta", "install.json"), "utf-8"));
const installedFile = fs.readFileSync(path.resolve(moduleRoot, "versions", "1.0.0", "any", "any", "myapp.txt"), "utf-8");
const installedFile = fs.readFileSync(path.resolve(moduleRoot, "versions", "1.0", "any", "any", "myapp.txt"), "utf-8");
expect(installed).toBe("1.0.0");
expect(installed).toBe("1.0");
expect(installedFile).toBe("hello v1");
expect(installJson.current_version).toBe("1.0.0");
expect(installJson.installed_versions).toContain("1.0.0");
expect(installJson.current_version).toBe("1.0");
expect(installJson.installed_versions).toContain("1.0");
expect(fs.existsSync(path.resolve(moduleRoot, "current"))).toBe(true);
});
test("install can target a custom group path", async () => {
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "1.0", "docker install", "/docker/net"));
const installed = await remote.install("/docker/net/myapp");
const installedFile = fs.readFileSync(path.resolve(localStore, "modules", "myapp", "versions", "1.0", "any", "any", "myapp.txt"), "utf-8");
expect(installed).toBe("1.0");
expect(installedFile).toBe("docker install");
});
test("list use rollback and remove manage installed versions", async () => {
const remote = await createRemote();
await remote.publish(writeFixtureProject(tempDir, "1.0.0", "hello v1"));
const remote = createRemote();
await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1"));
await remote.install("myapp");
await remote.publish(writeFixtureProject(tempDir, "2.0.0", "hello v2"));
await remote.publish(writeFixtureProject(tempDir, "2.0", "hello v2"));
await remote.install("myapp");
expect(await remote.list("myapp")).toEqual(["1.0.0", "2.0.0"]);
expect(await remote.list("myapp")).toEqual(["1.0", "2.0"]);
await remote.rollback("myapp");
let installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8"));
expect(installJson.current_version).toBe("1.0.0");
expect(installJson.current_version).toBe("1.0");
await remote.use("myapp@2.0.0");
await remote.use("myapp@2.0");
installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8"));
expect(installJson.current_version).toBe("2.0.0");
expect(installJson.current_version).toBe("2.0");
await remote.remove("myapp@1.0.0");
await remote.remove("myapp@1.0");
installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8"));
expect(installJson.installed_versions).toEqual(["2.0.0"]);
expect(fs.existsSync(path.resolve(localStore, "modules", "myapp", "versions", "1.0.0"))).toBe(false);
expect(installJson.installed_versions).toEqual(["2.0"]);
expect(fs.existsSync(path.resolve(localStore, "modules", "myapp", "versions", "1.0"))).toBe(false);
});
});
+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
```
+29 -4
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.