mirror of
https://github.com/certd/certd.git
synced 2026-10-14 01:46:00 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f3daae62 | ||
|
|
c72fa68e4c | ||
|
|
1f5cda452c | ||
|
|
5a19d2513d | ||
|
|
c0f8145615 | ||
|
|
2ad14b82fa | ||
|
|
c8bdcdc778 | ||
|
|
15d2bd8484 | ||
|
|
7d4311115c | ||
|
|
3efa0582ef | ||
|
|
7624babc99 | ||
|
|
6cf8f350f5 | ||
|
|
0e08555220 | ||
|
|
fe738fa7d4 | ||
|
|
2439f4ab02 | ||
|
|
75b2b146ce | ||
|
|
1f3c096517 | ||
|
|
d264388c00 | ||
|
|
b8eaeeaa64 | ||
|
|
dba1a486cb | ||
|
|
5408ae6ad8 | ||
|
|
f5430a8925 | ||
|
|
9cc21204b1 | ||
|
|
f911e06a69 | ||
|
|
4a6eab0ce6 | ||
|
|
1d90fadd0c | ||
|
|
1b1a5ec94d | ||
|
|
d67379f297 | ||
|
|
249b98df66 | ||
|
|
aebbb5cc7d | ||
|
|
4f020aa8a5 | ||
|
|
02426c507a | ||
|
|
ffb7ae79eb | ||
|
|
f58f2f3cd6 | ||
|
|
794abc3d7a | ||
|
|
da406014ad | ||
|
|
9842a24a50 | ||
|
|
fb21ec04a6 | ||
|
|
15b74207dd | ||
|
|
6055e9b6ba | ||
|
|
df3e0814c4 | ||
|
|
81f660dc49 | ||
|
|
f56e2a07c6 | ||
|
|
a2fce6cf0b | ||
|
|
ccb5356bfa | ||
|
|
f44084dfd2 | ||
|
|
948d76f98f | ||
|
|
6606f32678 | ||
|
|
6af12d49fc | ||
|
|
9decb49d04 | ||
|
|
69466c1c41 | ||
|
|
adf5163a79 | ||
|
|
b40bc1b487 | ||
|
|
9d30ccf1f0 | ||
|
|
396810eddf | ||
|
|
5dbf6694b1 | ||
|
|
c2df8d3fa3 | ||
|
|
c149662d54 | ||
|
|
ae1c5e38d1 | ||
|
|
718b46141e | ||
|
|
9c8986be10 | ||
|
|
1d12ad73c6 | ||
|
|
30d4fc84a9 | ||
|
|
834e7750ce | ||
|
|
f38522ca0b | ||
|
|
d87821ee02 | ||
|
|
bd9e226c36 | ||
|
|
2809267d7a | ||
|
|
4de24da9d5 | ||
|
|
580981a468 | ||
|
|
5bff2c8fa5 | ||
|
|
338de1f04c | ||
|
|
5e867c86c3 | ||
|
|
07efe717d0 | ||
|
|
2b4c066a2e | ||
|
|
c03e82e4e7 | ||
|
|
a91d7c000a | ||
|
|
faf9828168 | ||
|
|
eadf10f287 | ||
|
|
ec980bf68a | ||
|
|
6f9dffe6e5 | ||
|
|
f61487284f | ||
|
|
483e7c5f51 | ||
|
|
be47d732e0 | ||
|
|
c57110e42a | ||
|
|
e8adaad01f | ||
|
|
4db0f83304 | ||
|
|
2ab23f50b2 | ||
|
|
4df94a4e2b | ||
|
|
97c417b1ad | ||
|
|
c0f194f443 | ||
|
|
e79660c5d1 | ||
|
|
deaf5b7398 | ||
|
|
e4041eabec | ||
|
|
7a1a642dc2 | ||
|
|
555f3a800b | ||
|
|
145784e73c | ||
|
|
f8e492d6cb | ||
|
|
49e04784af | ||
|
|
f843e69a1b | ||
|
|
232ac4317b | ||
|
|
b9dc03cd85 | ||
|
|
1ad7750d8b | ||
|
|
b6c772a326 | ||
|
|
ac5bb63214 | ||
|
|
4d580deeb0 | ||
|
|
cbf337a002 | ||
|
|
15ee6ead49 | ||
|
|
1cc9641f56 | ||
|
|
0cfc3b7ae9 | ||
|
|
fbfe1256a5 | ||
|
|
5074572694 | ||
|
|
e004c34ae3 | ||
|
|
5c0e5c347d | ||
|
|
2935111c6b | ||
|
|
71155eeec5 | ||
|
|
135acb5240 | ||
|
|
5558376bae | ||
|
|
2da560a21f | ||
|
|
bc9a0988a7 | ||
|
|
6a731f986e | ||
|
|
2cb4e551fa | ||
|
|
e5a1844852 | ||
|
|
b8f82ecb5e | ||
|
|
18583f6ce0 | ||
|
|
1468eb1ef2 | ||
|
|
8ab699a910 | ||
|
|
b173311468 | ||
|
|
522dcd8ffb | ||
|
|
6809a35177 | ||
|
|
bd82147496 | ||
|
|
a4224ab1df | ||
|
|
4c4dc8ca46 | ||
|
|
0afabd38c6 | ||
|
|
cacd108802 | ||
|
|
7138243c63 | ||
|
|
73b096a6e5 | ||
|
|
8165a5ff89 | ||
|
|
1017a38eb4 | ||
|
|
80374cae8a | ||
|
|
6339cdffa9 | ||
|
|
96a33bd387 | ||
|
|
1042e599d9 | ||
|
|
1a4f1cb07d | ||
|
|
edb249c2ad | ||
|
|
e9921928df | ||
|
|
fa0c87dec3 | ||
|
|
ba64c2d147 | ||
|
|
cf008b9cff | ||
|
|
3a9069f6e6 | ||
|
|
01abf348d2 | ||
|
|
eae7edbda0 | ||
|
|
d7c6a06b61 | ||
|
|
9d6f7ef0f2 | ||
|
|
0ddb1f69d2 | ||
|
|
4091abdfd7 | ||
|
|
27008cb44a | ||
|
|
b84825eae5 | ||
|
|
1c6ffe8ac6 | ||
|
|
dc0845b2d2 | ||
|
|
7ab502991e | ||
|
|
319f555569 | ||
|
|
0bf323ab97 | ||
|
|
255960d434 | ||
|
|
83a6aed625 | ||
|
|
f1b67049d1 | ||
|
|
8cbca5761e | ||
|
|
a237179a72 | ||
|
|
967846bef5 | ||
|
|
0bf9a2d3da | ||
|
|
18b2d3ac20 | ||
|
|
7d22fe3d7d | ||
|
|
ee67b6c042 | ||
|
|
1cfa76683b | ||
|
|
4662e45e58 | ||
|
|
1fefbdc9ab | ||
|
|
1cb2a57c55 | ||
|
|
246ee83015 | ||
|
|
335ddfc7a5 | ||
|
|
5b500830a1 | ||
|
|
ce4839bd80 | ||
|
|
eee22154e3 | ||
|
|
49007d3915 | ||
|
|
f9b453ca8c | ||
|
|
5f53b81c75 | ||
|
|
9d83adaac8 | ||
|
|
c8c269f612 | ||
|
|
15740904e5 | ||
|
|
85e9ff7a96 | ||
|
|
743617dbda | ||
|
|
89806b828a | ||
|
|
2e530bfdb0 | ||
|
|
83495b3213 | ||
|
|
5108416904 | ||
|
|
5589da1822 | ||
|
|
21e5aed3f3 | ||
|
|
4a88f795e1 | ||
|
|
604fa5be63 | ||
|
|
7ed1be994f | ||
|
|
6cc74a1c0a | ||
|
|
167b303fae | ||
|
|
b91c9e4ea6 | ||
|
|
947fe729cb | ||
|
|
bfb3ee4c43 | ||
|
|
bab1df2c78 | ||
|
|
00eabec771 | ||
|
|
584b7b6f45 | ||
|
|
2312c444ef | ||
|
|
0373d019f8 | ||
|
|
fe09e75b80 | ||
|
|
432c9c6cc1 | ||
|
|
5fc1d55879 | ||
|
|
268cd6cc9c | ||
|
|
20ffe31638 | ||
|
|
02d30067ea | ||
|
|
0ce7808e08 | ||
|
|
d609ee2b7a | ||
|
|
083df61fcc | ||
|
|
a524988be6 | ||
|
|
f2855d6dac | ||
|
|
8517a0b564 | ||
|
|
ec69b8f11b | ||
|
|
4250d0e266 | ||
|
|
edda1b57f3 | ||
|
|
5a7766992d | ||
|
|
02dabe11db | ||
|
|
58024128d8 | ||
|
|
e44bf9d773 | ||
|
|
1114223bda | ||
|
|
55022e5fa6 | ||
|
|
ae732fb298 |
@@ -80,6 +80,8 @@ jobs:
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
context: ./packages/ui/
|
||||
tags: |
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}
|
||||
|
||||
@@ -86,10 +86,14 @@ jobs:
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
context: ./packages/ui/
|
||||
tags: |
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:latest
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:${{steps.get_certd_version.outputs.result}}
|
||||
greper/certd:latest
|
||||
greper/certd:${{steps.get_certd_version.outputs.result}}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
@@ -99,12 +103,16 @@ jobs:
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
context: ./packages/ui/
|
||||
build-args: |
|
||||
base_type=slim
|
||||
tags: |
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}-slim
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:slim
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:${{steps.get_certd_version.outputs.result}}-slim
|
||||
greper/certd:slim
|
||||
greper/certd:${{steps.get_certd_version.outputs.result}}-slim
|
||||
ghcr.io/${{ github.repository }}:slim
|
||||
@@ -115,26 +123,34 @@ jobs:
|
||||
with:
|
||||
platforms: linux/arm/v7
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
context: ./packages/ui/
|
||||
tags: |
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7
|
||||
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}-armv7
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:armv7
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:${{steps.get_certd_version.outputs.result}}-armv7
|
||||
greper/certd:armv7
|
||||
greper/certd:${{steps.get_certd_version.outputs.result}}-armv7
|
||||
ghcr.io/${{ github.repository }}:armv7
|
||||
ghcr.io/${{ github.repository }}:${{steps.get_certd_version.outputs.result}}-armv7
|
||||
|
||||
# - name: Build agent
|
||||
# uses: docker/build-push-action@v6
|
||||
# with:
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
# push: true
|
||||
# context: ./packages/ui/agent/
|
||||
# tags: |
|
||||
# registry.cn-shenzhen.aliyuncs.com/handsfree/certd-agent:latest
|
||||
# registry.cn-shenzhen.aliyuncs.com/handsfree/certd-agent:${{steps.get_certd_version.outputs.result}}
|
||||
# greper/certd-agent:latest
|
||||
# greper/certd-agent:${{steps.get_certd_version.outputs.result}}
|
||||
- name: Login to Certd Docker Hub account
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.dockerhub_username2 }}
|
||||
password: ${{ secrets.dockerhub_password2 }}
|
||||
|
||||
- name: Publish to certd Docker Hub repository
|
||||
run: |
|
||||
docker buildx imagetools create --tag certd/certd:latest greper/certd:latest
|
||||
docker buildx imagetools create --tag certd/certd:${{ steps.get_certd_version.outputs.result }} greper/certd:${{ steps.get_certd_version.outputs.result }}
|
||||
docker buildx imagetools create --tag certd/certd:slim greper/certd:slim
|
||||
docker buildx imagetools create --tag certd/certd:${{ steps.get_certd_version.outputs.result }}-slim greper/certd:${{ steps.get_certd_version.outputs.result }}-slim
|
||||
docker buildx imagetools create --tag certd/certd:armv7 greper/certd:armv7
|
||||
docker buildx imagetools create --tag certd/certd:${{ steps.get_certd_version.outputs.result }}-armv7 greper/certd:${{ steps.get_certd_version.outputs.result }}-armv7
|
||||
|
||||
- name: deploy-certd-doc
|
||||
uses: tyrrrz/action-http-request@prime
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: stable-release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "版本号(如 v1.42.5)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
make-stable:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to aliyun container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.cn-shenzhen.aliyuncs.com
|
||||
username: ${{ secrets.aliyun_cs_username }}
|
||||
password: ${{ secrets.aliyun_cs_password }}
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.dockerhub_username2 }}
|
||||
password: ${{ secrets.dockerhub_password2 }}
|
||||
|
||||
# stable 镜像:叠加 ENV 层
|
||||
- name: Build and push stable images
|
||||
run: |
|
||||
echo "FROM greper/certd:${{ inputs.version }}
|
||||
ENV certd_release_mode=stable" > Dockerfile.stable
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--provenance=false \
|
||||
--sbom=false \
|
||||
--push \
|
||||
-f Dockerfile.stable \
|
||||
-t registry.cn-shenzhen.aliyuncs.com/handsfree/certd:stable \
|
||||
-t registry.cn-shenzhen.aliyuncs.com/certd/certd:stable \
|
||||
-t certd/certd:stable \
|
||||
-t ghcr.io/${{ github.repository }}:stable \
|
||||
.
|
||||
|
||||
# slim-stable 镜像
|
||||
- name: Build and push slim-stable images
|
||||
run: |
|
||||
echo "FROM greper/certd:${{ inputs.version }}-slim
|
||||
ENV certd_release_mode=stable" > Dockerfile.slim-stable
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--provenance=false \
|
||||
--sbom=false \
|
||||
--push \
|
||||
-f Dockerfile.slim-stable \
|
||||
-t registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim-stable \
|
||||
-t registry.cn-shenzhen.aliyuncs.com/certd/certd:slim-stable \
|
||||
-t certd/certd:slim-stable \
|
||||
-t ghcr.io/${{ github.repository }}:slim-stable \
|
||||
.
|
||||
|
||||
- name: Set AtomGit release as stable
|
||||
run: |
|
||||
export ATOMGIT_TOKEN=${{ secrets.ATOMGIT_TOKEN }}
|
||||
export VERSION=${{ inputs.version }}
|
||||
node ./scripts/set-release-stable-fetch.js
|
||||
+4
-1
@@ -39,4 +39,7 @@ pnpm-lock.yaml
|
||||
# Certd 推广报告,仅本地使用
|
||||
/popularize/reports/
|
||||
output/
|
||||
.uploads/
|
||||
.uploads/
|
||||
.certd-plugin-history/
|
||||
.tmp
|
||||
.plugin-dev/
|
||||
@@ -1,412 +0,0 @@
|
||||
# 插件依赖按需加载方案
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 当前问题
|
||||
- `packages/ui/certd-server/node_modules` 包含 50+ 个插件的所有依赖,体积庞大
|
||||
- 大量云厂商 SDK(AWS、阿里云、腾讯云、华为云等)只在特定插件中使用
|
||||
- 用户通常只使用少数几个插件,但必须安装所有依赖
|
||||
|
||||
### 目标
|
||||
实现依赖的按需下载和加载:
|
||||
1. 插件依赖独立管理,不占用主 `node_modules` 空间
|
||||
2. 只有当用户首次使用某插件时,才动态下载该插件需要的依赖
|
||||
3. 依赖安装完成后,通过 `await import()` 从独立路径加载
|
||||
4. 保持现有插件代码的最小改动
|
||||
|
||||
## 当前架构分析
|
||||
|
||||
### 插件加载机制
|
||||
- 插件位于 `packages/ui/certd-server/src/plugins/` 下(50+ 个插件目录)
|
||||
- `AutoLoadPlugins` 类在启动时扫描 `dist/plugins` 目录并动态导入
|
||||
- 插件注册到不同的 registry:`accessRegistry`, `pluginRegistry`, `dnsProviderRegistry` 等
|
||||
- 插件代码已经使用 `await import()` 进行懒加载(如 `await import("@aws-sdk/client-acm")`)
|
||||
|
||||
### 重型依赖分布
|
||||
从 `packages/ui/certd-server/package.json` 分析,以下依赖体积大且仅特定插件使用:
|
||||
|
||||
**云厂商 SDK(按插件分组):**
|
||||
- **AWS 插件**:`@aws-sdk/client-acm`, `@aws-sdk/client-cloudfront`, `@aws-sdk/client-iam`, `@aws-sdk/client-route-53`, `@aws-sdk/client-s3`, `@aws-sdk/client-sts`
|
||||
- **阿里云插件**:`@alicloud/openapi-client`, `@alicloud/pop-core`, `@alicloud/tea-typescript`, `@alicloud/fc20230330` 等
|
||||
- **腾讯云插件**:`tencentcloud-sdk-nodejs`, `cos-nodejs-sdk-v5`
|
||||
- **华为云插件**:`@huaweicloud/huaweicloud-sdk-cdn`, `@huaweicloud/huaweicloud-sdk-core` 等
|
||||
- **Azure 插件**:`@azure/arm-dns`, `@azure/identity`
|
||||
- **Google Cloud 插件**:`@google-cloud/dns`, `@google-cloud/publicca`
|
||||
- **火山引擎插件**:`@volcengine/openapi`, `@volcengine/tos-sdk`
|
||||
|
||||
**网络/工具库:**
|
||||
- `ssh2`, `socks`, `socks-proxy-agent`(SSH 相关插件)
|
||||
- `ali-oss`, `qiniu`, `basic-ftp`(存储/传输插件)
|
||||
- `nodemailer`(邮件通知插件)
|
||||
|
||||
**通用依赖(保留在主 package.json):**
|
||||
- `@midwayjs/*` 系列(框架核心)
|
||||
- `@certd/*` 系列(项目内部包)
|
||||
- `axios`, `lodash-es`, `dayjs`, `js-yaml` 等基础工具
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 架构概览
|
||||
|
||||
```
|
||||
packages/ui/certd-server/
|
||||
├── package.json # 主依赖(框架、通用工具)
|
||||
├── node_modules/ # 主依赖安装目录
|
||||
├── optional-deps/ # 新增:可选依赖管理目录
|
||||
│ ├── package.json # 可选依赖总配置(用于 pnpm install)
|
||||
│ ├── pnpm-lock.yaml # 可选依赖锁文件
|
||||
│ └── node_modules/ # 可选依赖安装目录
|
||||
├── src/
|
||||
│ └── modules/
|
||||
│ └── dependency/ # 新增:依赖管理模块
|
||||
│ ├── dependency-manager.ts # 核心:依赖管理器
|
||||
│ ├── dependency-registry.ts # 依赖注册表(插件 -> 依赖映射)
|
||||
│ └── types.ts # 类型定义
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### 1. 依赖管理器(DependencyManager)
|
||||
|
||||
**职责:**
|
||||
- 检查依赖是否已安装
|
||||
- 动态执行 `pnpm install` 安装缺失依赖
|
||||
- 提供从 `optional-deps/node_modules` 加载依赖的方法
|
||||
- 并发控制:避免多个插件同时触发安装
|
||||
|
||||
**关键方法:**
|
||||
```typescript
|
||||
class DependencyManager {
|
||||
// 确保依赖已安装,返回依赖模块
|
||||
async ensureAndImport<T>(packageName: string): Promise<T>
|
||||
|
||||
// 检查依赖是否已安装
|
||||
async isInstalled(packageName: string): Promise<boolean>
|
||||
|
||||
// 安装依赖(带锁,避免并发)
|
||||
async installDependencies(packages: string[]): Promise<void>
|
||||
|
||||
// 从 optional-deps/node_modules 加载依赖
|
||||
async loadModule<T>(packageName: string): Promise<T>
|
||||
}
|
||||
```
|
||||
|
||||
**实现要点:**
|
||||
- 使用文件锁(如 `proper-lockfile`)防止并发安装
|
||||
- 安装前检查 `optional-deps/node_modules/{packageName}` 是否存在
|
||||
- 安装命令:`pnpm install --dir optional-deps --ignore-workspace`
|
||||
- 加载时使用绝对路径:`import('file:///absolute/path/to/optional-deps/node_modules/package')`
|
||||
|
||||
#### 2. 依赖注册表(DependencyRegistry)
|
||||
|
||||
**职责:**
|
||||
- 维护插件名称到依赖列表的映射
|
||||
- 提供依赖查询接口
|
||||
|
||||
**数据结构:**
|
||||
```typescript
|
||||
interface PluginDependencyConfig {
|
||||
pluginName: string;
|
||||
dependencies: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
optional?: boolean; // 是否可选(安装失败不阻塞)
|
||||
}[];
|
||||
}
|
||||
|
||||
// 示例注册
|
||||
dependencyRegistry.register('plugin-aws', [
|
||||
{ packageName: '@aws-sdk/client-acm', version: '^3.964.0' },
|
||||
{ packageName: '@aws-sdk/client-cloudfront', version: '^3.964.0' },
|
||||
{ packageName: '@aws-sdk/client-route-53', version: '^3.964.0' },
|
||||
]);
|
||||
```
|
||||
|
||||
#### 3. 插件集成
|
||||
|
||||
**改造现有插件代码:**
|
||||
|
||||
改造前(`plugin-aws/libs/aws-client.ts`):
|
||||
```typescript
|
||||
const { ACMClient, ImportCertificateCommand } = await import("@aws-sdk/client-acm");
|
||||
```
|
||||
|
||||
改造后:
|
||||
```typescript
|
||||
import { DependencyManager } from "../../../modules/dependency/dependency-manager.js";
|
||||
|
||||
const depManager = new DependencyManager();
|
||||
const { ACMClient, ImportCertificateCommand } = await depManager.ensureAndImport("@aws-sdk/client-acm");
|
||||
```
|
||||
|
||||
**简化方案(推荐):**
|
||||
|
||||
创建辅助函数,减少改动量:
|
||||
```typescript
|
||||
// src/modules/dependency/import-helper.ts
|
||||
export async function importOptionalDep<T>(packageName: string): Promise<T> {
|
||||
const depManager = new DependencyManager();
|
||||
return await depManager.ensureAndImport<T>(packageName);
|
||||
}
|
||||
|
||||
// 插件中使用
|
||||
import { importOptionalDep } from "../../../modules/dependency/import-helper.js";
|
||||
const { ACMClient } = await importOptionalDep("@aws-sdk/client-acm");
|
||||
```
|
||||
|
||||
### 实施步骤
|
||||
|
||||
#### 阶段一:基础设施搭建
|
||||
1. 创建 `optional-deps/` 目录结构
|
||||
2. 生成 `optional-deps/package.json`(包含所有可选依赖)
|
||||
3. 实现 `DependencyManager` 核心逻辑
|
||||
4. 实现依赖安装锁机制
|
||||
5. 编写单元测试
|
||||
|
||||
#### 阶段二:依赖迁移
|
||||
6. 从主 `package.json` 移除可选依赖
|
||||
7. 将依赖添加到 `optional-deps/package.json`
|
||||
8. 创建依赖注册表,映射插件到依赖
|
||||
|
||||
#### 阶段三:插件改造
|
||||
9. 创建 `import-helper.ts` 辅助函数
|
||||
10. 逐步改造插件代码,使用 `importOptionalDep` 加载依赖
|
||||
11. 优先改造重型依赖(AWS、阿里云、腾讯云等)
|
||||
|
||||
#### 阶段四:测试与优化
|
||||
12. 端到端测试:验证依赖按需安装和加载
|
||||
13. 性能优化:缓存已加载的模块
|
||||
14. 错误处理:安装失败时的降级策略
|
||||
15. 文档:编写使用说明和迁移指南
|
||||
|
||||
## 关键技术决策
|
||||
|
||||
### 1. 依赖分组策略
|
||||
**选择:按插件分组**
|
||||
- 每个插件声明自己需要的依赖
|
||||
- 优点:职责清晰,易于维护
|
||||
- 缺点:可能有重复依赖(但 pnpm 会去重)
|
||||
|
||||
**备选:按功能分组**
|
||||
- 将依赖按功能分组(如 "aws-deps", "aliyun-deps")
|
||||
- 优点:更细粒度控制
|
||||
- 缺点:增加复杂度
|
||||
|
||||
### 2. 安装触发时机
|
||||
**选择:首次使用时触发**
|
||||
- 在插件的 `execute()` 或 `getClient()` 方法中触发安装
|
||||
- 优点:真正的按需加载
|
||||
- 缺点:首次使用有延迟
|
||||
|
||||
**备选:启动时预检查**
|
||||
- 启动时扫描启用的插件,预安装依赖
|
||||
- 优点:避免运行时延迟
|
||||
- 缺点:可能安装不需要的依赖
|
||||
|
||||
### 3. 依赖路径解析
|
||||
**选择:使用绝对路径 + `file://` 协议**
|
||||
```typescript
|
||||
const modulePath = path.resolve(__dirname, '../../optional-deps/node_modules', packageName);
|
||||
return await import(`file://${modulePath}/index.js`);
|
||||
```
|
||||
|
||||
**原因:**
|
||||
- Node.js ESM 要求明确的 URL 格式
|
||||
- 避免模块解析冲突
|
||||
|
||||
### 4. 并发控制
|
||||
**选择:文件锁 + 内存锁双重保护**
|
||||
- 使用 `proper-lockfile` 锁定 `optional-deps/` 目录
|
||||
- 内存中使用 `Map` 记录正在安装的依赖
|
||||
- 避免多个插件同时触发安装
|
||||
|
||||
### 5. 错误处理
|
||||
**策略:**
|
||||
- 安装失败时记录日志,抛出明确的错误信息
|
||||
- 提供手动安装命令提示:`请运行: cd optional-deps && pnpm install`
|
||||
- 支持降级:某些非核心依赖安装失败时,插件可以部分功能可用
|
||||
|
||||
## 验证方案
|
||||
|
||||
### 单元测试
|
||||
1. 测试 `DependencyManager.isInstalled()` 正确检测依赖状态
|
||||
2. 测试 `DependencyManager.installDependencies()` 成功安装依赖
|
||||
3. 测试并发安装时的锁机制
|
||||
4. 测试从 `optional-deps/node_modules` 加载模块
|
||||
|
||||
### 集成测试
|
||||
1. 清空 `optional-deps/node_modules`
|
||||
2. 启动服务,验证不触发安装
|
||||
3. 调用 AWS 插件,验证触发安装并成功加载
|
||||
4. 再次调用,验证不重复安装
|
||||
5. 验证主 `node_modules` 体积减少
|
||||
|
||||
### 性能测试
|
||||
1. 测量首次安装依赖的耗时
|
||||
2. 测量后续加载的耗时(应该与正常 import 相近)
|
||||
3. 对比改造前后的 `node_modules` 大小
|
||||
|
||||
## 风险与挑战
|
||||
|
||||
### 1. 首次使用延迟
|
||||
**风险:** 用户首次使用插件时需要等待依赖安装(可能几十秒)
|
||||
**缓解:**
|
||||
- 在 UI 上显示安装进度
|
||||
- 提供预安装命令:`pnpm run install-optional-deps`
|
||||
- 文档说明首次使用会有延迟
|
||||
|
||||
### 2. 离线环境
|
||||
**风险:** 离线环境无法下载依赖
|
||||
**缓解:**
|
||||
- 提供完整安装包(包含所有可选依赖)
|
||||
- 支持手动复制 `node_modules`
|
||||
|
||||
### 3. 版本冲突
|
||||
**风险:** 可选依赖与主依赖版本冲突
|
||||
**缓解:**
|
||||
- 使用 `--ignore-workspace` 隔离安装
|
||||
- 定期同步主依赖版本
|
||||
|
||||
### 4. TypeScript 类型
|
||||
**风险:** 动态导入的类型推断
|
||||
**缓解:**
|
||||
- 保留 `@types/*` 在主 `devDependencies`
|
||||
- 使用泛型和类型断言
|
||||
|
||||
## 预期收益
|
||||
|
||||
1. **空间节省:** 主 `node_modules` 体积减少 60-70%(估算)
|
||||
2. **安装速度:** 初始 `pnpm install` 速度提升 3-5 倍
|
||||
3. **用户体验:** 不使用的插件不占用空间,按需加载
|
||||
4. **维护性:** 依赖分组清晰,易于管理
|
||||
|
||||
## 后续优化
|
||||
|
||||
1. **依赖预热:** 在后台预安装常用插件依赖
|
||||
2. **依赖缓存:** 支持从 CDN 或本地缓存安装
|
||||
3. **依赖更新:** 提供命令批量更新可选依赖
|
||||
4. **插件市场:** 支持从远程下载插件及其依赖配置
|
||||
|
||||
## 附录:依赖分类清单
|
||||
|
||||
### 可选依赖(迁移到 optional-deps/package.json)
|
||||
|
||||
**AWS 相关(plugin-aws, plugin-aws-cn):**
|
||||
```json
|
||||
{
|
||||
"@aws-sdk/client-acm": "^3.964.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.964.0",
|
||||
"@aws-sdk/client-iam": "^3.964.0",
|
||||
"@aws-sdk/client-route-53": "^3.964.0",
|
||||
"@aws-sdk/client-s3": "^3.964.0",
|
||||
"@aws-sdk/client-sts": "^3.990.0"
|
||||
}
|
||||
```
|
||||
|
||||
**阿里云相关(plugin-aliyun, plugin-lib/aliyun):**
|
||||
```json
|
||||
{
|
||||
"@alicloud/fc20230330": "^4.1.7",
|
||||
"@alicloud/openapi-client": "^0.4.12",
|
||||
"@alicloud/openapi-util": "^0.3.2",
|
||||
"@alicloud/pop-core": "^1.7.10",
|
||||
"@alicloud/sts-sdk": "^1.0.2",
|
||||
"@alicloud/tea-typescript": "^1.8.0",
|
||||
"@alicloud/tea-util": "^1.4.10",
|
||||
"ali-oss": "^6.21.0"
|
||||
}
|
||||
```
|
||||
|
||||
**腾讯云相关(plugin-tencent, plugin-lib/tencent):**
|
||||
```json
|
||||
{
|
||||
"tencentcloud-sdk-nodejs": "^4.1.112",
|
||||
"cos-nodejs-sdk-v5": "^2.14.6"
|
||||
}
|
||||
```
|
||||
|
||||
**华为云相关(plugin-huawei):**
|
||||
```json
|
||||
{
|
||||
"@huaweicloud/huaweicloud-sdk-cdn": "3.1.185",
|
||||
"@huaweicloud/huaweicloud-sdk-core": "3.1.185",
|
||||
"@huaweicloud/huaweicloud-sdk-elb": "3.1.185",
|
||||
"@huaweicloud/huaweicloud-sdk-iam": "3.1.185",
|
||||
"esdk-obs-nodejs": "^3.25.6"
|
||||
}
|
||||
```
|
||||
|
||||
**Azure 相关(plugin-azure):**
|
||||
```json
|
||||
{
|
||||
"@azure/arm-dns": "^5.1.0",
|
||||
"@azure/identity": "^4.13.1"
|
||||
}
|
||||
```
|
||||
|
||||
**Google Cloud 相关(plugin-google, plugin-cert/google):**
|
||||
```json
|
||||
{
|
||||
"@google-cloud/dns": "^5.3.1",
|
||||
"@google-cloud/publicca": "^1.3.0"
|
||||
}
|
||||
```
|
||||
|
||||
**火山引擎相关(plugin-volcengine):**
|
||||
```json
|
||||
{
|
||||
"@volcengine/openapi": "^1.28.1",
|
||||
"@volcengine/tos-sdk": "^2.9.1"
|
||||
}
|
||||
```
|
||||
|
||||
**SSH/网络相关(plugin-host, plugin-lib/ssh):**
|
||||
```json
|
||||
{
|
||||
"ssh2": "^1.17.0",
|
||||
"socks": "^2.8.3",
|
||||
"socks-proxy-agent": "^8.0.4",
|
||||
"basic-ftp": "^5.0.5"
|
||||
}
|
||||
```
|
||||
|
||||
**其他存储/传输(plugin-qiniu, plugin-lib/qiniu):**
|
||||
```json
|
||||
{
|
||||
"qiniu": "^7.12.0"
|
||||
}
|
||||
```
|
||||
|
||||
**邮件通知(plugin-notification/email):**
|
||||
```json
|
||||
{
|
||||
"nodemailer": "^6.9.16"
|
||||
}
|
||||
```
|
||||
|
||||
### 主依赖(保留在主 package.json)
|
||||
|
||||
**框架核心:**
|
||||
- `@midwayjs/*` 系列
|
||||
- `@koa/cors`
|
||||
- `typeorm`, `better-sqlite3`, `mysql2`, `pg`
|
||||
|
||||
**项目内部包:**
|
||||
- `@certd/*` 系列
|
||||
|
||||
**通用工具:**
|
||||
- `axios`, `lodash-es`, `dayjs`, `js-yaml`
|
||||
- `crypto-js`, `jsonwebtoken`, `bcryptjs`
|
||||
- `reflect-metadata`, `uuid`, `nanoid`
|
||||
- 等等
|
||||
|
||||
## 总结
|
||||
|
||||
本方案通过引入独立的可选依赖管理机制,实现了插件依赖的按需下载和加载。核心思路是:
|
||||
|
||||
1. **隔离管理:** 在 `optional-deps/` 目录下维护独立的 `package.json` 和 `node_modules`
|
||||
2. **动态安装:** 通过 `DependencyManager` 在首次使用时触发 `pnpm install`
|
||||
3. **路径加载:** 使用绝对路径从独立目录加载依赖模块
|
||||
4. **最小改动:** 通过辅助函数 `importOptionalDep` 简化插件代码改造
|
||||
|
||||
该方案可以显著减少主 `node_modules` 体积,提升初始安装速度,同时保持现有架构的兼容性和可维护性。
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: certd-online-plugin-dev
|
||||
description: 用于通过 Certd API 开发、修改、读取、保存和恢复在线插件。当用户要求使用 Codex 或 Trae 创建 Task、DNS Provider、Access 插件,或优化已有 Certd 插件时使用。
|
||||
---
|
||||
|
||||
# Certd 在线插件开发 Skill
|
||||
|
||||
## 开发方式
|
||||
|
||||
本 Skill 使用 Certd HTTP API,不使用 WebSocket,也不依赖浏览器传递代码草稿。
|
||||
|
||||
- 前端只生成包含需求、API 地址和认证 Token 的启动提示词。
|
||||
- Agent 直接调用 Certd API 读取和保存插件 YAML。
|
||||
- Agent 的进度、日志和代码修改在 Codex/Trae 中查看。
|
||||
- 插件开发临时文件和修改历史统一保存在 Agent 工作区的 `.plugin-dev/` 下,不保存到浏览器或 Certd 后端。
|
||||
|
||||
## 启动检查
|
||||
|
||||
开始开发前先确认当前工作目录是否已经在 Certd 项目内:
|
||||
|
||||
- 应存在 `package.json`。
|
||||
- 应存在 `packages/ui/certd-server/src/plugins/`。
|
||||
- 应存在 `.trae/skills/`。
|
||||
- 应存在 `.trae/skills/certd-online-plugin-dev/SKILL.md`。
|
||||
|
||||
如果当前目录不是 Certd 项目,或缺少 `certd-online-plugin-dev` Skill,先拉取 Certd 仓库代码并切换到仓库内工作( --depth 1 拉取第一层即可):
|
||||
|
||||
1. 优先使用 `https://atomgit.com/certd/certd/`。
|
||||
2. 如果 AtomGit 拉取失败,再使用 `https://github.com/certd/certd`。
|
||||
3. 拉取后重新检查上述特征,并读取 `.trae/skills/certd-online-plugin-dev/SKILL.md`。
|
||||
|
||||
开发插件时,参考 Certd 项目下已有内置插件实现:`packages/ui/certd-server/src/plugins/`。
|
||||
|
||||
## 插件来源
|
||||
|
||||
Certd 插件按来源分为三类:
|
||||
|
||||
- 内置插件:`type: "builtIn"`,随 Certd 安装包提供。可读取并在流水线中使用,不应通过本 Skill 修改或覆盖。
|
||||
- 市场插件:`type: "store"`,且存在 `appId` 或 `developerId`。它来自在线插件市场,可能尚未安装到本地;是否可修改只能以接口返回的 `editable` 为准。
|
||||
- 本地插件:`type: "store"`,但没有 `appId` 和 `developerId`。它是当前 Certd 实例本地创建、导入或复制的插件,可直接保存;发布到市场后会带上市场归属信息。
|
||||
|
||||
不要只根据 `type: "store"` 判断插件是否来自市场,也不要自行推断编辑权限;始终使用列表结果中的 `editable` 字段。
|
||||
|
||||
## API 认证
|
||||
|
||||
提示词会提供 Certd API 地址和仅限 AI 插件开发接口的受限 Token。调用 API 时使用:
|
||||
|
||||
```http
|
||||
Authorization: <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
不要把 Token 写入代码、历史摘要、日志、提交信息或插件 YAML。
|
||||
|
||||
所有 Certd API 请求统一使用 Node.js 的 `fetch`。不要使用 PowerShell 的 `Invoke-RestMethod`、`Invoke-WebRequest` 或 .NET HTTP 客户端发送插件 YAML/JSON;它们在 Windows 上可能造成中文乱码或使完整 YAML 导入请求长时间无响应。
|
||||
|
||||
Node 请求须直接读取 UTF-8 文件或在 Node 内构造 JSON,并使用 `JSON.stringify`:
|
||||
|
||||
```javascript
|
||||
const response = await fetch(`${apiBase}/scoped/sys/ai/plugin/find`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: token, "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({ keywords: ["nginx"], includeBuiltIn: true, includeStore: true }),
|
||||
})
|
||||
```
|
||||
|
||||
## UTF-8 保存
|
||||
|
||||
在 Windows 上,Node 直接以 UTF-8 读取 YAML 并用 `JSON.stringify` 发送;不要让 PowerShell 转发含中文的 YAML/JSON。保存后检查中文字段不含 `?`,再调用 `/scoped/sys/ai/plugin/find` 或 `/scoped/sys/ai/plugin/info` 验证。
|
||||
|
||||
## API 工作流
|
||||
|
||||
1. 使用 `/scoped/sys/ai/plugin/find` 查询插件和 Access,可通过 `keywords` 数组传递多个关键词。
|
||||
2. 查询结果包含 `editable`:
|
||||
- `editable: true`:允许当前 Agent 修改并保存。
|
||||
- `editable: false`:只能读取和使用,不能修改。
|
||||
3. 读取完整 YAML 时调用 `/scoped/sys/ai/plugin/export`。
|
||||
4. 所有插件保存统一调用 `/scoped/sys/ai/plugin/import`,并始终传递完整 YAML:
|
||||
- 新插件使用 `override: false`。
|
||||
- 已有插件使用 `override: true`;导入接口根据 `author` 和 `name` 定位并覆盖已有记录。
|
||||
5. 不使用 `/sys/plugin/add` 或 `/sys/plugin/update` 保存插件,避免保存路径分叉、字段丢失和 Windows 请求兼容性问题。
|
||||
6. 保存完成后重新调用 `/scoped/sys/ai/plugin/find` 或 `/scoped/sys/ai/plugin/info` 验证结果。
|
||||
|
||||
`/scoped/sys/ai/plugin/find` 会在一次请求中分别查询内置插件和 `store` 插件,再合并返回;`store` 插件需按上述字段区分市场插件与本地插件。
|
||||
|
||||
详细请求字段见 `references/certd-api.md`。
|
||||
|
||||
## Access 协作
|
||||
|
||||
开发 Task 或 DNS Provider 前,先用 `/scoped/sys/ai/plugin/find` 查询对应 Access:
|
||||
|
||||
1. 如果没有对应 Access,先创建 Access 插件,再创建业务插件。
|
||||
2. 如果已有 Access,先读取它的完整 YAML 和 `content`。
|
||||
3. 如果 Access 已提供所需 API/SDK,业务插件优先复用。
|
||||
4. 如果缺少能力:
|
||||
- `editable: true`:优先修改 Access,并先保存历史。
|
||||
- `editable: false`:在当前业务插件中实现必要的 API 调用。
|
||||
5. 业务插件通过 `dependPlugins` 声明 Access 依赖。
|
||||
|
||||
详细规则见 `references/access-development.md`。
|
||||
|
||||
## 本地历史
|
||||
|
||||
开发插件时,必须在当前工作区创建并使用 `.plugin-dev/{plugin-name}` 作为插件开发目录。历史记录、临时 YAML、脚本草稿和调试记录都放在该目录下。
|
||||
|
||||
每次修改插件前,必须将完整 YAML 保存到 `.plugin-dev/{plugin-name}/history/`:
|
||||
|
||||
```text
|
||||
.plugin-dev/{plugin-name}/xxxxx.yaml
|
||||
.plugin-dev/{plugin-name}/history/
|
||||
2026-08-02T12-30-00-before-edit.yaml
|
||||
2026-08-02T12-30-00-change.md
|
||||
```
|
||||
|
||||
保存要求:
|
||||
|
||||
- 修改前保存完整 YAML。
|
||||
- 修改后保存修改摘要。
|
||||
- 恢复前再次备份当前版本。
|
||||
- 不上传历史文件,不保存 Token、证书、私钥或真实授权值。
|
||||
|
||||
详细格式见 `references/local-history.md`。
|
||||
|
||||
## YAML 和脚本规范
|
||||
|
||||
插件始终以完整 YAML 传递和保存,脚本源码放在顶层 `content` 字段。
|
||||
|
||||
- 统一使用 `await _ctx.import(...)` 引用模块。
|
||||
- `"/@/..."` 表示以绝对路径引用 `server/src/` 下的模块。
|
||||
- 最后返回继承目标基类的 class。
|
||||
- 不使用 `import`、`export`、装饰器或独立源码文件语法。
|
||||
- 使用 `this.logger` 打印插件执行日志。
|
||||
- 使用 `this.ctx.http` 访问 HTTP 能力。
|
||||
- 失败时抛出 `Error`。
|
||||
|
||||
需要字段格式时读取 `references/online-yaml-format.md`。
|
||||
需要组件示例时读取 `references/component-examples.md`。
|
||||
|
||||
## 示例插件
|
||||
|
||||
开发对应类型插件前,先读取 `examples/` 下的示例:
|
||||
|
||||
- Access:`examples/DemoAccess.yaml`
|
||||
- 部署/Task:`examples/DemoDeploy.yaml`
|
||||
- DNS Provider:`examples/DemoDnsProvider.yaml`
|
||||
|
||||
示例是完整在线插件 YAML,重点参考 `input` 配置、依赖声明和 `content` 脚本结构。
|
||||
|
||||
## 子 Skill
|
||||
|
||||
- Task:`skills/task-plugin-dev/SKILL.md`
|
||||
- DNS Provider:`skills/dns-provider-dev/SKILL.md`
|
||||
- Access:`skills/access-plugin-dev/SKILL.md`
|
||||
|
||||
## 安全边界
|
||||
|
||||
Certd 会保存证书、私钥、API Token、云厂商密钥、SSH 凭据和其他敏感授权。
|
||||
|
||||
- 禁止读取、打印或上传真实授权值。
|
||||
- 禁止把认证 Token 写入插件、历史、日志或摘要。
|
||||
- 禁止读取无关的证书、私钥、Cookie、环境变量和系统设置。
|
||||
- 只使用脱敏示例数据和公开文档。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Certd 在线插件开发"
|
||||
short_description: "通过 Certd API 使用 Codex 或 Trae 开发在线插件"
|
||||
default_prompt: "读取在线插件 YAML 和对应类型规范,按需求修改 content,保存历史记录后通过 Certd API 写回。"
|
||||
@@ -0,0 +1,37 @@
|
||||
name: DemoAccess
|
||||
icon: logos:airflow-icon
|
||||
title: Demo-授权插件示例 # 模块-插件名
|
||||
group: null
|
||||
desc: 这只是一个示例
|
||||
version: 1.0.0
|
||||
pluginType: access
|
||||
author: greper
|
||||
input:
|
||||
username:
|
||||
title: 用户名
|
||||
required: true
|
||||
encrypt: false
|
||||
component:
|
||||
name: a-input
|
||||
allowClear: true
|
||||
password:
|
||||
title: 密码
|
||||
required: true
|
||||
encrypt: true
|
||||
component:
|
||||
name: a-input
|
||||
allowClear: true
|
||||
showRunStrategy: false
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 1
|
||||
content: |
|
||||
|
||||
// 必须使用 await import 来引入模块
|
||||
const { BaseAccess } = await import("@certd/pipeline")
|
||||
// 需要返回一个继承BaseAccess的类
|
||||
return class DemoAccess extends BaseAccess {
|
||||
// 授权的字段,跟左边input一一对应
|
||||
username;
|
||||
password;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
name: DemoDeploy
|
||||
icon: logos:amp-icon
|
||||
title: Demo-部署插件示例 # 模块-插件名
|
||||
group: cdn
|
||||
desc: 这仅仅是一个示例
|
||||
version: 1.0.0
|
||||
pluginType: deploy
|
||||
author: greper
|
||||
input:
|
||||
cert:
|
||||
title: 前置任务证书
|
||||
helper: 请选择前置任务产生的证书
|
||||
component:
|
||||
name: output-selector # 前置任务输出选择组件
|
||||
vModel: modelValue
|
||||
from:
|
||||
- ':cert:' # 前置任务输出作为输入的筛选条件 , 格式 pluginType:valueType:keyName , 为空表示任意匹配
|
||||
required: true
|
||||
certDomains:
|
||||
title: 当前证书域名
|
||||
component:
|
||||
name: cert-domains-getter
|
||||
mergeScript: |
|
||||
return {
|
||||
component:{
|
||||
inputKey: ctx.compute(({form})=>{
|
||||
return form.cert
|
||||
}),
|
||||
}
|
||||
}
|
||||
required: true
|
||||
accessId:
|
||||
title: Access授权
|
||||
helper: xxxx的授权
|
||||
component:
|
||||
name: access-selector
|
||||
type: aliyun
|
||||
required: true
|
||||
key1:
|
||||
title: 输入示例1
|
||||
required: false
|
||||
key2:
|
||||
title: 可选项
|
||||
component:
|
||||
name: a-select
|
||||
vMode: value
|
||||
options:
|
||||
- value: '1'
|
||||
label: 选项1
|
||||
- value: '2'
|
||||
label: 选项2
|
||||
required: false
|
||||
showRunStrategy: false # 用户是否可以选择运行策略, 【默认不显示】
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 0 # 运行策略, 0 = 成功后跳过,1 = 正常运行
|
||||
content: >
|
||||
|
||||
// 要用await来import模块
|
||||
|
||||
const { AbstractTaskPlugin } = await _ctx.import("@certd/pipeline")
|
||||
|
||||
// 使用_ctx.import("/@/xxx.js") 以绝对路径引用模块,/@相当于根路径
|
||||
|
||||
const {AliyunAccess} = await _ctx.import("/@/plugins/plugin-lib/aliyun/access/index.js")
|
||||
|
||||
_ctx.logger.info("AliyunAccess:",AliyunAccess)
|
||||
|
||||
// 要返回一个继承AbstractTaskPlugin的class
|
||||
|
||||
return class DemoTask extends AbstractTaskPlugin {
|
||||
// 这里是插件的输入参数,对应左边的input配置
|
||||
cert;
|
||||
certDomains;
|
||||
accessId;
|
||||
key1;
|
||||
key2;
|
||||
// 编写执行方法
|
||||
async execute(){
|
||||
// 根据accessId获取授权配置
|
||||
const access = await this.getAccess(this.accessId)
|
||||
|
||||
//必须使用this.logger打印日志
|
||||
// this.logger.info("cert:",this.cert);
|
||||
this.logger.info("certDomains:",this.certDomains);
|
||||
this.logger.info("access:",access);
|
||||
this.logger.info("key1:",this.key1);
|
||||
this.logger.info("key2:",this.key2);
|
||||
this.logger.info("开始xxx部署任务")
|
||||
// 你的部署任务代码 【必须实现】
|
||||
// this.ctx里面有一些常用的方法类,比如utils、http、logger等
|
||||
const res = await this.ctx.http.request({url:"https://www.baidu.com"})
|
||||
if(res.error){
|
||||
//抛出异常,终止任务,否则将被判定为执行成功
|
||||
throw new Error("部署失败:"+res.message)
|
||||
}
|
||||
this.logger.info("执行成功")
|
||||
// this.outputName = xxxx //设置输出参数,可以被其他插件选择使用
|
||||
|
||||
// 此处可以返回 "skip" , 表示本次任务没有做任何操作和改变 , 配合runStrategy=1使用
|
||||
// return "skip"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
name: DemoDnsProvider
|
||||
icon: fa-solid:frog
|
||||
title: Demo-Dns提供商插件示例 # 模块-插件名
|
||||
desc: 这只是一个示例
|
||||
type: custom
|
||||
version: 1.0.0
|
||||
pluginType: dnsProvider
|
||||
author: greper
|
||||
accessType: aliyun # 需要的授权类型
|
||||
showRunStrategy: false
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 1
|
||||
content: |+
|
||||
|
||||
const { AbstractDnsProvider } = await _ctx.import("@certd/pipeline")
|
||||
return class DemoDnsProvider extends AbstractDnsProvider {
|
||||
// 创建dns解析记录,用于验证域名所有权 【必须实现】
|
||||
async createRecord(options) {
|
||||
/**
|
||||
* fullRecord: '_acme-challenge.test.example.com',
|
||||
* value: 一串uuid
|
||||
* type: 'TXT',
|
||||
* domain: 'example.com'
|
||||
*/
|
||||
const { fullRecord, value, type, domain } = options;
|
||||
const access = this.ctx.access
|
||||
this.logger.info('添加域名解析:', fullRecord, value, type, domain);
|
||||
// const record = await sdk.createRecord() // 调用对应的接口创建解析记录
|
||||
|
||||
//返回解析记录,用于后面清理
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除dns解析记录,清理申请痕迹【必须实现】
|
||||
* @param options
|
||||
*/
|
||||
async removeRecord(options) {
|
||||
const { fullRecord, value } = options.recordReq;
|
||||
const record = options.recordRes; // createRecord接口返回的record
|
||||
const access = this.ctx.access
|
||||
this.logger.info('删除域名解析:', fullRecord, value);
|
||||
if (!record) {
|
||||
this.logger.info('record为空,不执行删除');
|
||||
return;
|
||||
}
|
||||
const recordId = record.id;
|
||||
// 这里调用删除txt dns解析记录接口
|
||||
// sdk.removeRecord(recordId)
|
||||
this.logger.info("删除域名解析成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取域名列表 【可选,没有实现的话,不支持在certd域名管理中导入域名列表,不影响证书申请】
|
||||
* @param req
|
||||
* @returns
|
||||
*/
|
||||
async getDomainListPage(req: PageSearch): Promise<PageRes<DomainRecord>> {
|
||||
const pager = new Pager(req);
|
||||
const params = {
|
||||
RegionId: "cn-hangzhou",
|
||||
PageSize: pager.pageSize,
|
||||
PageNumber: pager.pageNo,
|
||||
};
|
||||
|
||||
const requestOption = {
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
const ret = await this.client.request("DescribeDomains", params, requestOption);
|
||||
const list =
|
||||
ret.Domains?.Domain?.map(item => ({
|
||||
id: item.DomainId,
|
||||
domain: item.DomainName,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
list,
|
||||
total: ret.TotalCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取域名解析记录列表 【可选,没有实现的话,不支持在站点监控里面导入网址,不影响证书申请】
|
||||
* @param domain
|
||||
* @param req
|
||||
* @returns
|
||||
*/
|
||||
async getRecordListPage(domain: string, req: PageSearch): Promise<PageRes<DnsResolveRecord>> {
|
||||
const pager = new Pager(req);
|
||||
const params = {
|
||||
RegionId: "cn-hangzhou",
|
||||
DomainName: domain,
|
||||
PageSize: pager.pageSize,
|
||||
PageNumber: pager.pageNo,
|
||||
};
|
||||
|
||||
const requestOption = {
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
const ret = await this.client.request("DescribeDomainRecords", params, requestOption);
|
||||
const rawList = ret.DomainRecords?.Record || [];
|
||||
const list = rawList.map(item => ({
|
||||
id: item.RecordId,
|
||||
hostRecord: item.RR,
|
||||
fullRecord: item.RR === "@" ? domain : `${item.RR}.${domain}`,
|
||||
type: item.Type,
|
||||
value: item.Value,
|
||||
}));
|
||||
|
||||
return {
|
||||
list,
|
||||
total: ret.TotalCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Access 开发规范
|
||||
|
||||
Access 插件负责保存授权配置,也负责封装平台 API/SDK,供 Task 和 DNS Provider 复用。
|
||||
|
||||
## 查询顺序
|
||||
|
||||
1. 调用 `/scoped/sys/ai/plugin/find`,使用 `pluginType: access`。
|
||||
2. 根据 `name`、`author`、`fullName` 识别目标 Access。
|
||||
3. 使用 `/scoped/sys/ai/plugin/export` 读取完整 YAML。
|
||||
4. 检查 `content` 中已经提供的方法。
|
||||
|
||||
## 修改规则
|
||||
|
||||
- Access 的 `editable: true` 时才允许修改。
|
||||
- 修改前先保存本地历史。
|
||||
- 优先把通用 API/SDK 能力放入 Access。
|
||||
- 业务插件通过 `dependPlugins` 依赖 Access。
|
||||
- `editable: false` 时不要尝试修改 Access,在业务插件内部实现必要的调用。
|
||||
- 不要在日志中打印完整授权配置。
|
||||
@@ -0,0 +1,70 @@
|
||||
# Certd API
|
||||
|
||||
以下接口都以前端生成提示词中的 API 地址为基础地址,并使用 `Authorization` 请求头。
|
||||
|
||||
## 查询插件
|
||||
|
||||
```http
|
||||
POST /scoped/sys/ai/plugin/find
|
||||
Content-Type: application/json
|
||||
Authorization: <token>
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"keywords": ["aliyun", "dns"],
|
||||
"pluginType": "access",
|
||||
"includeBuiltIn": true,
|
||||
"includeStore": true
|
||||
}
|
||||
```
|
||||
|
||||
接口会分别查询内置插件和 `store` 插件,再合并返回:
|
||||
|
||||
- `type: "builtIn"`:内置插件,不通过在线开发 API 修改。
|
||||
- `type: "store"` 且有 `appId` 或 `developerId`:市场插件。
|
||||
- `type: "store"` 且没有 `appId`、`developerId`:本地插件。
|
||||
|
||||
结果中的 `editable` 是唯一的编辑权限依据;不能只按插件来源判断是否可修改。
|
||||
列表结果只返回插件基础信息,不返回 `content`、`setting`、`sysSetting`、`metadata` 或 `extra`。需要完整 YAML 时再调用 `/scoped/sys/ai/plugin/export`。
|
||||
|
||||
## 读取插件信息
|
||||
|
||||
```http
|
||||
POST /scoped/sys/ai/plugin/info?id=12
|
||||
Authorization: <token>
|
||||
```
|
||||
|
||||
## 导出完整 YAML
|
||||
|
||||
```http
|
||||
POST /scoped/sys/ai/plugin/export
|
||||
Content-Type: application/json
|
||||
Authorization: <token>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12
|
||||
}
|
||||
```
|
||||
|
||||
## 保存插件
|
||||
|
||||
使用完整 YAML 导入:
|
||||
|
||||
```http
|
||||
POST /scoped/sys/ai/plugin/import
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "完整 YAML",
|
||||
"override": true,
|
||||
"type": "store"
|
||||
}
|
||||
```
|
||||
|
||||
保存后重新调用 `/scoped/sys/ai/plugin/find` 或 `/scoped/sys/ai/plugin/info` 验证。
|
||||
@@ -0,0 +1,423 @@
|
||||
# Component Examples
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins`.
|
||||
Detected **30** distinct component names.
|
||||
|
||||
These snippets are extracted from existing Certd plugins. For online plugins, place the object under `input.<field>.component` in the YAML document.
|
||||
|
||||
## `EmailSelector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-other/plugins/plugin-deploy-to-mail.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "EmailSelector",
|
||||
vModel: "value",
|
||||
mode: "tags",
|
||||
}
|
||||
```
|
||||
|
||||
## `ParamsShow`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-template/email/plugin-common.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "ParamsShow",
|
||||
params: [
|
||||
{ label: "标题", value: "title" },
|
||||
{ label: "内容", value: "content" },
|
||||
{ label: "URL", value: "url" },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## `RemoteSelect`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/getter/aliyun.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "RemoteSelect",
|
||||
vModel: "value",
|
||||
pager: true,
|
||||
single: true,
|
||||
}
|
||||
```
|
||||
|
||||
## `a-alert`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-template/email/plugin-base.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-alert",
|
||||
props: {
|
||||
type: "info",
|
||||
message: "在标题和内容模版中,通过${name}引用参数,例如: 感谢注册,您的注册验证码为:${code}",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## `a-auto-complete`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-ack/index.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-auto-complete",
|
||||
vModel: "value",
|
||||
options: [
|
||||
{ value: "cn-qingdao", label: "华北1(青岛)" },
|
||||
{ value: "cn-beijing", label: "华北2(北京)" },
|
||||
{ value: "cn-zhangjiakou", label: "华北3(张家口)" },
|
||||
{ value: "cn-huhehaote", label: "华北5(呼和浩特)" },
|
||||
{ value: "cn-wulanchabu", label: "华北6(乌兰察布)" },
|
||||
{ value: "cn-hangzhou", label: "华东1(杭州)" },
|
||||
{ value: "cn-shanghai", label: "华东2(上海)" },
|
||||
{ value: "cn-shenzhen", label: "华南1(深圳)" },
|
||||
{ value: "cn-guangzhou", label: "华南3(广州)" },
|
||||
{ value: "ap-southeast-2", label: "澳大利亚(悉尼)" },
|
||||
{ value: "ap-southeast-3", label: "马来西亚(吉隆坡)" },
|
||||
{ value: "ap-northeast-1", label: "日本(东京)" },
|
||||
{ value: "cn-chengdu", label: "西南1(成都)" },
|
||||
{ value: "ap-southeast-1", label: "新加坡" },
|
||||
{ value: "ap-southeast-5", label: "印度尼西亚(雅加达)" },
|
||||
{ value: "cn-hongkong", label: "中国香港" },
|
||||
{ value: "eu-central-1", label: "德国(法兰克福)" },
|
||||
{ value: "us-east-1", label: "美国(弗吉尼亚)" },
|
||||
{ value: "us-west-1", label: "美国(硅谷)" },
|
||||
{ value: "eu-west-1", label: "英国(伦敦)" },
|
||||
{ value: "me-east-1", label: "阿联酋(迪拜)" },
|
||||
//金融云
|
||||
{ value: "cn-beijing-finance-1", label: "华北2 金融云(邀测)" },
|
||||
{ value: "cn-hangzhou-finance", label: "华东1 金融云" },
|
||||
{ value: "cn-shanghai-finance-1", label: "华东2 金融云" },
|
||||
{ value: "cn-shenzhen-finance-1", label: "华南1 金融云" },
|
||||
],
|
||||
placeholder: "集群所属大区",
|
||||
}
|
||||
```
|
||||
|
||||
## `a-input`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-admin/plugin-db-backup.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-input",
|
||||
type: "value",
|
||||
placeholder: `默认${defaultBackupDir}`,
|
||||
}
|
||||
```
|
||||
|
||||
## `a-input-number`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-acepanel/access.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-input-number",
|
||||
vModel: "value",
|
||||
}
|
||||
```
|
||||
|
||||
## `a-input-password`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-51dns/access.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-input-password",
|
||||
vModel: "value",
|
||||
placeholder: "密码",
|
||||
}
|
||||
```
|
||||
|
||||
## `a-radio-group`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-esa/index.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-radio-group",
|
||||
vModel: "value",
|
||||
options: [
|
||||
{ label: "边缘证书", value: "edge" },
|
||||
{ label: "SaaS证书", value: "saas" },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## `a-select`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-admin/plugin-db-backup.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-select",
|
||||
options: [
|
||||
{ label: "本地复制", value: "local" },
|
||||
{ label: "oss上传(推荐)", value: "oss" },
|
||||
{ label: "ssh上传(请使用oss上传方式)", value: "ssh", disabled: true },
|
||||
],
|
||||
placeholder: "",
|
||||
}
|
||||
```
|
||||
|
||||
## `a-switch`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-acepanel/access.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-switch",
|
||||
vModel: "checked",
|
||||
}
|
||||
```
|
||||
|
||||
## `a-textarea`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-admin/plugin-script.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "a-textarea",
|
||||
vModel: "value",
|
||||
rows: 10,
|
||||
style: "background-color: #000c17;color: #fafafa;",
|
||||
}
|
||||
```
|
||||
|
||||
## `access-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-deploy-to-website.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "access-selector",
|
||||
type: "acepanel",
|
||||
}
|
||||
```
|
||||
|
||||
## `api-test`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-51dns/access.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "api-test",
|
||||
action: "TestRequest",
|
||||
}
|
||||
```
|
||||
|
||||
## `cert-info-updater`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/custom/index.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "cert-info-updater",
|
||||
vModel: "modelValue",
|
||||
}
|
||||
```
|
||||
|
||||
## `dns-provider-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/apply.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "dns-provider-selector",
|
||||
}
|
||||
```
|
||||
|
||||
## `domain-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base-convert.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "domain-selector",
|
||||
vModel: "value",
|
||||
mode: "tags",
|
||||
// open: false,
|
||||
placeholder: "请输入证书域名/IP,比如:foo.com , *.foo.com , *.sub.foo.com , *.bar.com , 123.123.123.123",
|
||||
tokenSeparators: [",", " ", ",", "、", "|"],
|
||||
search: true,
|
||||
pager: true,
|
||||
}
|
||||
```
|
||||
|
||||
## `domains-verify-plan-editor`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/apply.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "domains-verify-plan-editor",
|
||||
}
|
||||
```
|
||||
|
||||
## `email-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "email-selector",
|
||||
vModel: "value",
|
||||
}
|
||||
```
|
||||
|
||||
## `fs-icon-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-oauth/oidc/plugin-oidc.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "fs-icon-selector",
|
||||
vModel: "modelValue",
|
||||
iconSets: IconSets,
|
||||
}
|
||||
```
|
||||
|
||||
## `icon-select`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/apply.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "icon-select",
|
||||
vModel: "value",
|
||||
options: [
|
||||
{ value: "letsencrypt", label: "Let's Encrypt(免费,新手推荐,支持IP证书)", icon: "simple-icons:letsencrypt" },
|
||||
{ value: "google", label: "Google(免费)", icon: "flat-color-icons:google" },
|
||||
{ value: "zerossl", label: "ZeroSSL(免费)", icon: "emojione:digit-zero" },
|
||||
{ value: "litessl", label: "litessl(免费)", icon: "roentgen:free" },
|
||||
{ value: "sslcom", label: "SSL.com(仅主域名和www免费)", icon: "la:expeditedssl" },
|
||||
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境(仅供测试)", icon: "simple-icons:letsencrypt" },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## `input-password`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base-convert.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "input-password",
|
||||
vModel: "value",
|
||||
}
|
||||
```
|
||||
|
||||
## `notification-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-github/plugins/plugin-check-release.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "notification-selector",
|
||||
select: {
|
||||
mode: "tags",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## `output-selector`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-deploy-to-website.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "output-selector",
|
||||
from: [...CertApplyPluginNames],
|
||||
}
|
||||
```
|
||||
|
||||
## `pem-input`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/custom/index.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "pem-input",
|
||||
vModel: "modelValue",
|
||||
textarea: {
|
||||
rows: 4,
|
||||
placeholder: "-----BEGIN CERTIFICATE-----\n...\n...\n-----END CERTIFICATE-----",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## `refresh-input`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-cert/access/acme-account-access.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "refresh-input",
|
||||
action: "GenerateAccount",
|
||||
buttonText: "生成ACME账号",
|
||||
successMessage: "ACME账号已生成,请保存授权配置",
|
||||
type: "textarea",
|
||||
rows: 4,
|
||||
}
|
||||
```
|
||||
|
||||
## `remote-auto-complete`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-apig/index.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "remote-auto-complete",
|
||||
}
|
||||
```
|
||||
|
||||
## `remote-select`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/plugin-deploy-to-proxy-hosts.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "remote-select",
|
||||
vModel: "value",
|
||||
mode: "tags",
|
||||
type: "plugin",
|
||||
action: "onGetProxyHostOptions",
|
||||
search: true,
|
||||
pager: false,
|
||||
single: false,
|
||||
watches: ["certDomains", "accessId"],
|
||||
}
|
||||
```
|
||||
|
||||
## `remote-tree-select`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-tencent/plugin/refresh-cert/index.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
name: "remote-tree-select",
|
||||
vModel: "value",
|
||||
action: TencentRefreshCert.prototype.onGetRegionsTree.name,
|
||||
pager: false,
|
||||
search: false,
|
||||
watches: ["certList"],
|
||||
}
|
||||
```
|
||||
|
||||
## `synology-device-id-getter`
|
||||
|
||||
Source: `packages/ui/certd-server/src/plugins/plugin-plus/synology/access.ts`
|
||||
|
||||
```ts
|
||||
component: {
|
||||
placeholder: "设备ID",
|
||||
name: "synology-device-id-getter",
|
||||
type: "access",
|
||||
typeName: "synology",
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# 本地历史记录
|
||||
|
||||
历史记录和开发临时文件只保存在 Codex/Trae 当前工作区的 `.tmp/online-plugin-dev/` 下,不调用 Certd 后端历史接口。
|
||||
|
||||
目录格式:
|
||||
|
||||
```text
|
||||
.tmp/online-plugin-dev/
|
||||
history/
|
||||
plugin-12/
|
||||
2026-08-02T12-30-00-before-edit.yaml
|
||||
2026-08-02T12-30-00-change.md
|
||||
work/
|
||||
plugin-12.yaml
|
||||
plugin-12-content.js
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 修改前保存完整 YAML。
|
||||
- 临时 YAML、脚本草稿、调试记录都放到 `.tmp/online-plugin-dev/` 下,不散落到项目目录。
|
||||
- `change.md` 只记录插件 ID、版本、时间和脱敏修改摘要。
|
||||
- 不保存 Token、证书、私钥、Cookie、环境变量和真实授权值。
|
||||
- 恢复历史版本前先备份当前 YAML。
|
||||
- 恢复后通过 `/scoped/sys/ai/plugin/import` 写回 Certd。
|
||||
@@ -0,0 +1,66 @@
|
||||
## 在线插件 YAML
|
||||
|
||||
在线插件始终以一个完整 YAML 文档传递、编辑、导入和导出。脚本源码必须放在顶层 `content` 字段中,不要输出独立的 `.ts` 文件,也不要使用 JSON Patch。
|
||||
|
||||
常用字段:
|
||||
|
||||
```yaml
|
||||
name: DemoTask
|
||||
author: demo
|
||||
title: Demo 任务
|
||||
desc: 插件说明
|
||||
icon: clarity:plugin-line
|
||||
pluginType: task
|
||||
group: other
|
||||
version: 1.0.0
|
||||
input:
|
||||
cert: # 输入字段定义
|
||||
title: 域名证书
|
||||
required: true # 是否必填
|
||||
component: # 输入字段组件
|
||||
name: cert-select # 前端vue组件名称
|
||||
output: # 【可选】输出字段定义
|
||||
cert:
|
||||
title: 证书 #输出字段标题
|
||||
type: "cert" # 输出字段类型
|
||||
dependPlugins: # 依赖的插件
|
||||
# - access:certd/DemoAccess: *
|
||||
dependPackages: # 依赖的nodejs包
|
||||
# xxx: ^1.0.0
|
||||
vip: "free" # 默认免费,plus 需要专业版
|
||||
showRunStrategy: false # 【默认不显示】
|
||||
default: {
|
||||
strategy: {
|
||||
runStrategy: 0, # 运行策略, 0 = 成功后跳过,1 = 正常运行
|
||||
},
|
||||
}
|
||||
content: |
|
||||
const { AbstractTaskPlugin } = await _ctx.import("@certd/pipeline")
|
||||
const { DemoAccess } = await _ctx.import("/@/plugins/plugin-lib/demo/access/index.js")
|
||||
_ctx.logger.info("DemoAccess:", DemoAccess)
|
||||
return class DemoTask extends AbstractTaskPlugin {
|
||||
async execute() {
|
||||
this.logger.info("执行成功")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `content` 规则
|
||||
|
||||
- 统一使用 `await _ctx.import(...)` 加载模块。
|
||||
- 使用 `_ctx.import("/@/...")` 以绝对路径加载 `certd-server/src/` 下的模块,`/@` 代表 `certd-server/src` 根路径。
|
||||
- 需要确认模块时使用 `_ctx.logger`,插件执行过程使用 `this.logger`。
|
||||
- 最后返回继承目标基类的 class。
|
||||
- 不使用 `import`、`export`、装饰器或独立源码文件语法。
|
||||
- 输入字段在 class 中声明为同名属性,并与 YAML 的 `input` 配置保持一致。
|
||||
- HTTP 使用 `this.ctx.http`;
|
||||
- 不要使用 `console.log`。
|
||||
- 读取授权使用 `await this.getAccess(accessId)` 或目标基类规定的授权方式。
|
||||
- 失败时抛出 `Error`,不要吞掉错误。
|
||||
|
||||
### 编辑规则
|
||||
|
||||
- 修改已有插件时保留 `name`、`author`、`pluginType` 和已有兼容字段。
|
||||
- 只修改需求涉及的字段,避免删除未知的 YAML 字段。
|
||||
- 脚本过长时仍放在同一个 `content` block scalar 中。
|
||||
- 提交前检查 YAML 可解析、`content` 非空、版本和插件类型没有被意外修改。
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: certd-online-access-plugin-dev
|
||||
description: 用于开发 Certd 在线 Access 插件。输出完整 YAML,content 中返回继承 BaseAccess 的 class,并在 input 中声明授权字段。
|
||||
---
|
||||
|
||||
# 在线 Access 插件
|
||||
|
||||
读取父 Skill 的 `references/online-yaml-format.md`。不要沿用代码版 `@IsAccess`、`@AccessInput` 装饰器和独立 TypeScript 文件。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- `pluginType` 使用 `access`。
|
||||
- `input` 中声明用户需要填写的授权字段。
|
||||
- 敏感字段在 input 中设置加密或密码类组件。
|
||||
- `content` 中实现授权 class 和 API 方法。
|
||||
|
||||
## `content` 模板
|
||||
|
||||
```javascript
|
||||
const { BaseAccess } = await _ctx.import("@certd/pipeline")
|
||||
|
||||
return class DemoAccess extends BaseAccess {
|
||||
demoKeyId
|
||||
demoKeySecret
|
||||
|
||||
async onTestRequest() {
|
||||
await this.getDomainList({ searchKey: "" })
|
||||
return "ok"
|
||||
}
|
||||
|
||||
async getDomainList(req) {
|
||||
this.logger.info("获取域名列表", { searchKey: req.searchKey })
|
||||
const res = await this.ctx.http.request({
|
||||
url: "https://api.example.com/domains",
|
||||
method: "GET",
|
||||
params: { keyword: req.searchKey },
|
||||
})
|
||||
if (res.error) {
|
||||
throw new Error(`获取域名列表失败: ${res.message}`)
|
||||
}
|
||||
return {
|
||||
total: res.data?.total || 0,
|
||||
list: res.data?.list || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 编写要求
|
||||
|
||||
- 统一使用 `await _ctx.import(...)` 加载模块。
|
||||
- 使用 `_ctx.import("/@/...")` 通过绝对路径加载 `server/src/` 下的模块,`/@` 代表 `server/src` 根路径。
|
||||
- 需要记录模块加载信息时使用 `_ctx.logger`,访问执行日志使用 `this.logger`。
|
||||
- 返回继承 `BaseAccess` 的 class,不使用装饰器。
|
||||
- class 属性名必须与 YAML `input` 字段一致。
|
||||
- 所有敏感授权值只通过 `this` 和 Certd 授权上下文使用,不打印真实值。
|
||||
- `onTestRequest` 应调用实际 API 方法并在失败时抛出异常。
|
||||
- 对外 API 方法应统一处理分页、错误和返回字段。
|
||||
- 使用 `this.logger` 或框架提供的 logger,禁止 `console.log`。
|
||||
- 一般将API接口方法封装到Access中,其他plugin调用access的方法当做client sdk使用
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: certd-online-dns-provider-dev
|
||||
description: 用于开发 Certd 在线 DNS Provider 插件。输出完整 YAML,content 中返回继承 AbstractDnsProvider 的 class。
|
||||
---
|
||||
|
||||
# 在线 DNS Provider 插件
|
||||
|
||||
读取父 Skill 的 `references/online-yaml-format.md`。不要沿用旧版 `@IsDnsProvider` 装饰器和独立 TypeScript 文件。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- `pluginType` 使用 `dnsProvider`。
|
||||
- `input` 中配置授权选择、域名或平台所需的参数。
|
||||
- `content` 中实现创建和删除 DNS 记录的 class。
|
||||
|
||||
## `content` 模板
|
||||
|
||||
```javascript
|
||||
const { AbstractDnsProvider } = await _ctx.import("@certd/pipeline")
|
||||
const { DemoAccess } = await _ctx.import("/@/plugins/plugin-lib/demo/access/index.js")
|
||||
_ctx.logger.info("DemoAccess:", DemoAccess)
|
||||
|
||||
return class DemoDnsProvider extends AbstractDnsProvider {
|
||||
accessId
|
||||
|
||||
async onInstance() {
|
||||
this.access = await this.getAccess(this.accessId)
|
||||
}
|
||||
|
||||
async createRecord(options) {
|
||||
const { fullRecord, value, type, domain } = options
|
||||
this.logger.info("添加 DNS 记录", { fullRecord, type, domain })
|
||||
const res = await this.ctx.http.request({
|
||||
url: "https://api.example.com/dns/records",
|
||||
method: "POST",
|
||||
data: { fullRecord, value, type, domain },
|
||||
})
|
||||
if (res.error) {
|
||||
throw new Error(`创建 DNS 记录失败: ${res.message}`)
|
||||
}
|
||||
return res.data
|
||||
}
|
||||
|
||||
async removeRecord(options) {
|
||||
const { fullRecord, value, domain } = options.recordReq
|
||||
const res = await this.ctx.http.request({
|
||||
url: "https://api.example.com/dns/records",
|
||||
method: "DELETE",
|
||||
data: { fullRecord, value, domain },
|
||||
})
|
||||
if (res.error) {
|
||||
this.logger.warn("删除 DNS 记录失败", res.message)
|
||||
return
|
||||
}
|
||||
this.logger.info("删除 DNS 记录成功", fullRecord)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 编写要求
|
||||
|
||||
- 统一使用 `await _ctx.import(...)` 加载模块。
|
||||
- 使用 `_ctx.import("/@/...")` 通过绝对路径加载 `server/src/` 下的模块,`/@` 代表 `server/src` 根路径。
|
||||
- 需要记录模块加载信息时使用 `_ctx.logger`。
|
||||
- 返回继承 `AbstractDnsProvider` 的 class。
|
||||
- `createRecord` 必须返回删除时需要的记录信息。
|
||||
- `removeRecord` 使用 `options.recordReq` 和 `options.recordRes`。
|
||||
- 只处理业务 API 所需的 TXT 记录参数,不在日志中输出授权密钥。
|
||||
- 网络失败、授权失败和 API 业务失败要有明确日志;创建失败必须抛出异常。
|
||||
- 保持创建和删除幂等,避免清理失败阻断无关流程。
|
||||
- 一般将API接口方法封装到Access中,其他plugin使用access当做client sdk使用
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: certd-online-task-plugin-dev
|
||||
description: 用于开发 Certd 在线 Task 插件。输出完整 YAML,脚本源码放在 content 字段中,继承 AbstractTaskPlugin 并返回插件 class。
|
||||
---
|
||||
|
||||
# 在线 Task 插件
|
||||
|
||||
读取父 Skill 的 `references/online-yaml-format.md`。在线插件不是原来的装饰器源码文件模式。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- `pluginType` 使用 `task`。
|
||||
- 保留或填写 `name`、`author`、`title`、`desc`、`icon`、`group`、`version`。
|
||||
- 输入配置放在 YAML 的 `input` 字段。
|
||||
- 执行脚本放在 YAML 顶层 `content` 字段。
|
||||
|
||||
|
||||
## runStrategy
|
||||
|
||||
整个流水线每次运行,会记录每个任务的运行结果状态,如果上次任务执行成功,且 runStrategy 为成功后跳过,那么下次运行时会跳过该任务。
|
||||
|
||||
|
||||
- `showRunStrategy`: false 【默认不显示】
|
||||
- 任务配置时是否显示 runStrategy 字段,用于指定任务的执行策略。
|
||||
- `default.strategy.runStrategy`: 0 【默认成功后跳过】
|
||||
- 0 表示成功后跳过,1 表示正常运行。
|
||||
- 设置任务的默认运行策略runStrategy。
|
||||
|
||||
当证书申请任务,申请证书成功后,会清空后续任务状态。此时后续`runStrategy = 0` 的任务,会重新执行。
|
||||
这样可以保证相同的证书不会重复部署。
|
||||
|
||||
在其他场景的部署任务,可能希望每次运行都检查是否需要部署,如果即将过期就部署,没有过期就跳过。
|
||||
此时可以设置 `default.strategy.runStrategy = 1`,然后在`execute`方法中,如果没有过期,就返回 `"skip"`。
|
||||
|
||||
## `content` 模板
|
||||
|
||||
```javascript
|
||||
const { AbstractTaskPlugin } = await _ctx.import("@certd/pipeline")
|
||||
const { DemoAccess } = await _ctx.import("/@/plugins/plugin-lib/demo/access/index.js")
|
||||
_ctx.logger.info("DemoAccess:", DemoAccess)
|
||||
|
||||
return class DemoTask extends AbstractTaskPlugin {
|
||||
cert
|
||||
certDomains
|
||||
accessId
|
||||
|
||||
async execute() {
|
||||
const access = await this.getAccess(this.accessId)
|
||||
this.logger.info("开始执行任务", { access })
|
||||
const res = await this.ctx.http.request({
|
||||
url: "https://api.example.com",
|
||||
})
|
||||
if (res.error) {
|
||||
// 抛异常让任务执行失败
|
||||
throw new Error(`任务执行失败: ${res.message}`)
|
||||
}
|
||||
this.logger.info("执行成功")
|
||||
// 还可以返回 "skip" 表示当前任务没有执行任何操作
|
||||
// 一般与 runStrategy = 1 配合使用。 (1=正常运行,0表示成功后跳过)
|
||||
// 比如检查无需更新,时就 返回 "skip"
|
||||
// return "skip"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 编写要求
|
||||
|
||||
- 统一用 `await _ctx.import(...)` 加载模块。
|
||||
- 使用 `_ctx.import("/@/...")` 通过绝对路径加载 `server/src/` 下的模块,`/@` 代表 `server/src` 根路径。
|
||||
- 需要记录模块加载信息时使用 `_ctx.logger`。
|
||||
- 返回继承 `AbstractTaskPlugin` 的 class,不写 `export class`。
|
||||
- class 属性名必须对应 `input` 配置的字段名。
|
||||
- 用 `this.logger` 记录关键步骤。
|
||||
- 用 `this.ctx.http` 请求远程 API,用 `this.getAccess` 获取授权。
|
||||
- 外部 API 返回失败或业务失败时抛出异常。
|
||||
- 对重复执行保持幂等,避免把真实 Token、证书和私钥写入日志。
|
||||
- 修改完成后把整个 YAML 通过 Certd `/scoped/sys/ai/plugin/import` 保存。
|
||||
- 一般将API接口方法封装到Access中,其他plugin使用access当做client sdk使用
|
||||
@@ -281,12 +281,12 @@ export class DemoTest extends AbstractTaskPlugin {
|
||||
return {
|
||||
value: item.siteName,
|
||||
label: item.siteName,
|
||||
domain: item.siteName,
|
||||
domain: item.siteName, //这里必须要包含domain 否则后面分组时候全部分配到未匹配中
|
||||
};
|
||||
});
|
||||
//将站点域名名称根据证书域名进行匹配分组,分成匹配的和不匹配的两组选项,返回给前端,供用户选择
|
||||
return {
|
||||
list: optionsUtils.buildGroupOptions(options, this.certDomains),
|
||||
list: optionsUtils.buildGroupOptions(options, this.certDomains), //分组后的列表
|
||||
total: siteRes.length,
|
||||
};
|
||||
}
|
||||
|
||||
Vendored
+28
@@ -87,6 +87,34 @@
|
||||
"plus_use_prod": "false",
|
||||
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-local-plus2",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/packages/ui/certd-server",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev-localplus2"],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"env": {
|
||||
"plus_use_prod": "false",
|
||||
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-local-comm",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/packages/ui/certd-server",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev-localcomm"],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"env": {
|
||||
"plus_use_prod": "false",
|
||||
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
|
||||
@@ -62,34 +62,45 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
- 前端改动文件格式化:`packages\ui\certd-client\node_modules\.bin\prettier.cmd --write <files>`。
|
||||
- 前端改动文件 ESLint 修复:`packages\ui\certd-client\node_modules\.bin\eslint.cmd --fix <files>`。
|
||||
- 后端单元测试:`cd packages\ui\certd-server && npm run unit`。
|
||||
- 后端改动文件 lint fix:`cd packages\ui\certd-server && npm run lint`。
|
||||
- 后端改动文件 lint fix:`cd packages\ui\certd-server && npm run lint`(该脚本即 `mwts fix`,对改动文件执行 ESLint 自动修复)。
|
||||
- 其他package lint fix:`cd packages\xxx\xxxx && npm run lint`。
|
||||
|
||||
## 通用工作规则
|
||||
|
||||
- 先读本文,再按任务读取具体代码或技能文件。
|
||||
- 与用户沟通、代码注释、文档、日志和面向用户的界面文案,尽量使用中文;技术专有名词、代码标识符、第三方库名称和命令可保留英文。
|
||||
- PowerShell 读取中文、Markdown、locale、文档类文件时使用 `Get-Content -Raw -Encoding UTF8`;仍乱码时先执行 `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()`。
|
||||
- PowerShell 中用 `rg` 搜索含引号、括号、反斜杠的 pattern 时,优先用单引号包裹整个 pattern,例如 `rg 'await import\("tencentcloud-sdk-nodejs' packages/ui/certd-server/src -g '*.ts'`。
|
||||
- 手工编辑或创建文件时优先使用 `apply_patch`。单个文件内有多处不连续改动时,拆成多个独立的 `*** Update File` 块,每块只改一处附近上下文;不要在同一个 update hunk 里强塞多个 `@@`。
|
||||
- 只有真正机械化的大批量替换、格式化或生成任务才考虑脚本/工具。若必须使用临时脚本,应放在临时目录并在同一个受控步骤内完成创建、执行、删除;不要把临时脚本落在仓库里跨多步工具调用执行。
|
||||
- 不要主动运行 `pnpm install`;缺依赖、TTY、网络导致安装或测试失败时,停止尝试并说明环境问题。
|
||||
- 优先沿用现有模块、插件、service、页面模式;不要为形式上的复用制造过度抽象。
|
||||
- 代码可读性优先于短写法。复杂条件、三元表达式、链式调用、内联对象和多层 helper 调用要拆成命名清晰的中间变量或小方法。
|
||||
- 代码可读性优先于短写法。尽量少用三元表达式,优先使用完整的 `if/else`;复杂条件、链式调用、内联对象和多层 helper 调用要拆成命名清晰的中间变量或小方法。
|
||||
- 方法调用链不要直接塞进另一个方法参数;先用有意义的局部变量承接返回值,再传入下一步。
|
||||
- 不要在单一表达式内嵌套分支、对象构造与方法调用。优先使用清晰的 `if/else` 分支;仅在确实能降低复杂度时才提取有意义的中间变量,避免为拆分而增加阅读跳转。
|
||||
- 注释优先使用中文,尤其是业务规则、兼容逻辑、协议细节和隐藏风险;文件已有英文风格或引用外部术语时可保持一致。
|
||||
- 遵守 DRY 和单一职责;第三次出现的业务规则、字段转换、权限判断、Repository 选择、事务传播、金额计算等逻辑,应优先抽成合适 helper 或 service 方法。
|
||||
- 如无明确复用价值,不要随意新增 `normalizeXxx` 等辅助方法;优先在现有业务方法中直接处理简单兼容或字段转换逻辑,避免增加不必要的抽象层。
|
||||
|
||||
|
||||
## 测试与验证
|
||||
|
||||
- 务必写单元测试,覆盖主要业务逻辑。
|
||||
- 实现新功能或修复行为缺陷前,优先补单元测试并先确认红灯,再实现并跑聚焦验证。
|
||||
- 新功能、缺陷修复和重构遵循 TDD:先编写或更新测试,覆盖正常路径、边界条件和明确的错误处理;先运行测试确认 RED,再实现最小改动,最后运行聚焦测试确认 GREEN。
|
||||
- 单元测试应优先直接测试原始业务方法,不要为了方便测试而抽取没有业务价值的 helper;数据库、RPC 和其他外部依赖可以使用 mock 隔离。
|
||||
- 确实不适合先写测试时,在回复中说明原因和替代验证方式。
|
||||
- 后补单元测试时,按正确行为写预期;若红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。
|
||||
- 后端纯单测放在 `src/**/*.test.ts`,尽量与被测文件相邻;`test:unit` 只跑这些文件,构建/打包应排除 `*.test.ts`。
|
||||
- 单测需要 mock ESM 静态 import 时,优先使用 `esmock`,不要为了测试改业务代码结构。
|
||||
- 单测需要 mock ESM 静态 import(如 `@certd/plus-core` 的 `isPlus`、第三方包)时,**优先使用 `esmock`**,通过 `esmock(被测模块路径, { 依赖模块: { 导出: mock实现 } })` 加载被测模块并替换依赖;**不要为了测试给业务代码加注入点/改业务结构**(如不需要给类加 `isPlusFn` 之类的可注入属性)。
|
||||
- 各包 `test:unit` 脚本应显式设置 `NODE_ENV=unittest`。
|
||||
- 单包单测优先用 `cd <包目录> && npm run test:unit`,例如 `cd packages\ui\certd-server && npm run test:unit`。
|
||||
- 优先对改动包运行聚焦测试或格式化/ESLint;只有跨包影响明显时再考虑更大范围构建。
|
||||
|
||||
## 规范回顾
|
||||
|
||||
- 每次任务收尾时,回顾用户提出的要求,识别其中可跨任务复用、稳定且不与既有规则冲突的约束。
|
||||
- 只将经过确认的共性要求精炼后写入本文;一次性任务细节、临时偏好和未经确认的推断不要写入长期规范。
|
||||
- 更新本文后检查规则是否清晰、可执行,并删除重复或互相矛盾的表述。
|
||||
|
||||
|
||||
## 后端规则
|
||||
|
||||
@@ -102,10 +113,18 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
- 只有需要事务传播时才定义 `ctx`;普通查询、纯函数和简单私有方法继续使用明确参数。
|
||||
- 需要按事务上下文取 Repository 时,用 `BaseService.getRepo(ctx, EntityClass)`。
|
||||
- 需要“有事务则复用、无事务则开启”时,用 `BaseService.transactionWithCtx(ctx, callback)`。
|
||||
- 基础 CRUD 数据访问优先复用 `BaseService` 的 `find`、`findOne`、`list`、`page`、`update`、`deleteWhere` 等方法;不要从 `repository.createQueryBuilder()` 开始重复实现完整查询或更新。仅将关键词组合筛选、联表、业务排序等基类无法表达的部分放入 `list/page` 的 `buildQuery`。
|
||||
- 拼接可选 `projectId` 查询条件时,**必须**使用 `BaseService.buildUserProjectQuery(userId, projectId)`,禁止直接写 `{ userId, projectId }`。因为 `projectId` 可能为 `null`/`undefined`,直接放入查询会生成错误的 `WHERE projectId = NULL` 条件。
|
||||
- `ctx` 类型复用 `BaseService` 导出的 `ServiceContext`。
|
||||
- 新增 service 方法避免与 `BaseService` 方法签名冲突,例如不要用 `delete(id)` 覆盖 `delete(ids, where?)`;改用 `deleteById` 等具体名称。
|
||||
|
||||
### 证书仓库(cd_cert_info)空证书记录【必须保留】
|
||||
|
||||
- 保存流水线时(`PipelineService.save`)会调用 `CertInfoService.updateDomains` 同步该流水线的 active 证书记录(占位记录,`certInfo` 为空)。**这些记录必须保留,不要删除**:开放接口(OpenAPI `autoApply`)触发申请证书前,会先查询证书仓库中是否有该流水线的记录——有记录(即使证书内容为空)说明该流水线已存在、证书正在申请中,直接复用/触发已有流水线;若删掉这些记录,在证书申请成功前的空窗期内,开放接口每次调用都会重复创建新流水线。
|
||||
- 同步规则(`updateDomains`,只处理 active 记录,inactive/revoked 历史保留):① 流水线中每个证书申请任务(CertApply 类步骤)对应仓库一条 active 记录(按 `task_id` 关联),没有则创建空证书记录;② 删除孤儿 active 记录(流水线中已不存在对应任务 id 的,含 `task_id` 为空的历史遗留);③ 一个流水线多个申请任务时维护多条记录,随时保持一致。
|
||||
- `updateCertByPipelineId`(申请证书成功后写入仓库)中同样**不得删除**该流水线的空证书记录;新证书总是新建一条 active 记录,并按相同 `task_id` 把同流水线其他 active 记录标记为 inactive,避免同一流水线出现多条 active。
|
||||
- 吊销旧证书(`CertRevokeOld` 后置任务)按证书申请任务 id(`task_id`)精确匹配:只吊销同一申请任务产出的 inactive 旧证书,不误伤其他任务仍在使用的证书。
|
||||
|
||||
### 后端地图
|
||||
|
||||
- `packages/ui/certd-server/bootstrap.js`:Midway 启动入口,使用 `@midwayjs/bootstrap`。
|
||||
@@ -132,13 +151,21 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
|
||||
- 前端主包是 `packages/ui/certd-client`,使用 Vue 3、Vite、TypeScript、Ant Design Vue、Fast Crud、Pinia、vue-router、vue-i18n。
|
||||
- 做前端任务时,先定位 `packages/ui/certd-client/src/views/certd` 下的页面,再找对应 `src/api`。
|
||||
- 前端用户提示**统一使用 `notification`**(ant-design-vue 的 `notification.success({ message: X })` / `.error` / `.warning` / `.info`),**不要使用 `message` API**;`src/vben/` 目录(vben 框架内置布局/组件)保持原样不动。
|
||||
- 不要运行前端 `pnpm tsc` / `vue-tsc`;当前 `vue-tsc@1.8.27` 会抛无效内部错误。前端 `test:unit` 只是占位脚本,也不要跑。
|
||||
- 前端 TS/Vue/locale 改动后,只对本次改动文件运行现有 Prettier / ESLint:`packages\ui\certd-client\node_modules\.bin\prettier.cmd --write <files>` 和 `packages\ui\certd-client\node_modules\.bin\eslint.cmd --fix <files>`。
|
||||
- 需要手写 render 的地方(如 `Modal.confirm` 的 `content`、`useFormDialog` 的 `body`、动态插槽等),一律用 JSX 编写,禁止使用 `h()` 创建 VNode:
|
||||
- `.vue` 组件:把 `<script lang="ts" setup>` 改为 `<script lang="tsx" setup>`,然后在 JSX 中直接写元素(如 `content: () => (<div class="xxx">...</div>)`)。
|
||||
- `.ts` 文件:直接把文件后缀改为 `.tsx`,再写 JSX render。
|
||||
- 列表管理、后台管理、记录查询、CRUD 表格页面优先使用 Fast Crud;开发或重构前读 `.trae/skills/fast-crud-page-dev/SKILL.md`。
|
||||
- 只有轻量只读展示、强交互自定义界面或既有页面模式明显不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明。
|
||||
- 内嵌 Fast Crud 时,外层必须有稳定高度或完整 `flex: 1; min-height: 0` 链路。
|
||||
- 前端组件样式统一写在 `<style>` / Less / CSS 文件里,通过样式名映射到元素;尽量不要在元素上直接写 `style`。
|
||||
- 每个组件都要有一个稳定的根样式名,并把组件下方样式全部包在该根样式名内;尽量不要使用 `scoped`。
|
||||
- 可复用的公共样式名放在 `packages/ui/certd-client/src/style` 下维护,优先使用 `cd-` 前缀,避免散落在业务组件里重复定义。
|
||||
- 后台管理列表展示或筛选用户字段时,优先参考 `packages/ui/certd-client/src/views/sys/suite/user-suite/crud.tsx` 的 `userId` 字段模式,用 `table-select` + `/sys/authority/user/getSimpleUserByIds` 字典回显和搜索。
|
||||
- 对话框里只做确认可用 `Modal.confirm`;有字段输入、表单校验或提交字段时,必须用 `useFormDialog` / `openFormDialog`。
|
||||
- 使用 `table-select` / `fs-table-select` 时,`dict` 必须配置 `getNodesByValues`,支持根据已选 ID 批量懒加载选项,确保编辑回显和异步数据场景正常工作。
|
||||
|
||||
### 前端地图
|
||||
|
||||
@@ -182,6 +209,31 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
- 插件开发前先读对应技能:`.trae/skills/dns-provider-dev/SKILL.md`、`.trae/skills/task-plugin-dev/SKILL.md`、`.trae/skills/access-plugin-dev/SKILL.md`、`.trae/skills/plugin-converter/SKILL.md`。
|
||||
- `.codex/skills` 是指向 `.trae/skills` 的目录链接;更新技能只维护 `.trae/skills`,不要复制第二份。
|
||||
|
||||
### 插件市场身份与卡片操作
|
||||
|
||||
- 插件唯一身份是 `fullName`:有作者时为 `author/name`,内置插件为其 `name`。数据库判重、同步/安装匹配、前端列表 `key` 和 Fast Crud `rowKey` 都必须使用 `fullName`,不得退回仅按 `name` 匹配。
|
||||
- 新增或导入本地插件必须生成 `fullName`;旧插件若缺失 `fullName`,应在迁移或兼容更新时按作者和名称补齐,避免同名不同作者的插件互相覆盖或误判已安装。
|
||||
- 插件分为四类:内置插件(无作者,需通过内置插件接口获取)、导入/自开发插件(无 `developerId`)、云端同步未安装插件(有 `developerId` 且 `installed=false`)、云端已安装插件(有 `developerId` 且 `installed=true`)。
|
||||
- 云端插件是否属于当前用户,只能以当前站点绑定账号的 `userId === developerId` 判断;不要以 `localEditable` 或插件名称、作者名称等字段代替。
|
||||
- 插件管理卡片所有类型都必须有“配置”入口。导入/自开发插件应显示编辑、复制、发布、删除;当前用户自己的已安装云端插件应显示编辑、复制、发布,但云端插件一律不显示删除。云端插件按 `installed` 显示安装或卸载,且管理操作和卸载入口可同时存在,不能互相覆盖。
|
||||
|
||||
### 后置任务(AfterTask)机制
|
||||
|
||||
- 概念:流水线主体运行结束后,按触发条件执行的任务;插件声明 `supportAfterTask: true` 后即可在后置任务中复用(如吊销旧证书 `CertRevokeOld`)。触发条件 `when`:`success`(主体执行成功)、`error`(主体失败)、`turnToSuccess`(主体被手动标记成功)。
|
||||
- 实现位置:`packages/core/pipeline/src/core/executor.ts`(`runAfterTasks` / `runOneAfterTask` / `executeAfterTaskPlugin`)与 `plugin/api.ts`。
|
||||
- **后置任务与普通任务步骤的执行链路完全一致**:`executeAfterTaskPlugin` 构造无 strategy 的伪 Step 后调用共用 `executePlugin`;不要给 executePlugin 增加差异化参数——后置任务与普通插件唯一区别是触发时机判断(`matchAfterTaskWhen`)。
|
||||
- 后置任务失败:`runAfterTasks` 聚合错误(格式 `后置任务[标题]执行失败:...`)并 throw → `runWithHistory` 将流水线整体标记为失败 → 统一走 error 通知;不再单独发送后置任务失败通知。
|
||||
- 后置任务没有内置等待时长:需要等待时由插件自身处理(`AbstractTaskPlugin.sleep(ms)` + `checkSignal()`)。
|
||||
- 未触发/禁用:写日志并标记 skip/disabled(与任务同语义),状态存 `afterTask.status`,日志 key 为 `afterTask.{id}`。
|
||||
- 通知在流水线整体(主体 + 后置任务)执行完成后发送,内容附加后置任务执行结果摘要。
|
||||
|
||||
### 流水线执行与状态写入规则
|
||||
|
||||
- `run()` 时序:`notification("start")` → `runtime.start(pipeline)` → `runtime.clearNotificationStatus()`(start 时机状态不参与显示)→ `runWithHistory(主体 + 后置任务)` → 成功/失败通知。`clearNotificationStatus` 不得移到 `notification("start")` 之前。
|
||||
- **插件主动返回 "skip" 时,runStep 仍必须执行结果处理**(把实例输出属性写回 `step.status.output` / files 等):否则保存的 lastRuntime 缺少输出,下次运行按上次结果做判断时(如 SkipWhenSucceed、证书未过期判断 `readLastCert`)会读不到上次证书,导致不跳过、重新申请(曾出 bug:第二次运行状态丢失)。SkipWhenSucceed 策略跳过的步骤,输出/files 直接从 lastStatus 拷贝(executePlugin 返回 `skipped: true`),此时可跳过结果处理。
|
||||
- 事件机制:`emit.ts` 为泛型 `PipelineEmitter<T> implements TaskEmitter<T>`,`emit(name, event)` 直接传事件对象,不再包装 `{pipeline, step, event}`;`PipelineEvent<T> = { cert: T; runnableId: string; pipelineId: number }`(`plugin/api.ts`)。
|
||||
- 证书申请成功事件:`base-convert.ts` `emitCertApplySuccess` 发 `{ cert, pipelineId: ctx.pipeline.id, runnableId: ctx.step.id }`;后端监听在 `auto-pipeline-emitter-register.ts`,写入证书仓库(`updateCertByPipelineId(pipelineId, cert, fromType, taskId)`,userId/projectId 从 pipeline 实体读取)。
|
||||
|
||||
### 流水线与插件地图
|
||||
|
||||
- `packages/core/pipeline/src/index.ts`:核心导出入口,导出 `core`、`dt`、`access`、`registry`、`plugin`、`context`、`decorator`、`service`、`notification`。
|
||||
@@ -210,8 +262,23 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
- 后端业务数据、接口、实体、权限、迁移:改 `packages/ui/certd-server/src/modules` 与 `src/controller`。
|
||||
- 表单、列表、插件配置 UI:改 `packages/ui/certd-client/src/views/certd` 及对应 `src/api`。
|
||||
|
||||
## 注意事项
|
||||
## 其他注意事项
|
||||
|
||||
### 旧版数据兼容
|
||||
|
||||
- 新增插件参数时,必须要考虑旧版数据兼容,比如新增一个deployType参数,有两种值:`default`和`custom`,需要在使用时判空,走旧版逻辑。
|
||||
- 新增插件参数时,必须要考虑旧版数据兼容,比如新增一个deployType参数,有两种值:`default`和`custom`,需要在使用时判空,走旧版逻辑。
|
||||
|
||||
## 前端路由与国际化
|
||||
|
||||
- 路由 `meta.title` 是 **i18n 国际化 key**,必须在 `src/locales/langs/zh-CN/` 和 `src/locales/langs/en-US/` 对应的模块文件中添加翻译。
|
||||
- 示例:路由 `title: "certd.auditLog"` 需要在中英 locales 文件中有对应 key(`"certd.auditLog": "操作日志"` / `"certd.auditLog": "Audit Log"`)。
|
||||
- 菜单通过路由自动生成,需设置 `meta.isMenu: true` 才会出现在左侧菜单。
|
||||
- Plus 版功能菜单需设置 `meta.show: () => { const settingStore = useSettingStore(); return settingStore.isPlus; }`。
|
||||
|
||||
## 审计日志
|
||||
|
||||
- 审计日志是 Plus 版功能,非 Plus 版不会写入。
|
||||
- Controller 继承 `BaseController`,通过 `this.auditLog({ content: "xxx" })` 记录日志。
|
||||
- Controller 中的 `@Post("/add", { summary: "xxxx" })`, 这个summary是必须要的,他是日志action字段的来源
|
||||
- `getAuditType()` 返回类型常量,中间件自动从 ctx.path 判定 scope(`/api/sys/` → system,其他 → user)。
|
||||
- 操作日志有系统级(scope=system)和用户级(scope=user)区分。
|
||||
|
||||
+122
@@ -3,6 +3,128 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复插件市场插件安装无效的bug ([15d2bd8](https://github.com/certd/certd/commit/15d2bd8484a9ddd142bf4ef03d839c74405f9e6d))
|
||||
* preserve Route53 TXT record values ([#791](https://github.com/certd/certd/issues/791)) ([f5430a8](https://github.com/certd/certd/commit/f5430a8925cf6cf4e16c75f2358fbd78437e6705))
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复编辑证书申请任务导致清空ACME账号的bug ([d67379f](https://github.com/certd/certd/commit/d67379f29725bbd6dcdefe97d101023c7fd11f03))
|
||||
* 修复大量问题 ([f911e06](https://github.com/certd/certd/commit/f911e06a698d568ae228880ec89b7e25fdd1305b))
|
||||
* 修复旧流水线保存时清空证书仓库证书内容的bug ([f58f2f3](https://github.com/certd/certd/commit/f58f2f3cd603c37e55ac5868fdd2ca99aa937e44))
|
||||
* **pipeline-controller:** 处理非编辑场景下保留id的问题 ([6606f32](https://github.com/certd/certd/commit/6606f3267868e3aadaca0092ef4c83198312553c))
|
||||
* **pipeline:** 修正流水线保存时的ID判断逻辑并增加参数校验 ([6af12d4](https://github.com/certd/certd/commit/6af12d49fce1144943dc37ba574b3c9356998dcd))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **dashboard:** 新增仪表盘站点监控和域名管理的统计卡片 ([9842a24](https://github.com/certd/certd/commit/9842a24a50a841f710ee7c8050a38e3334c58394))
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复证书申请时EAB判断逻辑,已选择Acme账号的情况下不需要EAB ([d87821e](https://github.com/certd/certd/commit/d87821ee02b17136fc925df4547223332bf22823))
|
||||
* **pipeline:** 校验定时触发器cron表达式,拦截2月31号等无效日期 ([#785](https://github.com/certd/certd/issues/785)) [@yukaidi1220](https://github.com/yukaidi1220) ([f8e492d](https://github.com/certd/certd/commit/f8e492d6cb49257e56bbd06b28fd6eb32c923c28))
|
||||
* **plugin-ai-dev:** 优化AI插件测试流程并修复日志获取问题 ([faf9828](https://github.com/certd/certd/commit/faf9828168f6ce82adc530a65eaf313d6c9904b5))
|
||||
* **plugin-item-card:** 移除分数格式化时去掉.0的逻辑 ([a91d7c0](https://github.com/certd/certd/commit/a91d7c000a2aa6456a249433ff90a47787ec90f0))
|
||||
* **ssh:** 修复 keyboard-interactive 认证可能抛类型错误的问题 ([5e867c8](https://github.com/certd/certd/commit/5e867c86c30eb43f1e5aeee8c22d47596e173a8c))
|
||||
* **start.sh:** 优化前端zip包下载校验逻辑 ([15ee6ea](https://github.com/certd/certd/commit/15ee6ead49db3620d2ade641aadbfcb13ac3e6dc))
|
||||
* **tencent access:** 修复腾讯云国际站接口endpoint拼接问题 ([f614872](https://github.com/certd/certd/commit/f61487284f79d3f5a72c9f7eee04999a93728326))
|
||||
|
||||
### Features
|
||||
|
||||
* 实现自定义ACME证书颁发机构支持 ([97c417b](https://github.com/certd/certd/commit/97c417b1ad64b9a14bee8bfe98a2e789b9278820))
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
* 新增AI插件开发全流程能力 ([6f9dffe](https://github.com/certd/certd/commit/6f9dffe6e59cc125bd394d7db6086d66a9c71e49))
|
||||
* 支持审计日志,操作日志 ([f2855d6](https://github.com/certd/certd/commit/f2855d6dac74c9db71d92f99601e35c360b10666))
|
||||
* 支持在线插件下载安装审核等 ([255960d](https://github.com/certd/certd/commit/255960d434eebb3d567a08f737f1642ab417b5d0))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 统一镜像地址格式 certd/certd ([49007d3](https://github.com/certd/certd/commit/49007d391565099d94dec375eb88e946856506b0))
|
||||
* 新增AI插件开发受限令牌与隔离API ([3a9069f](https://github.com/certd/certd/commit/3a9069f6e6242940229fd63c686f7e445408dbbb))
|
||||
* 优化源码启动脚本,增加快速启动 ([0cfc3b7](https://github.com/certd/certd/commit/0cfc3b7ae90fa031d75f0b395556a04d4efee76c))
|
||||
* 统一将路由前缀从/certd改为/cert ([4db0f83](https://github.com/certd/certd/commit/4db0f83304dc6fd9199673c48b1805421f024601))
|
||||
* 新增官方客户端Certd Client的多语言配置与页面入口 ([fbfe125](https://github.com/certd/certd/commit/fbfe1256a5c8be6dfb2fbae7e6130eb5f6c6d946))
|
||||
* 新增证书吊销功能,添加证书状态字段 ([5074572](https://github.com/certd/certd/commit/5074572694383c8e1bf2880e26827987683dc62c))
|
||||
* 新增自定义ACME管理功能,完善证书颁发机构适配 ([2ab23f5](https://github.com/certd/certd/commit/2ab23f50b216e27495fc823d2269ca0ba6366d15))
|
||||
* 移除全局反向代理配置,迁移到自定义ACME配置 ([c57110e](https://github.com/certd/certd/commit/c57110e42aada31dd0028b56b5baec1b19722525))
|
||||
* 在线插件支持通知类型 ([c0f194f](https://github.com/certd/certd/commit/c0f194f4438bd7ca3f52f63ca088543abde7428c))
|
||||
* 增加稳定版,预览版区分,预览版是最新版,稳定版在预览版验证稳定后,再将预览版转为稳定版 ([1574090](https://github.com/certd/certd/commit/15740904e5e97ee767c39e4401dbc1d645795c38))
|
||||
* 支持AI开发在线插件 ([eae7edb](https://github.com/certd/certd/commit/eae7edbda0fd7357163f33fb4084fcba21396f5f))
|
||||
* **domain:** 域名管理增加remark字段 ([e4041ea](https://github.com/certd/certd/commit/e4041eabece1bab2c1c4b955129cf7a7fe7302d0))
|
||||
* **login,settings:** 仅第三方登录开启后,在重启10分钟后将无法使用密码登录 ([1d12ad7](https://github.com/certd/certd/commit/1d12ad73c6ca827d2eb36bda867f9d9fe62dab48))
|
||||
* **plugin:** 插件安装支持检测依赖 ([e79660c](https://github.com/certd/certd/commit/e79660c5d17ed4049508a00a359950a54470332f))
|
||||
* **plugin:** 新增插件评分展示、排序与个人插件筛选功能 ([7ab5029](https://github.com/certd/certd/commit/7ab502991e0c001777b268b9f159311ef5e000a0))
|
||||
* **server:** response优化为gzip压缩 ([f38522c](https://github.com/certd/certd/commit/f38522ca0b2185f7f35538f53cb51dcf86d0bfc8))
|
||||
* **tencent-clb:** 重构腾讯云CLB部署插件的地域选择组件,改为远程下拉选择器 ([30d4fc8](https://github.com/certd/certd/commit/30d4fc84a9e940d1d07e1f25856eb6fa13da9ba1))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 优化火山引擎dcdn,单个单个部署,规避超时问题 ([cf008b9](https://github.com/certd/certd/commit/cf008b9cff54542dc551fb159578dbd49006229c))
|
||||
* **pipeline:** 修复过期/损坏的安装锁导致的依赖安装失败 ([1017a38](https://github.com/certd/certd/commit/1017a38eb48d6778745d4905d9ec4f6067a261d8))
|
||||
* **ui:** 修复oauth设置页保存后未正确刷新的问题 ([8ab699a](https://github.com/certd/certd/commit/8ab699a9104b47e02a88ad764e995a495deb775e))
|
||||
* **unicloud:** 修复unicloud登录接口改版后请求失败的问题 ([18583f6](https://github.com/certd/certd/commit/18583f6ce025fa8d365fad5a8c9c6020f3bcfbae))
|
||||
|
||||
### Features
|
||||
|
||||
* 优化开放接口证书自动申请功能 ([6339cdf](https://github.com/certd/certd/commit/6339cdffa9f79505dada5617d8c7fcfacc419a2e))
|
||||
* **pipeline:** 新增流水线批量编辑、阶段宽度调整功能 ([e5a1844](https://github.com/certd/certd/commit/e5a18448522be88bc504a45a46ad599f3c39ca30))
|
||||
* **ssh:** ssh连接支持跳板机 ([2cb4e55](https://github.com/certd/certd/commit/2cb4e551fa7616f587a2d907f9b513f7df3a9957))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 部署到京东云CDN支持设置跳转类型 ([e992192](https://github.com/certd/certd/commit/e9921928df011c2435af73b68ebc3d773f13c16a))
|
||||
* 统一通知类型常量管理,新增开放接口通知能力 ([edb249c](https://github.com/certd/certd/commit/edb249c2ad3c173dbfd04d8d156f895a6706ffd1))
|
||||
* 优化宝塔报错提示 ([1cfa766](https://github.com/certd/certd/commit/1cfa76683b40da9e9198552f2226419d4afa86a3))
|
||||
* 优化第三方登录设置提示 ([1468eb1](https://github.com/certd/certd/commit/1468eb1ef292a9e21a2a43131b86b8acb3688307))
|
||||
* 优化第三方依赖安装失败日志输出 ([18b2d3a](https://github.com/certd/certd/commit/18b2d3ac2078d0c6636af897f8f66e4eb5ff832c))
|
||||
* 优化SqliteError: disk I/O error报错修复提示 ([7d22fe3](https://github.com/certd/certd/commit/7d22fe3d7d0de97bae2ceedd051a0c16a1cd90e3))
|
||||
* 证书成功通知增加有效期显示 ([f1b6704](https://github.com/certd/certd/commit/f1b67049d16ebcabe6d6d9b6cb53a8a2b7a9265c))
|
||||
* **cert-plugin:** 证书申请支持失败重试 ([0ddb1f6](https://github.com/certd/certd/commit/0ddb1f69d2a46b993e792862ce9952336d532f5b))
|
||||
* Improve English localization coverage ([#778](https://github.com/certd/certd/issues/778)) @MephistoJB ([bd82147](https://github.com/certd/certd/commit/bd821474969a22078b9d2e73518e765b8c9a26c7))
|
||||
* **install:** 补充迁移到PostgreSQL后,表自增序列从1开始的修复SQL脚本和说明 ([80374ca](https://github.com/certd/certd/commit/80374cae8a43fd17eb180381a093267e466a0bc2))
|
||||
* **plugin-ucloud:** ucloud us3部署插件增加region选择 ([8165a5f](https://github.com/certd/certd/commit/8165a5ff8986e759948c06f924d12e605b2ec8f6))
|
||||
* **tencent-clb:** 为腾讯云CLB部署插件支持远程选择输入 ([967846b](https://github.com/certd/certd/commit/967846bef590b76d27e472180b96372ff55cfb6f))
|
||||
* **ui:** 域名选择器支持点击复制域名 ([b173311](https://github.com/certd/certd/commit/b173311468d2c1f318d1333257c3ebcf916da071))
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复正常批量删除流水线报权限不足的bug ([5b50083](https://github.com/certd/certd/commit/5b500830a122c6c42dab054e57fed509050f94da))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 优化动态加载依赖镜像地址,多次重试 ([5f53b81](https://github.com/certd/certd/commit/5f53b81c75dd242b4260ac08cae14c6d1a08a883))
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
|
||||
* 修复dingtalk通知格式没有换行的bug ([7ed1be9](https://github.com/certd/certd/commit/7ed1be994f8b4b74cdeb38743060c912c027248b))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim ([b91c9e4](https://github.com/certd/certd/commit/b91c9e4ea671cb359ef164e27864de1d66cba9d3))
|
||||
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复火山引擎查不到自定义源站域名的问题 ([02dabe1](https://github.com/certd/certd/commit/02dabe11db3e9b13ca4621ce9ddd2b808bfca390))
|
||||
* 修复火山引擎自定义源站域名查询不到的问题 ([e44bf9d](https://github.com/certd/certd/commit/e44bf9d77375d48ac7fd1582e69fae02dfd248fa))
|
||||
* **pipeline:** 重构运行时依赖加载逻辑,修复火山引擎DNS解析报runtimeDepsService未初始化的bug ([ec69b8f](https://github.com/certd/certd/commit/ec69b8f11bfd4b20991aef74a72a47182ca79a9d))
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 自定义 ACME 功能开发计划
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
Certd 目前仅支持内置 CA(Let's Encrypt / Google / ZeroSSL / SSL.com / litessl / LE 测试环境)。
|
||||
目标:支持用户**自定义 ACME 证书颁发机构**——用户可填写任意符合 RFC 8555 的 ACME Directory URL
|
||||
(内网 CA、企业 CA、其他公共 ACME 服务商),可选 EAB 授权,走完整的申请、续期、吊销流程。
|
||||
|
||||
## 二、需求确认(已与用户确认)
|
||||
|
||||
1. **Directory URL 填写位置**:在「ACME账号授权」中填写(账号与 CA 绑定,续期自动复用账号)。
|
||||
- 代码中 `directoryUrl` 字段、`caType === "custom"` 分支、`AcmeService.getAcmeClientByAccount()` 读取 `account.directoryUrl` 均已预留,架构改动最小。
|
||||
2. **不做系统级公共自定义 CA 账号**:仅用户自建自己的 ACME 账号。
|
||||
3. **自定义 CA 支持 EAB,选填**:填了就用,不填则跳过。
|
||||
4. **吊销链路改造**(用户追加要求):证书写入仓库时同时把 `acmeAccountAccessId` 记录到
|
||||
`cd_cert_info` 表,吊销时直接读表,**不再查询流水线**(旧数据回退兼容)。
|
||||
|
||||
## 三、现状分析
|
||||
|
||||
### 已有基础(无需改动或已预留)
|
||||
|
||||
| 位置 | 现状 |
|
||||
| --- | --- |
|
||||
| `@certd/acme-client` | 底层 `new Client({ directoryUrl })` 直接支持任意 URL,无需改动 |
|
||||
| `AcmeAccountAccess.directoryUrl` 字段 | 已定义,但 `mergeScript` 里 `show: false` 隐藏 |
|
||||
| `AcmeAccountAccess.getDirectoryUrl()` | 已有 `caType === "custom"` 分支:校验必填并返回 `directoryUrl` |
|
||||
| `AcmeAccountInfo` 类型 | 已含 `directoryUrl`、`eab` 字段 |
|
||||
| `AcmeService.getAcmeClientByAccount()` | 已支持 `account.directoryUrl \|\| acme.getDirectoryUrl(...)` |
|
||||
| `AcmeAccountAccess.createAccountInfo()` | EAB 已选填(`eabKid && eabHmacKey` 才构造),custom 不强制 EAB |
|
||||
| 前端 `Dicts.sslProviderDict` | 已含 `custom`("自定义ACME")选项 |
|
||||
|
||||
### 缺口(本次要开发)
|
||||
|
||||
1. `AcmeAccountAccess.caType` 下拉无 `custom` 选项;`directoryUrl` 被隐藏、无必填联动。
|
||||
2. `AcmeAccountAccess` EAB 字段 `show` 条件不含 custom;custom 下应为选填(内置 CA 保持必填)。
|
||||
3. `AcmeService.getAcmeClient()`(无账号临时建账号路径,生成账号时必走)用 `acme.getDirectoryUrl({ sslProvider: "custom" })` 会抛错,需要支持 `directoryUrl` 参数。
|
||||
4. `CertApplyPlugin.sslProvider` 下拉无 `custom` 选项;custom 时 ACME 账号必选、preferredChain/certProfile 显隐、DNS 持久验证限制。
|
||||
5. 吊销链路:`CertInfoService.revoke` 依赖 `resolveRevokeParams` 查流水线解析参数,需改为读表。
|
||||
6. 数据库迁移:`cd_cert_info` 新增 `acme_account_access_id` 列。
|
||||
|
||||
## 四、改动清单
|
||||
|
||||
### 4.1 数据层:cd_cert_info 新增 ACME 账号列
|
||||
|
||||
- **迁移 SQL**:`packages/ui/certd-server/db/migration/v10055__cert_info_acme_account.sql`
|
||||
|
||||
```sql
|
||||
-- 证书仓库记录增加ACME账号授权id:吊销旧证书时直接使用记录上的账号,无需再解析流水线配置
|
||||
ALTER TABLE cd_cert_info ADD COLUMN acme_account_access_id bigint NULL;
|
||||
```
|
||||
|
||||
- **Entity**:`packages/ui/certd-server/src/modules/monitor/entity/cert-info.ts`
|
||||
- 新增字段 `acmeAccountAccessId`(映射 `acme_account_access_id`,nullable)。
|
||||
|
||||
### 4.2 ACME账号授权:支持自定义CA(`access/acme-account-access.ts`)
|
||||
|
||||
1. `caType` 下拉 options 增加 `{ value: "custom", label: "自定义ACME" }`(放首位或末位均可,建议末位并注明"需自备 ACME 服务")。
|
||||
2. `directoryUrl`:
|
||||
- `mergeScript` 改为 `show: ctx.compute(({ form }) => form.access?.caType === "custom")`;
|
||||
- 动态 `required`(custom 时必填);
|
||||
- helper 说明:填写 `https://` 开头的 Directory URL,如内网 CA 端点。
|
||||
3. EAB 字段(`eabKid` / `eabHmacKey`):
|
||||
- `show` 条件增加 `custom`;
|
||||
- custom 下**选填**(mergeScript 动态 `required: false`),内置 CA 保持必填;
|
||||
- `onGenerateAccount()` 中 `needEab` 列表**不加** custom(已满足选填语义)。
|
||||
4. `getDirectoryUrl()` 已有 custom 分支,确认校验信息友好即可。
|
||||
|
||||
### 4.3 AcmeService 支持自定义 Directory URL(`plugin/cert-plugin/acme.ts`)
|
||||
|
||||
1. `AcmeServiceOptions` 增加 `directoryUrl?: string`。
|
||||
2. `getAcmeClient(email)`:
|
||||
- `const directoryUrl = this.options.directoryUrl || acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType })`;
|
||||
- custom 时若未传 `directoryUrl` 给出明确报错("自定义ACME需要填写Directory URL")。
|
||||
3. `revokeCert` 方式二(证书私钥方式)同样优先用 `this.options.directoryUrl`。
|
||||
4. `buildUrlMapping`:自定义域名不在内置映射表,天然走直连/用户反代,无需改动。
|
||||
|
||||
### 4.4 证书申请任务支持自定义CA(`plugin/cert-plugin/apply.ts`)
|
||||
|
||||
1. `sslProvider` 下拉 options 增加 `{ value: "custom", label: "自定义ACME", icon: "..." }`。
|
||||
2. `onInit()` 构造 `AcmeService` 时:
|
||||
- 若 `sslProvider === "custom"`,读取 `this.acmeAccountAccessId` 对应 access 的 `account.directoryUrl`,传入 `AcmeServiceOptions.directoryUrl`;
|
||||
- custom 且未选 ACME 账号时直接报错("自定义ACME必须选择对应的ACME账号")。
|
||||
3. 校验:
|
||||
- `doCertApply()` 中 custom 时强制 `acmeAccount`(version 1 也强制,version 2 已有强制逻辑);
|
||||
- `sslProvider === "custom"` 且 `challengeType === "dns-persist"` 时提示不支持(dns-persist 是 Let's Encrypt 特有机制)。
|
||||
4. 表单显隐(现有 mergeScript 天然兼容,需回归确认):
|
||||
- `preferredChain`:custom 不在 `preferredChainSupportedProviders`,`show: false` ✓;
|
||||
- `certProfile`:仅 letsencrypt 显示 ✓;
|
||||
- `getCommonAcmeAccount()`:custom 时 `${sslProvider}CommonAcmeAccountAccessId` 不存在,返回 null ✓。
|
||||
5. `getCheckChangeInputKeys()` 已含 `sslProvider`,切换 CA 会触发重新申请 ✓。
|
||||
|
||||
### 4.5 吊销链路改造(核心,用户指定方案)
|
||||
|
||||
**写入侧**:`packages/ui/certd-server/src/modules/monitor/service/cert-info-service.ts`
|
||||
|
||||
1. `updateCertByPipelineId(pipelineId, cert, fromType, taskId)`:
|
||||
- 已查询 `pipeline` 实体,从 `pipeline.content` 解析证书申请任务输入(复用/提取 `parseCertApplyInput` 为模块内方法),取出 `acmeAccountAccessId`;
|
||||
- 传入 `updateCert()`,写入记录(含占位记录复用更新场景)。
|
||||
2. `UploadCertReq` 增加 `acmeAccountAccessId?: number`;`updateCert()` 落库该字段。
|
||||
|
||||
**吊销侧**:`CertInfoService.revoke(id, userId, projectId)`
|
||||
|
||||
1. 读 `entity.acmeAccountAccessId`:
|
||||
- **有值(新数据)**:`accessService.getAccessById(acmeAccountAccessId, true, userId, projectId)` → `access.getAccount()` 得到 `acmeAccount`,`sslProvider = acmeAccount.caType`,`useProxy/reverseProxy` 不传(`resolveUrlMapping` 有"直连失败自动启用代理"兜底)→ `acmeService.revokeCert({ cert, acmeAccount })`,**不再查流水线**;
|
||||
- **为空(旧数据 / upload 来源)**:回退现有 `resolveRevokeParams` 查流水线逻辑,保证旧版数据可吊销。
|
||||
2. `createAcmeService()` 不再需要从流水线取 `useProxy/reverseProxy`(保留参数兼容即可)。
|
||||
|
||||
**事件链路**:`modules/auto/auto-pipeline-emitter-register.ts` 无需改动
|
||||
(`updateCertByPipelineId` 内部自行解析流水线配置落库)。
|
||||
|
||||
### 4.6 前端
|
||||
|
||||
- `Dicts.sslProviderDict` 已有 `custom`,无需改;
|
||||
- ACME账号授权、证书申请任务的表单 schema 均来自后端插件定义(`@AccessInput` / `@TaskInput`),前端组件零改动;
|
||||
- 回归确认 `icon-select`(sslProvider)与 `a-select`(caType)能正常渲染新增选项。
|
||||
|
||||
## 五、实施顺序(TDD)
|
||||
|
||||
1. **写测试(RED)**:
|
||||
- `acme-account-access.test.ts`:custom + directoryUrl 生成账号(含 EAB 选填)、缺 directoryUrl 报错;
|
||||
- `acme.test.ts`:`AcmeService` 传/不传 `directoryUrl` 时 `getAcmeClient` 使用正确端点;
|
||||
- `apply.test.ts`:custom + ACME账号 申请链路(mock acme client)、custom 未选账号报错;
|
||||
- `cert-info-service.test.ts`:写入时 `acmeAccountAccessId` 落库;吊销读表(mock accessService)不再解析流水线;旧数据(列为空)回退旧逻辑。
|
||||
2. **4.1 数据层**(迁移 SQL + entity)。
|
||||
3. **4.3 AcmeService directoryUrl 支持**。
|
||||
4. **4.2 ACME账号授权 custom 支持**。
|
||||
5. **4.4 证书申请任务 custom 支持**。
|
||||
6. **4.5 吊销链路改造**。
|
||||
7. **4.6 前端回归验证**。
|
||||
8. 全量单测 + lint。
|
||||
|
||||
## 六、测试与验证
|
||||
|
||||
- 后端单测:`cd packages\ui\certd-server && npm run test:unit`(聚焦 `cert-info-service`、`acme-account-access`、`acme`、`apply` 相关测试);
|
||||
- 改动文件 lint:`cd packages\ui\certd-server && npm run lint`;
|
||||
- 手动验证:新建 caType=custom 的 ACME 账号(填 Directory URL,可选 EAB)→ 生成账号 → 证书申请任务选「自定义ACME」+ 该账号 → 申请成功 → 吊销旧证书(观察不再解析流水线)。
|
||||
|
||||
## 七、风险与兼容性
|
||||
|
||||
1. **旧版数据兼容**:`acme_account_access_id` 列可空,旧记录吊销回退查流水线逻辑,行为不变。
|
||||
2. **dns-persist 限制**:自定义 CA 不支持 DNS 持久验证(LE 特有 `_validation-persist` 机制),申请任务做显式校验提示。
|
||||
3. **代理/反代**:吊销时不再携带任务级 `useProxy/reverseProxy`,依赖 `resolveUrlMapping` 直连失败自动启用代理的兜底;自定义 CA 通常内网/直连可达,影响可接受。若后续需要可在表中再增列。
|
||||
4. **无数据库以外的结构变更**;`updateDomains` 占位记录不强制写账号列(申请成功更新时补齐)。
|
||||
5. 内置 CA 行为完全不变(`directoryUrl` 为空时走原逻辑)。
|
||||
@@ -1,4 +1,4 @@
|
||||
# Certd
|
||||
# Certd
|
||||
|
||||
中文 | [English](./README_en.md)
|
||||
|
||||
@@ -105,31 +105,57 @@ https://certd.handfree.work/
|
||||
|
||||
#### Docker镜像说明:
|
||||
|
||||
**镜像版本:**
|
||||
##### 1. 镜像地址格式:
|
||||
|
||||
| 版本标签 | 基础系统 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `latest` / `[version]` | Alpine Linux | 默认版本,镜像体积小 |
|
||||
| `slim` / `[version]-slim` | Debian slim | 基于glibc,dns解析兼容性好(可能需要配置security_opt -seccomp=unconfined) |
|
||||
| `armv7` / `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
|
||||
```
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
|
||||
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型 (中括号表示可选)
|
||||
|
||||
**镜像地址:**
|
||||
```
|
||||
##### 2. 版本标签:
|
||||
|
||||
| 镜像仓库 | latest | slim | armv7 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阿里云 | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7` |
|
||||
| Docker Hub | `greper/certd:latest` | `greper/certd:slim` | `greper/certd:armv7` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `ghcr.io/certd/certd:slim` | `ghcr.io/certd/certd:armv7` |
|
||||
**最新版本标签:**
|
||||
|
||||
> 带版本号的标签请将 `latest` / `slim` / `armv7` 替换为 `[version]` / `[version]-slim` / `[version]-armv7`
|
||||
| 版本 | 标签 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 最新预览版【默认】 | `certd:latest` | 指向最新开发版本,包含最新功能,但稳定性不如稳定版 |
|
||||
| 最新稳定版 | `certd:stable` | 指向经过充分测试的生产就绪版本,推荐生产环境使用 |
|
||||
|
||||
**系统分支版本:**
|
||||
|
||||
> 根据基础镜像不同,分为如下三个分支版本,没有特殊需求选择默认的即可(他们功能是一样的)
|
||||
|
||||
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 | 指定版本 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小 | `certd:stable` | `certd:1.43.0` |
|
||||
| slim | `certd:slim` | Debian slim | 基于glibc,dns解析兼容性好 | `certd:slim-stable` | `certd:1.43.0-slim` |
|
||||
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` | `certd:1.43.0-armv7` |
|
||||
|
||||
##### 3. 镜像地址:
|
||||
|
||||
| 镜像仓库 | 最新预览版 | slim | armv7 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
| Docker Hub | `certd/certd:latest` | `certd:slim` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
|
||||
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
|
||||
> 2. 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
|
||||
|
||||
最新版本号:
|
||||

|
||||
|
||||
|
||||
##### 3. 镜像构建说明:
|
||||
|
||||
- 镜像构建通过`Actions`自动执行,过程公开透明,请放心使用
|
||||
- [点我查看镜像构建日志](https://github.com/certd/certd/actions/workflows/build-image.yml)
|
||||
- [点我查看预览版构建日志](https://github.com/certd/certd/actions/workflows/release-image.yml)
|
||||
- [点我查看稳定版发布日志](https://github.com/certd/certd/actions/workflows/stable-release.yml)
|
||||
|
||||

|
||||
|
||||
> 注意:
|
||||
>
|
||||
##### 4. 安全注意事项:
|
||||
|
||||
> - 本应用存储的证书、授权信息等属于高度敏感数据,请做好安全防护
|
||||
> - 请务必使用HTTPS协议访问本应用,避免被中间人攻击
|
||||
> - 请务必使用web应用防火墙防护本应用,防止XSS、SQL注入等攻击
|
||||
@@ -139,7 +165,14 @@ https://certd.handfree.work/
|
||||
|
||||
## 五、生态
|
||||
|
||||
### 1. 客户端工具 SSL-Assistant
|
||||
### 1. 官方客户端 Certd Client
|
||||
|
||||
`Certd Client` 是 Certd 官方证书部署客户端,运行于应用服务器上。自动发现本机 Nginx、Apache 和 IIS 站点,从 Certd 获取新证书并部署到本机。适用于不希望服务器开放 SSH、无法由 Certd 直接访问目标服务器的场景。
|
||||
|
||||
项目主页:[AtomGit](https://atomgit.com/certd/certd-client/) | [GitHub](https://github.com/certd/certd-client/)
|
||||
下载地址:[AtomGit Releases](https://atomgit.com/certd/certd-client/releases) | [GitHub Releases](https://github.com/certd/certd-client/releases)
|
||||
|
||||
### 2. 第三方客户端 SSL-Assistant
|
||||
|
||||
`SSL Assistant` 是一个运行于主机上的证书部署管理助手客户端。
|
||||
支持自动扫描主机`Nginx`配置,然后从`Certd`拉取证书并部署。
|
||||
|
||||
+44
-14
@@ -1,4 +1,4 @@
|
||||
# Certd
|
||||
# Certd
|
||||
|
||||
[中文](./README.md) | English
|
||||
|
||||
@@ -95,21 +95,44 @@ You can choose one of the following deployment methods based on your needs:
|
||||
|
||||
#### Docker Image Information:
|
||||
|
||||
- Domestic Image Addresses:
|
||||
- `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest`
|
||||
- `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7`, `[version]-armv7`
|
||||
- DockerHub Addresses:
|
||||
- `https://hub.docker.com/r/greper/certd`
|
||||
- `greper/certd:latest`
|
||||
- `greper/certd:armv7`, `greper/certd:[version]-armv7`
|
||||
- GitHub Packages Addresses:
|
||||
**Release channels:**
|
||||
|
||||
- `ghcr.io/certd/certd:latest`
|
||||
- `ghcr.io/certd/certd:armv7`, `ghcr.io/certd/certd:[version]-armv7`
|
||||
| Channel | Description |
|
||||
| --- | --- |
|
||||
| `stable` / `slim-stable` | **Stable version**, production-ready and fully tested, recommended for production environments |
|
||||
| `latest` / `slim` / `armv7` | **Preview version**, latest development build with newest features but potentially less stable |
|
||||
|
||||
**Image tags:**
|
||||
|
||||
| Channel | Tag | Versioned Tag | Base System | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Stable** | `stable` | `[version]-stable` | Alpine Linux | Recommended for production |
|
||||
| | `slim-stable` | `[version]-slim-stable` | Debian slim | Better DNS resolution compatibility |
|
||||
| **Preview** | `latest` | `[version]` | Alpine Linux | Default, small image size |
|
||||
| | `slim` | `[version]-slim` | Debian slim | Better DNS resolution compatibility |
|
||||
| | `armv7` | `[version]-armv7` | Alpine Linux | ARMv7 architecture |
|
||||
|
||||
**Stable version image addresses:**
|
||||
|
||||
| Registry | `stable` | `slim-stable` |
|
||||
| --- | --- | --- |
|
||||
| Aliyun | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:stable` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim-stable` |
|
||||
| Docker Hub | `greper/certd:stable` | `greper/certd:slim-stable` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:stable` | `ghcr.io/certd/certd:slim-stable` |
|
||||
|
||||
**Preview version image addresses:**
|
||||
|
||||
| Registry | `latest` | `slim` | `armv7` |
|
||||
| --- | --- | --- | --- |
|
||||
| Aliyun | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7` |
|
||||
| Docker Hub | `greper/certd:latest` | `greper/certd:slim` | `greper/certd:armv7` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `ghcr.io/certd/certd:slim` | `ghcr.io/certd/certd:armv7` |
|
||||
|
||||
> For versioned tags, replace tag name with `[version]-tag`, e.g. replace `stable` with `[version]-stable`
|
||||
|
||||
- Images are built automatically by `Actions`, with a transparent process. Please use them with confidence.
|
||||
- [Click here to view image build logs](https://github.com/certd/certd/actions/workflows/build-image.yml)
|
||||
|
||||
- [Click here to view preview version build logs](https://github.com/certd/certd/actions/workflows/release-image.yml)
|
||||
- [Click here to view stable version release logs](https://github.com/certd/certd/actions/workflows/stable-release.yml)
|
||||

|
||||
|
||||
> Note:
|
||||
@@ -123,7 +146,14 @@ You can choose one of the following deployment methods based on your needs:
|
||||
|
||||
## 5. Ecosystem
|
||||
|
||||
### 1. Client Tool: SSL-Assistant
|
||||
### 1. Official Client: Certd Client
|
||||
|
||||
`Certd Client` is the official Certd certificate deployment client that runs on application servers. It automatically discovers local Nginx, Apache, and IIS sites, retrieves new certificates from Certd, and deploys them locally. It is designed for servers that should not expose SSH or cannot be accessed directly by Certd.
|
||||
|
||||
Project Home: [AtomGit](https://atomgit.com/certd/certd-client/) | [GitHub](https://github.com/certd/certd-client/)
|
||||
Downloads: [AtomGit Releases](https://atomgit.com/certd/certd-client/releases) | [GitHub Releases](https://github.com/certd/certd-client/releases)
|
||||
|
||||
### 2. Third-Party Client: SSL-Assistant
|
||||
|
||||
`SSL Assistant` is a certificate deployment and management assistant client that runs on hosts. It supports automatic scanning of the host's `Nginx` configuration and pulling certificates from `Certd` for deployment. This tool is very useful when you don't want to expose your SSH host password.
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ version: '3.3' # 兼容旧版docker-compose
|
||||
services:
|
||||
certd:
|
||||
# 镜像 # ↓↓↓↓↓ ---- 镜像版本号,建议改成固定版本号,例如:certd:1.29.0
|
||||
image: registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
|
||||
image: registry.cn-shenzhen.aliyuncs.com/certd/certd:latest # 稳定版为 certd/certd:stable
|
||||
# image: ghcr.io/certd/certd:latest # --------- 如果 报镜像not found,可以尝试其他镜像源
|
||||
# image: greper/certd:latest
|
||||
# image: certd/certd:latest
|
||||
# --------- 生产建议使用稳定版, latest改成stable即可
|
||||
# image: registry.cn-shenzhen.aliyuncs.com/certd/certd:stable
|
||||
|
||||
|
||||
# security_opt: # --------- 如果slim镜像下启动报错,尝试去掉这两行注释
|
||||
# - seccomp=unconfined # 解决slim镜像下WorkerThreadsTaskRunner::DelayedTaskScheduler::Start() 报错问题
|
||||
container_name: certd # 容器名
|
||||
@@ -73,7 +77,13 @@ services:
|
||||
# - certd_typeorm_dataSource_default_password=yourpasswd # 密码
|
||||
# - certd_typeorm_dataSource_default_database=certd # 数据库名
|
||||
|
||||
# ↓↓↓↓ --------------------------------------------------------- 自动升级,上面certd的版本号要保持为latest
|
||||
# #↓↓↓↓ ---------------------------- 如果数据库服务端开启了加密传输 require_secure_transport-ON
|
||||
# - certd_typeorm_dataSource_default_ssl_rejectUnauthorized=false # 忽略证书校验,或者配置下面的三项路径
|
||||
# - certd_typeorm_dataSource_default_ssl_ca # 证书CA路径【如果上面false,无需配置】
|
||||
# - certd_typeorm_dataSource_default_ssl_key # 私钥路径【如果上面false,无需配置】
|
||||
# - certd_typeorm_dataSource_default_ssl_cert # 证书路径【如果上面false,无需配置】
|
||||
|
||||
# ↓↓↓↓ --------------------------------------------------------- 自动升级,上面certd的版本号要保持为latest / stable
|
||||
# certd-updater: # 添加 Watchtower 服务
|
||||
# image: containrrr/watchtower:latest
|
||||
# container_name: certd-updater
|
||||
|
||||
@@ -3,6 +3,127 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复编辑证书申请任务导致清空ACME账号的bug ([d67379f](https://github.com/certd/certd/commit/d67379f29725bbd6dcdefe97d101023c7fd11f03))
|
||||
* 修复大量问题 ([f911e06](https://github.com/certd/certd/commit/f911e06a698d568ae228880ec89b7e25fdd1305b))
|
||||
* 修复旧流水线保存时清空证书仓库证书内容的bug ([f58f2f3](https://github.com/certd/certd/commit/f58f2f3cd603c37e55ac5868fdd2ca99aa937e44))
|
||||
* **pipeline-controller:** 处理非编辑场景下保留id的问题 ([6606f32](https://github.com/certd/certd/commit/6606f3267868e3aadaca0092ef4c83198312553c))
|
||||
* **pipeline:** 修正流水线保存时的ID判断逻辑并增加参数校验 ([6af12d4](https://github.com/certd/certd/commit/6af12d49fce1144943dc37ba574b3c9356998dcd))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **dashboard:** 新增仪表盘站点监控和域名管理的统计卡片 ([9842a24](https://github.com/certd/certd/commit/9842a24a50a841f710ee7c8050a38e3334c58394))
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复证书申请时EAB判断逻辑,已选择Acme账号的情况下不需要EAB ([d87821e](https://github.com/certd/certd/commit/d87821ee02b17136fc925df4547223332bf22823))
|
||||
* **pipeline:** 校验定时触发器cron表达式,拦截2月31号等无效日期 ([#785](https://github.com/certd/certd/issues/785)) [@yukaidi1220](https://github.com/yukaidi1220) ([f8e492d](https://github.com/certd/certd/commit/f8e492d6cb49257e56bbd06b28fd6eb32c923c28))
|
||||
* **plugin-ai-dev:** 优化AI插件测试流程并修复日志获取问题 ([faf9828](https://github.com/certd/certd/commit/faf9828168f6ce82adc530a65eaf313d6c9904b5))
|
||||
* **plugin-item-card:** 移除分数格式化时去掉.0的逻辑 ([a91d7c0](https://github.com/certd/certd/commit/a91d7c000a2aa6456a249433ff90a47787ec90f0))
|
||||
* **ssh:** 修复 keyboard-interactive 认证可能抛类型错误的问题 ([5e867c8](https://github.com/certd/certd/commit/5e867c86c30eb43f1e5aeee8c22d47596e173a8c))
|
||||
* **start.sh:** 优化前端zip包下载校验逻辑 ([15ee6ea](https://github.com/certd/certd/commit/15ee6ead49db3620d2ade641aadbfcb13ac3e6dc))
|
||||
* **tencent access:** 修复腾讯云国际站接口endpoint拼接问题 ([f614872](https://github.com/certd/certd/commit/f61487284f79d3f5a72c9f7eee04999a93728326))
|
||||
|
||||
### Features
|
||||
|
||||
* 实现自定义ACME证书颁发机构支持 ([97c417b](https://github.com/certd/certd/commit/97c417b1ad64b9a14bee8bfe98a2e789b9278820))
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
* 新增AI插件开发全流程能力 ([6f9dffe](https://github.com/certd/certd/commit/6f9dffe6e59cc125bd394d7db6086d66a9c71e49))
|
||||
* 支持审计日志,操作日志 ([f2855d6](https://github.com/certd/certd/commit/f2855d6dac74c9db71d92f99601e35c360b10666))
|
||||
* 支持在线插件下载安装审核等 ([255960d](https://github.com/certd/certd/commit/255960d434eebb3d567a08f737f1642ab417b5d0))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 统一镜像地址格式 certd/certd ([49007d3](https://github.com/certd/certd/commit/49007d391565099d94dec375eb88e946856506b0))
|
||||
* 新增AI插件开发受限令牌与隔离API ([3a9069f](https://github.com/certd/certd/commit/3a9069f6e6242940229fd63c686f7e445408dbbb))
|
||||
* 优化源码启动脚本,增加快速启动 ([0cfc3b7](https://github.com/certd/certd/commit/0cfc3b7ae90fa031d75f0b395556a04d4efee76c))
|
||||
* 统一将路由前缀从/certd改为/cert ([4db0f83](https://github.com/certd/certd/commit/4db0f83304dc6fd9199673c48b1805421f024601))
|
||||
* 新增官方客户端Certd Client的多语言配置与页面入口 ([fbfe125](https://github.com/certd/certd/commit/fbfe1256a5c8be6dfb2fbae7e6130eb5f6c6d946))
|
||||
* 新增证书吊销功能,添加证书状态字段 ([5074572](https://github.com/certd/certd/commit/5074572694383c8e1bf2880e26827987683dc62c))
|
||||
* 新增自定义ACME管理功能,完善证书颁发机构适配 ([2ab23f5](https://github.com/certd/certd/commit/2ab23f50b216e27495fc823d2269ca0ba6366d15))
|
||||
* 移除全局反向代理配置,迁移到自定义ACME配置 ([c57110e](https://github.com/certd/certd/commit/c57110e42aada31dd0028b56b5baec1b19722525))
|
||||
* 在线插件支持通知类型 ([c0f194f](https://github.com/certd/certd/commit/c0f194f4438bd7ca3f52f63ca088543abde7428c))
|
||||
* 增加稳定版,预览版区分,预览版是最新版,稳定版在预览版验证稳定后,再将预览版转为稳定版 ([1574090](https://github.com/certd/certd/commit/15740904e5e97ee767c39e4401dbc1d645795c38))
|
||||
* 支持AI开发在线插件 ([eae7edb](https://github.com/certd/certd/commit/eae7edbda0fd7357163f33fb4084fcba21396f5f))
|
||||
* **domain:** 域名管理增加remark字段 ([e4041ea](https://github.com/certd/certd/commit/e4041eabece1bab2c1c4b955129cf7a7fe7302d0))
|
||||
* **login,settings:** 仅第三方登录开启后,在重启10分钟后将无法使用密码登录 ([1d12ad7](https://github.com/certd/certd/commit/1d12ad73c6ca827d2eb36bda867f9d9fe62dab48))
|
||||
* **plugin:** 插件安装支持检测依赖 ([e79660c](https://github.com/certd/certd/commit/e79660c5d17ed4049508a00a359950a54470332f))
|
||||
* **plugin:** 新增插件评分展示、排序与个人插件筛选功能 ([7ab5029](https://github.com/certd/certd/commit/7ab502991e0c001777b268b9f159311ef5e000a0))
|
||||
* **server:** response优化为gzip压缩 ([f38522c](https://github.com/certd/certd/commit/f38522ca0b2185f7f35538f53cb51dcf86d0bfc8))
|
||||
* **tencent-clb:** 重构腾讯云CLB部署插件的地域选择组件,改为远程下拉选择器 ([30d4fc8](https://github.com/certd/certd/commit/30d4fc84a9e940d1d07e1f25856eb6fa13da9ba1))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 优化火山引擎dcdn,单个单个部署,规避超时问题 ([cf008b9](https://github.com/certd/certd/commit/cf008b9cff54542dc551fb159578dbd49006229c))
|
||||
* **pipeline:** 修复过期/损坏的安装锁导致的依赖安装失败 ([1017a38](https://github.com/certd/certd/commit/1017a38eb48d6778745d4905d9ec4f6067a261d8))
|
||||
* **ui:** 修复oauth设置页保存后未正确刷新的问题 ([8ab699a](https://github.com/certd/certd/commit/8ab699a9104b47e02a88ad764e995a495deb775e))
|
||||
* **unicloud:** 修复unicloud登录接口改版后请求失败的问题 ([18583f6](https://github.com/certd/certd/commit/18583f6ce025fa8d365fad5a8c9c6020f3bcfbae))
|
||||
|
||||
### Features
|
||||
|
||||
* 优化开放接口证书自动申请功能 ([6339cdf](https://github.com/certd/certd/commit/6339cdffa9f79505dada5617d8c7fcfacc419a2e))
|
||||
* **pipeline:** 新增流水线批量编辑、阶段宽度调整功能 ([e5a1844](https://github.com/certd/certd/commit/e5a18448522be88bc504a45a46ad599f3c39ca30))
|
||||
* **ssh:** ssh连接支持跳板机 ([2cb4e55](https://github.com/certd/certd/commit/2cb4e551fa7616f587a2d907f9b513f7df3a9957))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 部署到京东云CDN支持设置跳转类型 ([e992192](https://github.com/certd/certd/commit/e9921928df011c2435af73b68ebc3d773f13c16a))
|
||||
* 统一通知类型常量管理,新增开放接口通知能力 ([edb249c](https://github.com/certd/certd/commit/edb249c2ad3c173dbfd04d8d156f895a6706ffd1))
|
||||
* 优化宝塔报错提示 ([1cfa766](https://github.com/certd/certd/commit/1cfa76683b40da9e9198552f2226419d4afa86a3))
|
||||
* 优化第三方登录设置提示 ([1468eb1](https://github.com/certd/certd/commit/1468eb1ef292a9e21a2a43131b86b8acb3688307))
|
||||
* 优化第三方依赖安装失败日志输出 ([18b2d3a](https://github.com/certd/certd/commit/18b2d3ac2078d0c6636af897f8f66e4eb5ff832c))
|
||||
* 优化SqliteError: disk I/O error报错修复提示 ([7d22fe3](https://github.com/certd/certd/commit/7d22fe3d7d0de97bae2ceedd051a0c16a1cd90e3))
|
||||
* 证书成功通知增加有效期显示 ([f1b6704](https://github.com/certd/certd/commit/f1b67049d16ebcabe6d6d9b6cb53a8a2b7a9265c))
|
||||
* **cert-plugin:** 证书申请支持失败重试 ([0ddb1f6](https://github.com/certd/certd/commit/0ddb1f69d2a46b993e792862ce9952336d532f5b))
|
||||
* Improve English localization coverage ([#778](https://github.com/certd/certd/issues/778)) @MephistoJB ([bd82147](https://github.com/certd/certd/commit/bd821474969a22078b9d2e73518e765b8c9a26c7))
|
||||
* **install:** 补充迁移到PostgreSQL后,表自增序列从1开始的修复SQL脚本和说明 ([80374ca](https://github.com/certd/certd/commit/80374cae8a43fd17eb180381a093267e466a0bc2))
|
||||
* **plugin-ucloud:** ucloud us3部署插件增加region选择 ([8165a5f](https://github.com/certd/certd/commit/8165a5ff8986e759948c06f924d12e605b2ec8f6))
|
||||
* **tencent-clb:** 为腾讯云CLB部署插件支持远程选择输入 ([967846b](https://github.com/certd/certd/commit/967846bef590b76d27e472180b96372ff55cfb6f))
|
||||
* **ui:** 域名选择器支持点击复制域名 ([b173311](https://github.com/certd/certd/commit/b173311468d2c1f318d1333257c3ebcf916da071))
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复正常批量删除流水线报权限不足的bug ([5b50083](https://github.com/certd/certd/commit/5b500830a122c6c42dab054e57fed509050f94da))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 优化动态加载依赖镜像地址,多次重试 ([5f53b81](https://github.com/certd/certd/commit/5f53b81c75dd242b4260ac08cae14c6d1a08a883))
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
|
||||
* 修复dingtalk通知格式没有换行的bug ([7ed1be9](https://github.com/certd/certd/commit/7ed1be994f8b4b74cdeb38743060c912c027248b))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim ([b91c9e4](https://github.com/certd/certd/commit/b91c9e4ea671cb359ef164e27864de1d66cba9d3))
|
||||
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复火山引擎查不到自定义源站域名的问题 ([02dabe1](https://github.com/certd/certd/commit/02dabe11db3e9b13ca4621ce9ddd2b808bfca390))
|
||||
* 修复火山引擎自定义源站域名查询不到的问题 ([e44bf9d](https://github.com/certd/certd/commit/e44bf9d77375d48ac7fd1582e69fae02dfd248fa))
|
||||
* **pipeline:** 重构运行时依赖加载逻辑,修复火山引擎DNS解析报runtimeDepsService未初始化的bug ([ec69b8f](https://github.com/certd/certd/commit/ec69b8f11bfd4b20991aef74a72a47182ca79a9d))
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **volcengine-alb:** 修复火山引擎ALB 默认证书部署类型会部署到扩展证书的问题 ([0a068a2](https://github.com/certd/certd/commit/0a068a274673e9768954e9f7367c267d44f3b530))
|
||||
|
||||
## [1.42.2](https://github.com/certd/certd/compare/v1.42.1...v1.42.2) (2026-07-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -103,9 +103,14 @@ docker-compose up -d
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> 重要: 建议Fetch大小改成1000,避免内存溢出
|
||||
|
||||
下一步、下一步,直到数据加载设置,勾选`在加载前截断目标表`(此选项很重要,并且会清空mysql certd数据库中的数据)
|
||||

|
||||
> 重要: 在插入行后做提交,也改成1000,避免内存溢出
|
||||
|
||||
#### 5. 导入完成
|
||||
|
||||
@@ -115,4 +120,58 @@ docker-compose up -d
|
||||
|
||||
访问MySQL版本测试,数据已成功迁移
|
||||
|
||||
确认没有问题之后,删除旧版certd
|
||||
确认没有问题之后,删除旧版certd
|
||||
|
||||
|
||||
## 三、问题
|
||||
|
||||
### 1. 迁移到PostgreSQL之后,所有表的Sequence会从1开始
|
||||
|
||||
执行如下sql 语句,批量将所有表的id自增设置到当前数据的最大值【仅迁移到postgresql需要执行】
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
seq_name TEXT;
|
||||
max_id BIGINT;
|
||||
BEGIN
|
||||
-- 遍历所有用户表(排除系统schema)
|
||||
FOR rec IN
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
-- 检查表是否存在名为 'id' 的列
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = rec.table_schema
|
||||
AND table_name = rec.table_name
|
||||
AND column_name = 'id'
|
||||
) THEN
|
||||
-- 获取该列关联的序列名(如果是 serial 或 identity 列)
|
||||
seq_name := pg_get_serial_sequence(
|
||||
quote_ident(rec.table_schema) || '.' || quote_ident(rec.table_name),
|
||||
'id'
|
||||
);
|
||||
|
||||
IF seq_name IS NOT NULL THEN
|
||||
-- 查询当前表中 id 的最大值
|
||||
EXECUTE format('SELECT MAX(id) FROM %I.%I', rec.table_schema, rec.table_name) INTO max_id;
|
||||
|
||||
IF max_id IS NULL THEN
|
||||
-- 表为空,将序列重置为初始值 1(下次调用 nextval 返回 1)
|
||||
PERFORM setval(seq_name, 1, false);
|
||||
RAISE NOTICE '表 %.% 为空,序列 % 已重置为 1', rec.table_schema, rec.table_name, seq_name;
|
||||
ELSE
|
||||
-- 将序列当前值设置为最大值,下次调用 nextval 返回 max_id + 1
|
||||
PERFORM setval(seq_name, max_id, true);
|
||||
RAISE NOTICE '表 %.% 的序列 % 已设置为 %', rec.table_schema, rec.table_name, seq_name, max_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
```
|
||||
@@ -6,13 +6,45 @@
|
||||
|
||||
Certd 提供多种 Docker 镜像版本,您可以根据需要选择:
|
||||
|
||||
| 版本标签 | 基础系统 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `latest` / `[version]` | Alpine Linux | 默认版本,镜像体积小 |
|
||||
| `slim` / `[version]-slim` | Debian slim | glibc版本,dns解析兼容性更好(可能需要配置security_opt -seccomp=unconfined)|
|
||||
| `armv7` / `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
|
||||
#### 1. 镜像地址格式:
|
||||
|
||||
```
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
|
||||
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型
|
||||
```
|
||||
#### 2. 版本标签:
|
||||
|
||||
**最新版本标签:**
|
||||
|
||||
| 版本 | 标签 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 最新预览版【默认】 | `certd:latest` | 指向最新开发版本,包含最新功能,但稳定性不如稳定版 |
|
||||
| 最新稳定版 | `certd:stable` | 指向经过充分测试的生产就绪版本,推荐生产环境使用 |
|
||||
|
||||
**系统分支版本:**
|
||||
|
||||
> 根据基础镜像不同,分为如下三个分支版本,没有特殊需求选择默认的即可(他们功能是一样的)
|
||||
|
||||
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 | 指定版本 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小,支持x86、ARM架构 | `certd:stable` | `certd:1.43.0` |
|
||||
| slim | `certd:slim` | Debian slim | 基于glibc,dns解析兼容性好 | `certd:slim-stable` | `certd:1.43.0-slim` |
|
||||
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` | `certd:1.43.0-armv7` |
|
||||
|
||||
#### 3. 镜像地址:
|
||||
|
||||
| 镜像仓库 | 最新预览版 | slim版 | armv7版 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
| Docker Hub | `certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
|
||||
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
|
||||
> 2. 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
|
||||
|
||||
最新版本号:
|
||||

|
||||
|
||||
> 如果您不确定使用哪个版本,请使用默认的 `latest` 版本。
|
||||
|
||||
### 一键脚本安装(推荐)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -47,7 +47,13 @@ header中传入x-certd-token即可调用开放接口
|
||||
|
||||
## 客户端工具
|
||||
|
||||
### SSL-Assistant
|
||||
### 官方客户端:Certd Client
|
||||
`Certd Client` 是 Certd 的官方证书部署客户端。运行在应用服务器上,自动发现本机 Nginx、Apache 和 IIS 站点,然后从 Certd 获取新证书并部署到本机。适用于不希望服务器开放 SSH、无法由 Certd 直接访问目标服务器的场景。
|
||||
|
||||
项目主页:[AtomGit](https://atomgit.com/certd/certd-client/) | [GitHub](https://github.com/certd/certd-client/)
|
||||
下载地址:[AtomGit Releases](https://atomgit.com/certd/certd-client/releases) | [GitHub Releases](https://github.com/certd/certd-client/releases)
|
||||
|
||||
### 第三方客户端:SSL-Assistant
|
||||
`SSL Assistant` 是一个基于 Go 语言开发的跨平台证书部署管理助手。
|
||||
支持自动扫描主机`Nginx`配置,然后从Certd拉取证书并部署。
|
||||
在不想暴露ssh主机密码情况下,该工具非常好用。
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# 任务插件
|
||||
共 `134` 款任务插件
|
||||
共 `135` 款任务插件
|
||||
## 1. 证书申请
|
||||
|
||||
| 序号 | 名称 | 说明 |
|
||||
|-----|-----|-----|
|
||||
| 1.| **证书申请(JS版)** | 免费通配符域名证书申请,支持多个域名打到同一个证书上 |
|
||||
| 2.| **已有证书托管** | 手动上传自定义证书后,自动部署(每次证书有更新,都需要手动上传一次) |
|
||||
| 3.| **获取阿里云订阅证书** | 从阿里云拉取订阅模式的商用证书(支持 API 1.0 和 2.0) |
|
||||
| 4.| **证书申请(Lego)** | 支持海量DNS解析提供商,推荐使用,一样的免费通配符域名证书申请,支持多个域名打到同一个证书上 |
|
||||
| 1.| **吊销旧证书** | 新证书申请成功,并全部部署成功后,吊销本流水线产生的旧证书。(注意 如果本流水线证书还在别处有使用或手动部署,请慎重添加此吊销任务) |
|
||||
| 2.| **证书申请(JS版)** | 免费通配符域名证书申请,支持多个域名打到同一个证书上 |
|
||||
| 3.| **已有证书托管** | 手动上传自定义证书后,自动部署(每次证书有更新,都需要手动上传一次) |
|
||||
| 4.| **获取阿里云订阅证书** | 从阿里云拉取订阅模式的商用证书(支持 API 1.0 和 2.0) |
|
||||
| 5.| **证书申请(Lego)** | 支持海量DNS解析提供商,推荐使用,一样的免费通配符域名证书申请,支持多个域名打到同一个证书上 |
|
||||
## 2. 主机
|
||||
|
||||
| 序号 | 名称 | 说明 |
|
||||
@@ -115,7 +116,7 @@
|
||||
|
||||
| 序号 | 名称 | 说明 |
|
||||
|-----|-----|-----|
|
||||
| 1.| **腾讯云-删除即将过期证书** | 仅删除未使用的证书 |
|
||||
| 1.| **腾讯云-删除即将过期证书** | 仅删除即将过期且未使用的证书 |
|
||||
| 2.| **腾讯云-部署证书到任意云资源** | 支持负载均衡、CDN、DDoS、直播、点播、Web应用防火墙、API网关、TEO、容器服务、对象存储、轻应用服务器、云原生微服务、云开发 |
|
||||
| 3.| **腾讯云-部署到CDN(废弃)** | 已废弃,请使用v2版 |
|
||||
| 4.| **腾讯云-部署到CDN-v2** | 推荐使用,支持CDN域名以及COS加速域名 |
|
||||
|
||||
@@ -4,26 +4,26 @@
|
||||
|-----|-----|-----|
|
||||
| 1.| **阿里ESA** | 阿里ESA DNS解析 |
|
||||
| 2.| **阿里云** | 阿里云DNS解析提供商 |
|
||||
| 3.| **AWS Route53** | AWS Route53 DNS解析提供商 |
|
||||
| 4.| **Azure DNS** | Azure DNS 解析提供商 |
|
||||
| 5.| **火山引擎** | 火山引擎DNS解析提供商 |
|
||||
| 6.| **京东云** | 京东云DNS解析提供商 |
|
||||
| 7.| **新网(代理方式)** | 新网域名解析(代理方式) |
|
||||
| 8.| **新网** | 新网域名解析 |
|
||||
| 9.| **BIND9 DNS** | 通过 SSH 连接到 BIND9 服务器,使用 nsupdate 命令管理 DNS 记录 |
|
||||
| 10.| **cloudflare** | cloudflare dns provider |
|
||||
| 11.| **dns.la** | dns.la |
|
||||
| 12.| **Dynadot** | Dynadot DNS提供商 |
|
||||
| 13.| **godaddy** | GoDaddy |
|
||||
| 14.| **HiPM DNSMgr** | HiPM DNSMgr DNS 解析提供商 |
|
||||
| 15.| **华为云** | 华为云DNS解析提供商 |
|
||||
| 16.| **namesilo** | namesilo dns provider |
|
||||
| 17.| **雨云** | 雨云DNS解析提供商 |
|
||||
| 18.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器 |
|
||||
| 19.| **腾讯云** | 腾讯云域名DNS解析提供者 |
|
||||
| 20.| **腾讯云EO DNS** | 腾讯云EO DNS解析提供者 |
|
||||
| 21.| **西部数码** | west dns provider |
|
||||
| 22.| **Google Cloud DNS** | Google Cloud DNS提供商 |
|
||||
| 3.| **腾讯云** | 腾讯云域名DNS解析提供者 |
|
||||
| 4.| **腾讯云EO DNS** | 腾讯云EO DNS解析提供者 |
|
||||
| 5.| **AWS Route53** | AWS Route53 DNS解析提供商 |
|
||||
| 6.| **Azure DNS** | Azure DNS 解析提供商 |
|
||||
| 7.| **cloudflare** | cloudflare dns provider |
|
||||
| 8.| **华为云** | 华为云DNS解析提供商 |
|
||||
| 9.| **火山引擎** | 火山引擎DNS解析提供商 |
|
||||
| 10.| **Google Cloud DNS** | Google Cloud DNS提供商 |
|
||||
| 11.| **京东云** | 京东云DNS解析提供商 |
|
||||
| 12.| **新网(代理方式)** | 新网域名解析(代理方式) |
|
||||
| 13.| **新网** | 新网域名解析 |
|
||||
| 14.| **BIND9 DNS** | 通过 SSH 连接到 BIND9 服务器,使用 nsupdate 命令管理 DNS 记录 |
|
||||
| 15.| **dns.la** | dns.la |
|
||||
| 16.| **Dynadot** | Dynadot DNS提供商 |
|
||||
| 17.| **godaddy** | GoDaddy |
|
||||
| 18.| **HiPM DNSMgr** | HiPM DNSMgr DNS 解析提供商 |
|
||||
| 19.| **namesilo** | namesilo dns provider |
|
||||
| 20.| **雨云** | 雨云DNS解析提供商 |
|
||||
| 21.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器 |
|
||||
| 22.| **西部数码** | west dns provider |
|
||||
| 23.| **Dns提供商Demo** | dns provider示例 |
|
||||
| 24.| **彩虹DNS** | 彩虹DNS管理系统 |
|
||||
| 25.| **Spaceship** | Spaceship 域名解析 |
|
||||
|
||||
@@ -46,8 +46,9 @@ docker compose up -d
|
||||
> 如果忘记管理员账号,请查看修改密码时的启动日志,会打印管理员账号名
|
||||
|
||||
|
||||
## 二、仅有第三方登录,没有登录窗口
|
||||
## 二、仅有第三方登录,没有登录窗口 / 自动跳到第三方登录等
|
||||
|
||||
当开启仅使用第三方登录模式时,如果第三方登录未配置或已失效,则会导致无法登录
|
||||
服务重启后的前10分钟内,可以访问 `http://你的certd地址/#/login?oauthOnly=false` 显示密码登录入口。
|
||||
|
||||
您可以通过访问 `http://你的certd地址/#/login?oauthOnly=false` 来临时关闭仅使用第三方登录模式,以使用密码登录。
|
||||
> 注意:仅使用第三方登录开启后,仅限于服务重启后的前10分钟内可使用密码登录;超过窗口期即使`oauthOnly=false`,也无法使用密码登录。
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@
|
||||
}
|
||||
},
|
||||
"npmClient": "pnpm",
|
||||
"version": "1.42.3"
|
||||
"version": "1.44.2"
|
||||
}
|
||||
|
||||
+6
-4
@@ -19,7 +19,7 @@
|
||||
"devb": "lerna run dev-build",
|
||||
"i-all": "lerna link && lerna exec npm install ",
|
||||
"publish": "pnpm run prepublishOnly2 && lerna publish --force-publish=pro/plus-core --conventional-commits && pnpm run afterpublishOnly ",
|
||||
"publish2":" npm run pub_all && pnpm run afterpublishOnly",
|
||||
"publish2": " npm run pub_all && pnpm run afterpublishOnly",
|
||||
"afterpublishOnly": "pnpm run copylogs && time /t >trigger/build.trigger && git add ./trigger/build.trigger && git commit -m \"build: trigger build image\" && TIMEOUT /T 10 && pnpm run commitAll",
|
||||
"transform-sql": "cd ./packages/ui/certd-server/db/ && node --experimental-json-modules transform.js",
|
||||
"plugin-doc-gen": "cd ./packages/ui/certd-server/ && pnpm run export-metadata",
|
||||
@@ -27,7 +27,7 @@
|
||||
"commitPro": "cd ./packages/pro/ && git add . && git commit -m \"build: publish\" && git push",
|
||||
"copylogs": "copyfiles \"CHANGELOG.md\" ./docs/guide/changelogs/",
|
||||
"prepublishOnly1": "pnpm run test:unit && pnpm run check && lerna run build ",
|
||||
"prepublishOnly2": "pnpm run test:unit && pnpm run check && pnpm run before-build && lerna run build && pnpm run plugin-doc-gen",
|
||||
"prepublishOnly2": "pnpm run before-build && lerna run build && pnpm run test:unit && pnpm run check && pnpm run plugin-doc-gen",
|
||||
"before-build": "pnpm run transform-sql && cd ./packages/core/basic && time /t >build.md && git add ./build.md && git commit -m \"build: prepare to build\"",
|
||||
"deploy1": "node --experimental-json-modules ./scripts/deploy.js ",
|
||||
"check": "node --experimental-json-modules ./scripts/publish-check.js",
|
||||
@@ -39,18 +39,20 @@
|
||||
"test:unit": "cross-env NODE_ENV=unittest pnpm -r --workspace-concurrency=1 run test:unit",
|
||||
"pub": "echo 1",
|
||||
"dev": "pnpm run -r --parallel compile ",
|
||||
"lint_all": "pnpm run -r --parallel lint ",
|
||||
"pub_all": "node ./scripts/pub-all.js",
|
||||
"release": "time /t >trigger/release.trigger && git add trigger/release.trigger && git commit -m \"build: release\" && git push",
|
||||
"publish_to_atomgit": "node --experimental-json-modules ./scripts/publish-atomgit.js",
|
||||
"publish_to_gitee": "node --experimental-json-modules ./scripts/publish-gitee.js",
|
||||
"publish_to_github": "node --experimental-json-modules ./scripts/publish-github.js",
|
||||
"get_version": "node --experimental-json-modules ./scripts/version.js"
|
||||
"get_version": "node --experimental-json-modules ./scripts/version.js",
|
||||
"stable": "node ./scripts/stable.js",
|
||||
"set-release-stable": "node ./scripts/set-release-stable.js"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"copyfiles": "^2.4.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"root": "link:",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"workspaces": [
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:prettier/recommended",
|
||||
"prettier"
|
||||
@@ -36,5 +32,9 @@
|
||||
"ignoreUrls": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,41 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/publishlab/node-acme-client/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.44.1](https://github.com/publishlab/node-acme-client/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
# [1.44.0](https://github.com/publishlab/node-acme-client/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Features
|
||||
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/publishlab/node-acme-client/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 新增证书吊销功能,添加证书状态字段 ([5074572](https://github.com/publishlab/node-acme-client/commit/5074572694383c8e1bf2880e26827987683dc62c))
|
||||
* 移除全局反向代理配置,迁移到自定义ACME配置 ([c57110e](https://github.com/publishlab/node-acme-client/commit/c57110e42aada31dd0028b56b5baec1b19722525))
|
||||
|
||||
# [1.43.0](https://github.com/publishlab/node-acme-client/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.42.6](https://github.com/publishlab/node-acme-client/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.42.5](https://github.com/publishlab/node-acme-client/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.42.4](https://github.com/publishlab/node-acme-client/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.42.3](https://github.com/publishlab/node-acme-client/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
@@ -147,21 +147,6 @@ const autoOpts = {
|
||||
const certificate = await client.auto(autoOpts);
|
||||
```
|
||||
|
||||
### Challenge priority
|
||||
|
||||
When ordering a certificate using auto mode, `acme-client` uses a priority list when selecting challenges to respond to. Its default value is `['http-01', 'dns-01']` which translates to "use `http-01` if any challenges exist, otherwise fall back to `dns-01`".
|
||||
|
||||
While most challenges can be validated using the method of your choosing, please note that **wildcard certificates can only be validated through `dns-01`**. More information regarding Let's Encrypt challenge types [can be found here](https://letsencrypt.org/docs/challenge-types/).
|
||||
|
||||
To modify challenge priority, provide a list of challenge types in `challengePriority`:
|
||||
|
||||
```js
|
||||
await client.auto({
|
||||
...,
|
||||
challengePriority: ['http-01', 'dns-01'],
|
||||
});
|
||||
```
|
||||
|
||||
### Internal challenge verification
|
||||
|
||||
When using auto mode, `acme-client` will first validate that challenges are satisfied internally before completing the challenge at the ACME provider. In some cases (firewalls, etc) this internal challenge verification might not be possible to complete.
|
||||
|
||||
@@ -49,7 +49,6 @@ function log(m) {
|
||||
csr,
|
||||
email: 'test@example.com',
|
||||
termsOfServiceAgreed: true,
|
||||
challengePriority: ['dns-01'],
|
||||
challengeCreateFn: (authz, challenge, keyAuthorization) => {
|
||||
/* TODO: Implement this */
|
||||
log(`[TODO] Add TXT record key=_acme-challenge.${authz.identifier.value} value=${keyAuthorization}`);
|
||||
|
||||
@@ -60,7 +60,6 @@ async function getCertOnDemand(client, servername, attempt = 0) {
|
||||
csr,
|
||||
email: 'test@example.com',
|
||||
termsOfServiceAgreed: true,
|
||||
challengePriority: ['http-01'],
|
||||
challengeCreateFn: (authz, challenge, keyAuthorization) => {
|
||||
challengeResponses[challenge.token] = keyAuthorization;
|
||||
},
|
||||
|
||||
@@ -59,7 +59,6 @@ async function getCertOnDemand(client, servername, attempt = 0) {
|
||||
csr,
|
||||
email: 'test@example.com',
|
||||
termsOfServiceAgreed: true,
|
||||
challengePriority: ['tls-alpn-01'],
|
||||
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
||||
alpnResponses[authz.identifier.value] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization);
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Simple and unopinionated ACME client",
|
||||
"private": false,
|
||||
"author": "nmorsman",
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"type": "module",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
@@ -18,7 +18,7 @@
|
||||
"types"
|
||||
],
|
||||
"dependencies": {
|
||||
"@certd/basic": "^1.42.3",
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@peculiar/x509": "^1.11.0",
|
||||
"asn1js": "^3.0.5",
|
||||
"axios": "^1.9.0",
|
||||
@@ -44,20 +44,21 @@
|
||||
"mocha": "^10.6.0",
|
||||
"nock": "^13.5.4",
|
||||
"prettier": "3.3.3",
|
||||
"rimraf": "^5.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsd": "^0.31.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"scripts": {
|
||||
"before-build": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true});\"",
|
||||
"before-build": "rimraf dist && rimraf dist-test && rimraf tsconfig.tsbuildinfo && rimraf .rollup.cache",
|
||||
"build": "npm run before-build && tsc -p tsconfig.build.json --skipLibCheck",
|
||||
"lint": "eslint \"src/**/*.ts\" \"types/**/*.ts\"",
|
||||
"lint": "eslint --fix \"src/**/*.ts\" \"types/**/*.ts\"",
|
||||
"lint-types": "tsd --files \"types/index.test-d.ts\"",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "mocha -t 60000 \"test/setup.js\" \"test/**/*.spec.js\"",
|
||||
"before-test:unit": "node -e \"const fs=require('fs');fs.rmSync('dist-test',{recursive:true,force:true});fs.rmSync('tsconfig.test.tsbuildinfo',{force:true});\"",
|
||||
"test:unit": "cross-env NODE_ENV=unittest npm run before-test:unit && cross-env NODE_ENV=unittest tsc -p tsconfig.test.json --skipLibCheck && cross-env NODE_ENV=unittest mocha -t 60000 \"dist-test/**/*.test.js\"",
|
||||
"test:unit": "cross-env NODE_ENV=unittest mocha --node-option no-warnings --node-option loader=ts-node/esm \"src/**/*.test.ts\"",
|
||||
"pub": "npm publish",
|
||||
"compile": "tsc --skipLibCheck --watch",
|
||||
"compile": " tsc --skipLibCheck --watch",
|
||||
"format": "prettier --write src"
|
||||
},
|
||||
"repository": {
|
||||
@@ -75,5 +76,5 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/publishlab/node-acme-client/issues"
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* ACME API client
|
||||
*/
|
||||
@@ -20,6 +20,7 @@ class AcmeApi {
|
||||
getLocationFromHeader(resp) {
|
||||
let locationUrl = resp.headers.location;
|
||||
const mapping = this.http.urlMapping;
|
||||
// 服务器返回的是反代地址,这里还原为 ACME 原始资源 URL,供后续 JWS url 使用。
|
||||
if (mapping.mappings) {
|
||||
// eslint-disable-next-line guard-for-in,no-restricted-syntax
|
||||
for (const key in mapping.mappings) {
|
||||
@@ -237,11 +238,12 @@ class AcmeApi {
|
||||
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.6
|
||||
*
|
||||
* @param {object} data Request payload
|
||||
* @param {object} [opts] Request options, e.g. `{ includeJwsKid: false }` to sign with certificate private key (jwk) instead of account key
|
||||
* @returns {Promise<object>} HTTP response
|
||||
*/
|
||||
|
||||
revokeCert(data) {
|
||||
return this.apiResourceRequest('revokeCert', data, [200]);
|
||||
revokeCert(data, opts = {}) {
|
||||
return this.apiResourceRequest('revokeCert', data, [200], opts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ const defaultOpts = {
|
||||
preferredChain: null,
|
||||
termsOfServiceAgreed: false,
|
||||
skipChallengeVerification: false,
|
||||
challengePriority: ["http-01", "dns-01"],
|
||||
challengeCreateFn: async () => {
|
||||
throw new Error("Missing challengeCreateFn()");
|
||||
},
|
||||
@@ -277,7 +276,7 @@ export default async (client, userOpts) => {
|
||||
}
|
||||
|
||||
log("开始向提供商请求检查验证");
|
||||
await runPromisePa(completeChallengeTasks, 1000);
|
||||
await runPromisePa(completeChallengeTasks, 4000);
|
||||
} catch (e) {
|
||||
log(`证书申请失败${e.message}`);
|
||||
throw e;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Axios instance
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import { parseRetryAfterHeader } from './util.js';
|
||||
const { AxiosError } = axios;
|
||||
import {getGlobalAgents, HttpError} from '@certd/basic'
|
||||
import { log } from './logger.js';
|
||||
@@ -113,12 +112,9 @@ instance.interceptors.response.use(null, async (error) => {
|
||||
log(`Caught ${code}, retry attempt ${config.retryAttempt}/${retryMaxAttempts} to URL ${config.url}`);
|
||||
|
||||
const retryAfter = (retryDefaultDelay * config.retryAttempt);
|
||||
/* Attempt to parse Retry-After header, fallback to default delay */
|
||||
const headerRetryAfter = response ? parseRetryAfterHeader(response.headers['retry-after']) : 0;
|
||||
|
||||
if (headerRetryAfter > 0) {
|
||||
const waitMinutes = (headerRetryAfter / 60).toFixed(1);
|
||||
log(`Found retry-after response header with value: ${response.headers['retry-after']}, waiting ${waitMinutes} minutes`);
|
||||
if (response?.status === 429) {
|
||||
// CA 限流按产品策略立即失败,不能依据 Retry-After 自动重试,避免继续消耗配额。
|
||||
log(`CA rate limit encountered (HTTP 429), failing immediately; Retry-After=${response.headers['retry-after']}`);
|
||||
log(JSON.stringify(response.data));
|
||||
return Promise.reject(new HttpError(error));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* ACME client
|
||||
*
|
||||
@@ -263,7 +263,14 @@ class AcmeClient {
|
||||
const accountUrl = this.api.getAccountUrl();
|
||||
|
||||
/* Create new HTTP and API clients using new key */
|
||||
const newHttpClient = new HttpClient(this.opts.directoryUrl, newAccountKey, this.opts.externalAccountBinding);
|
||||
const newHttpClient = new HttpClient(
|
||||
this.opts.directoryUrl,
|
||||
newAccountKey,
|
||||
this.opts.externalAccountBinding,
|
||||
this.opts.urlMapping,
|
||||
this.logger,
|
||||
true,
|
||||
);
|
||||
const newApiClient = new AcmeApi(newHttpClient, accountUrl);
|
||||
|
||||
/* Get old JWK */
|
||||
@@ -280,6 +287,8 @@ class AcmeClient {
|
||||
/* Replace existing HTTP and API client */
|
||||
this.http = newHttpClient;
|
||||
this.api = newApiClient;
|
||||
// 更新本地配置,避免后续重建客户端时又使用旧账号密钥。
|
||||
this.opts.accountKey = newAccountKey;
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
@@ -681,11 +690,21 @@ class AcmeClient {
|
||||
* reason: 4,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Revoke certificate with certificate private key (RFC 8555 §7.6)
|
||||
* 当无法提供ACME账号私钥时,可以使用证书私钥签名吊销请求:
|
||||
* ```js
|
||||
* const client = new acme.Client({
|
||||
* directoryUrl: acme.directory.letsencrypt.production,
|
||||
* accountKey: certificateKey, // 证书私钥,而非账号私钥
|
||||
* });
|
||||
* const result = await client.revokeCertificate(certificate, {}, { includeJwsKid: false });
|
||||
* ```
|
||||
*/
|
||||
|
||||
async revokeCertificate(cert, data = {}) {
|
||||
async revokeCertificate(cert, data = {}, opts = {}) {
|
||||
data.certificate = getPemBodyAsB64u(cert);
|
||||
const resp = await this.api.revokeCert(data);
|
||||
const resp = await this.api.revokeCert(data, opts);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
@@ -699,7 +718,6 @@ class AcmeClient {
|
||||
* @param {string} [opts.email] Account email address
|
||||
* @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false`
|
||||
* @param {boolean} [opts.skipChallengeVerification] Skip internal challenge verification before notifying ACME provider, default: `false`
|
||||
* @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']`
|
||||
* @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null`
|
||||
* @returns {Promise<string>} Certificate
|
||||
*
|
||||
|
||||
@@ -527,6 +527,8 @@ export const createAlpnCertificate = async (authz, keyAuthorization, keyPem = nu
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// TLS-ALPN-01 只需要一张短期挑战证书;有效期不能为零,否则部分 TLS 实现会直接拒绝。
|
||||
const notAfter = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
const commonName = authz.identifier.value;
|
||||
|
||||
/* Pseudo-random serial - max 20 bytes, 11 for epoch (year 5138), 9 random */
|
||||
@@ -537,14 +539,8 @@ export const createAlpnCertificate = async (authz, keyAuthorization, keyPem = nu
|
||||
const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem);
|
||||
|
||||
const extensions = [
|
||||
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), // eslint-disable-line no-bitwise
|
||||
|
||||
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9 */
|
||||
new x509.BasicConstraintsExtension(true, 2, true),
|
||||
|
||||
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.2 */
|
||||
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
|
||||
/* TLS 服务端挑战证书只用于签名,不是 CA 证书。 */
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||
|
||||
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
|
||||
createSubjectAltNameExtension([commonName]),
|
||||
@@ -562,7 +558,7 @@ export const createAlpnCertificate = async (authz, keyAuthorization, keyPem = nu
|
||||
extensions,
|
||||
serialNumber,
|
||||
notBefore: now,
|
||||
notAfter: now,
|
||||
notAfter,
|
||||
name: createCsrSubject({
|
||||
CN: commonName,
|
||||
}),
|
||||
|
||||
@@ -72,6 +72,7 @@ class HttpClient {
|
||||
*/
|
||||
|
||||
async request(url, method, opts = {}) {
|
||||
// 仅替换实际传输地址;signedRequest 已在映射前用 ACME 原始 URL 生成 JWS,协议要求两者保持一致。
|
||||
if (this.urlMapping && this.urlMapping.enabled && this.urlMapping.mappings) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const key in this.urlMapping.mappings) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { directory, getAllSslProviderDomains, getDirectoryUrl } from "./index.js";
|
||||
import { directory, getDirectoryUrl } from "./index.js";
|
||||
|
||||
declare const describe: any;
|
||||
declare const it: any;
|
||||
@@ -9,11 +9,4 @@ describe("directory helpers", () => {
|
||||
assert.equal(getDirectoryUrl({ sslProvider: "sslcom", pkType: "ec" }), directory.sslcom.ec);
|
||||
assert.equal(getDirectoryUrl({ sslProvider: "letsencrypt", pkType: "rsa" }), directory.letsencrypt.production);
|
||||
});
|
||||
|
||||
it("includes configured provider domains", () => {
|
||||
const domains = getAllSslProviderDomains();
|
||||
|
||||
assert.ok(domains.includes("acme.litessl.com"));
|
||||
assert.ok(domains.includes("acme.ssl.com"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* acme-client
|
||||
*/
|
||||
@@ -58,32 +58,6 @@ export function getDirectoryUrl(opts) {
|
||||
return list.production
|
||||
}
|
||||
|
||||
|
||||
export function getAllSslProviderDomains() {
|
||||
const list = Object.values(directory).map((item) => {
|
||||
let url = item.production.replace('https://', '')
|
||||
url = url.substring(0, url.indexOf('/'))
|
||||
return url
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
let sslProviderReverseProxies = {}
|
||||
|
||||
function initSslProviderReverseProxies() {
|
||||
for (const sslProvider of getAllSslProviderDomains()) {
|
||||
sslProviderReverseProxies[sslProvider] = ""
|
||||
}
|
||||
}
|
||||
initSslProviderReverseProxies()
|
||||
|
||||
export function getSslProviderReverseProxies() {
|
||||
return sslProviderReverseProxies
|
||||
}
|
||||
export function setSslProviderReverseProxies(reverseProxies) {
|
||||
Object.assign(sslProviderReverseProxies, reverseProxies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Crypto
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface ClientAutoOptions {
|
||||
email?: string;
|
||||
termsOfServiceAgreed?: boolean;
|
||||
skipChallengeVerification?: boolean;
|
||||
challengePriority?: string[];
|
||||
preferredChain?: string;
|
||||
signal?: AbortSignal;
|
||||
profile?: string;
|
||||
|
||||
@@ -13,8 +13,10 @@ import { utils } from '@certd/basic'
|
||||
|
||||
const dns = dnsSdk.promises
|
||||
|
||||
// 管理员可按当前部署环境全局开关权威 DNS 查询(例如内网环境可能无法访问权威服务器)。
|
||||
let walkFromAuthoritative = true
|
||||
export function setWalkFromAuthoritative(value = true) {
|
||||
// 这是进程级配置,修改后影响后续所有 ACME DNS-01 本地校验。
|
||||
walkFromAuthoritative = value
|
||||
}
|
||||
|
||||
|
||||
@@ -81,26 +81,17 @@ describe('http', () => {
|
||||
* Retry on HTTP errors
|
||||
*/
|
||||
|
||||
it('should retry on 429 rate limit', async () => {
|
||||
it('should fail immediately on 429 rate limit', async () => {
|
||||
let rateLimitCount = 0;
|
||||
|
||||
nock(endpoint).persist().get('/').reply(() => {
|
||||
rateLimitCount += 1;
|
||||
|
||||
if (rateLimitCount < 3) {
|
||||
return [429, 'Rate Limit Exceeded', { 'Retry-After': 1 }];
|
||||
}
|
||||
|
||||
return [200, 'ok'];
|
||||
return [429, 'Rate Limit Exceeded', { 'Retry-After': 1 }];
|
||||
});
|
||||
|
||||
assert.strictEqual(rateLimitCount, 0);
|
||||
const resp = await testClient.request(endpoint, 'get');
|
||||
|
||||
assert.isObject(resp);
|
||||
assert.strictEqual(resp.status, 200);
|
||||
assert.strictEqual(resp.data, 'ok');
|
||||
assert.strictEqual(rateLimitCount, 3);
|
||||
await assert.isRejected(testClient.request(endpoint, 'get'));
|
||||
assert.strictEqual(rateLimitCount, 1);
|
||||
});
|
||||
|
||||
it('should retry on 5xx server error', async () => {
|
||||
|
||||
@@ -251,6 +251,13 @@ describe('crypto', () => {
|
||||
testAlpnCertificate = cert;
|
||||
});
|
||||
|
||||
it(`${n}/should generate alpn certificate with a non-zero validity period`, () => {
|
||||
const info = crypto.readCertificateInfo(testAlpnCertificate);
|
||||
|
||||
assert.isBelow(info.notBefore.getTime(), info.notAfter.getTime());
|
||||
assert.isAtMost(info.notAfter.getTime() - info.notBefore.getTime(), 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it(`${n}/should generate alpn certificate with key as string`, async () => {
|
||||
const k = await createFn();
|
||||
const authz = { identifier: { value: 'test.example.com' } };
|
||||
|
||||
@@ -235,7 +235,6 @@ describe('client.auto', () => {
|
||||
termsOfServiceAgreed: true,
|
||||
challengeCreateFn: cts.assertHttpChallengeCreateFn,
|
||||
challengeRemoveFn: cts.challengeRemoveFn,
|
||||
challengePriority: ['http-01'],
|
||||
});
|
||||
|
||||
assert.isString(cert);
|
||||
@@ -251,7 +250,6 @@ describe('client.auto', () => {
|
||||
termsOfServiceAgreed: true,
|
||||
challengeCreateFn: cts.assertHttpsChallengeCreateFn,
|
||||
challengeRemoveFn: cts.challengeRemoveFn,
|
||||
challengePriority: ['http-01'],
|
||||
});
|
||||
|
||||
assert.isString(cert);
|
||||
@@ -267,7 +265,6 @@ describe('client.auto', () => {
|
||||
termsOfServiceAgreed: true,
|
||||
challengeCreateFn: cts.assertDnsChallengeCreateFn,
|
||||
challengeRemoveFn: cts.challengeRemoveFn,
|
||||
challengePriority: ['dns-01'],
|
||||
});
|
||||
|
||||
assert.isString(cert);
|
||||
@@ -283,7 +280,6 @@ describe('client.auto', () => {
|
||||
termsOfServiceAgreed: true,
|
||||
challengeCreateFn: cts.assertTlsAlpnChallengeCreateFn,
|
||||
challengeRemoveFn: cts.challengeRemoveFn,
|
||||
challengePriority: ['tls-alpn-01'],
|
||||
});
|
||||
|
||||
assert.isString(cert);
|
||||
|
||||
+1
-5
@@ -62,7 +62,6 @@ export interface ClientAutoOptions {
|
||||
email?: string;
|
||||
termsOfServiceAgreed?: boolean;
|
||||
skipChallengeVerification?: boolean;
|
||||
challengePriority?: string[];
|
||||
preferredChain?: string;
|
||||
signal?: AbortSignal;
|
||||
profile?:string;
|
||||
@@ -86,7 +85,7 @@ export class Client {
|
||||
completeChallenge(challenge: rfc8555.Challenge): Promise<rfc8555.Challenge>;
|
||||
waitForValidStatus<T = Order | Authorization | rfc8555.Challenge>(item: T): Promise<T>;
|
||||
getCertificate(order: Order, preferredChain?: string): Promise<string>;
|
||||
revokeCertificate(cert: CertificateBuffer | CertificateString, data?: rfc8555.CertificateRevocationRequest): Promise<void>;
|
||||
revokeCertificate(cert: CertificateBuffer | CertificateString, data?: rfc8555.CertificateRevocationRequest, opts?: { includeJwsKid?: boolean }): Promise<void>;
|
||||
auto(opts: ClientAutoOptions): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -126,9 +125,6 @@ export const directory: {
|
||||
};
|
||||
|
||||
export function getDirectoryUrl(opts:{sslProvider:string, pkType: string}): string;
|
||||
export function getAllSslProviderDomains(): string[];
|
||||
export function getSslProviderReverseProxies(): Record<string, string>;
|
||||
export function setSslProviderReverseProxies(reverseProxies: Record<string, string>): void;
|
||||
|
||||
/**
|
||||
* Crypto
|
||||
|
||||
@@ -65,7 +65,6 @@ import * as acme from '..';
|
||||
email: 'test@example.com',
|
||||
termsOfServiceAgreed: false,
|
||||
skipChallengeVerification: false,
|
||||
challengePriority: ['http-01', 'dns-01'],
|
||||
preferredChain: 'DST Root CA X3',
|
||||
challengeCreateFn: async (authz, keyAuthorization) => ({
|
||||
challenge: authz.challenges[0],
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,36 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Features
|
||||
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
@@ -1 +1 @@
|
||||
23:45
|
||||
21:57
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/basic",
|
||||
"private": false,
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -15,9 +15,9 @@
|
||||
"test": "mocha --loader=ts-node/esm",
|
||||
"test:unit": "cross-env NODE_ENV=unittest mocha --node-option no-warnings --node-option loader=ts-node/esm \"src/**/*.test.ts\"",
|
||||
"pub": "npm publish",
|
||||
"compile": "tsc --skipLibCheck --watch",
|
||||
"compile": " tsc --skipLibCheck --watch",
|
||||
"format": "prettier --write src",
|
||||
"lint": "eslint --fix"
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-lock": "^1.4.1",
|
||||
@@ -54,5 +54,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ export class LocalCache<V = any> {
|
||||
cache: Map<string, { value: V; expiresAt: number }>;
|
||||
constructor(opts: { clearInterval?: number } = {}) {
|
||||
this.cache = new Map();
|
||||
const intervalId = setInterval(() => {
|
||||
this.clearExpires();
|
||||
}, opts.clearInterval ?? 5 * 60 * 1000);
|
||||
const intervalId = setInterval(
|
||||
() => {
|
||||
this.clearExpires();
|
||||
},
|
||||
opts.clearInterval ?? 5 * 60 * 1000
|
||||
);
|
||||
intervalId.unref?.();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function isDev() {
|
||||
const nodeEnv = process.env.NODE_ENV || 'dev';
|
||||
return nodeEnv === 'development' || nodeEnv.includes('local') || nodeEnv.startsWith('dev');
|
||||
const nodeEnv = process.env.NODE_ENV || "dev";
|
||||
return nodeEnv === "development" || nodeEnv.includes("local") || nodeEnv.startsWith("dev");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'fs';
|
||||
import fs from "fs";
|
||||
function getFileRootDir(rootDir?: string) {
|
||||
if (rootDir == null) {
|
||||
const userHome = process.env.HOME || process.env.USERPROFILE;
|
||||
rootDir = userHome + '/.certd/storage/';
|
||||
rootDir = userHome + "/.certd/storage/";
|
||||
}
|
||||
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import mitt from 'mitt';
|
||||
import mitt from "mitt";
|
||||
export const mitter = mitt();
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,58 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/pipeline
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/pipeline
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Features
|
||||
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
* 支持在线插件下载安装审核等 ([255960d](https://github.com/certd/certd/commit/255960d434eebb3d567a08f737f1642ab417b5d0))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **pipeline:** 修复过期/损坏的安装锁导致的依赖安装失败 ([1017a38](https://github.com/certd/certd/commit/1017a38eb48d6778745d4905d9ec4f6067a261d8))
|
||||
|
||||
### Features
|
||||
|
||||
* **pipeline:** 新增流水线批量编辑、阶段宽度调整功能 ([e5a1844](https://github.com/certd/certd/commit/e5a18448522be88bc504a45a46ad599f3c39ca30))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 统一通知类型常量管理,新增开放接口通知能力 ([edb249c](https://github.com/certd/certd/commit/edb249c2ad3c173dbfd04d8d156f895a6706ffd1))
|
||||
* 优化第三方依赖安装失败日志输出 ([18b2d3a](https://github.com/certd/certd/commit/18b2d3ac2078d0c6636af897f8f66e4eb5ff832c))
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 优化动态加载依赖镜像地址,多次重试 ([5f53b81](https://github.com/certd/certd/commit/5f53b81c75dd242b4260ac08cae14c6d1a08a883))
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **pipeline:** 重构运行时依赖加载逻辑,修复火山引擎DNS解析报runtimeDepsService未初始化的bug ([ec69b8f](https://github.com/certd/certd/commit/ec69b8f11bfd4b20991aef74a72a47182ca79a9d))
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/pipeline
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/pipeline",
|
||||
"private": false,
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -14,15 +14,15 @@
|
||||
"build3": "rollup -c",
|
||||
"preview": "vite preview",
|
||||
"test": "mocha --loader=ts-node/esm",
|
||||
"test:unit": "cross-env NODE_ENV=unittest mocha --no-config --node-option no-warnings --node-option loader=ts-node/esm \"src/**/*.test.ts\"",
|
||||
"test:unit": "cross-env NODE_ENV=unittest mocha --node-option no-warnings --node-option loader=ts-node/esm \"src/**/*.test.ts\"",
|
||||
"pub": "npm publish",
|
||||
"compile": "tsc --skipLibCheck --watch",
|
||||
"format": "prettier --write src",
|
||||
"lint": "eslint --fix"
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/basic": "^1.42.3",
|
||||
"@certd/plus-core": "^1.42.3",
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/plus-core": "^1.44.2",
|
||||
"dayjs": "^1.11.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
@@ -51,5 +51,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FormItemProps } from "../dt/index.js";
|
||||
import { HttpClient, ILogger, utils } from "@certd/basic";
|
||||
import * as _ from "lodash-es";
|
||||
import { PluginRequestHandleReq } from "../plugin/index.js";
|
||||
import { IRuntimeDepsService, IServiceGetter } from "../service/index.js";
|
||||
import { IServiceGetter, getRuntimeDepsService } from "../service/index.js";
|
||||
|
||||
// export type AccessRequestHandleReqInput<T = any> = {
|
||||
// id?: number;
|
||||
@@ -48,23 +48,13 @@ export type AccessContext = {
|
||||
|
||||
export abstract class BaseAccess implements IAccess {
|
||||
ctx!: AccessContext;
|
||||
runtimeDepsService?: IRuntimeDepsService;
|
||||
|
||||
async importRuntime(specifier: string) {
|
||||
if (!this.runtimeDepsService) {
|
||||
throw new Error("runtimeDepsService 未初始化");
|
||||
}
|
||||
return await this.runtimeDepsService.importRuntime(specifier, this.ctx.logger);
|
||||
return await getRuntimeDepsService().importRuntime(specifier, this.ctx.logger);
|
||||
}
|
||||
|
||||
async setCtx(ctx: AccessContext) {
|
||||
this.ctx = ctx;
|
||||
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
|
||||
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
|
||||
}
|
||||
if (this.runtimeDepsService && this.ctx.define?.name) {
|
||||
await this.runtimeDepsService.ensureRuntimeDependencies({ pluginKeys: `access:${this.ctx.define.name}`, logger: this.ctx.logger });
|
||||
}
|
||||
}
|
||||
|
||||
async onRequest(req: AccessRequestHandleReq) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AccessInput(input?: AccessInputDefine): PropertyDecorator {
|
||||
};
|
||||
}
|
||||
|
||||
export async function newAccess(type: string, input: any, accessService: IAccessService, ctx?: AccessContext) {
|
||||
export async function newAccess(type: string, input: any, accessService: IAccessService, ctx: AccessContext) {
|
||||
const register = accessRegistry.get(type);
|
||||
if (register == null) {
|
||||
throw new Error(`access ${type} not found`);
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import assert from "assert";
|
||||
import { Executor } from "./executor.js";
|
||||
import { RunHistory, RunnableCollection } from "./run-history.js";
|
||||
import { AbstractTaskPlugin, IsTaskPlugin, TaskInput, TaskOutput } from "../plugin/index.js";
|
||||
import { AfterTask, Pipeline, ResultType } from "../dt/index.js";
|
||||
|
||||
/**
|
||||
* 测试用后置任务插件:记录执行时的输入与流水线结果
|
||||
*/
|
||||
@IsTaskPlugin({
|
||||
name: "TestAfterTaskPlugin",
|
||||
title: "测试后置任务插件",
|
||||
supportAfterTask: true,
|
||||
})
|
||||
class TestAfterTaskPlugin extends AbstractTaskPlugin {
|
||||
static calls: { message: string; pipelineResult: ResultType | undefined }[] = [];
|
||||
@TaskInput({ title: "消息" })
|
||||
message: string = "";
|
||||
|
||||
async execute() {
|
||||
TestAfterTaskPlugin.calls.push({
|
||||
message: this.message,
|
||||
pipelineResult: this.pipelineResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用失败插件:execute 直接抛错
|
||||
*/
|
||||
@IsTaskPlugin({
|
||||
name: "TestAfterTaskFailPlugin",
|
||||
title: "测试失败插件",
|
||||
supportAfterTask: true,
|
||||
})
|
||||
class TestAfterTaskFailPlugin extends AbstractTaskPlugin {
|
||||
async execute() {
|
||||
throw new Error("模拟后置任务失败");
|
||||
}
|
||||
}
|
||||
// 通过类型字符串引用(插件注册由装饰器完成)
|
||||
void TestAfterTaskFailPlugin;
|
||||
|
||||
/**
|
||||
* 测试用跳过插件:execute 写入输出属性后返回 skip(模拟申请证书插件证书未过期时跳过)
|
||||
*/
|
||||
@IsTaskPlugin({
|
||||
name: "TestSkipOutputPlugin",
|
||||
title: "测试跳过输出插件",
|
||||
})
|
||||
class TestSkipOutputPlugin extends AbstractTaskPlugin {
|
||||
@TaskOutput({ title: "证书" })
|
||||
cert: any;
|
||||
|
||||
async execute() {
|
||||
this.cert = { crt: "mock-crt", key: "mock-key" };
|
||||
return "skip";
|
||||
}
|
||||
}
|
||||
void TestSkipOutputPlugin;
|
||||
|
||||
function createPipeline(afterTasks?: AfterTask[]): Pipeline {
|
||||
return {
|
||||
id: 1,
|
||||
title: "测试流水线",
|
||||
userId: 1,
|
||||
stages: [],
|
||||
afterTasks,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createExecutor(pipeline: Pipeline): Executor {
|
||||
const executor = new Executor({
|
||||
pipeline,
|
||||
storage: {} as any,
|
||||
onChanged: async () => {},
|
||||
onFinished: async () => {},
|
||||
accessService: {} as any,
|
||||
emailService: {} as any,
|
||||
notificationService: { send: async () => {} } as any,
|
||||
cnameProxyService: {} as any,
|
||||
pluginConfigService: { getPluginConfig: async () => ({ sysSetting: {} }) } as any,
|
||||
urlService: { getPipelineDetailUrl: async () => "http://localhost/detail" } as any,
|
||||
user: { id: 1, role: "user" },
|
||||
serviceGetter: {} as any,
|
||||
});
|
||||
executor.runtime = new RunHistory(1, { type: "user" }, executor.pipeline);
|
||||
executor.currentStatusMap = new RunnableCollection(pipeline);
|
||||
executor.lastStatusMap = new RunnableCollection(undefined);
|
||||
return executor;
|
||||
}
|
||||
|
||||
describe("Executor 后置任务", () => {
|
||||
beforeEach(() => {
|
||||
TestAfterTaskPlugin.calls = [];
|
||||
});
|
||||
|
||||
it("成功条件下执行后置任务,并记录状态与日志", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at1", title: "吊销旧证书", when: ["success"], type: "TestAfterTaskPlugin", input: { message: "hello" } },
|
||||
]);
|
||||
const executor = createExecutor(pipeline);
|
||||
// 模拟 runWithHistory 已写入流水线最终状态
|
||||
executor.pipeline.status = { output: {}, status: ResultType.success, result: ResultType.success, startTime: Date.now() };
|
||||
|
||||
await (executor as any).runAfterTasks({ result: ResultType.success });
|
||||
|
||||
// 插件已执行,且能读取流水线运行结果
|
||||
assert.equal(TestAfterTaskPlugin.calls.length, 1);
|
||||
assert.equal(TestAfterTaskPlugin.calls[0].message, "hello");
|
||||
assert.equal(TestAfterTaskPlugin.calls[0].pipelineResult, ResultType.success);
|
||||
// 状态记录为成功
|
||||
const afterTask = executor.pipeline.afterTasks![0];
|
||||
assert.equal(afterTask.status?.result, ResultType.success);
|
||||
// 日志写入运行历史(key: afterTask.at1),可点击查看
|
||||
const logs = executor.runtime.logs["afterTask.at1"];
|
||||
assert.ok(logs && logs.length > 0);
|
||||
assert.ok(logs.some(line => line.includes("执行成功")));
|
||||
});
|
||||
|
||||
it("条件不满足时(失败条件、实际成功)不执行后置任务,记录未触发日志与跳过状态", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at2", title: "失败后清理", when: ["error"], type: "TestAfterTaskPlugin", input: {} },
|
||||
]);
|
||||
const executor = createExecutor(pipeline);
|
||||
|
||||
await (executor as any).runAfterTasks({ result: ResultType.success });
|
||||
|
||||
assert.equal(TestAfterTaskPlugin.calls.length, 0);
|
||||
// 未触发:状态标记为 skip(与任务 skip 同语义),日志记录原因
|
||||
const afterTask = executor.pipeline.afterTasks![0];
|
||||
assert.equal(afterTask.status?.result, ResultType.skip);
|
||||
const logs = executor.runtime.logs["afterTask.at2"];
|
||||
assert.ok(logs && logs.length > 0);
|
||||
assert.ok(logs.some(line => line.includes("后置任务未触发")));
|
||||
});
|
||||
|
||||
it("失败转成功条件:上次失败本次成功才触发", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at3", title: "转成功通知", when: ["turnToSuccess"], type: "TestAfterTaskPlugin", input: {} },
|
||||
]);
|
||||
const executor = createExecutor(pipeline);
|
||||
executor.lastRuntime = {
|
||||
pipeline: { status: { status: ResultType.error, result: ResultType.error } },
|
||||
} as any;
|
||||
|
||||
await (executor as any).runAfterTasks({ result: ResultType.success });
|
||||
|
||||
assert.equal(TestAfterTaskPlugin.calls.length, 1);
|
||||
});
|
||||
|
||||
it("上次也成功时,失败转成功条件不触发", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at4", title: "转成功通知", when: ["turnToSuccess"], type: "TestAfterTaskPlugin", input: {} },
|
||||
]);
|
||||
const executor = createExecutor(pipeline);
|
||||
executor.lastRuntime = {
|
||||
pipeline: { status: { status: ResultType.success, result: ResultType.success } },
|
||||
} as any;
|
||||
|
||||
await (executor as any).runAfterTasks({ result: ResultType.success });
|
||||
|
||||
assert.equal(TestAfterTaskPlugin.calls.length, 0);
|
||||
});
|
||||
|
||||
it("后置任务失败时返回错误(流水线整体视为失败),不再单独发送通知", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at5", title: "可能失败的任务", when: ["success"], type: "TestAfterTaskFailPlugin", input: {} },
|
||||
]);
|
||||
const executor = createExecutor(pipeline);
|
||||
let noticeSent = false;
|
||||
(executor as any).options.notificationService.send = async (req: any) => {
|
||||
noticeSent = true;
|
||||
};
|
||||
|
||||
// runAfterTasks 抛出聚合错误(后置任务失败不再单独发送通知)
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await (executor as any).runAfterTasks({ result: ResultType.success });
|
||||
},
|
||||
(err: any) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes("后置任务[可能失败的任务]执行失败"));
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
const afterTask = executor.pipeline.afterTasks![0];
|
||||
assert.equal(afterTask.status?.result, ResultType.error);
|
||||
assert.equal(afterTask.status?.message, "模拟后置任务失败");
|
||||
assert.equal(noticeSent, false);
|
||||
// 失败日志已记录
|
||||
const logs = executor.runtime.logs["afterTask.at5"];
|
||||
assert.ok(logs.some(line => line.includes("执行失败")));
|
||||
});
|
||||
|
||||
it("disabled 的后置任务不执行,记录禁用日志与状态", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at6", title: "禁用任务", when: ["success"], type: "TestAfterTaskPlugin", input: {}, disabled: true },
|
||||
]);
|
||||
const executor = createExecutor(pipeline);
|
||||
|
||||
await (executor as any).runAfterTasks({ result: ResultType.success });
|
||||
|
||||
assert.equal(TestAfterTaskPlugin.calls.length, 0);
|
||||
// 禁用:状态标记为 disabled(与任务 disabled 同语义),日志记录原因
|
||||
const afterTask = executor.pipeline.afterTasks![0];
|
||||
assert.equal(afterTask.status?.result, ResultType.disabled);
|
||||
const logs = executor.runtime.logs["afterTask.at6"];
|
||||
assert.ok(logs && logs.length > 0);
|
||||
assert.ok(logs.some(line => line.includes("已禁用")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Executor 通知日志", () => {
|
||||
it("通知发送成功时记录日志并写入成功状态", async () => {
|
||||
const pipeline = createPipeline();
|
||||
pipeline.notifications = [
|
||||
{ id: "n1", title: "成功通知", type: "other", when: ["success"], notificationId: 0 },
|
||||
];
|
||||
const executor = createExecutor(pipeline);
|
||||
let sendCalled = false;
|
||||
(executor as any).options.notificationService.send = async () => {
|
||||
sendCalled = true;
|
||||
};
|
||||
|
||||
await executor.notification("success");
|
||||
|
||||
assert.equal(sendCalled, true);
|
||||
// 状态写在 executor.pipeline(cloneDeep 后的实例)上,随运行历史保存
|
||||
const notification = executor.pipeline.notifications![0];
|
||||
assert.equal(notification.status?.result, ResultType.success);
|
||||
const logs = executor.runtime.logs["notification.n1"];
|
||||
assert.ok(logs && logs.length > 0);
|
||||
assert.ok(logs.some(line => line.includes("通知发送成功")));
|
||||
});
|
||||
|
||||
it("通知发送失败时记录日志与失败状态", async () => {
|
||||
const pipeline = createPipeline();
|
||||
pipeline.notifications = [
|
||||
{ id: "n2", title: "失败通知", type: "other", when: ["success"], notificationId: 0 },
|
||||
];
|
||||
const executor = createExecutor(pipeline);
|
||||
(executor as any).options.notificationService.send = async () => {
|
||||
throw new Error("通知渠道异常");
|
||||
};
|
||||
|
||||
await executor.notification("success");
|
||||
|
||||
const notification = executor.pipeline.notifications![0];
|
||||
assert.equal(notification.status?.result, ResultType.error);
|
||||
const logs = executor.runtime.logs["notification.n2"];
|
||||
assert.ok(logs.some(line => line.includes("发送失败")));
|
||||
});
|
||||
|
||||
it("通知触发条件不满足时记录未触发日志,并标记跳过状态", async () => {
|
||||
const pipeline = createPipeline();
|
||||
pipeline.notifications = [
|
||||
{ id: "n3", title: "失败才通知", type: "other", when: ["error"], notificationId: 0 },
|
||||
];
|
||||
const executor = createExecutor(pipeline);
|
||||
let sendCalled = false;
|
||||
(executor as any).options.notificationService.send = async () => {
|
||||
sendCalled = true;
|
||||
};
|
||||
|
||||
// 当前时机为 success,通知条件为 error:不发送
|
||||
await executor.notification("success");
|
||||
|
||||
assert.equal(sendCalled, false);
|
||||
// 状态标记为 skip(与任务 skip 同语义,前端显示跳过图标)
|
||||
const notification = executor.pipeline.notifications![0];
|
||||
assert.equal(notification.status?.result, ResultType.skip);
|
||||
// 日志已记录未触发原因,点击通知可查看
|
||||
const logs = executor.runtime.logs["notification.n3"];
|
||||
assert.ok(logs && logs.length > 0);
|
||||
assert.ok(logs.some(line => line.includes("通知未触发")));
|
||||
});
|
||||
|
||||
it("通知已发送后,其他时机条件不满足不覆盖已发送状态", async () => {
|
||||
const pipeline = createPipeline();
|
||||
pipeline.notifications = [
|
||||
{ id: "n4", title: "开始时通知", type: "other", when: ["start", "error"], notificationId: 0 },
|
||||
];
|
||||
const executor = createExecutor(pipeline);
|
||||
let sendCalled = false;
|
||||
(executor as any).options.notificationService.send = async () => {
|
||||
sendCalled = true;
|
||||
};
|
||||
|
||||
// start 时机触发发送成功
|
||||
await executor.notification("start");
|
||||
assert.equal(sendCalled, true);
|
||||
assert.equal(executor.pipeline.notifications![0].status?.result, ResultType.success);
|
||||
// success 时机条件不满足:不应覆盖已发送的成功状态
|
||||
await executor.notification("success");
|
||||
assert.equal(executor.pipeline.notifications![0].status?.result, ResultType.success);
|
||||
});
|
||||
|
||||
it("历史残留状态被运行时清空后,未触发能正确写入跳过状态", async () => {
|
||||
const pipeline = createPipeline();
|
||||
// 模拟历史残留:上一次运行发送成功的状态残留在通知上
|
||||
pipeline.notifications = [
|
||||
{
|
||||
id: "n5",
|
||||
title: "失败才通知",
|
||||
type: "other",
|
||||
when: ["error"],
|
||||
notificationId: 0,
|
||||
status: { output: {}, status: ResultType.success, result: ResultType.success, startTime: 1, endTime: 2 },
|
||||
},
|
||||
];
|
||||
const executor = createExecutor(pipeline);
|
||||
// 运行时清空通知/后置任务的历史状态
|
||||
executor.runtime.clearNotificationStatus();
|
||||
|
||||
// 当前时机为 success,通知条件为 error:未触发应写入 skip,而不是沿用残留的 success
|
||||
await executor.notification("success");
|
||||
|
||||
const notification = executor.pipeline.notifications![0];
|
||||
assert.equal(notification.status?.result, ResultType.skip);
|
||||
});
|
||||
|
||||
it("clearNotificationStatus 清空通知与后置任务的历史状态", () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at9", title: "后置任务", when: ["success"], type: "TestAfterTaskPlugin", input: {} },
|
||||
]);
|
||||
pipeline.notifications = [{ id: "n6", title: "通知", type: "other", when: ["error"], notificationId: 0 }];
|
||||
pipeline.notifications[0].status = { output: {}, status: ResultType.success, result: ResultType.success, startTime: 1, endTime: 2 };
|
||||
pipeline.afterTasks![0].status = { output: {}, status: ResultType.success, result: ResultType.success, startTime: 1, endTime: 2 };
|
||||
|
||||
const runtime = new RunHistory(1, { type: "user" }, pipeline);
|
||||
runtime.clearNotificationStatus();
|
||||
|
||||
assert.equal(pipeline.notifications![0].status, undefined);
|
||||
assert.equal(pipeline.afterTasks![0].status, undefined);
|
||||
});
|
||||
|
||||
it("通知内容附加后置任务执行结果(成功/失败详情)", async () => {
|
||||
const pipeline = createPipeline([
|
||||
{ id: "at10", title: "吊销旧证书", when: ["success"], type: "TestAfterTaskPlugin", input: {} },
|
||||
{ id: "at11", title: "同步CDN", when: ["success"], type: "TestAfterTaskPlugin", input: {} },
|
||||
]);
|
||||
pipeline.notifications = [
|
||||
{ id: "n7", title: "结果通知", type: "other", when: ["success"], notificationId: 0 },
|
||||
];
|
||||
const executor = createExecutor(pipeline);
|
||||
// 模拟后置任务执行结果:一个成功、一个失败
|
||||
executor.pipeline.afterTasks![0].status = {
|
||||
output: {},
|
||||
status: ResultType.success,
|
||||
result: ResultType.success,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
};
|
||||
executor.pipeline.afterTasks![1].status = {
|
||||
output: {},
|
||||
status: ResultType.error,
|
||||
result: ResultType.error,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
message: "模拟CDN同步失败",
|
||||
};
|
||||
let content = "";
|
||||
(executor as any).options.notificationService.send = async (req: any) => {
|
||||
content = req.body.content;
|
||||
};
|
||||
|
||||
await executor.notification("success");
|
||||
|
||||
assert.ok(content.includes("后置任务执行结果"));
|
||||
assert.ok(content.includes("吊销旧证书 执行成功"));
|
||||
assert.ok(content.includes("同步CDN 执行失败"));
|
||||
assert.ok(content.includes("模拟CDN同步失败"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Executor 步骤执行", () => {
|
||||
it("插件主动返回 skip 时,输出仍写回历史(下次运行可读取上次输出)", async () => {
|
||||
const pipeline = createPipeline();
|
||||
pipeline.stages = [
|
||||
{
|
||||
id: "stage1",
|
||||
title: "阶段",
|
||||
tasks: [
|
||||
{
|
||||
id: "task1",
|
||||
title: "任务",
|
||||
steps: [{ id: "step1", title: "申请证书", type: "TestSkipOutputPlugin", input: {} }],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
const executor = createExecutor(pipeline);
|
||||
const step: any = (pipeline.stages as any)[0].tasks[0].steps[0];
|
||||
|
||||
const res = await (executor as any).runWithHistory(step, "step", async () => {
|
||||
return await (executor as any).runStep(step);
|
||||
});
|
||||
|
||||
assert.equal(res, ResultType.skip);
|
||||
// 跳过时插件写入实例的输出属性应写回历史状态(否则下次运行读不到上次的证书)
|
||||
assert.ok(step.status.output.cert, "跳过时输出应写回历史");
|
||||
assert.equal(step.status.output.cert.crt, "mock-crt");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ConcurrencyStrategy, NotificationWhen, Pipeline, ResultType, Runnable, RunStrategy, Stage, Step, Task, ResultError } from "../dt/index.js";
|
||||
import { AfterTask, ConcurrencyStrategy, HistoryResult, NotificationWhen, Pipeline, ResultType, Runnable, RunStrategy, Stage, Step, Task, ResultError } from "../dt/index.js";
|
||||
import { RunHistory, RunnableCollection } from "./run-history.js";
|
||||
import { AbstractTaskPlugin, ITaskPlugin, PluginDefine, pluginRegistry, TaskInstanceContext, UserInfo } from "../plugin/index.js";
|
||||
import { ContextFactory, IContext } from "./context.js";
|
||||
import { IStorage } from "./storage.js";
|
||||
import { createAxiosService, hashUtils, HttpRequestConfig, ILogger, logger, utils } from "@certd/basic";
|
||||
import { buildLogger, createAxiosService, hashUtils, HttpRequestConfig, ILogger, logger, utils } from "@certd/basic";
|
||||
import { IAccessService } from "../access/index.js";
|
||||
import { RegistryItem } from "../registry/index.js";
|
||||
import { Decorator } from "../decorator/index.js";
|
||||
import { ICnameProxyService, IEmailService, IPluginConfigService, IServiceGetter, IUrlService } from "../service/index.js";
|
||||
import { FileStore } from "./file-store.js";
|
||||
import { cloneDeep, forEach, merge } from "lodash-es";
|
||||
import { INotificationService } from "../notification/index.js";
|
||||
import { taskEmitterCreate } from "../service/emit.js";
|
||||
import { INotificationService, PipelineNotificationTypes } from "../notification/index.js";
|
||||
import { pipelineEmitter } from "../service/emit.js";
|
||||
import { RunnableError } from "./exceptions.js";
|
||||
|
||||
export type SysInfo = {
|
||||
@@ -95,21 +95,44 @@ export class Executor {
|
||||
await this.notification("start");
|
||||
|
||||
this.runtime.start(this.pipeline);
|
||||
|
||||
//清理通知 和后置任务的状态(start 时机的状态不需要显示,此后从干净状态重新判断)
|
||||
this.runtime.clearNotificationStatus();
|
||||
intervalFlushLogId = setInterval(async () => {
|
||||
await this.onChanged(this.runtime);
|
||||
}, 5000);
|
||||
|
||||
const result = await this.runWithHistory(this.pipeline, "pipeline", async () => {
|
||||
return await this.runStages(this.pipeline);
|
||||
});
|
||||
if (result === ResultType.success) {
|
||||
let result: ResultType;
|
||||
let error: any = null;
|
||||
try {
|
||||
result = await this.runWithHistory(this.pipeline, "pipeline", async () => {
|
||||
const stagesResult = await this.runStages(this.pipeline);
|
||||
|
||||
// 执行后置任务(失败时流水线整体视为执行失败)
|
||||
const afterTaskResult = await this.runAfterTasks({ result: stagesResult });
|
||||
return this.compositionResultType([...afterTaskResult, stagesResult]);
|
||||
});
|
||||
} catch (e: any) {
|
||||
result = ResultType.error;
|
||||
error = e;
|
||||
this.logger.error("pipeline 执行失败", e);
|
||||
}
|
||||
|
||||
// 通知在后置任务之后发送(通知中附加后置任务执行结果与错误信息)
|
||||
const finalResult = result;
|
||||
if (finalResult === ResultType.success) {
|
||||
if (this.lastRuntime && this.lastRuntime.pipeline.status?.status === ResultType.error) {
|
||||
await this.notification("turnToSuccess");
|
||||
} else {
|
||||
await this.notification("success");
|
||||
}
|
||||
} else if (finalResult === ResultType.skip) {
|
||||
await this.notification("skip");
|
||||
} else if (finalResult === ResultType.error) {
|
||||
await this.notification("error", error);
|
||||
}
|
||||
return result;
|
||||
|
||||
return finalResult;
|
||||
} catch (e: any) {
|
||||
await this.notification("error", e);
|
||||
this.logger.error("pipeline 执行失败", e);
|
||||
@@ -277,8 +300,53 @@ export class Executor {
|
||||
private async runStep(step: Step) {
|
||||
const currentLogger = this.runtime._loggers[step.id];
|
||||
this.currentStatusMap.add(step);
|
||||
const lastStatus = this.lastStatusMap.get(step.id);
|
||||
//执行任务
|
||||
const plugin: RegistryItem<AbstractTaskPlugin> = pluginRegistry.get(step.type);
|
||||
// @ts-ignore
|
||||
const define: PluginDefine = plugin?.define;
|
||||
|
||||
const { instance, result, skipped } = await this.executePlugin(step, currentLogger);
|
||||
if (result === ResultType.skip && skipped) {
|
||||
// SkipWhenSucceed 跳过:状态与输出已在 executePlugin 内拷贝,无需结果处理
|
||||
return result;
|
||||
}
|
||||
|
||||
//执行结果处理(插件主动返回 skip 时也会执行:插件可能已把输出写入实例属性,需写回历史)
|
||||
if (instance._result.clearLastStatus) {
|
||||
//是否需要清除所有状态
|
||||
this.lastStatusMap.clear();
|
||||
}
|
||||
//输出上下文变量到output context
|
||||
forEach(define.output, (item: any, key: any) => {
|
||||
step.status!.output[key] = instance[key];
|
||||
});
|
||||
step.status!.files = instance.getFiles();
|
||||
//更新pipeline vars
|
||||
if (Object.keys(instance._result.pipelineVars).length > 0) {
|
||||
// 判断 pipelineVars 有值时更新
|
||||
let vars = await this.pipelineContext.getObj("vars");
|
||||
vars = vars || {};
|
||||
merge(vars, instance._result.pipelineVars);
|
||||
await this.pipelineContext.setObj("vars", vars);
|
||||
}
|
||||
if (Object.keys(instance._result.pipelinePrivateVars).length > 0) {
|
||||
// 判断 pipelineVars 有值时更新
|
||||
let vars = await this.pipelineContext.getObj("privateVars");
|
||||
vars = vars || {};
|
||||
merge(vars, instance._result.pipelinePrivateVars);
|
||||
await this.pipelineContext.setObj("privateVars", vars);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化插件并执行(任务步骤与后置任务共用同一执行链路)
|
||||
* 负责:插件实例化、系统参数注入、output-selector 解析、参数哈希与跳过判断、执行上下文构建与插件执行。
|
||||
* 后置任务通过伪步骤(无 strategy)复用,SkipWhenSucceed 跳过判断自然不触发。
|
||||
* @param step 步骤(后置任务传伪步骤)
|
||||
* @param currentLogger 当前节点日志logger
|
||||
*/
|
||||
private async executePlugin(step: Step, currentLogger: ILogger): Promise<{ instance: ITaskPlugin; result: any; skipped: boolean }> {
|
||||
const plugin: RegistryItem<AbstractTaskPlugin> = pluginRegistry.get(step.type);
|
||||
if (!plugin) {
|
||||
currentLogger.error(`未找到插件${step.type}`);
|
||||
@@ -295,13 +363,11 @@ export class Executor {
|
||||
currentLogger.error(`实例化插件失败:${step.type}:${e.message}`);
|
||||
throw new Error(`实例化插件失败`, e);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const define: PluginDefine = plugin.define;
|
||||
const pluginName = define.name;
|
||||
const pluginConfig = await this.options.pluginConfigService.getPluginConfig(pluginName);
|
||||
//从outputContext读取输入参数
|
||||
const input = cloneDeep(step.input);
|
||||
const pluginConfig = await this.options.pluginConfigService.getPluginConfig(define.name);
|
||||
//从配置读取输入参数
|
||||
const input = cloneDeep(step.input || {});
|
||||
const sysInput = pluginConfig.sysSetting?.input || {};
|
||||
//注入系统设置参数
|
||||
for (const sysInputKey in sysInput) {
|
||||
@@ -327,22 +393,25 @@ export class Executor {
|
||||
}
|
||||
});
|
||||
|
||||
//计算参数哈希与是否变化(供插件 ctx.inputChanged 与跳过判断使用;后置任务伪步骤不在 lastStatusMap 中,恒为 true)
|
||||
const newInputHash = hashUtils.md5(JSON.stringify(input));
|
||||
step.status!.inputHash = newInputHash;
|
||||
//判断是否需要跳过
|
||||
const lastNode = this.lastStatusMap.get(step.id);
|
||||
const lastResult = lastNode?.status?.status;
|
||||
if (step.status) {
|
||||
step.status.inputHash = newInputHash;
|
||||
}
|
||||
const lastStatus = this.lastStatusMap.get(step.id);
|
||||
let inputChanged = true;
|
||||
const lastInputHash = lastNode?.status?.inputHash;
|
||||
const lastInputHash = lastStatus?.status?.inputHash;
|
||||
if (lastInputHash && newInputHash && lastInputHash === newInputHash) {
|
||||
//参数没有变化
|
||||
inputChanged = false;
|
||||
}
|
||||
//跳过判断(SkipWhenSucceed 策略;后置任务伪步骤无 strategy 不触发)
|
||||
if (step.strategy?.runStrategy === RunStrategy.SkipWhenSucceed && define.runStrategy !== RunStrategy.AlwaysRun) {
|
||||
const lastResult = lastStatus?.status?.status;
|
||||
if (lastResult != null && lastResult === ResultType.success && !inputChanged) {
|
||||
step.status!.output = lastNode?.status?.output;
|
||||
step.status!.files = lastNode?.status?.files;
|
||||
return ResultType.skip;
|
||||
step.status!.output = lastStatus?.status?.output;
|
||||
step.status!.files = lastStatus?.status?.files;
|
||||
return { instance, result: ResultType.skip, skipped: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,47 +450,220 @@ export class Executor {
|
||||
signal: this.abort.signal,
|
||||
utils,
|
||||
user: this.options.user,
|
||||
emitter: taskEmitterCreate({
|
||||
step,
|
||||
pipeline: this.pipeline,
|
||||
}),
|
||||
emitter: pipelineEmitter,
|
||||
serviceGetter: this.options.serviceGetter,
|
||||
projectId: this.pipeline.projectId,
|
||||
};
|
||||
await instance.setCtx(taskCtx);
|
||||
|
||||
if (!(instance instanceof AbstractTaskPlugin)) {
|
||||
throw new Error(`插件类型错误:${step.type}不是AbstractTaskPlugin的实例`);
|
||||
}
|
||||
await instance.onInstance();
|
||||
const result = await instance.execute();
|
||||
//执行结果处理
|
||||
if (instance._result.clearLastStatus) {
|
||||
//是否需要清除所有状态
|
||||
this.lastStatusMap.clear();
|
||||
}
|
||||
//输出上下文变量到output context
|
||||
forEach(define.output, (item: any, key: any) => {
|
||||
step.status!.output[key] = instance[key];
|
||||
// const stepOutputKey = `step.${step.id}.${key}`;
|
||||
// this.runtime.context[stepOutputKey] = instance[key];
|
||||
});
|
||||
step.status!.files = instance.getFiles();
|
||||
//更新pipeline vars
|
||||
if (Object.keys(instance._result.pipelineVars).length > 0) {
|
||||
// 判断 pipelineVars 有值时更新
|
||||
let vars = await this.pipelineContext.getObj("vars");
|
||||
vars = vars || {};
|
||||
merge(vars, instance._result.pipelineVars);
|
||||
await this.pipelineContext.setObj("vars", vars);
|
||||
}
|
||||
if (Object.keys(instance._result.pipelinePrivateVars).length > 0) {
|
||||
// 判断 pipelineVars 有值时更新
|
||||
let vars = await this.pipelineContext.getObj("privateVars");
|
||||
vars = vars || {};
|
||||
merge(vars, instance._result.pipelinePrivateVars);
|
||||
await this.pipelineContext.setObj("privateVars", vars);
|
||||
}
|
||||
return { instance, result, skipped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后置任务(流水线运行结束后触发)
|
||||
* 后置任务失败时流水线整体视为执行失败:收集所有失败任务的错误信息返回(不再单独发送通知)
|
||||
* @returns 聚合错误(有后置任务失败时返回 Error,否则返回 null)
|
||||
*/
|
||||
private async runAfterTasks(opts: { result: ResultType }): Promise<ResultType[]> {
|
||||
const { result } = opts;
|
||||
const pipelineResult = result;
|
||||
if (!this.pipeline.afterTasks || this.pipeline.afterTasks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const afterTaskResults: ResultType[] = [];
|
||||
const errorList: { afterTask: AfterTask; err: Error }[] = [];
|
||||
for (const afterTask of this.pipeline.afterTasks) {
|
||||
if (this.abort.signal.aborted) {
|
||||
//用户取消,不再执行后续后置任务
|
||||
break;
|
||||
}
|
||||
if (afterTask.disabled) {
|
||||
// 已禁用:记录日志与状态(与任务 disabled 同语义)
|
||||
await this.markAfterTaskSkipped(afterTask, ResultType.disabled, `后置任务已禁用,不执行`, pipelineResult);
|
||||
continue;
|
||||
}
|
||||
if (!this.matchAfterTaskWhen(afterTask, pipelineResult)) {
|
||||
// 触发条件不满足:记录日志与跳过状态(与任务 skip 同语义)
|
||||
await this.markAfterTaskSkipped(afterTask, ResultType.skip, `后置任务未触发:当前运行结果 ${pipelineResult},触发条件 ${(afterTask.when || []).join(",")}`, pipelineResult);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await this.runOneAfterTask(afterTask);
|
||||
afterTaskResults.push(res);
|
||||
} catch (err: any) {
|
||||
errorList.push({ afterTask, err });
|
||||
}
|
||||
}
|
||||
if (errorList.length > 0) {
|
||||
// 聚合所有失败后置任务的错误信息,抛出后由 runWithHistory 将流水线整体标记为失败
|
||||
const errMessage = errorList.map(item => `后置任务[${item.afterTask.title}]执行失败:${item.err.message}`).join("\n");
|
||||
throw new Error(errMessage);
|
||||
}
|
||||
return afterTaskResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录后置任务未执行(未触发/禁用)的日志与状态
|
||||
* 日志与任务一致包含“开始/结束”两条;状态与任务 skip/disabled 一致,前端显示对应图标
|
||||
*/
|
||||
private async markAfterTaskSkipped(afterTask: AfterTask, result: ResultType, message: string, pipelineResult: ResultType) {
|
||||
const logKey = this.buildAfterTaskLogKey(afterTask);
|
||||
const taskLogger = this.buildTaskLogger(logKey);
|
||||
const now = new Date().getTime();
|
||||
taskLogger.info(message);
|
||||
afterTask.status = {
|
||||
output: {},
|
||||
status: result === ResultType.disabled ? ResultType.canceled : ResultType.skip,
|
||||
result,
|
||||
startTime: now,
|
||||
endTime: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断后置任务触发条件是否满足(与通知 when 同语义)
|
||||
*/
|
||||
private matchAfterTaskWhen(afterTask: AfterTask, pipelineResult: ResultType): boolean {
|
||||
const whenList = afterTask.when || [];
|
||||
if (whenList.includes("success") && pipelineResult === ResultType.success) {
|
||||
return true;
|
||||
}
|
||||
if (whenList.includes("error") && pipelineResult === ResultType.error) {
|
||||
return true;
|
||||
}
|
||||
// 失败转成功:本次成功且上一次运行失败
|
||||
if (whenList.includes("turnToSuccess") && pipelineResult === ResultType.success && this.lastRuntime?.pipeline?.status?.status === ResultType.error) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个后置任务:记录状态与日志,失败时返回错误(由流水线整体承担失败结果)
|
||||
* @returns 失败时返回错误对象,成功/跳过/取消返回 null
|
||||
*/
|
||||
private async runOneAfterTask(afterTask: AfterTask): Promise<ResultType> {
|
||||
const logKey = this.buildAfterTaskLogKey(afterTask);
|
||||
const taskLogger = this.buildTaskLogger(logKey);
|
||||
const status: HistoryResult = {
|
||||
output: {},
|
||||
status: ResultType.start,
|
||||
result: ResultType.start,
|
||||
startTime: new Date().getTime(),
|
||||
};
|
||||
afterTask.status = status;
|
||||
taskLogger.info(`后置任务开始执行,触发条件:${(afterTask.when || []).join(",")}`);
|
||||
try {
|
||||
if (this.abort.signal.aborted) {
|
||||
//用户取消
|
||||
status.status = ResultType.canceled;
|
||||
status.result = ResultType.canceled;
|
||||
status.endTime = new Date().getTime();
|
||||
status.message = "用户取消";
|
||||
taskLogger.warn("用户取消,后置任务不执行");
|
||||
return ResultType.canceled;
|
||||
}
|
||||
const result = await this.executeAfterTaskPlugin(afterTask, taskLogger);
|
||||
if (result === "skip") {
|
||||
// 跳过:记录日志与状态(与任务 skip 同语义)
|
||||
await this.markAfterTaskSkipped(afterTask, ResultType.skip, `后置任务已跳过`, ResultType.success);
|
||||
return ResultType.skip;
|
||||
}
|
||||
status.status = ResultType.success;
|
||||
status.result = ResultType.success;
|
||||
status.endTime = new Date().getTime();
|
||||
taskLogger.info("后置任务执行成功");
|
||||
return ResultType.success;
|
||||
} catch (e: any) {
|
||||
status.status = ResultType.error;
|
||||
status.result = ResultType.error;
|
||||
status.endTime = new Date().getTime();
|
||||
status.message = e.message;
|
||||
taskLogger.error(`后置任务执行失败:`, e);
|
||||
// 抛出由 runAfterTasks 聚合;聚合错误抛出后由 runWithHistory 将流水线整体标记为失败(不再单独发送通知)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置任务日志在运行历史中的key(与任务日志同机制,可点击查看)
|
||||
*/
|
||||
private buildAfterTaskLogKey(afterTask: AfterTask): string {
|
||||
return `afterTask.${afterTask.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建一个写入运行历史日志的logger(任务/通知/后置任务共用)
|
||||
*/
|
||||
private buildTaskLogger(logKey: string): ILogger {
|
||||
if (this.runtime._loggers[logKey]) {
|
||||
return this.runtime._loggers[logKey];
|
||||
}
|
||||
const taskLogger = buildLogger((text: string) => {
|
||||
if (!this.runtime.logs[logKey]) {
|
||||
this.runtime.logs[logKey] = [];
|
||||
}
|
||||
this.runtime.logs[logKey].push(text);
|
||||
});
|
||||
this.runtime._loggers[logKey] = taskLogger;
|
||||
return taskLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后置任务插件:与任务步骤共用同一执行链路(伪Step,插件可访问流水线上下文与运行结果)
|
||||
*/
|
||||
private async executeAfterTaskPlugin(afterTask: AfterTask, taskLogger: ILogger): Promise<void | string> {
|
||||
const plugin: RegistryItem<AbstractTaskPlugin> = pluginRegistry.get(afterTask.type);
|
||||
// 伪步骤:与任务步骤完全相同的插件执行链路(无 strategy,跳过判断自然不触发)
|
||||
const pseudoStep: Step = {
|
||||
id: this.buildAfterTaskLogKey(afterTask),
|
||||
title: afterTask.title || plugin?.define?.name,
|
||||
type: afterTask.type,
|
||||
input: afterTask.input || {},
|
||||
status: afterTask.status,
|
||||
};
|
||||
const { result } = await this.executePlugin(pseudoStep, taskLogger);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集后置任务执行结果摘要(成功/失败/跳过等),用于附加到通知内容
|
||||
*/
|
||||
private buildAfterTaskSummary(): string {
|
||||
if (!this.pipeline.afterTasks || this.pipeline.afterTasks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const lines: string[] = [];
|
||||
const resultTextMap: Record<string, string> = {
|
||||
success: "执行成功",
|
||||
error: "执行失败",
|
||||
skip: "未触发",
|
||||
disabled: "已禁用",
|
||||
canceled: "已取消",
|
||||
};
|
||||
for (const afterTask of this.pipeline.afterTasks) {
|
||||
const result = afterTask.status?.result;
|
||||
if (result == null) {
|
||||
continue;
|
||||
}
|
||||
if (result === ResultType.error) {
|
||||
lines.push(` - ${afterTask.title} 执行失败,错误详情:${afterTask.status?.message || ""}`);
|
||||
} else {
|
||||
lines.push(` - ${afterTask.title} ${resultTextMap[result] || result}`);
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `\n\n后置任务执行结果:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
async notification(when: NotificationWhen, error?: any) {
|
||||
if (!this.pipeline.notifications) {
|
||||
return;
|
||||
@@ -463,6 +705,10 @@ export class Executor {
|
||||
errors = error.message;
|
||||
content = `流水线ID:${this.pipeline.id},运行ID:${this.runtime.id}\n\n${this.currentStatusMap?.currentStep?.title} 执行失败\n\n错误详情:${error.message}`;
|
||||
}
|
||||
} else if (when === "skip") {
|
||||
pipelineResult = "执行跳过";
|
||||
subject = `${pipelineResult},${this.pipeline.title}【${this.pipeline.id}】`;
|
||||
content = `流水线ID:${this.pipeline.id},运行ID:${this.runtime.id}\n\n${this.currentStatusMap?.currentStep?.title} 执行跳过\n`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -470,33 +716,62 @@ export class Executor {
|
||||
templateData.errors = errors;
|
||||
templateData.pipelineResult = pipelineResult;
|
||||
templateData.title = subject;
|
||||
// 附加后置任务执行结果(通知在后置任务之后发送,可看到后置任务的成功/失败详情)
|
||||
const afterTaskSummary = this.buildAfterTaskSummary();
|
||||
if (afterTaskSummary) {
|
||||
content += afterTaskSummary;
|
||||
}
|
||||
templateData.content = content;
|
||||
|
||||
for (const notification of this.pipeline.notifications) {
|
||||
// 记录通知日志(无论是否触发,便于确认通知发送情况)
|
||||
const logKey = `notification.${notification.id}`;
|
||||
const notificationLogger = this.buildTaskLogger(logKey);
|
||||
if (!notification.when.includes(when)) {
|
||||
// 触发条件不满足:记录“开始/结束”两条日志;若本次运行尚未触发过,则标记为跳过(与任务 skip 同语义)
|
||||
const skipMessage = `通知未触发:当前时机 ${when},通知触发条件 ${(notification.when || []).join(",")}`;
|
||||
notificationLogger.info(skipMessage);
|
||||
if (notification.status == null) {
|
||||
const now = new Date().getTime();
|
||||
notification.status = {
|
||||
output: {},
|
||||
status: ResultType.skip,
|
||||
result: ResultType.skip,
|
||||
startTime: now,
|
||||
endTime: now,
|
||||
message: skipMessage,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const notificationStatus: HistoryResult = {
|
||||
output: {},
|
||||
status: ResultType.start,
|
||||
result: ResultType.start,
|
||||
startTime: new Date().getTime(),
|
||||
};
|
||||
notification.status = notificationStatus;
|
||||
notificationLogger.info(`通知开始发送,触发时机:${when},通知方式:${notification.type},标题:${subject}`);
|
||||
|
||||
if (notification.type === "email" && notification.options?.receivers) {
|
||||
try {
|
||||
try {
|
||||
if (notification.type === "email" && notification.options?.receivers) {
|
||||
await this.options.emailService?.sendByTemplate({
|
||||
type: "pipelineResult",
|
||||
type: PipelineNotificationTypes.PipelineResult,
|
||||
data: templateData,
|
||||
receivers: notification.options?.receivers,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("send email error", e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
notificationStatus.status = ResultType.success;
|
||||
notificationStatus.result = ResultType.success;
|
||||
notificationLogger.info("邮件通知发送成功");
|
||||
} else {
|
||||
//发送通知
|
||||
await this.options.notificationService.send({
|
||||
id: notification.notificationId,
|
||||
useDefault: true,
|
||||
useEmail: false,
|
||||
logger: this.logger,
|
||||
logger: notificationLogger,
|
||||
body: {
|
||||
notificationType: "pipelineResult",
|
||||
notificationType: PipelineNotificationTypes.PipelineResult,
|
||||
title: subject,
|
||||
content,
|
||||
userId: this.pipeline.userId,
|
||||
@@ -507,9 +782,17 @@ export class Executor {
|
||||
...templateData,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("send notification error", e);
|
||||
notificationStatus.status = ResultType.success;
|
||||
notificationStatus.result = ResultType.success;
|
||||
notificationLogger.info("通知发送成功");
|
||||
}
|
||||
} catch (e: any) {
|
||||
notificationStatus.status = ResultType.error;
|
||||
notificationStatus.result = ResultType.error;
|
||||
notificationStatus.message = e.message;
|
||||
notificationLogger.error("通知发送失败:", e);
|
||||
} finally {
|
||||
notificationStatus.endTime = new Date().getTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,19 @@ export class RunHistory {
|
||||
this.trigger = trigger;
|
||||
}
|
||||
|
||||
clearNotificationStatus() {
|
||||
if (this.pipeline.notifications) {
|
||||
for (const item of this.pipeline!.notifications) {
|
||||
delete item.status;
|
||||
}
|
||||
}
|
||||
if (this.pipeline.afterTasks) {
|
||||
for (const item of this.pipeline!.afterTasks) {
|
||||
delete item.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start(runnable: Runnable): HistoryResult {
|
||||
const now = new Date().getTime();
|
||||
this.logs[runnable.id] = [];
|
||||
|
||||
@@ -47,6 +47,10 @@ export type Stage = Runnable & {
|
||||
concurrency: ConcurrencyStrategy;
|
||||
next: NextStrategy;
|
||||
maxTaskCount?: number;
|
||||
style?: {
|
||||
width?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
export type Trigger = {
|
||||
@@ -79,7 +83,7 @@ export type Runnable = {
|
||||
export type EmailOptions = {
|
||||
receivers: string[];
|
||||
};
|
||||
export type NotificationWhen = "error" | "success" | "turnToSuccess" | "start";
|
||||
export type NotificationWhen = "error" | "success" | "turnToSuccess" | "start" | "skip";
|
||||
export type NotificationType = "email" | "other";
|
||||
export type Notification = {
|
||||
type: NotificationType;
|
||||
@@ -88,14 +92,46 @@ export type Notification = {
|
||||
notificationId: number;
|
||||
title: string;
|
||||
id: string;
|
||||
// 运行时状态(随运行历史保存,用于画布展示与日志查看)
|
||||
status?: HistoryResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* 后置任务触发条件(与通知 when 同语义,去掉 start:后置任务只在流水线结束后触发)
|
||||
*/
|
||||
export type AfterTaskWhen = "success" | "error" | "turnToSuccess";
|
||||
|
||||
/**
|
||||
* 流水线后置任务:
|
||||
* 流水线整体运行结束后触发,执行一个特殊的插件任务(接收流水线上下文与运行结果),
|
||||
* 失败时流水线整体视为执行失败(不影响任务本身状态,但通知会附加失败信息)。
|
||||
* 是否需要等待由插件自身控制(如吊销旧证书插件内置等待时长参数)。
|
||||
*/
|
||||
export type AfterTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
// 触发条件:流水线最终结果为成功/失败/失败转成功
|
||||
when: AfterTaskWhen[];
|
||||
// 执行的插件类型(pluginRegistry 中注册的插件,需声明 supportAfterTask)
|
||||
type: string;
|
||||
// 插件输入参数(支持 output-selector 引用步骤输出)
|
||||
input: {
|
||||
[key: string]: any;
|
||||
};
|
||||
disabled?: boolean;
|
||||
// 运行时状态(随运行历史保存,用于画布展示成功/失败图标与日志查看)
|
||||
status?: HistoryResult;
|
||||
};
|
||||
|
||||
export type Pipeline = Runnable & {
|
||||
version?: number;
|
||||
userId: any;
|
||||
projectId?: number;
|
||||
stages: Stage[];
|
||||
triggers: Trigger[];
|
||||
notifications?: Notification[];
|
||||
// 流水线运行结束后触发的后置任务(失败时流水线整体视为执行失败)
|
||||
afterTasks?: AfterTask[];
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Registrable } from "../registry/index.js";
|
||||
import { FormItemProps, HistoryResult, Pipeline } from "../dt/index.js";
|
||||
import { HttpClient, ILogger, utils } from "@certd/basic";
|
||||
import * as _ from "lodash-es";
|
||||
import { IEmailService, IRuntimeDepsService, IServiceGetter } from "../service/index.js";
|
||||
import { IEmailService, IServiceGetter, getRuntimeDepsService } from "../service/index.js";
|
||||
|
||||
export type NotificationBody = {
|
||||
userId?: number;
|
||||
@@ -89,16 +89,16 @@ export abstract class BaseNotification implements INotification {
|
||||
ctx!: NotificationContext;
|
||||
http!: HttpClient;
|
||||
logger!: ILogger;
|
||||
runtimeDepsService?: IRuntimeDepsService;
|
||||
|
||||
async importRuntime(specifier: string) {
|
||||
if (!this.runtimeDepsService) {
|
||||
return await import(specifier);
|
||||
}
|
||||
return await this.runtimeDepsService.importRuntime(specifier, this.logger);
|
||||
return await getRuntimeDepsService().importRuntime(specifier, this.logger);
|
||||
}
|
||||
|
||||
async doSend(body: NotificationBody) {
|
||||
if (body.content) {
|
||||
const content = body.content?.replace(/\n/g, " \n");
|
||||
body.content = content;
|
||||
}
|
||||
return await this.send(body);
|
||||
}
|
||||
abstract send(body: NotificationBody): Promise<void>;
|
||||
@@ -109,12 +109,6 @@ export abstract class BaseNotification implements INotification {
|
||||
this.ctx = ctx;
|
||||
this.http = ctx.http;
|
||||
this.logger = ctx.logger;
|
||||
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
|
||||
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
|
||||
}
|
||||
if (this.runtimeDepsService && this.ctx.define?.name) {
|
||||
await this.runtimeDepsService.ensureRuntimeDependencies({ pluginKeys: `notification:${this.ctx.define.name}`, logger: this.logger });
|
||||
}
|
||||
}
|
||||
setDefine = (define: NotificationDefine) => {
|
||||
this.define = define;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <reference types="mocha" />
|
||||
/// <reference types="node" />
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { PipelineNotificationTypes } from "./constants.js";
|
||||
|
||||
describe("PipelineNotificationTypes", () => {
|
||||
it("contains the pipeline result notification type", () => {
|
||||
assert.equal(PipelineNotificationTypes.PipelineResult, "pipelineResult");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 流水线核心使用的通知类型。业务层通知类型由 @certd/lib-server 扩展。 */
|
||||
export const PipelineNotificationTypes = {
|
||||
PipelineResult: "pipelineResult",
|
||||
} as const;
|
||||
|
||||
export type PipelineNotificationType = (typeof PipelineNotificationTypes)[keyof typeof PipelineNotificationTypes];
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./api.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./decorator.js";
|
||||
|
||||
@@ -5,12 +5,12 @@ import { accessRegistry, IAccessService } from "../access/index.js";
|
||||
import { PageSearch } from "../context/index.js";
|
||||
import { FileStore } from "../core/file-store.js";
|
||||
import { CancelError, IContext, RunHistory, RunnableCollection } from "../core/index.js";
|
||||
import { FileItem, FormItemProps, Pipeline, Runnable, Step } from "../dt/index.js";
|
||||
import { FileItem, FormItemProps, Pipeline, ResultType, Runnable, Step } from "../dt/index.js";
|
||||
import { INotificationService } from "../notification/index.js";
|
||||
import { Registrable } from "../registry/index.js";
|
||||
import { IPluginConfigService } from "../service/config.js";
|
||||
import { TaskEmitter } from "../service/emit.js";
|
||||
import { ICnameProxyService, IEmailService, IRuntimeDepsService, IServiceGetter, IUrlService } from "../service/index.js";
|
||||
import { ICnameProxyService, IEmailService, IServiceGetter, IUrlService, getRuntimeDepsService } from "../service/index.js";
|
||||
|
||||
export type PluginRequestHandleReq<T = any> = {
|
||||
typeName: string;
|
||||
@@ -69,6 +69,8 @@ export type PluginDefine = Registrable & {
|
||||
runStrategy?: any;
|
||||
pluginType?: string; //类型
|
||||
type?: string; //来源
|
||||
// 标记该插件可作为流水线后置任务使用(展示在后置任务选择器中)
|
||||
supportAfterTask?: boolean;
|
||||
};
|
||||
|
||||
export type ITaskPlugin = {
|
||||
@@ -76,7 +78,7 @@ export type ITaskPlugin = {
|
||||
execute(): Promise<void | string>;
|
||||
onRequest(req: PluginRequestHandleReq<any>): Promise<any>;
|
||||
setCtx(ctx: TaskInstanceContext): Promise<void>;
|
||||
importRuntime?(specifier: string): Promise<any>;
|
||||
importRuntime(specifier: string): Promise<any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
@@ -92,6 +94,12 @@ export type CertTargetItem = {
|
||||
label: string;
|
||||
domain: string | string[];
|
||||
};
|
||||
|
||||
export type PipelineEvent<T = any> = {
|
||||
cert: T;
|
||||
runnableId: string;
|
||||
pipelineId: number;
|
||||
};
|
||||
export type TaskInstanceContext = {
|
||||
//流水线定义
|
||||
pipeline: Pipeline;
|
||||
@@ -138,7 +146,7 @@ export type TaskInstanceContext = {
|
||||
//项目id
|
||||
projectId?: number;
|
||||
|
||||
emitter: TaskEmitter;
|
||||
emitter: TaskEmitter<PipelineEvent>;
|
||||
|
||||
//service 容器
|
||||
serviceGetter?: IServiceGetter;
|
||||
@@ -150,13 +158,24 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin {
|
||||
logger!: ILogger;
|
||||
http!: HttpClient;
|
||||
accessService!: IAccessService;
|
||||
runtimeDepsService!: IRuntimeDepsService;
|
||||
|
||||
get pipeline() {
|
||||
return this.ctx.pipeline;
|
||||
}
|
||||
|
||||
get step() {
|
||||
return this.ctx.step;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流水线本次运行结果(后置任务插件可用:success/error/canceled/skip/disabled)
|
||||
*/
|
||||
get pipelineResult(): ResultType | undefined {
|
||||
return this.ctx?.runtime?.pipeline?.status?.result;
|
||||
}
|
||||
|
||||
async importRuntime(specifier: string) {
|
||||
if (!this.runtimeDepsService) {
|
||||
throw new Error("runtimeDepsService 未初始化");
|
||||
}
|
||||
return await this.runtimeDepsService.importRuntime(specifier, this.logger);
|
||||
return await getRuntimeDepsService().importRuntime(specifier, this.logger);
|
||||
}
|
||||
|
||||
clearLastStatus() {
|
||||
@@ -173,17 +192,18 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待指定毫秒(插件内自行控制延迟,如等待部署生效后再执行)
|
||||
*/
|
||||
async sleep(ms: number) {
|
||||
await this.ctx.utils.sleep(ms);
|
||||
}
|
||||
|
||||
async setCtx(ctx: TaskInstanceContext) {
|
||||
this.ctx = ctx;
|
||||
this.logger = ctx.logger;
|
||||
this.accessService = ctx.accessService;
|
||||
this.http = ctx.http;
|
||||
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
|
||||
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
|
||||
}
|
||||
if (this.runtimeDepsService && this.ctx.define?.name) {
|
||||
await this.runtimeDepsService.ensureRuntimeDependencies({ pluginKeys: `plugin:${this.ctx.define.name}`, logger: this.logger });
|
||||
}
|
||||
// 将证书加入secret
|
||||
// @ts-ignore
|
||||
if (this.cert && this.cert.crt && this.cert.key) {
|
||||
@@ -262,14 +282,6 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin {
|
||||
this._result.files.push(...(this.ctx.lastStatus?.status?.files || []));
|
||||
}
|
||||
|
||||
get pipeline() {
|
||||
return this.ctx.pipeline;
|
||||
}
|
||||
|
||||
get step() {
|
||||
return this.ctx.step;
|
||||
}
|
||||
|
||||
async onInstance(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { logger } from "@certd/basic";
|
||||
import { Pipeline, Runnable } from "../dt";
|
||||
|
||||
export type PipelineEventListener = (...args: any[]) => Promise<void>;
|
||||
export type PipelineEvent<T> = {
|
||||
pipeline: Pipeline;
|
||||
step: Runnable;
|
||||
event: T;
|
||||
};
|
||||
export class PipelineEmitter {
|
||||
export class PipelineEmitter<T> implements TaskEmitter<T> {
|
||||
events: Record<string, PipelineEventListener[]>;
|
||||
constructor() {
|
||||
this.events = {};
|
||||
@@ -18,7 +11,7 @@ export class PipelineEmitter {
|
||||
}
|
||||
this.events[event].push(listener);
|
||||
}
|
||||
async emit<T>(name: string, event: PipelineEvent<T>) {
|
||||
async emit<T>(name: string, event: T) {
|
||||
const listeners = this.events[name];
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
@@ -46,23 +39,6 @@ export class PipelineEmitter {
|
||||
|
||||
export const pipelineEmitter = new PipelineEmitter();
|
||||
|
||||
export type TaskEmitterCreateReq = {
|
||||
step: Runnable;
|
||||
pipeline: Pipeline;
|
||||
export type TaskEmitter<T> = {
|
||||
emit: (name: string, event: T) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TaskEmitter = {
|
||||
emit: <T>(name: string, event: T) => Promise<void>;
|
||||
};
|
||||
|
||||
export function taskEmitterCreate(req: TaskEmitterCreateReq) {
|
||||
return {
|
||||
emit: async <T>(name: string, event: T) => {
|
||||
await pipelineEmitter.emit(name, {
|
||||
pipeline: req.pipeline,
|
||||
step: req.step,
|
||||
event,
|
||||
});
|
||||
},
|
||||
} as TaskEmitter;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import assert from "assert";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { RuntimeDepsService, NpmRegistryResolver } from "./runtime.js";
|
||||
|
||||
describe("RuntimeDepsService", () => {
|
||||
it("builds a runtime package manifest in the target directory", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return ["https://registry.npmmirror.com"];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
assert.equal(args[0], "install");
|
||||
assert.ok(args.includes("--ignore-workspace"));
|
||||
assert.ok(args.includes("--no-frozen-lockfile"));
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
const result = await service.ensureDependencies({ dependencies: { foo: "^1.0.0" } });
|
||||
assert.equal(result.registryUrl, "https://registry.npmmirror.com");
|
||||
assert.ok(fs.existsSync(path.join(rootDir, "package.json")));
|
||||
});
|
||||
|
||||
it("installs direct dependency maps without plugin metadata", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-direct-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
await service.ensureDependencies({ dependencies: { directPkg: "^1.0.0" } });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
assert.deepEqual(manifest.dependencies, { directPkg: "^1.0.0" });
|
||||
});
|
||||
|
||||
it("imports from runtime node_modules without installing", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-import-"));
|
||||
const packageDir = path.join(rootDir, "node_modules", "runtime-only");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify({ name: "runtime-root", type: "module" }), "utf8");
|
||||
fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ name: "runtime-only", type: "module", main: "index.js" }), "utf8");
|
||||
fs.writeFileSync(path.join(packageDir, "index.js"), "export const value = 42;\n", "utf8");
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
throw new Error("install should not run");
|
||||
},
|
||||
} as any;
|
||||
const mod = await service.importRuntime("runtime-only");
|
||||
assert.equal(mod.value, 42);
|
||||
});
|
||||
|
||||
it("installs configured lazy dependency when import target is missing", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "lazy-pkg": "^1.2.3" } }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
const packageDir = path.join(rootDir, "node_modules", "lazy-pkg", "sub");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "node_modules", "lazy-pkg", "package.json"), JSON.stringify({ name: "lazy-pkg", type: "module" }), "utf8");
|
||||
fs.writeFileSync(path.join(packageDir, "entry.js"), "export const value = 7;\n", "utf8");
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
const mod = await service.importRuntime("lazy-pkg/sub/entry.js");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
assert.deepEqual(manifest.dependencies, { "lazy-pkg": "^1.2.3" });
|
||||
assert.equal(mod.value, 7);
|
||||
});
|
||||
|
||||
it("reports lazy dependency installation failure without falling back to project dependencies", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-failed-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "failed-pkg": "^1.2.3" } }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
return { stdout: "", stderr: "package download failed", code: 1 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
await assert.rejects(() => service.importRuntime("failed-pkg/sub/entry.js"), /动态依赖安装失败: failed-pkg: 动态依赖安装失败: package download failed/);
|
||||
});
|
||||
|
||||
it("resolves scoped package names for lazy imports", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-scoped-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "@scope/lazy": "^2.0.0" } }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
const packageDir = path.join(rootDir, "node_modules", "@scope", "lazy", "dist");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "node_modules", "@scope", "lazy", "package.json"), JSON.stringify({ name: "@scope/lazy", type: "module" }), "utf8");
|
||||
fs.writeFileSync(path.join(packageDir, "index.js"), "export const scoped = true;\n", "utf8");
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
const mod = await service.importRuntime("@scope/lazy/dist/index.js");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
assert.deepEqual(manifest.dependencies, { "@scope/lazy": "^2.0.0" });
|
||||
assert.equal(mod.scoped, true);
|
||||
});
|
||||
|
||||
it("reports missing lazy dependency configuration", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-missing-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }, null);
|
||||
await assert.rejects(() => service.importRuntime("missing-pkg/sub.js"), /未配置懒加载版本: missing-pkg/);
|
||||
});
|
||||
|
||||
it("rejects lazy dependency installation when disabled", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-disabled-"));
|
||||
const service = new RuntimeDepsService({ rootDir, enabled: false, lazyDependencies: { "lazy-pkg": "^1.0.0" } }, null);
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
throw new Error("disabled runtime dependencies should not install packages");
|
||||
},
|
||||
} as any;
|
||||
|
||||
await assert.rejects(() => service.importRuntime("lazy-pkg/index.js"), /动态安装依赖未开启/);
|
||||
});
|
||||
|
||||
it("prefers project dependencies over lazy dependency installation", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-project-fallback-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { dayjs: "^1.11.0" } }, null);
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
throw new Error("project dependency should not trigger installation");
|
||||
},
|
||||
} as any;
|
||||
const mod = await service.importRuntime("dayjs");
|
||||
assert.equal(typeof mod.default, "function");
|
||||
});
|
||||
|
||||
it("rejects a second installation while the first installation is running", async () => {
|
||||
const rootDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-concurrent-")), ".runtime-deps");
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
|
||||
let finishInstall: () => void;
|
||||
const installFinished = new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
await installFinished;
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
const firstInstall = service.ensureDependencies({ dependencies: { first: "^1.0.0" } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await assert.rejects(() => service.ensureDependencies({ dependencies: { second: "^1.0.0" } }), /动态安装依赖正在执行中/);
|
||||
await assert.rejects(() => service.clearRuntimeDeps(), /仍有依赖正在安装/);
|
||||
finishInstall!();
|
||||
await firstInstall;
|
||||
});
|
||||
|
||||
it("releases the installation lock after an installation failure", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-retry-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
|
||||
let installCount = 0;
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
installCount += 1;
|
||||
if (installCount === 1) {
|
||||
return { stdout: "", stderr: "installation timeout", code: 1 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
await assert.rejects(() => service.ensureDependencies({ dependencies: { first: "^1.0.0" } }), /动态依赖安装失败/);
|
||||
await service.ensureDependencies({ dependencies: { second: "^1.0.0" } });
|
||||
assert.equal(installCount, 2);
|
||||
});
|
||||
|
||||
it("keeps previously installed dependencies when adding a later package", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-merge-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
if (args.includes("--version")) {
|
||||
return { stdout: "9.1.0\n", stderr: "", code: 0 };
|
||||
}
|
||||
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
await service.ensureDependencies({ dependencies: { foo: "^1.0.0" } });
|
||||
await service.ensureDependencies({ dependencies: { bar: "^2.0.0" } });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
assert.deepEqual(manifest.dependencies, { foo: "^1.0.0", bar: "^2.0.0" });
|
||||
});
|
||||
|
||||
it("does not pass node debugger options to pnpm child process", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-env-"));
|
||||
const oldNodeOptions = process.env.NODE_OPTIONS;
|
||||
const oldInspectorOptions = process.env.VSCODE_INSPECTOR_OPTIONS;
|
||||
process.env.NODE_OPTIONS = "--inspect=127.0.0.1:9229 --max-old-space-size=4096";
|
||||
process.env.VSCODE_INSPECTOR_OPTIONS = '{"inspectorIpc":"test"}';
|
||||
try {
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) {
|
||||
assert.equal(options.env?.NODE_OPTIONS, "--max-old-space-size=4096");
|
||||
assert.equal(options.env?.VSCODE_INSPECTOR_OPTIONS, undefined);
|
||||
assert.equal(options.env?.CI, "true");
|
||||
assert.equal(options.env?.pnpm_config_confirm_modules_purge, "false");
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
await service.ensureDependencies({ dependencies: { foo: "^1.0.0" } });
|
||||
} finally {
|
||||
if (oldNodeOptions == null) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = oldNodeOptions;
|
||||
}
|
||||
if (oldInspectorOptions == null) {
|
||||
delete process.env.VSCODE_INSPECTOR_OPTIONS;
|
||||
} else {
|
||||
process.env.VSCODE_INSPECTOR_OPTIONS = oldInspectorOptions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects clearing unexpected runtime dependency path", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-clear-invalid-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
await assert.rejects(() => service.clearRuntimeDeps(), /动态依赖目录配置异常/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NpmRegistryResolver", () => {
|
||||
it("uses fixed registry without probing", async () => {
|
||||
const resolver = new NpmRegistryResolver({
|
||||
mode: "fixed",
|
||||
fixedUrl: "https://registry.example.com",
|
||||
probeTimeoutMs: 100,
|
||||
cacheTtlMs: 1000,
|
||||
});
|
||||
const result = await resolver.resolveOrdered();
|
||||
assert.deepEqual(result, ["https://registry.example.com"]);
|
||||
});
|
||||
it("returns ordered list via resolveOrdered (fastest first)", async () => {
|
||||
const resolver = new NpmRegistryResolver({
|
||||
mode: "auto",
|
||||
candidates: ["https://slow.example.com", "https://fast.example.com"],
|
||||
probeTimeoutMs: 100,
|
||||
cacheTtlMs: 1000,
|
||||
});
|
||||
resolver.probe = async (registryUrl: string) => ({
|
||||
registryUrl,
|
||||
ok: true,
|
||||
elapsedMs: registryUrl.includes("fast") ? 10 : 50,
|
||||
});
|
||||
const result = await resolver.resolveOrdered();
|
||||
assert.deepEqual(result, ["https://fast.example.com", "https://slow.example.com"]);
|
||||
});
|
||||
it("includes failed registries at the end of resolveOrdered", async () => {
|
||||
const resolver = new NpmRegistryResolver({
|
||||
mode: "auto",
|
||||
candidates: ["https://good.example.com", "https://bad.example.com"],
|
||||
probeTimeoutMs: 100,
|
||||
cacheTtlMs: 1000,
|
||||
});
|
||||
resolver.probe = async (registryUrl: string) => {
|
||||
if (registryUrl.includes("bad")) {
|
||||
return { registryUrl, ok: false, elapsedMs: 200 };
|
||||
}
|
||||
return { registryUrl, ok: true, elapsedMs: 30 };
|
||||
};
|
||||
const result = await resolver.resolveOrdered();
|
||||
assert.deepEqual(result, ["https://good.example.com", "https://bad.example.com"]);
|
||||
});
|
||||
it("returns empty ordered list when no candidates", async () => {
|
||||
const resolver = new NpmRegistryResolver({ mode: "auto", candidates: [] });
|
||||
const result = await resolver.resolveOrdered();
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
it("re-validates cached URL on resolveOrdered call", async () => {
|
||||
let probeCount = 0;
|
||||
const resolver = new NpmRegistryResolver({
|
||||
mode: "auto",
|
||||
candidates: ["https://mirror.example.com"],
|
||||
cacheTtlMs: 60000,
|
||||
});
|
||||
resolver.probe = async (registryUrl: string) => {
|
||||
probeCount++;
|
||||
return { registryUrl, ok: true, elapsedMs: 10 };
|
||||
};
|
||||
const first = await resolver.resolveOrdered();
|
||||
assert.deepEqual(first, ["https://mirror.example.com"]);
|
||||
assert.equal(probeCount, 1);
|
||||
const second = await resolver.resolveOrdered();
|
||||
assert.deepEqual(second, ["https://mirror.example.com"]);
|
||||
assert.equal(probeCount, 2);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,529 @@
|
||||
/**
|
||||
* 运行时动态导入函数类型
|
||||
*/
|
||||
export type ImportRuntime = (specifier: string, logger?: ILogger) => Promise<any>;
|
||||
|
||||
/**
|
||||
* 日志接口
|
||||
*/
|
||||
import { logger as defaultLogger } from "@certd/basic";
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import { createRequire } from "module";
|
||||
import path from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import type { Registry } from "../registry/registry.js";
|
||||
export type ILogger = {
|
||||
info: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
error?: (message: string, ...args: any[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行时依赖服务参数
|
||||
*/
|
||||
export type EnsureRuntimeDepsOptions = {
|
||||
pluginKeys: string | string[];
|
||||
logger?: ILogger;
|
||||
type InstallResult = {
|
||||
registryUrl: string;
|
||||
packageJsonPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行时依赖服务接口
|
||||
*/
|
||||
export interface IRuntimeDepsService {
|
||||
ensureRuntimeDependencies(options: EnsureRuntimeDepsOptions): Promise<any>;
|
||||
importRuntime: ImportRuntime;
|
||||
type RuntimeImportResolveResult = {
|
||||
resolved: string;
|
||||
packageName: string;
|
||||
};
|
||||
|
||||
type CommandRunnerResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
};
|
||||
|
||||
type CommandRunner = {
|
||||
run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult>;
|
||||
};
|
||||
|
||||
export type NpmRegistryResolverConfig = {
|
||||
mode?: "auto" | "fixed" | "system";
|
||||
fixedUrl?: string;
|
||||
candidates?: string[];
|
||||
probeTimeoutMs?: number;
|
||||
cacheTtlMs?: number;
|
||||
};
|
||||
|
||||
export type RegistryProbeResult = {
|
||||
registryUrl: string;
|
||||
ok: boolean;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
export class NpmRegistryResolver {
|
||||
config: NpmRegistryResolverConfig;
|
||||
private cache?: { orderedUrls: string[]; expiresAt: number };
|
||||
|
||||
constructor(config?: NpmRegistryResolverConfig) {
|
||||
this.config = config || {};
|
||||
}
|
||||
|
||||
async resolveOrdered(): Promise<string[]> {
|
||||
const config = this.config;
|
||||
if (config?.mode === "fixed" && config.fixedUrl) {
|
||||
return [config.fixedUrl];
|
||||
}
|
||||
if (config?.mode === "system") {
|
||||
return [];
|
||||
}
|
||||
const cached = this.cache;
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
const fastUrl = cached.orderedUrls[0];
|
||||
if (fastUrl) {
|
||||
const probeResult = await this.probe(fastUrl);
|
||||
if (probeResult.ok) {
|
||||
return cached.orderedUrls;
|
||||
}
|
||||
}
|
||||
this.cache = undefined;
|
||||
}
|
||||
const candidates = (config?.candidates || []).filter(Boolean);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const orderedUrls = await this.internalProbeAll(candidates);
|
||||
this.cache = { orderedUrls, expiresAt: Date.now() + (config?.cacheTtlMs ?? 300_000) };
|
||||
return orderedUrls;
|
||||
}
|
||||
|
||||
private async internalProbeAll(candidates: string[]): Promise<string[]> {
|
||||
const probes = await Promise.allSettled(candidates.map(registryUrl => this.probe(registryUrl)));
|
||||
const okList: RegistryProbeResult[] = [];
|
||||
const failList: RegistryProbeResult[] = [];
|
||||
for (const item of probes) {
|
||||
const result = item.status === "fulfilled" ? item.value : null;
|
||||
if (result && result.ok) {
|
||||
okList.push(result);
|
||||
} else if (result) {
|
||||
failList.push(result);
|
||||
}
|
||||
}
|
||||
okList.sort((a, b) => a.elapsedMs - b.elapsedMs);
|
||||
failList.sort((a, b) => a.elapsedMs - b.elapsedMs);
|
||||
return [...okList.map(r => r.registryUrl), ...failList.map(r => r.registryUrl)];
|
||||
}
|
||||
|
||||
async probe(registryUrl: string): Promise<RegistryProbeResult> {
|
||||
const timeoutMs = this.config?.probeTimeoutMs || 3000;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${registryUrl.replace(/\/$/, "")}/-/ping`, { signal: controller.signal });
|
||||
return { registryUrl, ok: res.ok, elapsedMs: Date.now() - started };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} catch {
|
||||
return { registryUrl, ok: false, elapsedMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimeDepsConfig = {
|
||||
rootDir?: string;
|
||||
enabled?: boolean;
|
||||
installTimeoutMs?: number;
|
||||
pnpmCommand?: string;
|
||||
lazyDependencies?: Record<string, string>;
|
||||
registry?: NpmRegistryResolverConfig;
|
||||
};
|
||||
|
||||
function normalizeRange(range: string) {
|
||||
return range.trim().replace(/^\^/, "").replace(/^~?/, "");
|
||||
}
|
||||
|
||||
function areRangesCompatible(a: string, b: string) {
|
||||
if (!a || !b) {
|
||||
return true;
|
||||
}
|
||||
if (a === "*" || b === "*") {
|
||||
return true;
|
||||
}
|
||||
const left = normalizeRange(a).split(".");
|
||||
const right = normalizeRange(b).split(".");
|
||||
return left[0] === right[0];
|
||||
}
|
||||
|
||||
let INSTALL_LOCKER: any = null;
|
||||
|
||||
class DefaultCommandRunner implements CommandRunner {
|
||||
async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult> {
|
||||
return await new Promise<CommandRunnerResult>(resolve => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const child = spawn(command, args, { cwd: options.cwd, env: options.env, windowsHide: true, shell: process.platform === "win32" });
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
child.kill("SIGTERM");
|
||||
resolve({ stdout, stderr: stderr || `command timeout after ${options.timeoutMs}ms`, code: 1 });
|
||||
}, options.timeoutMs);
|
||||
child.stdout?.on("data", chunk => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", chunk => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", error => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr: error.message, code: 1 });
|
||||
});
|
||||
child.on("close", code => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code: code || 0 });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeDepsService {
|
||||
runtimeDepsRootDir: string;
|
||||
enabled: boolean;
|
||||
installTimeoutMs: number;
|
||||
pnpmCommand: string;
|
||||
lazyDependencies: Record<string, string>;
|
||||
registryResolver!: NpmRegistryResolver;
|
||||
commandRunner: CommandRunner = new DefaultCommandRunner();
|
||||
pluginLazyDependencies: Record<string, string> = {};
|
||||
private registriesMap: Record<string, { registry: Registry<any>; pluginType: string; addonType?: string }> | null = null;
|
||||
|
||||
constructor(config: RuntimeDepsConfig, registries: any) {
|
||||
this.runtimeDepsRootDir = config?.rootDir ?? "./data/.runtime-deps";
|
||||
this.enabled = config?.enabled ?? true;
|
||||
this.installTimeoutMs = config?.installTimeoutMs ?? 60000;
|
||||
this.pnpmCommand = config?.pnpmCommand ?? "";
|
||||
this.lazyDependencies = config?.lazyDependencies ?? {};
|
||||
this.registryResolver = new NpmRegistryResolver(config?.registry);
|
||||
if (registries) {
|
||||
this.setRegistries(registries);
|
||||
}
|
||||
}
|
||||
|
||||
setRegistries(registries: { pluginRegistry?: Registry<any>; accessRegistry?: Registry<any>; notificationRegistry?: Registry<any>; dnsProviderRegistry?: Registry<any>; addonRegistry?: Registry<any> }) {
|
||||
const map: Record<string, { registry: Registry<any>; pluginType: string; addonType?: string }> = {};
|
||||
if (registries.pluginRegistry) {
|
||||
map["plugin"] = { registry: registries.pluginRegistry, pluginType: "plugin" };
|
||||
}
|
||||
if (registries.accessRegistry) {
|
||||
map["access"] = { registry: registries.accessRegistry, pluginType: "access" };
|
||||
}
|
||||
if (registries.notificationRegistry) {
|
||||
map["notification"] = { registry: registries.notificationRegistry, pluginType: "notification" };
|
||||
}
|
||||
if (registries.dnsProviderRegistry) {
|
||||
map["dnsProvider"] = { registry: registries.dnsProviderRegistry, pluginType: "dnsProvider" };
|
||||
}
|
||||
if (registries.addonRegistry) {
|
||||
map["addon"] = { registry: registries.addonRegistry, pluginType: "addon", addonType: "" };
|
||||
}
|
||||
this.registriesMap = map;
|
||||
}
|
||||
|
||||
async ensureDependencies(options: { dependencies: Record<string, string>; logger?: ILogger }): Promise<InstallResult> {
|
||||
const { dependencies, logger: log } = options;
|
||||
|
||||
if (INSTALL_LOCKER) {
|
||||
throw new Error("动态安装依赖正在执行中,请稍后再试");
|
||||
}
|
||||
INSTALL_LOCKER = true;
|
||||
const installPromise = this.doEnsureInstalled({ dependencies, logger: log }).finally(() => {
|
||||
INSTALL_LOCKER = null;
|
||||
});
|
||||
// 25s 内如果没返回就提前返回
|
||||
// AI注意,不要改我的实现,这里提前在25s就报超时是我故意这么做的实现
|
||||
// 因为前端配置的超时时间是30s,大部分的情况下install会在25s内完成,所以这里提前在25s就报超时,并且容忍60s的install
|
||||
const timeoutPromise: Promise<any> = new Promise((resolve, reject) => setTimeout(() => reject(new Error("安装依赖超时,请稍后重试")), 25 * 1000));
|
||||
return await Promise.race([installPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
async importRuntime(specifier: string, logger: ILogger = defaultLogger) {
|
||||
if (this.isNativeImportSpecifier(specifier)) {
|
||||
//是否本地模块
|
||||
return await import(specifier);
|
||||
}
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
//尝试加载本项目依赖
|
||||
resolved = this.resolveProjectSpecifier(specifier).resolved;
|
||||
} catch (error) {
|
||||
if (!this.isModuleNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
resolved = await this.resolveLazyOrInstallSpecifier(specifier, logger);
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new Error(`依赖未安装成功: ${specifier}`);
|
||||
}
|
||||
|
||||
return await import(pathToFileURL(resolved).href);
|
||||
}
|
||||
|
||||
//动态获取模块解析
|
||||
private async resolveLazyOrInstallSpecifier(specifier: string, logger: ILogger = defaultLogger) {
|
||||
try {
|
||||
//解析动态目录里面的模块是否存在
|
||||
return this.resolveRuntimeSpecifier(specifier).resolved;
|
||||
} catch (runtimeError: any) {
|
||||
if (!this.isModuleNotFoundError(runtimeError)) {
|
||||
//其他错误
|
||||
throw runtimeError;
|
||||
}
|
||||
if (!this.enabled) {
|
||||
throw new Error("动态安装依赖未开启");
|
||||
}
|
||||
//模块不存在,安装
|
||||
await this.installLazyDependencies(specifier, logger);
|
||||
|
||||
try {
|
||||
return this.resolveRuntimeSpecifier(specifier).resolved;
|
||||
} catch (lazyError: any) {
|
||||
// logger?.error?.(`动态依赖安装失败: ${lazyError.message}`);
|
||||
throw new Error(`依赖加载失败,可能动态依赖未安装成功: ${lazyError.message}`, { cause: lazyError });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//不存在模块动态依赖安装,再获取解析
|
||||
private async installLazyDependencies(specifier: string, logger?: ILogger) {
|
||||
const packageName = this.parsePackageName(specifier);
|
||||
const mergedDeps = this.getMergedLazyDependencies();
|
||||
const lazyRange = mergedDeps[packageName];
|
||||
if (!lazyRange) {
|
||||
throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`);
|
||||
}
|
||||
try {
|
||||
await this.ensureDependencies({ dependencies: { [packageName]: lazyRange }, logger });
|
||||
} catch (lazyError: any) {
|
||||
// logger?.error?.(`动态依赖安装失败: ${lazyError.message}`);
|
||||
throw new Error(`动态依赖安装失败: ${packageName}: ${lazyError.message}`, { cause: lazyError });
|
||||
}
|
||||
}
|
||||
|
||||
private isNativeImportSpecifier(specifier: string) {
|
||||
return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:") || specifier.startsWith("node:");
|
||||
}
|
||||
|
||||
//尝试加载runtime依赖
|
||||
private resolveRuntimeSpecifier(specifier: string): RuntimeImportResolveResult {
|
||||
const packageName = this.parsePackageName(specifier);
|
||||
const packageJsonPath = path.join(this.getRuntimeDepsRootDir(), "package.json");
|
||||
const require = createRequire(packageJsonPath);
|
||||
const resolved = require.resolve(specifier);
|
||||
return { packageName, resolved };
|
||||
}
|
||||
|
||||
//尝试加载本地依赖
|
||||
private resolveProjectSpecifier(specifier: string): RuntimeImportResolveResult {
|
||||
const packageName = this.parsePackageName(specifier);
|
||||
const packageJsonPath = path.resolve("package.json");
|
||||
const require = createRequire(packageJsonPath);
|
||||
const resolved = require.resolve(specifier);
|
||||
return { packageName, resolved };
|
||||
}
|
||||
|
||||
private parsePackageName(specifier: string) {
|
||||
if (!specifier || specifier.trim() !== specifier) {
|
||||
throw new Error(`动态依赖导入路径无效: ${specifier}`);
|
||||
}
|
||||
const parts = specifier.split("/");
|
||||
if (specifier.startsWith("@")) {
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error(`动态依赖导入路径无效: ${specifier}`);
|
||||
}
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
if (!parts[0]) {
|
||||
throw new Error(`动态依赖导入路径无效: ${specifier}`);
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
private isModuleNotFoundError(error: any) {
|
||||
return error?.code === "MODULE_NOT_FOUND" || error?.code === "ERR_MODULE_NOT_FOUND";
|
||||
}
|
||||
|
||||
private async doEnsureInstalled(options: { dependencies: Record<string, string>; logger?: ILogger }): Promise<InstallResult> {
|
||||
let { dependencies } = options;
|
||||
const log = options.logger || defaultLogger;
|
||||
const rootDir = this.getRuntimeDepsRootDir();
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
}
|
||||
const packageJsonPath = path.join(rootDir, "package.json");
|
||||
log.info(`第三方依赖安装: ${JSON.stringify(dependencies)}`);
|
||||
dependencies = this.mergeInstalledDependencies(this.readManifestDependencies(packageJsonPath), dependencies);
|
||||
|
||||
const manifest = { name: "certd-runtime-deps", private: true, type: "module", dependencies };
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(manifest, null, 2), "utf8");
|
||||
const allRegistryUrls = await this.registryResolver.resolveOrdered();
|
||||
const urlsToTry = allRegistryUrls.length > 0 ? allRegistryUrls : [""];
|
||||
const command = this.getPnpmCommand();
|
||||
let lastError: string | undefined;
|
||||
for (const tryUrl of urlsToTry) {
|
||||
const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--no-frozen-lockfile", "--reporter=append-only"];
|
||||
if (tryUrl) {
|
||||
args.push(`--registry=${tryUrl}`);
|
||||
}
|
||||
const tryEnv = this.buildChildEnv(tryUrl);
|
||||
log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}${tryUrl ? `,镜像: ${tryUrl}` : ""}`);
|
||||
const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv });
|
||||
if (result.code === 0) {
|
||||
log.info(`${result.stdout?.slice(-2000) || "无npm安装日志输出"}`);
|
||||
log.info("第三方依赖安装完成");
|
||||
return { registryUrl: tryUrl, packageJsonPath };
|
||||
}
|
||||
const errOutput = (result.stderr || "").trim();
|
||||
const outOutput = (result.stdout || "").trim();
|
||||
lastError = errOutput || outOutput || "unknown error";
|
||||
log.info(`镜像 ${tryUrl || "默认"} 安装失败,退出码: ${result.code}${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
|
||||
log.info(` pnpm stderr: ${(errOutput || "无npm安装日志输出").slice(-2000)}`);
|
||||
if (outOutput) {
|
||||
log.info(` pnpm stdout: ${outOutput.slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`动态依赖安装失败: ${lastError}`);
|
||||
}
|
||||
|
||||
async clearRuntimeDeps() {
|
||||
const rootDir = this.getRuntimeDepsRootDir();
|
||||
const normalizedRootDir = path.normalize(rootDir);
|
||||
if (!normalizedRootDir.endsWith(path.normalize(".runtime-deps"))) {
|
||||
throw new Error(`动态依赖目录配置异常,拒绝清理: ${rootDir}`);
|
||||
}
|
||||
|
||||
if (INSTALL_LOCKER) {
|
||||
throw new Error("仍有依赖正在安装,请稍后");
|
||||
}
|
||||
|
||||
if (fs.existsSync(rootDir)) {
|
||||
const entries = fs.readdirSync(rootDir);
|
||||
for (const entry of entries) {
|
||||
fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getMergedLazyDependencies(): Record<string, string> {
|
||||
return { ...this.lazyDependencies, ...this.pluginLazyDependencies };
|
||||
}
|
||||
|
||||
collectPluginDeps(logger?: ILogger) {
|
||||
if (!this.registriesMap) {
|
||||
return;
|
||||
}
|
||||
const deps: Record<string, string> = {};
|
||||
for (const { registry } of Object.values(this.registriesMap)) {
|
||||
const defineList = registry.getDefineList();
|
||||
for (const define of defineList) {
|
||||
const dependPackages = (define as any).dependPackages as Record<string, string> | undefined;
|
||||
if (!dependPackages) {
|
||||
continue;
|
||||
}
|
||||
for (const [pkgName, range] of Object.entries(dependPackages)) {
|
||||
const existing = deps[pkgName];
|
||||
if (existing && !areRangesCompatible(existing, range)) {
|
||||
(logger || defaultLogger).warn?.(`懒加载依赖版本冲突: ${pkgName} => ${existing} vs ${range},保留已有版本`);
|
||||
continue;
|
||||
}
|
||||
deps[pkgName] = range;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pluginLazyDependencies = deps;
|
||||
(logger || defaultLogger).info(`从插件注册表收集到 ${Object.keys(deps).length} 个懒加载依赖`);
|
||||
}
|
||||
|
||||
refreshPluginDeps(logger?: ILogger) {
|
||||
this.collectPluginDeps(logger);
|
||||
}
|
||||
|
||||
private readManifestDependencies(packageJsonPath: string): Record<string, string> {
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
return manifest.dependencies || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private mergeInstalledDependencies(installed: Record<string, string>, requested: Record<string, string>) {
|
||||
const dependencies = { ...installed };
|
||||
for (const [packageName, range] of Object.entries(requested)) {
|
||||
const installedRange = dependencies[packageName];
|
||||
if (installedRange && !areRangesCompatible(installedRange, range)) {
|
||||
throw new Error(`动态依赖版本冲突: ${packageName} => installed:${installedRange}, requested:${range}`);
|
||||
}
|
||||
dependencies[packageName] = installedRange || range;
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private getPnpmCommand() {
|
||||
return this.pnpmCommand || "pnpm";
|
||||
}
|
||||
|
||||
private buildChildEnv(registryUrl: string) {
|
||||
const env = { ...process.env };
|
||||
for (const key of ["NODE_OPTIONS", "VSCODE_INSPECTOR_OPTIONS", "NODE_INSPECTOR_PORT", "NODE_DEBUG"]) {
|
||||
if (!env[key]) {
|
||||
continue;
|
||||
}
|
||||
if (key === "NODE_OPTIONS") {
|
||||
env[key] = this.stripDebugNodeOptions(env[key] as string);
|
||||
} else {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
if (registryUrl) {
|
||||
env.npm_config_registry = registryUrl;
|
||||
env.pnpm_config_registry = registryUrl;
|
||||
}
|
||||
env.CI = env.CI || "true";
|
||||
env.npm_config_confirm_modules_purge = "false";
|
||||
env.pnpm_config_confirm_modules_purge = "false";
|
||||
return env;
|
||||
}
|
||||
|
||||
private stripDebugNodeOptions(value: string) {
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter(item => !/^--inspect(-brk|-port)?(=|$)/.test(item))
|
||||
.filter(item => !/^--debug(=|$)/.test(item))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
getRuntimeDepsRootDir() {
|
||||
return path.resolve(this.runtimeDepsRootDir);
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeDepsServiceInstance: RuntimeDepsService | null = null;
|
||||
|
||||
export function initRuntimeDepsService(config: RuntimeDepsConfig, registries: any): RuntimeDepsService {
|
||||
runtimeDepsServiceInstance = new RuntimeDepsService(config, registries);
|
||||
return runtimeDepsServiceInstance;
|
||||
}
|
||||
|
||||
export function getRuntimeDepsService(): RuntimeDepsService {
|
||||
if (!runtimeDepsServiceInstance) {
|
||||
throw new Error("RuntimeDepsService 未初始化");
|
||||
}
|
||||
return runtimeDepsServiceInstance!;
|
||||
}
|
||||
|
||||
export async function importRuntime(specifier: string, logger: ILogger = defaultLogger): Promise<any> {
|
||||
return getRuntimeDepsService().importRuntime(specifier, logger);
|
||||
}
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,36 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Features
|
||||
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/lib-huawei",
|
||||
"private": false,
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"main": "./dist/bundle.js",
|
||||
"module": "./dist/bundle.js",
|
||||
"types": "./dist/d/index.d.ts",
|
||||
@@ -13,9 +13,9 @@
|
||||
"preview": "vite preview",
|
||||
"test:unit": "cross-env NODE_ENV=unittest echo no unit tests",
|
||||
"pub": "npm publish",
|
||||
"compile": "npm run build",
|
||||
"compile": " npm run build",
|
||||
"format": "prettier --write src",
|
||||
"lint": "eslint --fix"
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
@@ -26,9 +26,10 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"esmock": "^2.7.5",
|
||||
"prettier": "3.3.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,41 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Features
|
||||
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
* 支持在线插件下载安装审核等 ([255960d](https://github.com/certd/certd/commit/255960d434eebb3d567a08f737f1642ab417b5d0))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **plugin:** 新增插件评分展示、排序与个人插件筛选功能 ([7ab5029](https://github.com/certd/certd/commit/7ab502991e0c001777b268b9f159311ef5e000a0))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/lib-iframe",
|
||||
"private": false,
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -16,9 +16,9 @@
|
||||
"preview": "vite preview",
|
||||
"test:unit": "cross-env NODE_ENV=unittest echo no unit tests",
|
||||
"pub": "npm publish",
|
||||
"compile": "npm run build",
|
||||
"compile": " npm run build",
|
||||
"format": "prettier --write src",
|
||||
"lint": "eslint --fix"
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": "^5.0.7"
|
||||
@@ -37,5 +37,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './lib/iframe.client.js';
|
||||
export * from "./lib/iframe.client.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export type IframeMessageData<T> = {
|
||||
action: string;
|
||||
@@ -29,10 +29,12 @@ export class IframeClient {
|
||||
onError?: any;
|
||||
|
||||
handlers: Record<string, (data: IframeMessageData<any>) => Promise<void>> = {};
|
||||
private messageHandler: (event: MessageEvent<IframeMessageData<any>>) => Promise<void>;
|
||||
|
||||
constructor(iframe?: HTMLIFrameElement, onError?: (e: any) => void) {
|
||||
this.iframe = iframe;
|
||||
this.onError = onError;
|
||||
window.addEventListener('message', async (event: MessageEvent<IframeMessageData<any>>) => {
|
||||
this.messageHandler = async (event: MessageEvent<IframeMessageData<any>>) => {
|
||||
const data = event.data;
|
||||
if (data.action) {
|
||||
console.log(`收到消息[isSub:${this.isInFrame()}]`, data);
|
||||
@@ -40,20 +42,21 @@ export class IframeClient {
|
||||
const handler = this.handlers[data.action];
|
||||
if (handler) {
|
||||
const res = await handler(data);
|
||||
if (data.id && data.action !== 'reply') {
|
||||
await this.send('reply', res, data.id);
|
||||
if (data.id && data.action !== "reply") {
|
||||
await this.send("reply", res, data.id);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`action:${data.action} 未注册处理器,可能版本过低`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
await this.send('reply', {}, data.id, 500, e.message);
|
||||
await this.send("reply", {}, data.id, 500, e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("message", this.messageHandler);
|
||||
|
||||
this.register('reply', async data => {
|
||||
this.register("reply", async data => {
|
||||
const req = this.requestQueue[data.replyId!];
|
||||
if (req) {
|
||||
req.onReply(data);
|
||||
@@ -61,11 +64,20 @@ export class IframeClient {
|
||||
}
|
||||
});
|
||||
}
|
||||
isInFrame() {
|
||||
|
||||
public destroy() {
|
||||
window.removeEventListener("message", this.messageHandler);
|
||||
this.requestQueue = {};
|
||||
this.handlers = {};
|
||||
}
|
||||
public close() {
|
||||
this.destroy();
|
||||
}
|
||||
public isInFrame() {
|
||||
return window.self !== window.top;
|
||||
}
|
||||
|
||||
register<T = any>(action: string, handler: (data: IframeMessageData<T>) => Promise<any>) {
|
||||
public register<T = any>(action: string, handler: (data: IframeMessageData<T>) => Promise<any>) {
|
||||
this.handlers[action] = handler;
|
||||
}
|
||||
|
||||
@@ -106,12 +118,12 @@ export class IframeClient {
|
||||
console.log(`send message[isSub:${this.isInFrame()}]:`, reqMessageData);
|
||||
if (!this.iframe) {
|
||||
if (!window.parent) {
|
||||
reject('当前页面不在 iframe 中');
|
||||
reject("当前页面不在 iframe 中");
|
||||
}
|
||||
window.parent.postMessage(reqMessageData, '*');
|
||||
window.parent.postMessage(reqMessageData, "*");
|
||||
} else {
|
||||
//子页面
|
||||
this.iframe.contentWindow?.postMessage(reqMessageData, '*');
|
||||
this.iframe.contentWindow?.postMessage(reqMessageData, "*");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,34 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/jdcloud",
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"description": "jdcloud openApi sdk",
|
||||
"main": "./dist/bundle.js",
|
||||
"module": "./dist/bundle.js",
|
||||
@@ -13,7 +13,7 @@
|
||||
"pub": "npm publish",
|
||||
"compile": "npm run build",
|
||||
"format": "prettier --write src",
|
||||
"lint": "eslint --fix"
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"author": "",
|
||||
"license": "Apache",
|
||||
@@ -35,6 +35,7 @@
|
||||
"chai": "^5.1.0",
|
||||
"config": "^1.30.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"esmock": "^2.7.5",
|
||||
"js-yaml": "^3.11.0",
|
||||
"mocha": "^10.6.0",
|
||||
@@ -62,5 +63,5 @@
|
||||
"fetch"
|
||||
]
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import jdCloud from "./lib/core.js";
|
||||
import jdService from './lib/service.js'
|
||||
import jdCloud from './lib/core.js';
|
||||
import jdService from './lib/service.js';
|
||||
|
||||
import domainService from './repo/domainservice/v2/domainservice.js'
|
||||
import cdnService from './repo/cdn/v1/cdn.js'
|
||||
import sslService from './repo/ssl/v1/ssl.js'
|
||||
import domainService from './repo/domainservice/v2/domainservice.js';
|
||||
import cdnService from './repo/cdn/v1/cdn.js';
|
||||
import sslService from './repo/ssl/v1/ssl.js';
|
||||
export const JDCloud = jdCloud;
|
||||
export const JDService = jdService;
|
||||
export const JDDomainService = domainService;
|
||||
export const JDCdnService = cdnService;
|
||||
export const JDSslService = sslService;
|
||||
export const JDSslService = sslService;
|
||||
|
||||
@@ -19,5 +19,9 @@
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
// "no-unused-expressions": "off",
|
||||
"max-len": [0, 160, 2, { "ignoreUrls": true }]
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,36 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
# [1.44.0](https://github.com/certd/certd/compare/v1.43.0...v1.44.0) (2026-08-28)
|
||||
|
||||
### Features
|
||||
|
||||
* 新增证书吊销后置任务与证书状态管理 ([1cc9641](https://github.com/certd/certd/commit/1cc9641f5669710d1e5809995ad395c843855a12))
|
||||
|
||||
# [1.43.0](https://github.com/certd/certd/compare/v1.42.6...v1.43.0) (2026-08-14)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/lib-k8s",
|
||||
"private": false,
|
||||
"version": "1.42.3",
|
||||
"version": "1.44.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -16,12 +16,12 @@
|
||||
"preview": "vite preview",
|
||||
"test:unit": "cross-env NODE_ENV=unittest echo no unit tests",
|
||||
"pub": "npm publish",
|
||||
"compile": "tsc --skipLibCheck --watch",
|
||||
"compile": " tsc --skipLibCheck --watch",
|
||||
"format": "prettier --write src",
|
||||
"lint": "eslint --fix"
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/basic": "^1.42.3",
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@kubernetes/client-node": "0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,5 +38,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "7808d028e9c7e1b1e3ebc41f12b023d7b553300b"
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type K8sClientOpts = {
|
||||
//暂时没用
|
||||
lookup?: any;
|
||||
skipTLSVerify?: boolean;
|
||||
debug?: boolean;
|
||||
};
|
||||
export class K8sClient {
|
||||
kubeconfig!: KubeConfig;
|
||||
@@ -18,11 +19,13 @@ export class K8sClient {
|
||||
client!: CoreV1Api;
|
||||
logger: ILogger;
|
||||
skipTLSVerify?: boolean;
|
||||
debug?: boolean;
|
||||
constructor(opts: K8sClientOpts) {
|
||||
this.kubeConfigStr = opts.kubeConfigStr;
|
||||
this.logger = opts.logger;
|
||||
this.setLookup(opts.lookup);
|
||||
this.skipTLSVerify = opts.skipTLSVerify;
|
||||
this.debug = opts.debug;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -86,6 +89,9 @@ export class K8sClient {
|
||||
yml.metadata = {};
|
||||
}
|
||||
yml.metadata.resourceVersion = existing.body.metadata.resourceVersion;
|
||||
if (this.debug) {
|
||||
this.logger.info("patch yaml body:", JSON.stringify(yml));
|
||||
}
|
||||
const res = await client.patch(yml);
|
||||
return res?.body;
|
||||
}
|
||||
@@ -126,6 +132,9 @@ export class K8sClient {
|
||||
async createSecret(opts: { namespace: string; body: V1Secret }) {
|
||||
const namespace = opts.namespace || "default";
|
||||
this.logger.info("create secret:", opts.body.metadata);
|
||||
if (this.debug) {
|
||||
this.logger.info("create secret body:", JSON.stringify(opts.body));
|
||||
}
|
||||
const created = await this.client.createNamespacedSecret(namespace, opts.body);
|
||||
this.logger.info("new secrets:", opts.body.metadata);
|
||||
return created.body;
|
||||
@@ -162,6 +171,9 @@ export class K8sClient {
|
||||
},
|
||||
opts.body
|
||||
);
|
||||
if (this.debug) {
|
||||
this.logger.info("create secret:", JSON.stringify(body));
|
||||
}
|
||||
const res = await this.createSecret({ namespace, body });
|
||||
this.logger.info(`secret ${secretName} 已创建`);
|
||||
return res;
|
||||
@@ -173,6 +185,9 @@ export class K8sClient {
|
||||
}
|
||||
|
||||
const newSecret = merge(oldSecret.body, opts.body);
|
||||
if (this.debug) {
|
||||
this.logger.info("patch secret:", JSON.stringify(newSecret));
|
||||
}
|
||||
const res = await this.client.replaceNamespacedSecret(secretName, namespace, newSecret);
|
||||
this.logger.info(`secret ${secretName} 已更新`);
|
||||
return res.body;
|
||||
@@ -207,6 +222,9 @@ export class K8sClient {
|
||||
const client = this.kubeconfig.makeApiClient(NetworkingV1Api);
|
||||
const oldIngress = await client.readNamespacedIngress(ingressName, namespace);
|
||||
const newIngress = merge(oldIngress.body, opts.body);
|
||||
if (this.debug) {
|
||||
this.logger.info("patch ingress:", JSON.stringify(newIngress));
|
||||
}
|
||||
const res = await client.replaceNamespacedIngress(ingressName, namespace, newIngress);
|
||||
|
||||
this.logger.info("ingress patched", opts.body);
|
||||
@@ -222,6 +240,7 @@ export class K8sClient {
|
||||
},
|
||||
};
|
||||
for (const ingress of ingressNames) {
|
||||
this.logger.info(`ingress 开始重启:${ingress}`);
|
||||
await this.patchIngress({ namespace, ingressName: ingress, body });
|
||||
this.logger.info(`ingress已重启:${ingress}`);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user