mirror of
https://github.com/certd/certd.git
synced 2026-08-09 16:45:50 +08:00
perf: 支持AI开发在线插件
This commit is contained in:
+3
-1
@@ -39,4 +39,6 @@ pnpm-lock.yaml
|
|||||||
# Certd 推广报告,仅本地使用
|
# Certd 推广报告,仅本地使用
|
||||||
/popularize/reports/
|
/popularize/reports/
|
||||||
output/
|
output/
|
||||||
.uploads/
|
.uploads/
|
||||||
|
.certd-plugin-history/
|
||||||
|
.tmp
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
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 工作区的 `.tmp/online-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 地址和当前用户 Token。调用 API 时使用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把 Token 写入代码、历史摘要、日志、提交信息或插件 YAML。
|
||||||
|
|
||||||
|
所有 Certd API 请求统一使用 Node.js 18+ 的 `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}/sys/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。保存后检查中文字段不含 `?`,再调用 `/sys/plugin/find` 或 `/sys/plugin/info` 验证。
|
||||||
|
|
||||||
|
## API 工作流
|
||||||
|
|
||||||
|
1. 使用 `/sys/plugin/find` 查询插件和 Access,可通过 `keywords` 数组传递多个关键词。
|
||||||
|
2. 查询结果包含 `editable`:
|
||||||
|
- `editable: true`:允许当前 Agent 修改并保存。
|
||||||
|
- `editable: false`:只能读取和使用,不能修改。
|
||||||
|
3. 读取完整 YAML 时调用 `/sys/plugin/export`。
|
||||||
|
4. 所有插件保存统一调用 `/sys/plugin/import`,并始终传递完整 YAML:
|
||||||
|
- 新插件使用 `override: false`。
|
||||||
|
- 已有插件使用 `override: true`;导入接口根据 `author` 和 `name` 定位并覆盖已有记录。
|
||||||
|
5. 不使用 `/sys/plugin/add` 或 `/sys/plugin/update` 保存插件,避免保存路径分叉、字段丢失和 Windows 请求兼容性问题。
|
||||||
|
6. 保存完成后重新调用 `/sys/plugin/find` 或 `/sys/plugin/info` 验证结果。
|
||||||
|
|
||||||
|
`/sys/plugin/find` 会在一次请求中分别查询内置插件和 `store` 插件,再合并返回;`store` 插件需按上述字段区分市场插件与本地插件。
|
||||||
|
|
||||||
|
详细请求字段见 `references/certd-api.md`。
|
||||||
|
|
||||||
|
## Access 协作
|
||||||
|
|
||||||
|
开发 Task 或 DNS Provider 前,先用 `/sys/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`。
|
||||||
|
|
||||||
|
## 本地历史
|
||||||
|
|
||||||
|
开发插件时,必须在当前工作区创建并使用 `.tmp/online-plugin-dev/` 作为临时目录。历史记录、临时 YAML、脚本草稿和调试记录都放在该目录下。
|
||||||
|
|
||||||
|
每次修改插件前,必须将完整 YAML 保存到 `.tmp/online-plugin-dev/history/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.tmp/online-plugin-dev/
|
||||||
|
history/
|
||||||
|
plugin-12/
|
||||||
|
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: "通过本地 Agent Server 使用 Codex 或 Trae 开发 Certd 在线插件"
|
||||||
|
default_prompt: "启动本地 Agent Server,读取插件 YAML 和对应类型规范,按需求修改 content 并返回完整 YAML 草稿。"
|
||||||
@@ -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,100 @@
|
|||||||
|
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:'
|
||||||
|
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: 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 //设置输出参数,可以被其他插件选择使用
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. 调用 `/sys/plugin/find`,使用 `pluginType: access`。
|
||||||
|
2. 根据 `name`、`author`、`fullName` 识别目标 Access。
|
||||||
|
3. 使用 `/sys/plugin/export` 读取完整 YAML。
|
||||||
|
4. 检查 `content` 中已经提供的方法。
|
||||||
|
|
||||||
|
## 修改规则
|
||||||
|
|
||||||
|
- Access 的 `editable: true` 时才允许修改。
|
||||||
|
- 修改前先保存本地历史。
|
||||||
|
- 优先把通用 API/SDK 能力放入 Access。
|
||||||
|
- 业务插件通过 `dependPlugins` 依赖 Access。
|
||||||
|
- `editable: false` 时不要尝试修改 Access,在业务插件内部实现必要的调用。
|
||||||
|
- 不要在日志中打印完整授权配置。
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Certd API
|
||||||
|
|
||||||
|
以下接口都以前端生成提示词中的 API 地址为基础地址,并使用 `Authorization` 请求头。
|
||||||
|
|
||||||
|
## 查询插件
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /sys/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 时再调用 `/sys/plugin/export`。
|
||||||
|
|
||||||
|
## 读取插件信息
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /sys/plugin/info?id=12
|
||||||
|
Authorization: <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 导出完整 YAML
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /sys/plugin/export
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 保存插件
|
||||||
|
|
||||||
|
已有插件使用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /sys/plugin/update
|
||||||
|
```
|
||||||
|
|
||||||
|
新插件使用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /sys/plugin/add
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以使用完整 YAML 导入:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /sys/plugin/import
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "完整 YAML",
|
||||||
|
"override": true,
|
||||||
|
"type": "store"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
保存后重新调用 `/sys/plugin/find` 或 `/sys/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。
|
||||||
|
- 恢复后通过 `/sys/plugin/update` 或 `/sys/plugin/import` 写回 Certd。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## 在线插件 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
|
||||||
|
output: {}
|
||||||
|
dependPlugins: []
|
||||||
|
dependPackages: []
|
||||||
|
default: {}
|
||||||
|
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,59 @@
|
|||||||
|
---
|
||||||
|
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`。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
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 业务失败要有明确日志;创建失败必须抛出异常。
|
||||||
|
- 保持创建和删除幂等,避免清理失败阻断无关流程。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
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` 字段。
|
||||||
|
|
||||||
|
## `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("执行成功")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 编写要求
|
||||||
|
|
||||||
|
- 统一用 `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 `/sys/plugin/update` 或 `/sys/plugin/import` 保存。
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fs-icon {
|
.fs-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.fs-iconify {
|
.fs-iconify {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
@@ -191,6 +193,7 @@
|
|||||||
|
|
||||||
body a {
|
body a {
|
||||||
color: #1890ff;
|
color: #1890ff;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #40a9ff;
|
color: #40a9ff;
|
||||||
}
|
}
|
||||||
@@ -216,10 +219,12 @@ span.fs-icon-svg {
|
|||||||
&:first-child {
|
&:first-child {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fs-icon,
|
.fs-icon,
|
||||||
.fs-button-icon {
|
.fs-button-icon {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@@ -252,13 +257,48 @@ button.ant-btn.ant-btn-default.isPlus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fs-form-none-content {
|
.fs-form-none-content {
|
||||||
.fs-form-wrapper{
|
.fs-form-wrapper {
|
||||||
.fs-form-content{display: none;}
|
.fs-form-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fs-actionbar{
|
.fs-actionbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap:4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fs-icon-image-container {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
&.font-size-16 {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.font-size-18 {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.font-size-20 {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.font-size-24 {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.font-size-32 {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,24 @@ export async function GetList(query: any) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function FindPlugins(query: {
|
||||||
|
type?: "builtIn" | "store";
|
||||||
|
pluginType?: string;
|
||||||
|
group?: string;
|
||||||
|
name?: string;
|
||||||
|
author?: string;
|
||||||
|
keyword?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
includeBuiltIn?: boolean;
|
||||||
|
includeStore?: boolean;
|
||||||
|
}) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/find",
|
||||||
|
method: "post",
|
||||||
|
data: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function AddObj(obj: any) {
|
export async function AddObj(obj: any) {
|
||||||
return await request({
|
return await request({
|
||||||
url: apiPrefix + "/add",
|
url: apiPrefix + "/add",
|
||||||
@@ -84,6 +102,7 @@ export type OnlinePluginBean = {
|
|||||||
downloadCount?: number;
|
downloadCount?: number;
|
||||||
score?: number;
|
score?: number;
|
||||||
aiCheckStatus?: string;
|
aiCheckStatus?: string;
|
||||||
|
editable?: boolean;
|
||||||
selfAuthored?: boolean;
|
selfAuthored?: boolean;
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
installedVersion?: string;
|
installedVersion?: string;
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<div class="plugin-ai-dev">
|
||||||
|
<section class="plugin-ai-dev__setup">
|
||||||
|
<div class="plugin-ai-dev__form">
|
||||||
|
<div class="plugin-ai-dev__field">
|
||||||
|
<label class="plugin-ai-dev__label">开发需求</label>
|
||||||
|
<a-textarea v-model:value="requirement" :rows="8" placeholder="描述你想开发或优化的插件,例如:开发一个 Foo DNS Provider,支持创建和删除 TXT 记录。" />
|
||||||
|
</div>
|
||||||
|
<div class="plugin-ai-dev__field">
|
||||||
|
<label class="plugin-ai-dev__label">选择现有插件</label>
|
||||||
|
<a-select v-model:value="selectedPluginId" allow-clear show-search placeholder="不选择则开发新插件" :filter-option="filterPluginOption" :options="pluginOptions" :loading="pluginLoading" />
|
||||||
|
</div>
|
||||||
|
<div class="plugin-ai-dev__actions">
|
||||||
|
<a-button type="primary" :loading="creating" @click="createPrompt">生成启动提示词</a-button>
|
||||||
|
<a-button :disabled="!prompt" @click="copyPrompt">复制提示词</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-ai-dev__prompt">
|
||||||
|
<div class="plugin-ai-dev__prompt-head">
|
||||||
|
<span>Codex / Trae 启动提示词</span>
|
||||||
|
<a-tag color="blue">API 模式</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-textarea class="plugin-ai-dev__prompt-text" :value="prompt" readonly :rows="24" placeholder="生成后复制到 Codex 或 Trae 中运行。" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { useUserStore } from "/@/store/user";
|
||||||
|
import { env } from "/src/utils/util.env";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PluginAiDevDialogBody",
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
pluginId?: number | string;
|
||||||
|
pluginName?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const requirement = ref("");
|
||||||
|
const selectedPluginId = ref<number | string | undefined>(props.pluginId);
|
||||||
|
const pluginOptions = ref<{ label: string; value: number | string }[]>([]);
|
||||||
|
const pluginLoading = ref(false);
|
||||||
|
const creating = ref(false);
|
||||||
|
const prompt = ref("");
|
||||||
|
|
||||||
|
function filterPluginOption(input: string, option: any) {
|
||||||
|
return `${option?.label || ""}`.toLowerCase().includes(input.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPluginOptions() {
|
||||||
|
pluginLoading.value = true;
|
||||||
|
try {
|
||||||
|
const setting = await api.OnlinePluginSetting();
|
||||||
|
const lastSyncTime = Number(setting?.lastSyncTime || 0);
|
||||||
|
const syncExpired = !lastSyncTime || Date.now() - lastSyncTime > 3 * 24 * 60 * 60 * 1000;
|
||||||
|
if (syncExpired) {
|
||||||
|
await api.OnlinePluginSync();
|
||||||
|
}
|
||||||
|
const records = await api.FindPlugins({
|
||||||
|
keyword: "",
|
||||||
|
includeBuiltIn: true,
|
||||||
|
includeStore: true,
|
||||||
|
});
|
||||||
|
pluginOptions.value = (records || []).map((item: any) => {
|
||||||
|
return {
|
||||||
|
label: item.fullName || (item.author ? `${item.author}/${item.name}` : item.name) || item.title,
|
||||||
|
value: item.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (props.pluginId && props.pluginName && !pluginOptions.value.some(item => item.value === props.pluginId)) {
|
||||||
|
pluginOptions.value.unshift({
|
||||||
|
label: props.pluginName,
|
||||||
|
value: props.pluginId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pluginLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPrompt() {
|
||||||
|
if (!requirement.value.trim()) {
|
||||||
|
message.warning("请先填写插件开发需求");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
|
prompt.value = buildPrompt();
|
||||||
|
message.success("启动提示词已生成");
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt() {
|
||||||
|
const pluginLabel = props.pluginName || selectedPluginId.value;
|
||||||
|
const pluginText = pluginLabel ? `当前插件:${pluginLabel}` : "当前为新插件开发";
|
||||||
|
const token = userStore.getToken || "";
|
||||||
|
const certdUrl = window.location.origin;
|
||||||
|
const apiBase = new URL(env.API || "/api", certdUrl).toString().replace(/\/$/, "");
|
||||||
|
return `你是 Certd 在线插件开发 Agent。
|
||||||
|
|
||||||
|
用户需求:
|
||||||
|
${requirement.value.trim()}
|
||||||
|
|
||||||
|
${pluginText}
|
||||||
|
|
||||||
|
Certd 地址:
|
||||||
|
${certdUrl}
|
||||||
|
|
||||||
|
Certd API 地址:
|
||||||
|
${apiBase}
|
||||||
|
|
||||||
|
当前用户 Token:
|
||||||
|
${token}
|
||||||
|
|
||||||
|
开发流程:
|
||||||
|
1. 开始开发前,先检查当前工作目录是否已经是 certd 项目:应能看到 package.json、packages/ui/certd-server/src/plugins/、.trae/skills/ 等特征。
|
||||||
|
2. 检查 .trae/skills/ 下是否已经有 certd-online-plugin-dev 技能。
|
||||||
|
3. 如果当前目录不是 certd 项目,或缺少该技能,则先拉取 certd 仓库代码:优先 https://atomgit.com/certd/certd/,如果 AtomGit 拉取失败,再使用 https://github.com/certd/certd。
|
||||||
|
4. 加载 .trae/skills/certd-online-plugin-dev/SKILL.md,并按插件类型加载对应子 Skill。
|
||||||
|
5. 参考 certd 项目下已有内置插件 packages/ui/certd-server/src/plugins/ 的实现方式进行开发。
|
||||||
|
6. 使用当前 Token 调用 Certd API,通过 /sys/plugin/find 查询插件和 Access。
|
||||||
|
7. 开发 Task 或 DNS 插件前,先查询对应 Access,优先复用 Access 提供的 API/SDK 能力。
|
||||||
|
8. 如果没有 Access,先创建 Access 插件;如果 Access 的 editable 为 true 且缺少能力,可以先修改 Access。
|
||||||
|
9. 在当前工作区创建并使用 tmp-online-plugin-dev 作为本次插件开发临时目录,历史记录、临时 YAML、脚本草稿和调试记录都放在该目录下。
|
||||||
|
10. 修改任何插件前,先在 tmp-online-plugin-dev/history 下保存完整 YAML 历史记录,便于恢复。
|
||||||
|
11. 通过 Certd API 读取和保存完整插件 YAML,不使用 WebSocket,不依赖浏览器草稿。
|
||||||
|
12. 保存完成后向用户报告 API 操作结果,不自动发布。
|
||||||
|
|
||||||
|
认证请求要求:
|
||||||
|
- 请求头使用 Authorization: ${token}
|
||||||
|
- 所有请求使用 JSON。
|
||||||
|
- 不要把 Token、证书、私钥、API 密钥或授权配置写入日志和历史摘要。
|
||||||
|
|
||||||
|
当前插件 ID:
|
||||||
|
${selectedPluginId.value || "无"}
|
||||||
|
|
||||||
|
开始前先读取 Skill 和当前插件信息。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPrompt() {
|
||||||
|
if (!prompt.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(prompt.value);
|
||||||
|
message.success("已复制启动提示词");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPluginOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.plugin-ai-dev {
|
||||||
|
display: flex;
|
||||||
|
min-height: 600px;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__setup {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: minmax(320px, 0.8fr) minmax(480px, 1.2fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form,
|
||||||
|
&__prompt {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #e5eaf1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__prompt-head {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__prompt-text {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<div class="plugin-edit-dialog-body__header">
|
<div class="plugin-edit-dialog-body__header">
|
||||||
<span>插件名称:</span>
|
<span>插件名称:</span>
|
||||||
<fs-copyable :model-value="pluginName" />
|
<fs-copyable :model-value="pluginName" />
|
||||||
|
<a-button v-if="canEditPlugin" class="plugin-edit-dialog-body__ai-dev" @click="openAiDev">AI 开发</a-button>
|
||||||
<a-button v-if="canEditPlugin" class="plugin-edit-dialog-body__publish" :loading="isPublishingPlugin(plugin)" @click="doPublish">发布到插件市场</a-button>
|
<a-button v-if="canEditPlugin" class="plugin-edit-dialog-body__publish" :loading="isPublishingPlugin(plugin)" @click="doPublish">发布到插件市场</a-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plugin-edit-dialog-body__content">
|
<div class="plugin-edit-dialog-body__content">
|
||||||
@@ -41,6 +42,7 @@ import createCrudOptions from "../crud";
|
|||||||
import { usePluginPublish } from "../use-publish";
|
import { usePluginPublish } from "../use-publish";
|
||||||
import { usePluginStore } from "/@/store/plugin";
|
import { usePluginStore } from "/@/store/plugin";
|
||||||
import { useSettingStore } from "/@/store/settings";
|
import { useSettingStore } from "/@/store/settings";
|
||||||
|
import { usePluginAiDev } from "../use-ai-dev";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "PluginEditDialogBody",
|
name: "PluginEditDialogBody",
|
||||||
@@ -61,6 +63,7 @@ const formOptionsRef: Ref = ref();
|
|||||||
const baseFormRef: Ref = ref({});
|
const baseFormRef: Ref = ref({});
|
||||||
const saveLoading = ref(false);
|
const saveLoading = ref(false);
|
||||||
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
|
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
|
||||||
|
const { openAiDevDialog } = usePluginAiDev();
|
||||||
|
|
||||||
function initFormOptions() {
|
function initFormOptions() {
|
||||||
const formCrudOptions = createCrudOptions({
|
const formCrudOptions = createCrudOptions({
|
||||||
@@ -161,6 +164,13 @@ async function doPublish() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAiDev() {
|
||||||
|
await openAiDevDialog({
|
||||||
|
pluginId: props.pluginId,
|
||||||
|
pluginName: pluginName.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadPlugin);
|
onMounted(loadPlugin);
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -186,6 +196,14 @@ defineExpose({
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__ai-dev {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ai-dev + &__publish {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { usePluginConfig } from "./use-config";
|
|||||||
import { useSettingStore } from "/src/store/settings/index";
|
import { useSettingStore } from "/src/store/settings/index";
|
||||||
import { usePluginStore } from "/@/store/plugin";
|
import { usePluginStore } from "/@/store/plugin";
|
||||||
import PluginAuthorField from "./components/plugin-author-field.vue";
|
import PluginAuthorField from "./components/plugin-author-field.vue";
|
||||||
|
import { usePluginAiDev } from "./use-ai-dev";
|
||||||
|
|
||||||
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
|
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -57,6 +58,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||||||
|
|
||||||
const { openImportDialog } = usePluginImport();
|
const { openImportDialog } = usePluginImport();
|
||||||
const { openConfigDialog } = usePluginConfig();
|
const { openConfigDialog } = usePluginConfig();
|
||||||
|
const { openAiDevDialog } = usePluginAiDev();
|
||||||
|
|
||||||
const settingStore = useSettingStore();
|
const settingStore = useSettingStore();
|
||||||
const pluginStore = usePluginStore();
|
const pluginStore = usePluginStore();
|
||||||
@@ -149,6 +151,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||||||
},
|
},
|
||||||
actionbar: {
|
actionbar: {
|
||||||
buttons: {
|
buttons: {
|
||||||
|
aiDev: {
|
||||||
|
show: true,
|
||||||
|
icon: "ion:sparkles-outline",
|
||||||
|
text: "AI 开发插件",
|
||||||
|
type: "primary",
|
||||||
|
async click() {
|
||||||
|
await openAiDevDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
add: {
|
add: {
|
||||||
show: true,
|
show: true,
|
||||||
icon: "ion:ios-add-circle-outline",
|
icon: "ion:ios-add-circle-outline",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { h, ref } from "vue";
|
||||||
|
import { useFormDialog } from "/@/use/use-dialog";
|
||||||
|
import PluginAiDevDialogBody from "./components/plugin-ai-dev-dialog-body.vue";
|
||||||
|
|
||||||
|
export type PluginAiDevOpenOptions = {
|
||||||
|
pluginId?: number | string;
|
||||||
|
pluginName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePluginAiDev() {
|
||||||
|
const { openFormDialog } = useFormDialog();
|
||||||
|
|
||||||
|
async function openAiDevDialog(options: PluginAiDevOpenOptions = {}) {
|
||||||
|
const bodyRef = ref();
|
||||||
|
await openFormDialog({
|
||||||
|
title: "AI 开发插件",
|
||||||
|
columns: {},
|
||||||
|
noneForm: true,
|
||||||
|
wrapper: {
|
||||||
|
width: 980,
|
||||||
|
destroyOnClose: true,
|
||||||
|
maskClosable: false,
|
||||||
|
footer: null,
|
||||||
|
class: "plugin-ai-dev-dialog",
|
||||||
|
},
|
||||||
|
body: () =>
|
||||||
|
h(PluginAiDevDialogBody, {
|
||||||
|
ref: bodyRef,
|
||||||
|
pluginId: options.pluginId,
|
||||||
|
pluginName: options.pluginName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openAiDevDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
OnlinePluginPublishInfoReq,
|
OnlinePluginPublishInfoReq,
|
||||||
OnlinePluginPublishReq,
|
OnlinePluginPublishReq,
|
||||||
OnlinePluginVersionSubmitReq,
|
OnlinePluginVersionSubmitReq,
|
||||||
|
PluginFindReq,
|
||||||
PluginImportReq,
|
PluginImportReq,
|
||||||
PluginService,
|
PluginService,
|
||||||
} from "../../../modules/plugin/service/plugin-service.js";
|
} from "../../../modules/plugin/service/plugin-service.js";
|
||||||
@@ -39,6 +40,12 @@ export class PluginController extends CrudController<PluginService> {
|
|||||||
return await super.page(body);
|
return await super.page(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/find", { description: "sys:settings:view", summary: "查询插件" })
|
||||||
|
async find(@Body(ALL) body: PluginFindReq) {
|
||||||
|
const res = await this.service.findPlugins(body || {});
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/add", { description: "sys:settings:edit", summary: "添加插件" })
|
@Post("/add", { description: "sys:settings:edit", summary: "添加插件" })
|
||||||
async add(@Body(ALL) bean: any) {
|
async add(@Body(ALL) bean: any) {
|
||||||
const def: any = {
|
const def: any = {
|
||||||
@@ -182,4 +189,5 @@ export class PluginController extends CrudController<PluginService> {
|
|||||||
const res = await this.service.exportPlugin(id);
|
const res = await this.service.exportPlugin(id);
|
||||||
return this.ok(res);
|
return this.ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
import { Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||||
import { pluginGroups, pluginRegistry } from "@certd/pipeline";
|
import { accessRegistry, pluginGroups, pluginRegistry } from "@certd/pipeline";
|
||||||
|
import { dnsProviderRegistry } from "@certd/plugin-cert";
|
||||||
import { cloneDeep } from "lodash-es";
|
import { cloneDeep } from "lodash-es";
|
||||||
|
|
||||||
@Provide()
|
@Provide()
|
||||||
@@ -25,6 +26,26 @@ export class BuiltInPluginService {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllList() {
|
||||||
|
const pluginList = this.getList();
|
||||||
|
const accessList = accessRegistry.getDefineList().map(item => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
pluginType: "access",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const dnsProviderList = dnsProviderRegistry.getDefineList().map(item => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
pluginType: "dnsProvider",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const list = [...pluginList, ...accessList, ...dnsProviderList];
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
return (a.order ?? 10) - (b.order ?? 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getGroups() {
|
getGroups() {
|
||||||
const groups: any = cloneDeep(pluginGroups);
|
const groups: any = cloneDeep(pluginGroups);
|
||||||
for (const key in groups) {
|
for (const key in groups) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { addonRegistry, BaseService, PageReq, PlusService, SysInstallInfo, SysPluginSetting, SysSettingsService } from "@certd/lib-server";
|
import { addonRegistry, BaseService, PageReq, PlusService, SysInstallInfo, SysPluginSetting, SysSettingsService } from "@certd/lib-server";
|
||||||
import { PluginEntity } from "../entity/plugin.js";
|
import { PluginEntity } from "../entity/plugin.js";
|
||||||
import { InjectEntityModel } from "@midwayjs/typeorm";
|
import { InjectEntityModel } from "@midwayjs/typeorm";
|
||||||
import { Brackets, IsNull, Not, Repository } from "typeorm";
|
import { Brackets, IsNull, Like, Not, Repository } from "typeorm";
|
||||||
import { isComm } from "@certd/plus-core";
|
import { isComm } from "@certd/plus-core";
|
||||||
import { BuiltInPluginService } from "../../pipeline/service/builtin-plugin-service.js";
|
import { BuiltInPluginService } from "../../pipeline/service/builtin-plugin-service.js";
|
||||||
import { merge } from "lodash-es";
|
import { merge } from "lodash-es";
|
||||||
@@ -26,6 +26,18 @@ export type OnlinePluginListReq = {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginFindReq = {
|
||||||
|
type?: "builtIn" | "store";
|
||||||
|
pluginType?: string;
|
||||||
|
group?: string;
|
||||||
|
name?: string;
|
||||||
|
author?: string;
|
||||||
|
keyword?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
includeBuiltIn?: boolean;
|
||||||
|
includeStore?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type OnlinePluginInstallReq = {
|
export type OnlinePluginInstallReq = {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
@@ -99,6 +111,7 @@ export type OnlinePluginBean = {
|
|||||||
downloadCount?: number;
|
downloadCount?: number;
|
||||||
score?: number;
|
score?: number;
|
||||||
aiCheckStatus?: string;
|
aiCheckStatus?: string;
|
||||||
|
editable?: boolean;
|
||||||
selfAuthored?: boolean;
|
selfAuthored?: boolean;
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
installedVersion?: string;
|
installedVersion?: string;
|
||||||
@@ -297,6 +310,106 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||||||
const keyword = `${query.name}`.trim().toLowerCase();
|
const keyword = `${query.name}`.trim().toLowerCase();
|
||||||
records = records.filter(item => (item.name || "").toLowerCase().includes(keyword));
|
records = records.filter(item => (item.name || "").toLowerCase().includes(keyword));
|
||||||
}
|
}
|
||||||
|
const keywords = this.normalizePluginFindKeywords(query as any);
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
records = records.filter(item => {
|
||||||
|
return keywords.some(keyword => {
|
||||||
|
return [item.name, item.title, item.desc, item.group, item.pluginType].some(value => `${value || ""}`.toLowerCase().includes(keyword));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePluginFindKeywords(req: PluginFindReq) {
|
||||||
|
const values = [...(req.keywords || []), req.keyword || ""];
|
||||||
|
return values.map(item => `${item || ""}`.trim().toLowerCase()).filter((item, index, list) => item.length > 0 && list.indexOf(item) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPlugins(req: PluginFindReq = {}) {
|
||||||
|
const includeBuiltIn = req.includeBuiltIn ?? req.type !== "store";
|
||||||
|
const includeStore = req.includeStore ?? req.type !== "builtIn";
|
||||||
|
const records: any[] = [];
|
||||||
|
|
||||||
|
if (includeBuiltIn) {
|
||||||
|
const builtInQuery = {
|
||||||
|
...req,
|
||||||
|
type: "builtIn",
|
||||||
|
};
|
||||||
|
const builtInList = this.filterBuiltInList(await this.getBuiltInEntityList(), builtInQuery);
|
||||||
|
records.push(
|
||||||
|
...builtInList.map(item => {
|
||||||
|
const record = {
|
||||||
|
...item,
|
||||||
|
type: "builtIn",
|
||||||
|
editable: false,
|
||||||
|
};
|
||||||
|
delete (record as any).content;
|
||||||
|
delete (record as any).setting;
|
||||||
|
delete (record as any).sysSetting;
|
||||||
|
delete (record as any).metadata;
|
||||||
|
delete (record as any).extra;
|
||||||
|
return record;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeStore) {
|
||||||
|
const storeQuery: any = {
|
||||||
|
type: "store",
|
||||||
|
};
|
||||||
|
if (req.pluginType) {
|
||||||
|
storeQuery.pluginType = req.pluginType;
|
||||||
|
}
|
||||||
|
if (req.group) {
|
||||||
|
storeQuery.group = req.group;
|
||||||
|
}
|
||||||
|
if (req.name) {
|
||||||
|
storeQuery.name = Like(`%${req.name}%`);
|
||||||
|
}
|
||||||
|
if (req.author) {
|
||||||
|
storeQuery.author = req.author;
|
||||||
|
}
|
||||||
|
|
||||||
|
let storeWhere: any = storeQuery;
|
||||||
|
const keywords = this.normalizePluginFindKeywords(req);
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
const searchFields = ["fullName", "name", "title", "desc", "group", "pluginType"];
|
||||||
|
storeWhere = keywords.flatMap(keyword => {
|
||||||
|
const keywordLike = Like(`%${keyword}%`);
|
||||||
|
return searchFields.map(field => {
|
||||||
|
return {
|
||||||
|
...storeQuery,
|
||||||
|
[field]: keywordLike,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const storeList = await this.find({
|
||||||
|
where: storeWhere,
|
||||||
|
order: {
|
||||||
|
pluginType: "ASC",
|
||||||
|
group: "ASC",
|
||||||
|
title: "ASC",
|
||||||
|
fullName: "ASC",
|
||||||
|
id: "ASC",
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
let bindUserId = 0;
|
||||||
|
if (this.sysSettingsService) {
|
||||||
|
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
|
||||||
|
bindUserId = installInfo.bindUserId || 0;
|
||||||
|
}
|
||||||
|
records.push(
|
||||||
|
...storeList.map(item => {
|
||||||
|
return {
|
||||||
|
...this.toOnlinePluginBean(item),
|
||||||
|
editable: canEditStorePlugin(item.developerId, bindUserId),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +544,7 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getBuiltInEntityList() {
|
async getBuiltInEntityList() {
|
||||||
const builtInList = this.builtInPluginService.getList();
|
const builtInList = this.builtInPluginService.getAllList();
|
||||||
const list = await this.list({
|
const list = await this.list({
|
||||||
query: {
|
query: {
|
||||||
type: "builtIn",
|
type: "builtIn",
|
||||||
|
|||||||
Reference in New Issue
Block a user