Compare commits

...
277 Commits
Author SHA1 Message Date
xiaojunnuo b12f7c54d4 build: release 2026-10-03 02:47:15 +08:00
xiaojunnuo b055bbecfc build: publish 2026-10-03 01:01:10 +08:00
xiaojunnuo a52a6ce8f6 build: trigger build image 2026-10-03 01:01:00 +08:00
xiaojunnuo fcbf1045fe v1.45.0 2026-10-03 01:00:08 +08:00
xiaojunnuo f5cfe054b7 build: prepare to build 2026-10-03 00:51:15 +08:00
xiaojunnuo 5ded5ff2b6 test: 更新测试用例的断言数据和测试描述 2026-10-03 00:51:09 +08:00
xiaojunnuo 8d31346cd3 build: prepare to build 2026-10-03 00:22:38 +08:00
xiaojunnuo 44b41b6a08 chore(certd-server): 同步迁移脚本 2026-10-02 03:17:46 +08:00
xiaojunnuo 4420472f8f perf: 宝塔站点列表支持搜索和排序 2026-10-01 21:49:39 +08:00
xiaojunnuo c4497a7018 chore: 更新DNS持久验证的支持证书类型为Google证书
更新了多个位置的提示文本,将原Let's Encrypt测试环境的说明替换为Google证书,同时调整了验证方式的说明顺序和内容,还修改了插件加载模式为metadata,优化了代码中的格式细节。
2026-10-01 10:47:28 +08:00
xiaojunnuo 03f1a7bd0e chore: 修复获取证书颁发者时未处理空directoryUrl的问题
修改两处代码,在获取DNS持久化颁发者时,当directoryUrl为空时使用accountUri作为备用,避免因缺少必要参数导致的错误
2026-10-01 10:01:41 +08:00
xiaojunnuo 97d2b50074 fix: 支持google persist校验方式 2026-09-30 18:19:23 +08:00
xiaojunnuo 2d0b85b305 fix: 缺失运行时嵌套依赖时添加清除缓存提示
当加载运行时依赖时出现嵌套模块未找到错误,添加明确的错误提示,指导用户前往插件管理页面清除缓存依赖后重试。同时新增单元测试验证该提示逻辑。
2026-09-30 16:34:11 +08:00
xiaojunnuo 28052c9387 chore: clb tencent 2026-09-30 16:22:59 +08:00
xiaojunnuo c4277350bc perf(tencent clb): 修改所有CLB地域的endpoint为就近接入 2026-09-30 15:58:13 +08:00
xiaojunnuo 97160373d6 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-09-30 15:32:57 +08:00
xiaojunnuo 0ce24618a1 docs: 修正HTTPS环境变量命名为小写格式 2026-09-29 15:06:06 +08:00
xiaojunnuo f60e63fbaf perf(monitor): 站点证书监控支持手动选中触发检查 2026-09-20 23:04:49 +08:00
xiaojunnuo 2735feb330 fix(webhook): 修复模版转义换行符,避免手动上传的证书报错
1.  将webhook插件内的replaceTemplate方法迁移到stringUtils公共工具
2.  优化模板替换处理,支持转义更多控制字符和兼容非字符串变量
3.  添加对应的单元测试用例
2026-09-20 23:04:13 +08:00
xiaojunnuo b6381d79fc chore: 批量优化插件开发相关配置与文档
1.  更新gitignore,新增.tmp_chunks忽略项
2.  切换certd插件加载模式为dev
3.  修正插件示例yaml的title注释
2026-09-20 01:26:02 +08:00
xiaojunnuo 241b2e5584 perf: 网宿插件双鉴权支持 2026-09-20 01:25:52 +08:00
xiaojunnuo bb1f232f41 fix: [BUG]使用天翼云CDN部署证书时无法匹配域名
https://github.com/certd/certd/issues/805
2026-09-19 18:05:22 +08:00
xiaojunnuo f82d3a5fcc chore(monitor): 补全站点新增/导入时的IP检查相关布尔默认值
解决批量导入等场景未传ipCheck等字段导致数据库存NULL,编辑弹窗开关过不了校验的issue #803,同时新增对应单测验证默认值和显式传参的正确性
2026-09-19 18:00:15 +08:00
xiaojunnuo bbeaf617d1 perf(open-api): openkey增加用途备注
1. 新增数据库字段存储开放接口密钥备注信息
2. 多语言添加密钥备注相关文案
3. 开放密钥接口支持备注参数传递与CRUD
4. 前端页面支持备注的展示、搜索与编辑
5. 调整插件加载模式为metadata并修复代码格式
2026-09-19 17:41:02 +08:00
xiaojunnuo ea7d14c289 fix(ctyun-cdn): 修复天翼云CDN部署插件的域名选择问题
1.  添加产品类型监听,切换产品类型时重新拉取域名列表
2.  未选择产品类型时抛出明确错误提示
3.  补全域名选项的domain字段以支持证书域名匹配分组
4.  新增单元测试覆盖上述场景
2026-09-18 01:12:36 +08:00
xiaojunnuo 3272a68ff6 style(certd client page): 调整头部布局和VIP按钮展示逻辑
优化页面头部子标题区域的对齐样式,移除非Plus版本的提示文本和条件渲染逻辑,统一展示VIP按钮
2026-09-18 00:18:13 +08:00
xiaojunnuo b5715d99ff Merge branch 'v2-dev' into v2_client 2026-09-17 23:52:49 +08:00
xiaojunnuo 3f5ba14fde ci(workflow): 修正stable-release.yml里版本号示例格式
将版本号示例的v前缀移除,和输入要求保持一致
2026-09-17 23:26:32 +08:00
xiaojunnuo 07f0d3b4d6 fix(config): 修复环境变量加载逻辑并更新配置文件
1.  重构loadDotEnv方法,支持指定加载的env文件路径,增加文件存在校验
2.  跳过注释和空行的环境变量加载,添加加载日志
3.  在默认配置中加载对应环境的env文件
4.  更新开发和生产环境的env配置项
2026-09-17 23:23:35 +08:00
xiaojunnuo 3dbbe72fc1 feat(client): 新增客户端管理功能
实现了客户端管理的完整流程:
1. 新增数据库表与迁移脚本,存储客户端信息与心跳快照
2. 实现服务端心跳上报接口与管理API
3. 新增前端页面与国际化多语言支持
4. 完善客户端在线状态判定逻辑与分页查询功能
2026-09-17 22:46:03 +08:00
xiaojunnuo 7f006d1359 refactor(plugin): 迁移同步插件逻辑到页面组件并优化上下文传递
1. 将plugin crud组件中的syncOnlinePlugins方法迁移到index.vue中
2. 把syncLoading和syncOnlinePlugins加入页面上下文传递给crud组件
3. 移除crud组件中冗余的syncLoading声明
4. 调整页面初始化调用顺序
2026-09-16 00:15:16 +08:00
xiaojunnuo d30f1c4785 refactor(plugin): 优化在线插件同步逻辑与时间格式化
1.  将自动同步相关逻辑抽离到页面组件统一管理
2.  使用dayjs替代原生Date进行时间格式化
3.  移除crud.tsx中冗余的同步配置加载代码
4.  统一维护自动同步的间隔时间常量
2026-09-15 23:52:29 +08:00
xiaojunnuo 96e0122b93 build: release 2026-09-12 20:26:56 +08:00
xiaojunnuo 7786a87b21 refactor(certd-server): 移除getReleaseMode函数,改用配置注入获取发布模式
1.  从环境配置文件中注释移除certd_release_mode的注释,启用稳定发布模式
2.  在AppController和SettingsController中注入release.mode配置替代原有的全局函数获取方式
3.  移除冗余的getReleaseMode工具函数
4.  在SettingsController中添加发布模式的日志打印
2026-09-12 20:22:18 +08:00
xiaojunnuo 34476ba14f build: publish 2026-09-12 19:24:56 +08:00
xiaojunnuo 4b942385a8 build: trigger build image 2026-09-12 19:24:46 +08:00
xiaojunnuo 14809127d6 v1.44.4 2026-09-12 19:23:35 +08:00
xiaojunnuo bd8b251d6e build: prepare to build 2026-09-12 19:17:14 +08:00
xiaojunnuo 5563abb13a fix: 修复证书插件配置无法打开的bug 2026-09-12 19:13:23 +08:00
xiaojunnuo 51e83e4c38 fix: 修复添加步骤会修改所有步骤标题的bug 2026-09-12 18:51:53 +08:00
xiaojunnuo 2b1a5296fe chore(certd-client): fix step form save data overwritten issue and add no payment tip
1. 修复步骤表单保存时因复用reactive对象导致已保存步骤被覆盖的问题,深拷贝currentStep后再传出
2. 新增无支付方式时显示暂不支持在线支付的提示
2026-09-12 18:49:24 +08:00
xiaojunnuo bded368cbb perf: 新增余额管理,管理员充值功能
1. 新增余额管理页面,支持用户余额查询与充值
2. 新增余额变更记录页面,查看用户余额明细
3. 补充多语言国际化文案
4. 修复免费订单显示文案为0元而非免费
5. 隐藏审核页面的操作按钮
6. 注释掉无用的堆日志启动代码
2026-09-12 16:06:53 +08:00
xiaojunnuo 6a649aaca0 perf(acme-account-access): AcmeAccount授权允许修改eab,重新生成account
修改了三处ACME账号表单的禁用判断条件,新增判断当editAccount为true时不禁用控件,允许在编辑已有账号时修改相关配置
2026-09-12 14:44:43 +08:00
xiaojunnuo e971145686 perf(ui): 商业版套餐购买不要求设置支付方式
1. 将套餐设置页面的"需要开启支付方式"提示改为"建议"
2. 新增支付方式为空且余额不足时的红色警告提示
3. 默认开启返利余额使用并修复支付字典加载异常处理
2026-09-12 14:28:52 +08:00
xiaojunnuo bbca449f94 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-09-12 09:32:36 +08:00
xiaojunnuo e6852c2715 docs: 更新AGENTS.md编码规范说明
refactor(plugin-fastly): 简化多行API调用代码
refactor(plugin-serv
2026-09-11 19:01:29 +08:00
xiaojunnuo 47cb523307 chore(cloudflare): 优化权限说明的换行展示
修复Cloudflare插件的权限说明文本排版,将权限项用换行分开展示,提升可读性
2026-09-10 15:13:38 +08:00
xiaojunnuo cbf46b5c48 build: release 2026-09-07 00:07:49 +08:00
xiaojunnuo 7ed3105d97 build: publish 2026-09-06 23:51:23 +08:00
xiaojunnuo 61b945c08f build: trigger build image 2026-09-06 23:51:13 +08:00
xiaojunnuo cc60cd4cd1 v1.44.3 2026-09-06 23:50:28 +08:00
xiaojunnuo dade599acb build: prepare to build 2026-09-06 23:46:14 +08:00
xiaojunnuo 1070417a36 chore: 修复fastly 通配符型号转换格式的问题 2026-09-06 23:45:56 +08:00
xiaojunnuo 81f676f1ca build: prepare to build 2026-09-06 23:29:25 +08:00
xiaojunnuo 0cf0608d3f build: prepare to build 2026-09-06 23:25:52 +08:00
xiaojunnuo dac94b64f0 fix(plugin-service): 修复同步自己开发的云端插件不显示已安装状态问题
将开发者ID、VIP状态、依赖插件、AI检查状态、插件状态、应用ID和作者字段加入插件更新逻辑,完善插件信息同步的完整性
2026-09-06 23:08:31 +08:00
xiaojunnuo 8c88092235 docs: 优化docker镜像文档的排版和内容
调整了README和docker安装指南中的镜像说明部分,将原有的零散说明改为表格形式统一展示,修正了镜像地址的展示位置,补充了稳定版slim/armv7的完整镜像地址信息,清理了重复的说明内容。
2026-09-06 17:28:00 +08:00
Steven ZhuandClaude Sonnet 5 2a2e963914 perf(plugin): add Fastly custom TLS certificates integration (#797) @szhu25
* feat(plugin): add Fastly custom TLS certificates integration

Introduces a complete set of Fastly plugins for automating custom TLS certificates deployment:
- `FastlyAccess`: Handles Fastly API authentication and provides helpers for dynamic data fetching (Services, Domains, Configs).
- `FastlyUploadCert`: Uploads a new private key and certificate (2-step POST process) or updates an existing one (PATCH).
- `FastlyDeployCert`: Activates a custom TLS certificate by linking it to a TLS configuration and domain via TLS Activations API.
- `FastlyRefreshCert`: Bulk updates existing custom TLS certificates.
- `FastlyPurgeCache`: Purges all cache for a specified Fastly Service.

Includes comprehensive unit tests covering API request formats and error handling for all tasks.

* fix(fastly): drop invalid 'multiple' option from refresh-cert input define

createRemoteSelectInputDefine has no 'multiple' option; multi-select is
already its default (mode: "tags", single: false). The excess property
broke the production build (TS2353).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh

* fix(fastly): correct TLS list unwrapping, paginate, harden purge

- access: ctx.http already unwraps to the response body, so the JSON:API
  item array is at body.data, not body.data.data — getTlsConfigurations /
  getTlsDomains / getCertificates always returned [] and the selectors
  were empty. Fetch via listAllJsonApi, which also pages past Fastly's
  20-item default so accounts with more than one page are fully listed.
- purge-cache: send no body to /purge_all (auth is header-only) instead
  of an empty JSON:API object.
- upload-cert / refresh-cert: note in the helper text that Fastly requires
  the renewed cert to reuse the original private key for PATCH updates.
- test: cover the list-helper unwrapping, pagination aggregation, and the
  bare-array /service response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh

* fix(fastly): use output-selector for deploy cert ID input

The FastlyDeployCert `certificateId` input was a free-text box whose
helper pointed users at a non-existent "advanced settings" workflow to
grab the upstream output variable. certd only surfaces a prior step's
@TaskOutput through an input whose component is `output-selector`, so the
FastlyUploadCert `fastlyCertId` output was never selectable.

Bind the input to `output-selector` with `from: ["FastlyUploadCert"]`,
matching the pattern used by plugin-aliyun deploy-to-cdn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J

* fix(fastly): accept cert or uploaded id in deploy, share upload helpers

Rework FastlyDeployCert to mirror the AWS CloudFront pattern instead of
demanding a hand-typed / separately-uploaded certificate id:

- The `certificateId` text box (and the earlier FastlyUploadCert-only
  output-selector) is replaced by a `cert: CertInfo | string` input whose
  output-selector accepts both a cert-apply step's cert and a
  FastlyUploadCert step's `fastlyCertId`.
- execute() binds the id directly when it got a string; when it got a raw
  CertInfo it uploads to Fastly first (new optional `name` input), then
  creates the TLS activation. So a plain "apply cert -> activate" pipeline
  needs no separate upload step.

Extract the Fastly certificate create (2-step key + cert) and update
(PATCH) flows into FastlyAccess.createCertificate / updateCertificate and
reuse them from both FastlyUploadCert and FastlyDeployCert.

Tests updated to drive the plugins through a real FastlyAccess with only
doRequestApi stubbed; added a deploy case for the raw-CertInfo path.

Note: build/tests not run locally (no Node toolchain on this machine) —
needs CI / local verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J

* fix(fastly): reuse existing key/cert when Fastly reports "already exists"

Fastly deduplicates TLS private keys (and byte-identical certificates) by
content, so re-running an upload fails with 400 "... already exists:
'<id>'". Recover that id and carry on instead of aborting:

- ensurePrivateKey(): on "already exists", parse the id from the error
  (or match an existing key by name), else throw actionable guidance.
- createCertificate(): same treatment for the /tls/certificates POST;
  when no id is recoverable, tell the user to supply the certificate id
  so the PATCH (update) path is used.
- Shared FastlyAccess.extractExistingId() helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J

* fix(fastly): per-domain TLS activation, idempotent on renewal

The TLS 域名 / TLS 配置 selectors use createRemoteSelectInputDefine, which
defaults to multi-select ("tags"), so domainId arrived as an array and was
written straight into tls_domain.data.id -> Fastly 500. A tls_activation
also binds exactly one domain, so wildcard + apex needs one each.

- domainId -> domainIds (string[]); loop, one activation per domain.
  configurationId gets single:true; both inputs are still normalised in
  execute() (array-or-scalar, plus legacy `domainId` key).
- New FastlyAccess.deployActivation(): looks up an existing activation for
  the domain+configuration and PATCHes it onto the new certificate;
  otherwise POSTs a fresh one. Renewals no longer fail on a duplicate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J

* feat(fastly): list service domains for TLS activation, not cert SANs

The TLS 域名 selector was populated from GET /tls/domains, which only
echoes certificate SAN entries — so a wildcard cert offered "*.g0l.net",
which cannot be activated unless a service actually serves that wildcard.

onGetTlsDomainList now lists the domains configured on the active version
of each Fastly service (new FastlyAccess.getServiceDomains()), which are
the names you can actually activate (e.g. www.g0l.net, covered by the
wildcard). Cert-derived TLS domains are still appended, tagged "(来自证书)".
The field stays free-entry so an unlisted host can be typed in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:20:14 +08:00
Steven ZhuandClaude Sonnet 5 1b882a2ca4 AWS ratelimit fix (#796) @szhu25
* Parse PEM chain and import certificate chain

Split the PEM in certInfo.crt into a leaf certificate and intermediate chain (using a lookbehind regex), trim the blocks, and pass the chain to ImportCertificateCommand only when present. Replace console.log with this.logger.info and log the returned CertificateArn. This ensures the leaf cert is uploaded separately from its chain and avoids sending an empty CertificateChain.

* Add AWS retry & CloudFront deployment wait

Introduce robust retry and polling helpers to handle AWS throttling and CloudFront propagation. Added AwsClient.withRetry (exponential backoff, handles common throttling errors, default 5 attempts/base 2s) and waitForDistributionDeployed (polls until distribution Status is "Deployed", default 10min timeout/15s interval). Update deploy-to-cloudfront plugin to use withRetry for Get/UpdateDistribution and importCertificate, pass AwsClient into uploadToACM, and wait for each distribution to finish deploying before continuing to avoid PreconditionFailed errors. Improves reliability when facing rate limits and global CloudFront propagation delays; adds informative logging for retry and deployment status.

* fix(aws): resolve rate limit and runtime import bugs

* fix(aws): broaden retry detection and harden CloudFront poll

- withRetry: retry on Route53 PriorRequestNotComplete, SlowDown, SDK v3
  $retryable.throttling hints and HTTP 429/503; extract detection into
  isRetryableAwsError
- withRetry: add equal jitter to backoff so concurrent pipelines don't
  retry in lockstep
- waitForDistributionDeployed: a throttle that outlives withRetry's budget
  keeps polling until the deadline instead of aborting the deploy
- test: cover withRetry retry/exhaustion/non-throttle paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh

* fix(aws): fix build break and make withRetry the sole retry authority

- withRetry<T = any>: default the type param so call sites passing an
  untyped SDK client (@aws-sdk/* is a lazyDependency with no build-time
  types) don't collapse the result to `unknown`. Also annotate the two
  result consumers as `any`. Fixes the TS2339 errors that broke the
  Docker build (aws-client.ts, plugin-deploy-to-cloudfront.ts).
- Set maxAttempts: 1 on every AWS SDK client (ACM/STS/Route53/CloudFront)
  so the SDK's own retry strategy no longer stacks on top of withRetry.
- isRetryableAwsError: broaden to cover what the SDK's `standard` strategy
  used to handle now that it's disabled - $retryable hint, HTTP 429 + any
  5xx, connection-level errors (ECONNRESET/ETIMEDOUT/EAI_AGAIN/...).
- waitForDistributionDeployed: a transient error that outlives withRetry's
  budget now retries on the next poll instead of aborting the whole deploy.
- test: cover 5xx, $retryable, and connection-error retry paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrMVmQyvQjes6t4DGjuBaD

* feat(aws): make CloudFront deploy-wait opt-in (default off)

Polling GetDistribution until Status=Deployed adds 3-8min of pipeline
wall-time per run for no functional gain - CloudFront finishes the
rollout on its own once UpdateDistribution returns 200. Gate it behind
a new "等待部署完成" switch, default false. Retry/backoff on the actual
mutating calls (GetDistributionConfig / UpdateDistribution / ACM import
/ Route53) is unaffected - it wraps those sends directly, not the poll.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrMVmQyvQjes6t4DGjuBaD

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:19:47 +08:00
xiaojunnuo bd9433478a fix(plugin): 修复插件配置保存与加载的相关问题
1.  修复插件配置保存时使用fullName或name作为标识的问题
2.  为证书申请插件配置补充pluginType字段
3.  新增插件配置全局加载逻辑,启动时同步数据库插件配置到内存注册表
4.  优化配置编辑器默认值与选项添加交互
5.  补充vue依赖导入与上下文注入
2026-09-05 00:27:26 +08:00
xiaojunnuo 32ade8e4a3 build: release 2026-09-03 22:16:49 +08:00
xiaojunnuo f2bcdeeb33 build: publish 2026-09-03 22:05:17 +08:00
xiaojunnuo 4099af7f5d build: trigger build image 2026-09-03 22:05:07 +08:00
xiaojunnuo 67f3daae62 v1.44.2 2026-09-03 22:04:19 +08:00
xiaojunnuo c72fa68e4c build: prepare to build 2026-09-03 21:57:56 +08:00
xiaojunnuo 1f5cda452c test(cert): 修复DNS持久化记录服务测试用例的查询方法适配问题
将测试中使用的findOneBy替换为标准的findOne方法,适配服务层实际的查询逻辑,修正测试用例的模拟实现
2026-09-03 21:57:47 +08:00
xiaojunnuo 5a19d2513d build: prepare to build 2026-09-03 21:49:35 +08:00
xiaojunnuo c0f8145615 test(plugin-service): 完善插件服务测试用例
修复并补充插件安装相关的单元测试逻辑,新增findOne仓库方法模拟,校验查询参数正确性,调整断言逻辑以匹配实际的查询次数
2026-09-03 21:49:15 +08:00
xiaojunnuo 2ad14b82fa chore(base-service): 修复findOneBy方法调用不兼容的问题
将弃用的findOneBy替换为标准的findOne查询方式,兼容新版本的TypeORM接口
2026-09-03 21:37:52 +08:00
xiaojunnuo c8bdcdc778 build: prepare to build 2026-09-03 21:20:43 +08:00
xiaojunnuo 15d2bd8484 fix: 修复插件市场插件安装无效的bug 2026-09-03 21:20:17 +08:00
xiaojunnuo 7d4311115c chore(plugin-service): 补全内置插件的installed字段赋值逻辑
为内置插件添加默认的installed状态设置,统一插件安装状态的处理逻辑,修复部分场景下插件安装状态不准确的问题
2026-09-03 01:39:22 +08:00
xiaojunnuo 3efa0582ef Merge branch 'v2-dev' into v2 2026-09-03 01:17:35 +08:00
xiaojunnuo 7624babc99 build: release 2026-09-03 01:17:10 +08:00
xiaojunnuo 6cf8f350f5 build: publish 2026-09-03 00:58:57 +08:00
xiaojunnuo 0e08555220 build: trigger build image 2026-09-03 00:58:47 +08:00
xiaojunnuo fe738fa7d4 v1.44.1 2026-09-03 00:50:48 +08:00
xiaojunnuo 2439f4ab02 build: prepare to build 2026-09-03 00:44:44 +08:00
xiaojunnuo 75b2b146ce test: 优化两个测试文件的代码结构与测试逻辑
1.  为证书信息服务测试提取复用的服务创建函数,减少重复代码
2.  修复插件服务测试用例,修正作者校验逻辑、同步逻辑、插件导入更新等多处测试断言
2026-09-03 00:43:53 +08:00
xiaojunnuo 1f3c096517 build: prepare to build 2026-09-03 00:22:26 +08:00
xiaojunnuo d264388c00 build: prepare to build 2026-09-03 00:17:04 +08:00
xiaojunnuo b8eaeeaa64 refactor(plugin-service): 调整在线插件作者请求的必填字段
将name和email从必填改为可选字段,优化接口参数灵活性
2026-09-03 00:16:59 +08:00
xiaojunnuo dba1a486cb chore(ui/certd-server): update metadata config and fix event name
1. 修正deploy_CertApply.yaml中DNS持久验证的说明文本
2. 将表单的selected-change事件名改为驼峰式selectedChange
3. 为acmeAccount的选择组件添加emitImmediate配置
2026-09-03 00:15:49 +08:00
xiaojunnuo 5408ae6ad8 build: prepare to build 2026-09-03 00:08:37 +08:00
Atirna f5430a8925 fix: preserve Route53 TXT record values (#791) 2026-09-02 23:59:45 +08:00
xiaojunnuo 9cc21204b1 docs(install/docker): update mirror table and add aliyun registry tags
1. 补全docker镜像表格的分隔线对齐格式
2. 在release镜像工作流中新增阿里云certd镜像仓库的推送标签
2026-09-02 22:42:25 +08:00
xiaojunnuo f911e06a69 fix: 修复大量问题
1.  更新多处文案,将"仅Let's Encrypt"改为"仅Let's Encrypt 测试环境"
2.  新增getSiteId方法到设置store
3.  优化项目store缓存key和变量声明顺序
4.  重构插件作者字段组件:添加作者注册状态管理、禁用输入框、支持修改邮箱
5.  暴露updatePluginAuthorEmail方法并完善插件类型注释
6.  调整首页统计卡片布局位置
7.  移除多余空行和注释掉Addon插件类型选项
2026-09-01 23:21:06 +08:00
xiaojunnuo 4a6eab0ce6 refactor(plugin): 调整插件相关代码结构与逻辑
1. 为createRemoteSelectInputDefine新增emitImmediate参数
2. 新增BaseService.updateById方法简化更新操作
3. 移除插件服务中多余的YAML序列化工具方法,改为前端处理
4. 优化插件更新、同步逻辑,替换部分update调用为updateById
5. 简化插件作者校验逻辑
2026-09-01 20:53:50 +08:00
xiaojunnuo 1d90fadd0c Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-09-01 00:23:56 +08:00
xiaojunnuo 1b1a5ec94d refactor(dashboard): 重构统计逻辑,优化前端展示与代码结构
1.  重构用户统计数据模型与累加逻辑,使用lodash简化字段操作
2.  调整仪表盘布局与统计卡片样式,适配暗色模式
3.  优化插件系统默认值加载时机,修复配置同步问题
4.  简化多语言文案,统一统计数据展示格式
5.  清理冗余代码与注释,修复代码规范问题
2026-09-01 00:23:53 +08:00
xiaojunnuo d67379f297 fix: 修复编辑证书申请任务导致清空ACME账号的bug
这一版本新增了用户证书生成统计、流水线运行统计功能,优化了插件配置获取和更新流程:
1.  新增用户统计设置模型,统计单域名/多域名/泛域名证书生成数和流水线运行次数
2.  仪表盘新增累计证书数、分类统计和节省金额估算卡片
3.  重构插件配置相关接口,统一插件获取、配置保存逻辑
4.  修复远程选择组件初始值触发逻辑,新增emitImmediate配置项
5.  优化ACME账号切换逻辑,修复对象展开语法语法问题
6.  补充多语言国际化文案,调整部分前端样式细节
2026-08-31 22:54:28 +08:00
xiaojunnuo 249b98df66 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-31 13:23:47 +08:00
xiaojunnuo aebbb5cc7d docs: 更新DNS持久验证的说明,移除测试环境限制
将多处提及的"仅Let's Encrypt测试环境可以申请"修改为正式环境支持,同步更新所有相关提示文案
2026-08-31 13:23:40 +08:00
xiaojunnuo 4f020aa8a5 chore: 移除compile脚本中多余的rimraf前置步骤
统一移除多个package.json里compile命令前冗余的rimraf dist && 前缀,简化编译脚本命令
2026-08-30 10:56:42 +08:00
xiaojunnuo 02426c507a docs: update database install doc and docker compose config
1. 修正文档中PG的全称表述并补充执行SQL的前置条件
2. 新增PostgreSQL SSL连接配置注释到docker-compose示例
3. 更新自动升级注释说明支持stable版本
2026-08-30 10:51:06 +08:00
xiaojunnuo ffb7ae79eb Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-30 01:52:31 +08:00
xiaojunnuo f58f2f3cd6 fix: 修复旧流水线保存时清空证书仓库证书内容的bug 2026-08-30 01:52:25 +08:00
xiaojunnuo 794abc3d7a Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-30 00:43:53 +08:00
xiaojunnuo da406014ad chore(plugin): add online plugin author email update function
1. 新增修改在线插件作者邮箱的接口与前后端实现
2. 调整插件作者相关类型定义,精简冗余字段并添加email字段
3. 新增邮箱格式校验逻辑
4. 在插件发布页面添加修改作者邮箱的交互功能
2026-08-30 00:43:47 +08:00
xiaojunnuo 9842a24a50 perf(dashboard): 新增仪表盘站点监控和域名管理的统计卡片
1. 新增仪表盘站点监控和域名管理的统计卡片,包含总数、异常/过期状态统计
2. 补充多语言国际化文案(zh-CN和en-US)
3. 后端接口新增站点和域名的统计数据查询逻辑
4. 优化统计卡片样式,根据状态显示不同色调
5. 修复代码格式问题和冗余代码
2026-08-30 00:23:21 +08:00
xiaojunnuo fb21ec04a6 chore(scripts): 为release接口url添加v前缀
修正获取指定版本发布信息的接口地址,补上版本号前的v前缀,确保接口调用正确
2026-08-29 15:24:12 +08:00
xiaojunnuo 15b74207dd ci(github actions): 替换npm脚本为自定义node脚本
将原来通过npm调用的设置版本稳定状态的步骤,替换为直接调用自定义的node脚本,简化CI流程并移除额外的npm依赖安装步骤
2026-08-29 15:20:52 +08:00
xiaojunnuo 6055e9b6ba ci(github actions): 优化稳定版发布的npm安装步骤
仅安装指定版本的axios并跳过包锁和保存,减少不必要的依赖安装
2026-08-29 15:12:00 +08:00
xiaojunnuo df3e0814c4 chore: 移除package.json里的root本地链接依赖并调整CI安装步骤
1.  从package.json中删除了无用的root本地链接依赖
2.  简化CI步骤,移除全局安装axios的命令,直接执行npm install
2026-08-29 15:05:25 +08:00
xiaojunnuo 81f660dc49 ci(github actions): 为稳定发布工作流全局安装axios
修复发布流程中axios依赖缺失导致的执行失败问题
2026-08-29 15:03:13 +08:00
xiaojunnuo f56e2a07c6 ci(github-actions): 为稳定发布工作流添加npm安装步骤
在stable-release.yml的发布任务中新增npm install命令,确保执行发布脚本前依赖已正确安装
2026-08-29 14:59:44 +08:00
xiaojunnuo a2fce6cf0b ci(github actions): 更新Docker发布配置
1. 更换Docker Hub登录的密钥名称
2. 为buildx构建添加--provenance和--sbom参数关闭物料清单生成
2026-08-29 12:08:51 +08:00
xiaojunnuo ccb5356bfa ci(ci): 移除稳定版镜像的版本标签构建
更新文档以适配不再发布带版本后缀的稳定版镜像
2026-08-29 12:01:40 +08:00
xiaojunnuo f44084dfd2 chore(base-service): 修复更新方法未校验id和新增重复id问题
1. 在新增逻辑中删除param的id字段避免重复插入
2. 格式化更新方法的id非空校验代码块
2026-08-29 04:22:13 +08:00
xiaojunnuo 948d76f98f build: release 2026-08-29 04:15:09 +08:00
xiaojunnuo 6606f32678 fix(pipeline-controller): 处理非编辑场景下保留id的问题
在非编辑流程中删除请求参数里的id字段,避免意外更新已有数据
2026-08-29 03:52:54 +08:00
xiaojunnuo 6af12d49fc fix(pipeline): 修正流水线保存时的ID判断逻辑并增加参数校验
调整了流水线新增的判断条件,将原来的bean.id <=0改为bean.id ==0或null,同时增加了当bean.id小于0时抛出错误的参数校验逻辑,避免非法ID传入
2026-08-29 03:47:55 +08:00
xiaojunnuo 9decb49d04 refactor: 重构插件同步逻辑,优化插件数据处理流程
1.  调整插件同步时的字段处理逻辑,减少冗余数据存储
2.  提前查询已有插件数据,避免重复创建和更新
3.  拆分插件同步代码结构,提升可读性和可维护性
4.  注释掉下载次数同步逻辑,暂时关闭该功能
5.  简化插件编辑接口的字段处理逻辑
6.  移除插件列表页的复制按钮,新增插件导出功能
7.  更新文档和镜像配置,修正Docker Hub镜像地址
8.  调整文档章节顺序,新增版本号徽章展示
2026-08-29 02:48:56 +08:00
xiaojunnuo 69466c1c41 build: publish 2026-08-29 00:46:02 +08:00
xiaojunnuo adf5163a79 build: trigger build image 2026-08-29 00:45:51 +08:00
xiaojunnuo b40bc1b487 v1.44.0 2026-08-29 00:44:55 +08:00
xiaojunnuo 9d30ccf1f0 build: prepare to build 2026-08-29 00:39:32 +08:00
xiaojunnuo 396810eddf chore: 调整单元测试相关配置与脚本顺序
1. 移除pipeline包测试脚本里多余的--no-config参数
2. 给acme-client包添加te
2026-08-29 00:37:59 +08:00
xiaojunnuo 5dbf6694b1 chore(acme-client): remove unused test:unit script
删除了不再使用的单元测试运行脚本,清理package.json中的冗余配置项
2026-08-29 00:30:08 +08:00
xiaojunnuo c2df8d3fa3 build(acme-client): 简化并统一清理脚本
- 将多个文件清理命令替换为统一的rimraf调用
- 移除before-t
2026-08-29 00:28:28 +08:00
xiaojunnuo c149662d54 build: prepare to build 2026-08-29 00:21:05 +08:00
xiaojunnuo ae1c5e38d1 chore: 完成多组功能优化与缺陷修复
1. 新增vip按钮弹窗注册工具类,优化事件监听逻辑
2. 调整用户设置页面表单宽度至700px
3. 修复审计日志匿名请求记录逻辑,优化登录日志用户名展示
4. 调整VIP按钮样式,优化暗色模式适配
5. 移除插件页面冗余的needPlus标识,更新ACME配置提示文案
6. 新增插件缓存清除逻辑,修复审计日志数据库字段
7. 优化自定义ACME反向代理帮助文档排版
2026-08-29 00:15:15 +08:00
xiaojunnuo 718b46141e docs: update plugin development docs with access encapsulation guidelines
统一补充各插件开发文档中关于将API接口封装到Access中的开发规范,修正access-plugin-dev文档中的表述错误
2026-08-27 23:55:29 +08:00
xiaojunnuo 9c8986be10 docs(certd-plugin): update example and docs for runStrategy and component comments
补充了示例yaml和文档中关于运行策略、组件注释的说明,完善了runStrategy的使用场景介绍,新增了返回skip跳过任务的代码示例注释
2026-08-27 23:52:43 +08:00
xiaojunnuo 1d12ad73c6 perf(login,settings): 仅第三方登录开启后,在重启10分钟后将无法使用密码登录 2026-08-27 23:18:57 +08:00
xiaojunnuo 30d4fc84a9 perf(tencent-clb): 重构腾讯云CLB部署插件的地域选择组件,改为远程下拉选择器 2026-08-27 22:33:12 +08:00
xiaojunnuo 834e7750ce refactor(acme-client): 移除内置challengePriority配置并调整相关逻辑 2026-08-27 22:27:07 +08:00
xiaojunnuo f38522ca0b perf(server): response优化为gzip压缩
添加了响应内容压缩中间件,支持对可压缩类型的响应进行gzip压缩,减少传输体积提升性能,同时添加了对应的测试用例
2026-08-27 22:09:18 +08:00
xiaojunnuo d87821ee02 fix: 修复证书申请时EAB判断逻辑,已选择Acme账号的情况下不需要EAB
同时更新文档章节标题,补充自动跳到第三方登录的场景,移除无用的lodash-es导入
2026-08-27 18:36:54 +08:00
xiaojunnuo bd9e226c36 refactor(step-form): 重构步骤表单响应式状态管理
替换ref和UnwrapNestedRefs替换ref包裹的currentStep为reactive对象,简化属性访问逻辑,移除冗余的getContext函数并优化作用域返回
2026-08-26 23:53:16 +08:00
xiaojunnuo 2809267d7a refactor(step-form): 替换fs-form-item为fs-form-item-col组件
统一步骤表单中所有表单项的渲染组件,替换原有fs-form-item为fs-form-item-col,同时优化插件数据的计算方式,移除冗余的useCompute调用改用原生computed。
2026-08-26 23:42:02 +08:00
xiaojunnuo 4de24da9d5 chore: 修复多场景下的插件管理相关问题并优化配置
1. 调整acme客户端挑战检查的超时时间从1000ms到4000ms
2. 修复远程选择组件首次加载时未触发选中值回调的问题
3. 重构插件发布和保存逻辑,优化可编辑字段处理和版本号传递
4. 注释掉默认自动选择第一个访问凭证的逻辑
5. 新增插件更新测试用例,确保只更新允许修改的字段
2026-08-26 22:46:12 +08:00
xiaojunnuo 580981a468 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-25 23:51:27 +08:00
xiaojunnuo 5bff2c8fa5 chore(plugin): add batch upgrade all plugins and optimize plugin detail view
1. 新增批量升级所有已安装可升级插件功能
2. 重构插件详情弹窗实现,使用iframe内嵌详情页替代旧弹窗组件
3. 新增
2026-08-25 23:38:01 +08:00
xiaojunnuo 338de1f04c Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-25 13:36:56 +08:00
xiaojunnuo 5e867c86c3 fix(ssh): 修复 keyboard-interactive 认证可能抛类型错误的问题
新增 resolveKeyboardInteractiveAnswers 方法处理交互式认证提示,按关键字匹配配置的密码/口令,保证所有返回值均为字符串,避免调用 Buffer.byteLength 时抛出类型错误。补充对应单元测试覆盖各类场景。
2026-08-25 13:36:47 +08:00
xiaojunnuo 07efe717d0 chore(plugin-service): 移除冲突的merge残留代码
清理了代码中多余的HEAD和commit冲突标记,恢复正常的代码逻辑
2026-08-24 23:29:04 +08:00
xiaojunnuo 2b4c066a2e Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-24 23:28:25 +08:00
xiaojunnuo c03e82e4e7 chore(plugin): 修复插件评分类型不匹配问题 2026-08-24 23:27:18 +08:00
xiaojunnuo a91d7c000a fix(plugin-item-card): 移除分数格式化时去掉.0的逻辑
移除了将形如x.0的分数格式简化为x的处理,保留完整的一位小数展示
2026-08-24 23:16:18 +08:00
xiaojunnuo faf9828168 fix(plugin-ai-dev): 优化AI插件测试流程并修复日志获取问题
1. 更新测试轮询间隔为5-10秒
2. 简化日志获取逻辑,直接通过historyId获取对应日志
3. 调整空状态提示文案为"暂未获取到运行日志"
4. 增加对trigger返回historyId的校验和错误提示
2026-08-24 22:59:20 +08:00
xiaojunnuo eadf10f287 chore(plugin-service): 完善插件搜索功能,支持多字段匹配与复杂查询条件
1.  新增支持通过name、fullName、desc字段模糊匹配插件关键词
2.  重构插件搜索逻辑,合并原有name搜索与关键词搜索逻辑
3.  修复并增强mock测试中的多条件过滤,支持developerId的OR查询
4.  新增单元测试验证插件多字段搜索功能
2026-08-24 22:06:21 +08:00
xiaojunnuo ec980bf68a refactor(plugin): 重构插件数据模型与权限校验逻辑
1. 简化OnlinePluginBean类型,移除冗余本地状态字段,统一使用version/disabled替代原有本地字段
2. 重构权限校验逻辑,使用editable字段替代原有的开发者ID绑定校验
3. 移除多余的localPluginId相关逻辑,直接使用plugin.id作为唯一标识
4. 优化插件列表与详情接口的返回数据结构,隐藏敏感字段
5. 调整流水线触发接口的代码格式,修复空格问题
2026-08-24 21:52:39 +08:00
xiaojunnuo 6f9dffe6e5 feat: 新增AI插件开发全流程能力
- 新增流水线编辑弹窗组件,支持可视化编辑流水线
- 新增AI插件开发专属页面,支持插件创建、调试与测试
- 优化插件查询接口,新增本地插件查询与去重能力
- 重构流水线触发逻辑,支持返回执行历史ID等信息
- 更新Google ACME配置说明,简化翻墙操作描述
- 新增插件依赖选择器ID模式,适配后端插件ID查询
- 新增SKILL.md文档,规范在线插件开发流程
- 新增AI插件管理控制器,提供流水线触发与状态查询接口
- 调整.gitignore,新增.plugin-dev临时目录
2026-08-23 23:38:50 +08:00
xiaojunnuo f61487284f fix(tencent access): 修复腾讯云国际站接口endpoint拼接问题
调整endpoint拼接逻辑,先移除原地址中的.tencentcloudapi.com后缀,再拼接国际站域名后缀,确保国际站接口地址格式正确
2026-08-23 17:43:45 +08:00
xiaojunnuo 483e7c5f51 refactor: 完成多文件功能优化与体验改进
1. 修复步骤表单和插件选择器的tabs高度问题
2. 增加AI插件查看的专业版权限校验
3. 重构AI开发对话框,改用分步流程与tsx语法
4. 优化默认通知插件示例代码与参数配置
5. 调整插件开发临时目录文档说明
6. 增强依赖插件输入组件的单选模式与编辑权限控制
2026-08-23 17:39:39 +08:00
xiaojunnuo be47d732e0 style(apply-template): update subtitle copy for clarity
optimize the template page subtitle to better explain the feature's usage scenario
2026-08-22 22:14:40 +08:00
xiaojunnuo c57110e42a perf: 移除全局反向代理配置,迁移到自定义ACME配置
1.  移除全局reverseProxies相关的代码、类型定义和UI展示
2.  新增自动迁移任务,将旧版全局反代配置迁移到对应内置ACME提供商的reverseProxy字段
3.  重构ACME服务的反代获取逻辑,改为从当前颁发机构配置读取
4.  更新文档注释和多语言文案,说明新的反代配置位置
5.  清理冗余的工具函数和废弃代码
2026-08-22 22:12:28 +08:00
xiaojunnuo e8adaad01f chore(acme): 调整ACME相关配置的表单字段结构
1. 修复acme账户访问配置的默认请求模式为tags
2. 调整自定义ACME提供商列表的needEAB字段配置结构,将switch组件移到form配置内
2026-08-22 21:08:46 +08:00
xiaojunnuo 4db0f83304 perf: 统一将路由前缀从/certd改为/cert
本次提交将全项目中所有以/certd开头的路由路径统一修改为/cert前缀,同时在路由守卫中添加了旧路径兼容逻辑,自动将用户访问的旧版/certd路径重定向到新的/cert路径,保留原有query和hash参数,确保业务功能不受影响。
2026-08-22 20:52:00 +08:00
xiaojunnuo 2ab23f50b2 perf: 新增自定义ACME管理功能,完善证书颁发机构适配 2026-08-22 20:47:02 +08:00
xiaojunnuo 4df94a4e2b Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-22 17:16:41 +08:00
xiaojunnuo 97c417b1ad feat: 实现自定义ACME证书颁发机构支持
此提交完整实现了自定义ACME证书颁发机构功能,包含以下核心改动:
1. 新增系统自定义ACME配置管理,支持添加/编辑/删除自定义ACME服务
2. 扩展证书申请任务与ACME账号授权,支持选择系统配置的任意ACME颁发机构
3. 新增数据库字段记录ACME账号授权ID,优化吊销流程无需再解析流水线配置
4. 完善类型定义与测试用例,兼容原有内置CA功能与旧版数据
5. 支持自定义ACME的EAB配置、反向代理、目录URL等完整功能
2026-08-22 17:16:36 +08:00
xiaojunnuo c0f194f443 perf: 在线插件支持通知类型 2026-08-22 01:47:21 +08:00
xiaojunnuo e79660c5d1 perf(plugin): 插件安装支持检测依赖 2026-08-22 01:47:02 +08:00
xiaojunnuo deaf5b7398 Merge branch 'v2_audit_log' into v2-dev 2026-08-21 00:03:17 +08:00
xiaojunnuo e4041eabec perf(domain): 域名管理增加remark字段 2026-08-21 00:01:22 +08:00
xiaojunnuo 7a1a642dc2 Merge remote-tracking branch 'origin/v2' into v2-dev 2026-08-21 00:00:24 +08:00
xiaojunnuo 555f3a800b chore(plugin-publish): 修复作者名称二次校验逻辑 2026-08-21 00:00:15 +08:00
xiaojunnuo 145784e73c chore: 优化表单校验与错误提示逻辑
1. 替换crud-withdraw和use-publish中的throw error为友好的notification提示并提前返回
2. 调整插件页面的AI开发按钮位置到正确区域
3. 为插件发布表单添加双向的作者名称校验规则
2026-08-20 23:33:42 +08:00
yukaidi1220 f8e492d6cb fix(pipeline): 校验定时触发器cron表达式,拦截2月31号等无效日期 (#785) @yukaidi1220
问题:添加流水线时定时触发器可保存2月31号等cron-parser无法解析的cron表达式,保存后每次打开流水线列表(page接口)都会因解析失败报错,并影响定时任务注册。

修复:1) doUpdatePipelineJson保存前校验所有timer触发器的cron,无效则抛出友好错误阻止入库;2) getCronNextTimes对历史脏数据容错返回空,避免列表接口崩溃;3) registerCron对无效cron跳过注册并告警。

补充单元测试覆盖无效/有效cron的校验与容错行为。
2026-08-20 18:24:52 +08:00
xiaojunnuo 49e04784af refactor: 整理代码优化与功能完善
主要变更:
1. 审计日志新增匿名请求过滤逻辑
2. 移除多余的antd message导入,统一使用notification
3. 优化受限令牌时效计算逻辑与注释
4. 插件作者接口新增错误提示开关
5. 完善证书服务逻辑:优先复用空证书占位记录
6. 优化单测用例与文档说明
2026-08-20 00:27:01 +08:00
xiaojunnuo f843e69a1b chore: 新增localplus2开发环境,替换message为notification提示,优化插件管理逻辑
1.  新增server-local-plus2调试配置和dev-localplus2启动脚本,新增对应的环境配置文件
2.  将项目内多处antd message调用替换为notification组件,统一提示样式
3.  优化插件管理逻辑:使用fullName作为插件唯一标识,修正插件可编辑判断逻辑,调整插件
2026-08-19 23:59:22 +08:00
xiaojunnuo 232ac4317b Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-19 10:27:40 +08:00
xiaojunnuo b9dc03cd85 chore(aliyun-alb): 修复过期证书清理逻辑重复执行的问题 2026-08-19 10:25:16 +08:00
xiaojunnuo 1ad7750d8b style(ui): 更新流水线相关的tooltip文本和样式
调整了阶段标题编辑、批量编辑任务、批量编辑步骤的tooltip文案,补充了空的fs-icon样式修复排版问题
2026-08-17 23:41:56 +08:00
xiaojunnuo b6c772a326 chore: 修复插件匹配逻辑,调整插件排序与UI样式,新增专业版功能限制
1.  重构插件服务的插件匹配逻辑,改用fullName唯一识别插件
2.  调整AWS Route53插件排序为1,移除腾讯EO插件默认排序
3.  更新插件卡片UI样式,调整内边距与最小高
2026-08-17 23:04:51 +08:00
xiaojunnuo ac5bb63214 Merge remote-tracking branch 'origin/v2_audit_log' into v2-dev 2026-08-17 21:57:46 +08:00
xiaojunnuo 4d580deeb0 Merge branch 'v2_audit_log' into v2-dev 2026-08-17 21:32:50 +08:00
xiaojunnuo cbf337a002 docs: add aftertask mechanism and pipeline execution docs 2026-08-16 23:44:15 +08:00
xiaojunnuo 15ee6ead49 fix(start.sh): 优化前端zip包下载校验逻辑
新增zip文件完整性校验函数,修复原有逻辑未校验已存在zip包的问题,下载后新增校验步骤,同时修复curl命令参数格式,统一变量引用引号
2026-08-16 23:28:38 +08:00
xiaojunnuo 1cc9641f56 feat: 新增证书吊销后置任务与证书状态管理
1.  为证书仓库新增status与revoke_time、task_id字段,支持证书生命周期管理
2.  新增CertRevokeOld后置任务,可在流水线结束后吊销同申请任务的旧证书
3.  重构流水线事件传递,新增pipelineId、runnableId上下文
4.  新增流水线后置任务管理功能,支持配置触发条件与插件
5.  优化证书仓库同步逻辑,按申请任务维护active占位记录
6.  补充多语言文案与前端插件选择、任务日志展示功能
7.  修复部分构建脚本与ESLint配置
2026-08-16 23:23:31 +08:00
xiaojunnuo 0cfc3b7ae9 perf: 优化源码启动脚本,增加快速启动 2026-08-16 14:22:11 +08:00
xiaojunnuo fbfe1256a5 perf: 新增官方客户端Certd Client的多语言配置与页面入口
1. 新增官方客户端Certd Client的多语言配置与页面入口
2. 更新文档生态部分,将官方客户端与第三方客户端区分开
3. 移除旧的菜单组件与相关冗余代码,简化路由配置
4. 修复证书统计接口,按证书状态active进行计数
2026-08-15 22:38:29 +08:00
xiaojunnuo 5074572694 perf: 新增证书吊销功能,添加证书状态字段
1. 数据库新增cert_info表的status和revoke_time字段
2. 实现证书吊销API与前端交互逻辑
3. 支持ACME账号/证书私钥两种吊销方式
4. 新增证书状态管理与前端展示
5. 优化证书仓库更新逻辑,支持继承原有记录信息
6. 补充相关单元测试与国际化文案
2026-08-15 17:01:37 +08:00
xiaojunnuo e004c34ae3 build: release 2026-08-15 01:33:18 +08:00
xiaojunnuo 5c0e5c347d chore: 1 2026-08-15 01:05:13 +08:00
xiaojunnuo 2935111c6b ci(github actions): 关闭镜像构建的provenance和sbom生成(阿里云容器镜像管理不支持)
在build-image.yml和release-image.yml的所有build-push-action步骤中添加provenance: false和sbom: false参数,禁用相关附加功能以避免构建报错或冗余输出
2026-08-15 00:06:56 +08:00
xiaojunnuo 71155eeec5 build: publish 2026-08-14 23:10:25 +08:00
xiaojunnuo 135acb5240 build: trigger build image 2026-08-14 23:10:14 +08:00
xiaojunnuo 5558376bae v1.43.0 2026-08-14 23:09:01 +08:00
xiaojunnuo 2da560a21f build: prepare to build 2026-08-14 23:03:22 +08:00
xiaojunnuo bc9a0988a7 chore: 1 2026-08-14 23:01:59 +08:00
xiaojunnuo 6a731f986e chore: 代码格式化 2026-08-14 22:25:36 +08:00
xiaojunnuo 2cb4e551fa feat(ssh): ssh连接支持跳板机 2026-08-14 21:20:47 +08:00
xiaojunnuo e5a1844852 feat(pipeline): 新增流水线批量编辑、阶段宽度调整功能
1. 新增流水线阶段和任务的批量操作能力,支持批量启用/禁用/删除选中项
2. 为流水线阶段添加可拖拽调整宽度的功能,限制宽度范围260-720px
3. 新增TEO DNS插件配置项order,添加流水线步骤批量编辑工具类与测试用例
4. 为Stage类型添加style扩展字段支持自定义样式
2026-08-14 18:33:08 +08:00
xiaojunnuo b8f82ecb5e chore: 1 2026-08-14 17:31:42 +08:00
xiaojunnuo 18583f6ce0 fix(unicloud): 修复unicloud登录接口改版后请求失败的问题 2026-08-14 10:10:12 +08:00
xiaojunnuo 1468eb1ef2 perf: 优化第三方登录设置提示 2026-08-14 09:58:19 +08:00
xiaojunnuo 8ab699a910 fix(ui): 修复oauth设置页保存后未正确刷新的问题
将保存后的loadOauthProviders调用替换为loadSysSettings,确保系统配置数据正确更新
2026-08-14 09:52:25 +08:00
xiaojunnuo b173311468 perf(ui): 域名选择器支持点击复制域名 2026-08-14 01:03:08 +08:00
xiaojunnuo 522dcd8ffb refactor: 重构运行时依赖服务,简化逻辑并修复部分配置
1.  移除废弃的getRuntimeDependencyPluginDefines方法和相关插件依赖解析逻辑
2.  简化RuntimeDepsService的锁机制、依赖收集和安装流程
3.  更新测试用例适配重构后的API
4.  调整开发环境数据库配置和运行时依赖超时时间
5.  优化注释和代码结构
2026-08-14 00:23:52 +08:00
xiaojunnuo 6809a35177 Merge remote-tracking branch 'origin/v2' into v2-dev 2026-08-13 21:57:56 +08:00
MephistoJB bd82147496 perf: Improve English localization coverage (#778) @MephistoJB
* Improve English localization coverage

* Improve English UI translations

* Improve English localization coverage

* Revert "Improve English localization coverage"

This reverts commit 5b29381ebf.

* Revert "Improve English UI translations"

This reverts commit 11c1f673da.

* Revert "Improve English localization coverage"

This reverts commit 9bc7a05795.

* Add landing page language switching
2026-08-13 21:57:36 +08:00
xiaojunnuo a4224ab1df chore: 1 2026-08-12 19:59:27 +08:00
xiaojunnuo 4c4dc8ca46 docs(install): 更新数据库安装文档的配图和补充提示
替换了旧的数据库同步步骤截图,新增两张分步截图,并补充内存溢出相关的注意事项
2026-08-12 19:51:21 +08:00
xiaojunnuo 0afabd38c6 chore(plugin): add online plugin uninstall function
1. 新增在线插件卸载接口与后端逻辑,支持卸载已同步的在线插件
2. 更新前端插件列表页的卸载调用逻辑,替换原有删除接口
3. 补充对应单元测试用例,验证功能正确性与边界情况
4. 移除冗余的空行注释代码
2026-08-12 00:58:49 +08:00
xiaojunnuo cacd108802 chore(plugin): 完善插件管理功能,优化作者校验与异常处理
1.  新增插件作者必填校验,修复本地插件命名逻辑
2.  优化在线插件安装的异常处理,支持隐藏错误通知
3.  更新文档规范与单元测试用例
4.  重构插件作者字段组件,支持手动输入作者信息
2026-08-12 00:43:32 +08:00
xiaojunnuo 7138243c63 chore(plugin): add vip membership requirement field for plugins
1. add vip select field in plugin crud form with free/plus options
2. replace crown icon with vip icon for vip required plugins
3. fix plugin edit dialog layout and add custom className support
4. correct chinese locale text for plugin title
5. add type ignore and default value handling for vip field
2026-08-12 00:09:33 +08:00
xiaojunnuo 73b096a6e5 chore(plugin): add vip restriction support for plugins
1. 新增数据库迁移脚本添加vip字段
2. 补充实体类和接口的vip字段支持
3. 添加插件卡片会员标识展示逻辑
4. 实现会员限制判断
2026-08-10 23:47:56 +08:00
xiaojunnuo 8165a5ff89 perf(plugin-ucloud): ucloud us3部署插件增加region选择 2026-08-10 22:25:11 +08:00
xiaojunnuo 1017a38eb4 fix(pipeline): 修复过期/损坏的安装锁导致的依赖安装失败
新增锁过期检测与清理逻辑,当存在超时的安装锁或者无效的锁文件时,自动清理锁并继续安装流程,新增两个测试用例验证该修复场景
2026-08-10 21:14:12 +08:00
xiaojunnuo 80374cae8a perf(install): 补充迁移到PostgreSQL后,表自增序列从1开始的修复SQL脚本和说明 2026-08-09 23:19:26 +08:00
xiaojunnuo 6339cdffa9 feat: 优化开放接口证书自动申请功能
1. 新增TextedException统一异常类
2. 新增AccessService获取默认授权方法
3. 扩展证书流水线批量更新字段,支持自动挑战类型、SSL提供商和ACME账户
4. 调整证书申请默认重试次数为1并优化重试逻辑
5. 优化证书模板获取逻辑,支持ID为0时获取默认模板
6. 完善自动流水线创建逻辑,默认设置挑战类型为auto
7. 调整默认证书链为ISRG Root X2
8. 优化自动申请证书的参数处理,补充邮箱、ACME账户和重试次数默认值
9. 替换部分异常抛出为TextedException
2026-08-09 22:38:29 +08:00
xiaojunnuo 96a33bd387 chore: 修复企业版模式判断逻辑 2026-08-09 17:10:40 +08:00
xiaojunnuo 1042e599d9 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-08 23:30:21 +08:00
xiaojunnuo 1a4f1cb07d refactor(certd-server): 优化流水线触发逻辑并新增配置
1. 新增流水线执行异常错误码和提示信息
2. 调整服务端口配置为7004/7005
3. 优化流水线状态查询逻辑,增加更新时间判断
4. 新增失败流水线重试间隔限制逻辑
5. 添加对应单元测试
2026-08-08 22:27:36 +08:00
xiaojunnuo edb249c2ad perf: 统一通知类型常量管理,新增开放接口通知能力 2026-08-08 20:48:52 +08:00
xiaojunnuo e9921928df perf: 部署到京东云CDN支持设置跳转类型 2026-08-07 09:15:00 +08:00
xiaojunnuo fa0c87dec3 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-08-06 12:00:18 +08:00
xiaojunnuo ba64c2d147 prf: 新增阿里云ACK部署调试支持 2026-08-06 12:00:11 +08:00
xiaojunnuo cf008b9cff fix: 优化火山引擎dcdn,单个单个部署,规避超时问题 2026-08-06 11:57:40 +08:00
xiaojunnuo 3a9069f6e6 perf: 新增AI插件开发受限令牌与隔离API 2026-08-05 21:13:40 +08:00
xiaojunnuo 01abf348d2 refactor(certd-online-plugin): 更新插件开发相关配置与文档 2026-08-02 23:41:47 +08:00
xiaojunnuo eae7edbda0 perf: 支持AI开发在线插件 2026-08-02 23:25:52 +08:00
xiaojunnuo d7c6a06b61 Merge remote-tracking branch 'origin/v2-dev' into v2_audit_log 2026-08-02 21:34:13 +08:00
xiaojunnuo 9d6f7ef0f2 style: 统一代码中单引号为双引号并补全格式 2026-08-02 21:31:30 +08:00
xiaojunnuo 0ddb1f69d2 perf(cert-plugin): 证书申请支持失败重试 2026-08-02 21:07:11 +08:00
xiaojunnuo 4091abdfd7 chore: lint fix 2026-08-02 21:06:33 +08:00
xiaojunnuo 27008cb44a Merge remote-tracking branch 'origin/v2' into v2-dev 2026-08-02 19:51:18 +08:00
xiaojunnuo b84825eae5 Merge remote-tracking branch 'origin/v2-dev' into v2_audit_log 2026-08-02 17:06:52 +08:00
xiaojunnuo 1c6ffe8ac6 chore: log add 2026-08-02 17:06:46 +08:00
xiaojunnuo dc0845b2d2 Merge remote-tracking branch 'origin/v2' into v2_audit_log 2026-08-02 17:06:31 +08:00
xiaojunnuo 7ab502991e perf(plugin): 新增插件评分展示、排序与个人插件筛选功能 2026-08-02 17:04:02 +08:00
ahe 319f555569 Feature/偏好设置优化 (#775) @nicheng-he
* feat: 偏好设置支持从剪切板导入

在现有「复制偏好设置」基础上增加从剪切板导入,便于在浏览器间同步主题与布局配置。

* feat: 偏好设置支持保存到账号并在登录后自动同步

增加「保存到账号」能力,将本地偏好差异写入用户设置;登录成功后自动从账号拉取并应用到本地。
2026-08-02 04:27:07 +08:00
xiaojunnuo 0bf323ab97 refactor(plugin): 简化插件模块代码,复用BaseService方法 2026-08-02 02:48:39 +08:00
xiaojunnuo 255960d434 feat: 支持在线插件下载安装审核等 2026-08-02 00:46:29 +08:00
xiaojunnuo 83a6aed625 chore: 优化腾讯云删除证书提示 2026-07-30 19:08:57 +08:00
xiaojunnuo f1b67049d1 perf: 证书成功通知增加有效期显示 2026-07-28 19:22:12 +08:00
xiaojunnuo 8cbca5761e chore(pipeline): adjust npm install log truncation logic
change log output to take last 2000 chars instead of first 2000 for both stdout/stderr, and add consistent empty log placeholder text
2026-07-27 00:35:00 +08:00
xiaojunnuo a237179a72 Merge remote-tracking branch 'origin/v2-dev' into v2_audit_log 2026-07-26 20:37:28 +08:00
xiaojunnuo 967846bef5 perf(tencent-clb): 为腾讯云CLB部署插件支持远程选择输入
- 重构插件输入配置,使用createRemoteSelectInputDefine统一封装远程选择组件
- 新增onGetCLBList、onGetListenerList、onGetDomainList方法实现数据拉取和格式转换
- 补全完整的单元测试用例,覆盖输入配置校验和数据映射逻辑
2026-07-26 19:01:18 +08:00
xiaojunnuo 0bf9a2d3da chore: plugin 有store page版本 2026-07-25 13:46:11 +08:00
xiaojunnuo 18b2d3ac20 perf: 优化第三方依赖安装失败日志输出 2026-07-23 18:35:04 +08:00
xiaojunnuo 7d22fe3d7d perf: 优化SqliteError: disk I/O error报错修复提示 2026-07-23 10:49:00 +08:00
xiaojunnuo ee67b6c042 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-07-21 14:13:16 +08:00
xiaojunnuo 1cfa76683b perf: 优化宝塔报错提示 2026-07-21 14:13:07 +08:00
xiaojunnuo 4662e45e58 build: release 2026-07-19 01:28:05 +08:00
xiaojunnuo 1fefbdc9ab build: publish 2026-07-19 01:14:35 +08:00
xiaojunnuo 1cb2a57c55 build: trigger build image 2026-07-19 01:14:24 +08:00
xiaojunnuo 246ee83015 v1.42.6 2026-07-19 01:13:42 +08:00
xiaojunnuo 335ddfc7a5 build: prepare to build 2026-07-19 01:12:01 +08:00
xiaojunnuo 5b500830a1 fix: 修复正常批量删除流水线报权限不足的bug 2026-07-19 01:05:03 +08:00
xiaojunnuo ce4839bd80 chore: 完善审计日志 2026-07-19 00:49:38 +08:00
xiaojunnuo eee22154e3 chore: 1 2026-07-18 23:52:34 +08:00
xiaojunnuo 49007d3915 perf: 统一镜像地址格式 certd/certd 2026-07-18 23:51:24 +08:00
xiaojunnuo f9b453ca8c Merge remote-tracking branch 'origin/v2-dev' into v2_audit_log 2026-07-18 21:28:30 +08:00
xiaojunnuo 5f53b81c75 perf: 优化动态加载依赖镜像地址,多次重试 2026-07-18 21:07:56 +08:00
xiaojunnuo 9d83adaac8 chore: 1 2026-07-18 19:58:16 +08:00
xiaojunnuo c8c269f612 chore: 1 2026-07-17 15:44:23 +08:00
xiaojunnuo 15740904e5 perf: 增加稳定版,预览版区分,预览版是最新版,稳定版在预览版验证稳定后,再将预览版转为稳定版 2026-07-17 00:58:19 +08:00
xiaojunnuo 85e9ff7a96 Merge branch 'v2-dev' into v2_audit_log 2026-07-16 22:19:04 +08:00
xiaojunnuo 743617dbda build: release 2026-07-15 23:54:59 +08:00
xiaojunnuo 89806b828a build: publish 2026-07-15 23:35:15 +08:00
xiaojunnuo 2e530bfdb0 build: trigger build image 2026-07-15 23:35:05 +08:00
xiaojunnuo 83495b3213 v1.42.5 2026-07-15 23:34:16 +08:00
xiaojunnuo 5108416904 build: prepare to build 2026-07-15 23:31:35 +08:00
xiaojunnuo 5589da1822 chore: 修复单元测试 2026-07-15 23:30:49 +08:00
xiaojunnuo 21e5aed3f3 chore: 修复单元测试的问题 2026-07-15 23:25:28 +08:00
xiaojunnuo 4a88f795e1 chore: vke kubeconfig 有效期校验 2026-07-15 23:10:58 +08:00
xiaojunnuo 604fa5be63 perf: 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config 2026-07-15 23:01:32 +08:00
xiaojunnuo 7ed1be994f fix: 修复dingtalk通知格式没有换行的bug 2026-07-15 23:00:42 +08:00
xiaojunnuo 6cc74a1c0a chore: lint 2026-07-15 01:25:55 +08:00
xiaojunnuo 167b303fae fix: 修复上传到cos报runtimeDepsService未初始化的问题 2026-07-15 01:09:57 +08:00
xiaojunnuo b91c9e4ea6 perf: 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim 2026-07-14 21:32:32 +08:00
xiaojunnuo 947fe729cb chore: 继续完善审计日志 2026-07-13 00:53:19 +08:00
xiaojunnuo bfb3ee4c43 chore: 登录失败也记录日志 2026-07-12 22:28:51 +08:00
xiaojunnuo bab1df2c78 chore(i18n): add permanent VIP label and handle permanent VIP case
为国际化多语言添加永久VIP的翻译文本,同时在激活成功弹窗中处理永久会员的过期日期显示逻辑,当过期时间为-1时显示永久而非固定日期
2026-07-12 17:37:25 +08:00
xiaojunnuo 00eabec771 Merge branch 'v2-dev' into v2_audit_log 2026-07-12 17:28:21 +08:00
xiaojunnuo 584b7b6f45 build: release 2026-07-12 03:41:59 +08:00
xiaojunnuo 2312c444ef build: release 2026-07-12 03:36:09 +08:00
xiaojunnuo 0373d019f8 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-07-12 03:11:04 +08:00
xiaojunnuo fe09e75b80 refactor(nginx-proxy-manager): 替换直接动态导入为运行时依赖导入
将插件中直接使用的动态导入改为通过runtimeDepsService统一处理,同时重构导入方式为runtimeImport变量,移除冗余的form-data和otplib直接导入,统一导入逻辑
2026-07-12 03:10:52 +08:00
xiaojunnuo 432c9c6cc1 build: publish 2026-07-12 02:48:34 +08:00
xiaojunnuo 5fc1d55879 build: trigger build image 2026-07-12 02:48:22 +08:00
xiaojunnuo f2855d6dac feat: 支持审计日志,操作日志 2026-07-12 02:29:54 +08:00
xiaojunnuo 4250d0e266 chore: audit log first 2026-07-10 19:24:42 +08:00
662 changed files with 29581 additions and 6884 deletions
+2
View File
@@ -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}}
+27 -11
View File
@@ -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:
+84
View File
@@ -0,0 +1,84 @@
name: stable-release
on:
workflow_dispatch:
inputs:
version:
description: "版本号(如 1.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
+5 -1
View File
@@ -39,4 +39,8 @@ pnpm-lock.yaml
# Certd 推广报告,仅本地使用
/popularize/reports/
output/
.uploads/
.uploads/
.certd-plugin-history/
.tmp
.plugin-dev/
.tmp_chunks/
@@ -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` 体积,提升初始安装速度,同时保持现有架构的兼容性和可维护性。
+60
View File
@@ -392,6 +392,66 @@ export class OAuthDemoAccess extends BaseAccess {
}
```
## 字段动态显隐(mergeScript)
多种鉴权方式共用一个授权时,用 `@AccessInput` 上的 `mergeScript` 按所选方式显隐字段:
```typescript
@AccessInput({
title: '鉴权方式',
value: 'apikey',
component: {
name: 'a-select',
vModel: 'value',
options: [
{ label: 'AccessKey签名', value: 'aksk' },
{ label: 'ApiKey', value: 'apikey' },
],
},
required: true,
})
authType = 'apikey';
@AccessInput({
title: 'accessKeyId',
component: { name: 'a-input', vModel: 'value' },
required: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
return !form.access.authType || form.access.authType === 'aksk';
})
}
`,
})
accessKeyId = '';
```
要点:
- `mergeScript` 返回的对象会深合并进本字段定义,可动态化的属性有 `show`、`required`、`helper`、
`component` 下的属性(`options`、`placeholder` 等)以及 `component.on` 事件。
- **动态判断必须写在 `ctx.compute(({form})=>{ ... })` 回调里**。`mergeScript` 本身在插件类
定义阶段就被拼成字符串,那时表单还没打开,用参数在服务端判断字段值没有意义。
- 授权表单字段值在 `form.access.xxx`(任务表单才是 `form.xxx`)。
- 历史授权没有新字段,条件里要用 `!form.access.authType || ...` 兜底走旧逻辑,保证老授权还能编辑保存。
- 联动其他字段用 `component.on` 事件回调,回调参数是 `{ form }`:
```typescript
mergeScript: `
return {
component: {
on: {
selectedChange: (scope) => {
const form = scope.form;
form.acmeAccountAccessId = null;
},
},
},
}
`,
```
## 注意事项
1. **插件命名**:插件名称应简洁明了,反映其功能。
@@ -0,0 +1,184 @@
---
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
```
所有 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、证书、私钥或真实授权值。
- 修改完一版后立即提交更新到certd,但不要发布到市场。
详细格式见 `references/local-history.md`。
## YAML 和脚本规范
插件始终以完整 YAML 传递和保存,脚本源码放在顶层 `content` 字段。
- 新插件的 `author` 必须使用启动提示词中给出的插件作者,不要自行编造作者名;修改已有插件时保留原 `author`,不要改成其他作者。
- 统一使用 `await _ctx.import(...)` 引用模块。
- `"/@/..."` 表示以绝对路径引用 `server/src/` 下的模块。
- 最后返回继承目标基类的 class。
- 不使用 `import`、`export`、装饰器或独立源码文件语法。
- 使用 `this.logger` 打印插件执行日志。
- 使用 `this.ctx.http` 访问 HTTP 能力。
- 失败时抛出 `Error`。
需要字段格式时读取 `references/online-yaml-format.md`。
需要组件示例时读取 `references/component-examples.md`。
需要按其他字段动态显隐/必填/联动时读取 `references/merge-script.md`。
### 字段动态显隐(mergeScript)
字段定义支持 `mergeScript`:脚本 `return` 的对象会合并进本字段,用来做「选了 A 才显示 B」这类联动。
```yaml
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return form.access.authType === 'apikey';
})
}
```
- 动态逻辑必须写在 `ctx.compute(({form})=>{ ... })` 回调里;`mergeScript` 本身在插件类定义阶段就
被拼成字符串,那时表单还没打开,用参数在 node 侧判断字段值没有任何意义。
- 授权表单字段值在 `form.access.xxx`,任务表单字段值在 `form.xxx`。
- 新增可选字段时要给历史数据兜底,例如 `!form.access.authType || form.access.authType === 'aksk'`。
完整用法(能力、可动态化的属性、事件联动、常见错误、验证方式)见 `references/merge-script.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-部署插件示例 # 格式:平台-功能 ,例如: 网宿-更新CDNpro证书 ; 阿里云-上传到CAS证书管理服务 ; 天翼云-更新CDN证书
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提供商插件示例 # 格式:平台-功能 ,例如: 阿里云-DNS提供商 ;腾讯云-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,181 @@
# mergeScript 用法
来源:前端 `packages/ui/certd-client/src/use/use-refrence.tsx`,类型定义来自 `@fast-crud/fast-crud`。
`mergeScript` 用来让输入字段的定义**依赖当前表单里的其他字段值动态变化**,例如:
选了某个鉴权方式才显示对应字段、切换类型后清空已选值、下拉选项跟着另一个字段变。
它写成字符串放在字段定义上(内置插件放在 `@AccessInput` / `@TaskInput` 里,在线插件放在 `input.<字段>` 下):
```ts
@AccessInput({
title: "API Key",
component: { name: "a-input", vModel: "value" },
mergeScript: `
return {
show: ctx.compute(({form})=>{
//access里面 要用 form.access.[字段名]
return form.access.authType === 'apikey';
//deploy插件里面要用form.[字段名]
return form.authType === 'apikey';
})
}
`,
})
apiKey = "";
```
## 执行时机
字段定义被前端加载时,对**每一个字段**执行一次:
```js
const func = new Function('ctx', formItem.mergeScript);
const merged = func(ctx); // 字段脚本只执行这一次
merge(formItem, merged); // 返回值深合并进该字段的定义
delete formItem.mergeScript; // 执行完就删掉,最终定义里不再有 mergeScript
```
要点:
- 脚本体内**必须 `return` 一个对象**,返回的对象会深合并到本字段的定义上。
- 脚本本身只在加载字段时跑一次,**拿不到、也不应该判断用户填了什么值**。
- 真正的动态逻辑写在返回对象里的 `ctx.compute(...)` 回调中,由 fast-crud 在渲染时求值。
## ctx 提供的能力
| 能力 | 说明 |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.compute(fn)` | 最常用。返回一个响应式的计算值:`fn` 里读到的表单字段会被自动追踪,字段变化时重新计算,视图跟着刷新 |
`ctx.compute` 的回调参数是一个 context 对象,常用字段:
- `form`:当前表单数据。**这是唯一常用的一个**。
- `row`:行数据。
- `mode`:表单模式 `add` / `edit` / `view`。
- `getComponentRef(key)`:取其他字段组件的 ref。
注意:Certd 在把 `form` 传进回调前做过一次兼容处理(`form.input || form.body || form`),
所以两种表单取值路径不同:
- **授权(Access)表单**:字段值在 `form.access.xxx`(字段 key 会被加上 `access.` 前缀)。
- **任务(Task)表单**:字段值直接是 `form.xxx`。
## 可以动态化的属性
返回的对象里,任何字段定义属性都可以用 `ctx.compute`,常用的有:
| 属性 | 用途 |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| `show` | 显隐(最常用) |
| `required` | 动态必填,例如 `required: ctx.compute(({form}) => form.targetType === 'ingress')` |
| `disabled` | 动态禁用,例如账号已创建后不允许再改:`disabled: ctx.compute(({form}) => !!form.access?.account)` |
| `helper` | 动态帮助文案,回调里按条件 `return` 不同字符串 |
| `value` | 动态默认值 |
| `component` 下的属性 | 动态 `options`、`placeholder`、`inputKey`、`type`、`disabled` 等 |
| `component.on` | 事件处理,见下面「联动」 |
| `col` / `order` | 布局与排序 |
只写了一层 `show` 时最简形式就是本文开头的例子。
## 联动(监听事件并改动其他字段)
`component.on` 的事件回调**不**用 `ctx.compute` 包,它本身就是回调,参数是
`{ form, $event, ... }`;`form` 同样做过 `form.input || form.body || form` 的兼容处理。
事件名按组件而定,例如 `selectedChange`。
```ts
mergeScript: `
return {
component: {
on: {
selectedChange: (scope) => {
const form = scope.form;
// 切换颁发机构后清空 ACME 账号,避免账号与机构不匹配
form.acmeAccountAccessId = null;
},
},
},
}
`,
```
另一种写法是把回调也做成计算值(需要用到组件上配置的 `onSelectedChange` / `onChange` 属性):
```ts
mergeScript: `
return {
component: {
onSelectedChange: ctx.compute(({form}) => {
return ($event) => {
form.dnsProviderAccessType = $event.accessType;
};
}),
},
}
`,
```
两种写法都能用,优先参考同目录已有插件的写法,保持一致。
## 常见错误
- **不要用 node 侧变量决定条件**。`mergeScript` 是在插件类定义阶段(node 侧)拼出来的字符串,
此时表单还没打开,拿不到任何用户输入;用参数拼条件等于把逻辑写死在服务端。
条件必须原样落到 `ctx.compute` 回调里。
- **不要用 `if` 直接判断字段值**。回调必须 `return` 一个值,写成
`show: ctx.compute(({form}) => { if (...) { return true } })` 也可以,但不要漏掉 `return`。
- **不要漏掉历史数据兼容**。新增可选字段时,历史数据里没有这个字段(值为 `undefined`),
条件要按旧行为兜底,例如 `!form.access.authType || form.access.authType === 'aksk'`。
- **不要访问 `form.access` 以外的路径**去读授权字段,也不要在授权表单里按任务表单的路径取值。
- **不要依赖脚本执行时的副作用**(打印日志、改全局状态),它在每次打开表单时都会重新执行。
## YAML 写法(在线插件)
`mergeScript` 是含换行的脚本字符串,必须用 YAML 块标量:
```yaml
input:
apiKey:
title: API Key
component:
name: a-input
vModel: value
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return form.access.authType === 'apikey';
})
}
```
导出生成的缩进比较特殊(块标量内容统一缩进),**不要手工调整缩进**:
用脚本生成 YAML,并保证导出后再导入内容一致。转换成 block scalar 时注意:
- `|` / `|2-` 等块标量不需要转义换行,不要把脚本写成带 `\n` 的单行。
- 脚本里不要出现 `??` 这类容易被误判为乱码的写法,用显式 `if (x == null)` 代替。
## 验证方式
前端表单没法在插件开发流程里直接断言,可以在后端加一个 `src/**/*.test.ts`,
按前端 `useReference` 的路径执行字段上的 `mergeScript`:
```ts
const computedGetters = [];
const ctx = {
compute: getter => {
computedGetters.push(getter);
return getter;
},
asyncCompute: () => undefined,
computed: () => undefined,
};
new Function('ctx', mergeScript)(ctx);
// 用假的 form 求值,断言显隐结果
const show = computedGetters[0]({ form: { access: { authType: 'apikey' } } });
```
参考实现:`packages/ui/certd-server/src/plugins/plugin-wangsu/access-input.test.ts`。
要点是断言「同一个 show 函数换 `authType` 结果会变」,证明显隐确实是运行时算的,
而不是在 node 侧定死的。
@@ -0,0 +1,68 @@
## 在线插件 YAML
在线插件始终以一个完整 YAML 文档传递、编辑、导入和导出。脚本源码必须放在顶层 `content` 字段中,不要输出独立的 `.ts` 文件,也不要使用 JSON Patch。
常用字段:
```yaml
name: DemoTask
author: demo
title: Demo-插件名称 # 格式:平台-功能 ,例如: 网宿-更新CDNpro证书 ; 阿里云-上传到CAS证书管理服务 ; 天翼云-更新CDN证书
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` 和已有兼容字段。
- 新插件的 `author` 使用启动提示词中给出的插件作者,不要自行编造作者名;启动提示词未给出作者时先向用户确认。
- `fullName` 由 `author/name` 组成,作者变化会导致插件身份变化,不要随意修改。
- 只修改需求涉及的字段,避免删除未知的 YAML 字段。
- 脚本过长时仍放在同一个 `content` block scalar 中。
- 提交前检查 YAML 可解析、`content` 非空、版本和插件类型没有被意外修改。
@@ -0,0 +1,97 @@
---
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使用
## 字段动态显隐
多种鉴权方式(例如「ApiKey」和「AccessKey签名」)共用一个授权时,
用字段上的 `mergeScript` 按所选方式显隐对应字段,只让用户看到当前方式需要的字段:
```yaml
input:
authType:
title: 鉴权方式
value: apikey
component:
name: a-select
vModel: value
options:
- { label: AccessKey签名, value: aksk }
- { label: ApiKey, value: apikey }
accessKeyId:
title: accessKeyId
component:
name: a-input
vModel: value
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return !form.access.authType || form.access.authType === 'aksk';
})
}
```
- 判断必须写在 `ctx.compute(({form})=>{ ... })` 回调里;脚本本身在插件定义阶段就拼好了,
那时表单还没打开,用参数在服务端判断字段值没有意义。
- 授权表单的字段值在 `form.access.xxx`(任务表单才是 `form.xxx`)。
- 历史授权没有 `authType` 字段,条件里要用 `!form.access.authType || ...` 兜底走旧逻辑。
完整用法见父 Skill 的 `references/merge-script.md`。
@@ -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使用
+28
View File
@@ -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": [
+74 -7
View File
@@ -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)区分。
+161
View File
@@ -3,6 +3,167 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* [BUG]使用天翼云CDN部署证书时无法匹配域名 ([bb1f232](https://github.com/certd/certd/commit/bb1f232f415c522851dede979f5d609dda844fd3))
* 缺失运行时嵌套依赖时添加清除缓存提示 ([2d0b85b](https://github.com/certd/certd/commit/2d0b85b3054fc1aa82b3ab5db34a2a56308f71f2))
* 支持google persist校验方式 ([97d2b50](https://github.com/certd/certd/commit/97d2b5007409ec79ea864d437c473a2f9b5dfae4))
* **config:** 修复环境变量加载逻辑并更新配置文件 ([07f0d3b](https://github.com/certd/certd/commit/07f0d3b4d626628a0e3dfc7e5c52e8c4a98bad47))
* **ctyun-cdn:** 修复天翼云CDN部署插件的域名选择问题 ([ea7d14c](https://github.com/certd/certd/commit/ea7d14c289b8720781bf101bfef59eac7bb03765))
* **webhook:** 修复模版转义换行符,避免手动上传的证书报错 ([2735feb](https://github.com/certd/certd/commit/2735feb3302f8308708dafc8b295adcd4b91d7f0))
### Features
* **client:** 新增客户端管理功能 ([3dbbe72](https://github.com/certd/certd/commit/3dbbe72fc15c114ca6b5e8ff45cb21f258b80650))
### Performance Improvements
* 宝塔站点列表支持搜索和排序 ([4420472](https://github.com/certd/certd/commit/4420472f8fac272d30fb8ccb1cc2ee9763989001))
* 网宿插件双鉴权支持 ([241b2e5](https://github.com/certd/certd/commit/241b2e55848471e61b5d2be0c87a7dc9a0d29557))
* **monitor:** 站点证书监控支持手动选中触发检查 ([f60e63f](https://github.com/certd/certd/commit/f60e63fbaf00460d5050cd7705bd75d57dec202d))
* **open-api:** openkey增加用途备注 ([bbeaf61](https://github.com/certd/certd/commit/bbeaf617d19750515f6eca51863e74e23bc1de50))
* **tencent clb:** 修改所有CLB地域的endpoint为就近接入 ([c427735](https://github.com/certd/certd/commit/c4277350bcc8cf034cf4e76db2a6daa1842bc5d7))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Bug Fixes
* 修复添加步骤会修改所有步骤标题的bug ([51e83e4](https://github.com/certd/certd/commit/51e83e4c38f7f319314fdc38ef33cf5720b2dde0))
* 修复证书插件配置无法打开的bug ([5563abb](https://github.com/certd/certd/commit/5563abb13a62140f4dee668ab768cf4ff9371ddb))
### Performance Improvements
* 新增余额管理,管理员充值功能 ([bded368](https://github.com/certd/certd/commit/bded368cbb8974b6937ae260e448c25fae5c33ad))
* **acme-account-access:** AcmeAccount授权允许修改eab,重新生成account ([6a649aa](https://github.com/certd/certd/commit/6a649aaca083ccd3b517f7d676a58220aa33b37d))
* **ui:** 商业版套餐购买不要求设置支付方式 ([e971145](https://github.com/certd/certd/commit/e9711456860d7db40914a0c295534f7bca56f83c))
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
### Bug Fixes
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
### Performance Improvements
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
## [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
+144
View File
@@ -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` 为空时走原逻辑)。
+46 -18
View File
@@ -1,4 +1,4 @@
# Certd
# Certd
中文 | [English](./README_en.md)
@@ -105,31 +105,52 @@ 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 架构专用版本 |
| 镜像仓库 | 最新预览版 | 稳定版 | slim版 | armv7版 |
| --- | --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| Docker Hub | `certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
**镜像地址:**
> 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
| 镜像仓库 | 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` |
##### 2. 版本标签说明:
> 带版本号的标签请将 `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` |
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
最新版本号:
![GitHub release (latest by date)](https://img.shields.io/github/v/release/certd/certd?label=Latest%20Release&sort=semver)
##### 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)
![](./docs/images/action/action-build.jpg)
> 注意:
>
##### 4. 安全注意事项:
> - 本应用存储的证书、授权信息等属于高度敏感数据,请做好安全防护
> - 请务必使用HTTPS协议访问本应用,避免被中间人攻击
> - 请务必使用web应用防火墙防护本应用,防止XSS、SQL注入等攻击
@@ -139,7 +160,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
View File
@@ -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)
![](./docs/images/action/action-build.jpg)
> 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.
+13 -3
View File
@@ -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
+169
View File
@@ -3,6 +3,175 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* [BUG]使用天翼云CDN部署证书时无法匹配域名 ([bb1f232](https://github.com/certd/certd/commit/bb1f232f415c522851dede979f5d609dda844fd3))
* 缺失运行时嵌套依赖时添加清除缓存提示 ([2d0b85b](https://github.com/certd/certd/commit/2d0b85b3054fc1aa82b3ab5db34a2a56308f71f2))
* 支持google persist校验方式 ([97d2b50](https://github.com/certd/certd/commit/97d2b5007409ec79ea864d437c473a2f9b5dfae4))
* **config:** 修复环境变量加载逻辑并更新配置文件 ([07f0d3b](https://github.com/certd/certd/commit/07f0d3b4d626628a0e3dfc7e5c52e8c4a98bad47))
* **ctyun-cdn:** 修复天翼云CDN部署插件的域名选择问题 ([ea7d14c](https://github.com/certd/certd/commit/ea7d14c289b8720781bf101bfef59eac7bb03765))
* **webhook:** 修复模版转义换行符,避免手动上传的证书报错 ([2735feb](https://github.com/certd/certd/commit/2735feb3302f8308708dafc8b295adcd4b91d7f0))
### Features
* **client:** 新增客户端管理功能 ([3dbbe72](https://github.com/certd/certd/commit/3dbbe72fc15c114ca6b5e8ff45cb21f258b80650))
### Performance Improvements
* 宝塔站点列表支持搜索和排序 ([4420472](https://github.com/certd/certd/commit/4420472f8fac272d30fb8ccb1cc2ee9763989001))
* 网宿插件双鉴权支持 ([241b2e5](https://github.com/certd/certd/commit/241b2e55848471e61b5d2be0c87a7dc9a0d29557))
* **monitor:** 站点证书监控支持手动选中触发检查 ([f60e63f](https://github.com/certd/certd/commit/f60e63fbaf00460d5050cd7705bd75d57dec202d))
* **open-api:** openkey增加用途备注 ([bbeaf61](https://github.com/certd/certd/commit/bbeaf617d19750515f6eca51863e74e23bc1de50))
* **tencent clb:** 修改所有CLB地域的endpoint为就近接入 ([c427735](https://github.com/certd/certd/commit/c4277350bcc8cf034cf4e76db2a6daa1842bc5d7))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Bug Fixes
* 修复添加步骤会修改所有步骤标题的bug ([51e83e4](https://github.com/certd/certd/commit/51e83e4c38f7f319314fdc38ef33cf5720b2dde0))
* 修复证书插件配置无法打开的bug ([5563abb](https://github.com/certd/certd/commit/5563abb13a62140f4dee668ab768cf4ff9371ddb))
### Performance Improvements
* 新增余额管理,管理员充值功能 ([bded368](https://github.com/certd/certd/commit/bded368cbb8974b6937ae260e448c25fae5c33ad))
* **acme-account-access:** AcmeAccount授权允许修改eab,重新生成account ([6a649aa](https://github.com/certd/certd/commit/6a649aaca083ccd3b517f7d676a58220aa33b37d))
* **ui:** 商业版套餐购买不要求设置支付方式 ([e971145](https://github.com/certd/certd/commit/e9711456860d7db40914a0c295534f7bca56f83c))
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
### Bug Fixes
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
### Performance Improvements
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
## [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
+61 -2
View File
@@ -103,9 +103,14 @@ docker-compose up -d
![db-6.png](images/db-sync-1.jpg)
![db-7.png](images/db-sync-2.png)
![db-8.png](images/db-sync-3.png)
![db-8.png](images/db-sync-3-0.png)
![db-8.png](images/db-sync-3-1.png)
> 重要: 建议Fetch大小改成1000,避免内存溢出
下一步、下一步,直到数据加载设置,勾选`在加载前截断目标表`(此选项很重要,并且会清空mysql certd数据库中的数据)
![db-7.png](images/db-sync-4.png)
> 重要: 在插入行后做提交,也改成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 $$;
```
+34 -6
View File
@@ -6,13 +6,41 @@
Certd 提供多种 Docker 镜像版本,您可以根据需要选择:
| 版本标签 | 基础系统 | 说明 |
| --- | --- | --- | --- |
| `latest` / `[version]` | Alpine Linux | 默认版本,镜像体积小 |
| `slim` / `[version]-slim` | Debian slim | glibc版本,dns解析兼容性更好(可能需要配置security_opt -seccomp=unconfined)|
| `armv7` / `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
#### 1. 镜像地址:
| 镜像仓库 | 最新预览版 | 稳定版 | slim版 | armv7版 |
| --- | --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| Docker Hub | `certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
> 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
#### 2. 版本标签说明:
**最新版本标签:**
| 版本 | 标签 | 说明 |
| --- | --- | --- |
| 最新预览版【默认】 | `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` |
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
最新版本号:
![GitHub release (latest by date)](https://img.shields.io/github/v/release/certd/certd?label=Latest%20Release&sort=semver)
> 如果您不确定使用哪个版本,请使用默认的 `latest` 版本。
### 一键脚本安装(推荐)

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+7 -1
View File
@@ -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主机密码情况下,该工具非常好用。
+47 -46
View File
@@ -36,52 +36,53 @@
| 32.| **Dokploy授权** | |
| 33.| **Dynadot授权** | |
| 34.| **farcdn授权** | |
| 35.| **FlexCDN授权** | |
| 36.| **Gcore** | Gcore |
| 37.| **Github授权** | |
| 38.| **godaddy授权** | |
| 39.| **HiPM DNSMgr** | HiPM DNSMgr API Token 授权 |
| 40.| **金山云授权** | |
| 41.| **FTP授权** | |
| 42.| **七牛OSS授权** | |
| 43.| **腾讯云COS授权** | 腾讯云对象存储授权,包含地域和存储桶 |
| 44.| **s3/minio授权** | S3/minio oss授权 |
| 45.| **namesilo授权** | |
| 46.| **Next Terminal 授权** | 用于访问 Next Terminal API 的授权配置 |
| 47.| **Nginx Proxy Manager 授权** | 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。 |
| 48.| **1panel授权** | 账号和密码 |
| 49.| **支付宝** | |
| 50.| **白山云授权** | |
| 51.| **宝塔云WAF授权** | 用于连接和管理宝塔云WAF服务的授权配置 |
| 52.| **cdnfly授权** | |
| 53.| **k8s授权** | |
| 54.| **括彩云cdn授权** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 55.| **LeCDN授权** | |
| 56.| **lucky** | |
| 57.| **猫云授权** | |
| 58.| **plesk授权** | |
| 59.| **长亭雷池授权** | |
| 60.| **群晖登录授权** | |
| 61.| **uniCloud** | unicloud授权 |
| 62.| **微信支付** | |
| 63.| **易盾rcdn授权** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 64.| **易发云短信** | sms.yfyidc.cn/ |
| 65.| **易盾DCDN授权** | https://user.yiduncdn.com |
| 66.| **易支付** | |
| 67.| **proxmox** | |
| 68.| **Spaceship.com 授权** | Spaceship.com API 授权插件 |
| 69.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器授权 |
| 70.| **UCloud授权** | 优刻得授权 |
| 71.| **又拍云** | |
| 72.| **网宿授权** | |
| 73.| **西部数码授权** | |
| 74.| **我爱云授权** | 我爱云CDN |
| 75.| **新网授权(代理方式)** | |
| 76.| **新网授权** | |
| 77.| **新网互联授权** | 仅支持代理账号,ip需要加入白名单 |
| 78.| **Zenlayer授权** | Zenlayer授权 |
| 79.| **GoEdge授权** | |
| 80.| **雨云授权** | https://app.rainyun.com/ |
| 35.| **Fastly授权** | Fastly CDN / TLS Custom Certificates API 授权 |
| 36.| **FlexCDN授权** | |
| 37.| **Gcore** | Gcore |
| 38.| **Github授权** | |
| 39.| **godaddy授权** | |
| 40.| **HiPM DNSMgr** | HiPM DNSMgr API Token 授权 |
| 41.| **金山云授权** | |
| 42.| **FTP授权** | |
| 43.| **七牛OSS授权** | |
| 44.| **腾讯云COS授权** | 腾讯云对象存储授权,包含地域和存储桶 |
| 45.| **s3/minio授权** | S3/minio oss授权 |
| 46.| **namesilo授权** | |
| 47.| **Next Terminal 授权** | 用于访问 Next Terminal API 的授权配置 |
| 48.| **Nginx Proxy Manager 授权** | 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。 |
| 49.| **1panel授权** | 账号和密码 |
| 50.| **支付宝** | |
| 51.| **白山云授权** | |
| 52.| **宝塔云WAF授权** | 用于连接和管理宝塔云WAF服务的授权配置 |
| 53.| **cdnfly授权** | |
| 54.| **k8s授权** | |
| 55.| **括彩云cdn授权** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 56.| **LeCDN授权** | |
| 57.| **lucky** | |
| 58.| **猫云授权** | |
| 59.| **plesk授权** | |
| 60.| **长亭雷池授权** | |
| 61.| **群晖登录授权** | |
| 62.| **uniCloud** | unicloud授权 |
| 63.| **微信支付** | |
| 64.| **易盾rcdn授权** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 65.| **易发云短信** | sms.yfyidc.cn/ |
| 66.| **易盾DCDN授权** | https://user.yiduncdn.com |
| 67.| **易支付** | |
| 68.| **proxmox** | |
| 69.| **Spaceship.com 授权** | Spaceship.com API 授权插件 |
| 70.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器授权 |
| 71.| **UCloud授权** | 优刻得授权 |
| 72.| **又拍云** | |
| 73.| **网宿授权** | |
| 74.| **西部数码授权** | |
| 75.| **我爱云授权** | 我爱云CDN |
| 76.| **新网授权(代理方式)** | |
| 77.| **新网授权** | |
| 78.| **新网互联授权** | 仅支持代理账号,ip需要加入白名单 |
| 79.| **Zenlayer授权** | Zenlayer授权 |
| 80.| **GoEdge授权** | |
| 81.| **雨云授权** | https://app.rainyun.com/ |
<style module>
table th:first-of-type {
+31 -26
View File
@@ -1,13 +1,14 @@
# 任务插件
共 `134` 款任务插件
共 `139` 款任务插件
## 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. 主机
| 序号 | 名称 | 说明 |
@@ -30,26 +31,30 @@
| 4.| **中国移动-部署证书到CDN** | 中国移动自动部署证书到CDN |
| 5.| **多吉云-部署到多吉云CDN** | |
| 6.| **farcdn-更新证书** | www.farcdn.net |
| 7.| **FlexCDN-更新证书** | |
| 8.| **Gcore-刷新Gcore证书** | 刷新现有的证书 |
| 9.| **Gcore-部署证书到Gcore** | 仅上传 并不会部署到cdn |
| 10.| **GoEdge-更新证书** | GoEdge |
| 11.| **金山云-更新CDN证书** | 金山云自动更新CDN证书 |
| 12.| **白山云-更新证书** | |
| 13.| **cdnfly-部署证书到cdnfly** | cdnfly |
| 14.| **天翼云-部署证书到CDN** | 部署证书到天翼云CDN和全站加速 |
| 15.| **括彩云-部署到括彩云CDN** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 16.| **LeCDN-更新证书V2** | 支持新版本LeCDN |
| 17.| **LeCDN-更新证书** | |
| 18.| **Maoyun-更新猫云CDN证书** | |
| 19.| **易盾-部署到易盾DCDN** | 主要是防御,http://user.yiduncdn.com/ |
| 20.| **易盾-部署到易盾RCDN** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 21.| **雨云-更新证书** | app.rainyun.com |
| 22.| **又拍云-部署证书到CDN/USS** | 支持又拍云CDN,又拍云云存储USS |
| 23.| **网宿-更新证书** | 网宿证书自动更新 |
| 24.| **西数-部署到虚拟主机** | 西部数码部署证书到虚拟主机 |
| 25.| **我爱云-部署证书到我爱云CDN** | 部署证书到我爱云CDN |
| 26.| **Zenlayer-刷新证书** | 刷新Zenlayer CDN证书 |
| 7.| **Fastly-部署TLS激活** | 部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名) |
| 8.| **Fastly-清除缓存** | 清除 Fastly 指定 Service 的所有缓存 (Purge All) |
| 9.| **Fastly-更新证书** | 自动更新 Fastly CDN 上的已有自定义证书 |
| 10.| **Fastly-上传证书到Fastly** | 部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN |
| 11.| **FlexCDN-更新证书** | |
| 12.| **Gcore-刷新Gcore证书** | 刷新现有的证书 |
| 13.| **Gcore-部署证书到Gcore** | 仅上传 并不会部署到cdn |
| 14.| **GoEdge-更新证书** | GoEdge |
| 15.| **金山云-更新CDN证书** | 金山云自动更新CDN证书 |
| 16.| **白山云-更新证书** | |
| 17.| **cdnfly-部署证书到cdnfly** | cdnfly |
| 18.| **天翼云-部署证书到CDN** | 部署证书到天翼云CDN和全站加速 |
| 19.| **括彩云-部署到括彩云CDN** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 20.| **LeCDN-更新证书V2** | 支持新版本LeCDN |
| 21.| **LeCDN-更新证书** | |
| 22.| **Maoyun-更新猫云CDN证书** | |
| 23.| **易盾-部署到易盾DCDN** | 主要是防御,http://user.yiduncdn.com/ |
| 24.| **易盾-部署到易盾RCDN** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 25.| **雨云-更新证书** | app.rainyun.com |
| 26.| **又拍云-部署证书到CDN/USS** | 支持又拍云CDN,又拍云云存储USS |
| 27.| **网宿-更新证书** | 网宿证书自动更新 |
| 28.| **西数-部署到虚拟主机** | 西部数码部署证书到虚拟主机 |
| 29.| **我爱云-部署证书到我爱云CDN** | 部署证书到我爱云CDN |
| 30.| **Zenlayer-刷新证书** | 刷新Zenlayer CDN证书 |
## 4. 面板
| 序号 | 名称 | 说明 |
@@ -115,7 +120,7 @@
| 序号 | 名称 | 说明 |
|-----|-----|-----|
| 1.| **腾讯云-删除即将过期证书** | 仅删除未使用的证书 |
| 1.| **腾讯云-删除即将过期证书** | 仅删除即将过期且未使用的证书 |
| 2.| **腾讯云-部署证书到任意云资源** | 支持负载均衡、CDN、DDoS、直播、点播、Web应用防火墙、API网关、TEO、容器服务、对象存储、轻应用服务器、云原生微服务、云开发 |
| 3.| **腾讯云-部署到CDN(废弃)** | 已废弃,请使用v2版 |
| 4.| **腾讯云-部署到CDN-v2** | 推荐使用,支持CDN域名以及COS加速域名 |
+20 -20
View File
@@ -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 域名解析 |
+3 -2
View File
@@ -46,8 +46,9 @@ docker compose up -d
> 如果忘记管理员账号,请查看修改密码时的启动日志,会打印管理员账号名
## 二、仅有第三方登录,没有登录窗口
## 二、仅有第三方登录,没有登录窗口 / 自动跳到第三方登录等
当开启仅使用第三方登录模式时,如果第三方登录未配置或已失效,则会导致无法登录
服务重启后的前10分钟内,可以访问 `http://你的certd地址/#/login?oauthOnly=false` 显示密码登录入口。
您可以通过访问 `http://你的certd地址/#/login?oauthOnly=false` 来临时关闭仅使用第三方登录模式,以使用密码登录。
> 注意:仅使用第三方登录开启后,仅限于服务重启后的前10分钟内可使用密码登录;超过窗口期即使`oauthOnly=false`,也无法使用密码登录。
+2 -2
View File
@@ -5,8 +5,8 @@
`Certd`默认启用https,监听7002端口
如果你想关闭https,或者修改端口,可以在环境变量中配置
```shell
CERTD_HTTPS_ENABLE=true
CERTD_HTTPS_port=7002
certd_https_enabled=true
certd_https_port=7002
```
+1 -1
View File
@@ -9,5 +9,5 @@
}
},
"npmClient": "pnpm",
"version": "1.42.4"
"version": "1.45.0"
}
+6 -4
View File
@@ -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": [
+5 -5
View File
@@ -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/**"
]
}
+45
View File
@@ -3,6 +3,51 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/publishlab/node-acme-client/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* 支持google persist校验方式 ([97d2b50](https://github.com/publishlab/node-acme-client/commit/97d2b5007409ec79ea864d437c473a2f9b5dfae4))
## [1.44.4](https://github.com/publishlab/node-acme-client/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/acme-client
## [1.44.3](https://github.com/publishlab/node-acme-client/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/acme-client
## [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
-15
View File
@@ -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);
},
+10 -8
View File
@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client",
"private": false,
"author": "nmorsman",
"version": "1.42.4",
"version": "1.45.0",
"type": "module",
"module": "./dist/index.js",
"main": "./dist/index.js",
@@ -18,7 +18,7 @@
"types"
],
"dependencies": {
"@certd/basic": "^1.42.4",
"@certd/basic": "^1.45.0",
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.9.0",
@@ -27,6 +27,7 @@
"https-proxy-agent": "^7.0.5",
"lodash-es": "^4.17.21",
"node-forge": "^1.3.1",
"psl": "^1.15.0",
"punycode.js": "^2.3.1"
},
"devDependencies": {
@@ -44,20 +45,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 +77,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "fcbf1045fe3806fa1a0bebf5038a6f67aec283d0"
}
+5 -3
View File
@@ -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);
}
}
+1 -2
View File
@@ -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
View File
@@ -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));
}
+23 -5
View File
@@ -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,
}),
@@ -0,0 +1,21 @@
// @ts-ignore
import psl from "psl";
export function getDnsPersistIssuer(directoryUrl?: string) {
if (!directoryUrl) {
return "letsencrypt.org";
}
let directory: URL;
try {
directory = new URL(directoryUrl);
} catch {
throw new Error(`ACME Directory URL无效,无法生成DNS持久验证Issuer:${directoryUrl}`);
}
const issuer = psl.get(directory.hostname);
if (!issuer) {
throw new Error(`无法从ACME Directory URL获取可注册主域名:${directory.hostname}`);
}
return issuer;
}
+1
View File
@@ -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 -8
View File
@@ -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"));
});
});
+2 -27
View File
@@ -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
*/
@@ -105,3 +79,4 @@ export * from './verify.js'
export * from './error.js'
export * from './util.js'
export * from './dns-persist.js'
-1
View File
@@ -55,7 +55,6 @@ export interface ClientAutoOptions {
email?: string;
termsOfServiceAgreed?: boolean;
skipChallengeVerification?: boolean;
challengePriority?: string[];
preferredChain?: string;
signal?: AbortSignal;
profile?: string;
+2
View File
@@ -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
}
+4 -13
View File
@@ -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
View File
@@ -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],
+5 -1
View File
@@ -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/**"
]
}
+40
View File
@@ -3,6 +3,46 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* **webhook:** 修复模版转义换行符,避免手动上传的证书报错 ([2735feb](https://github.com/certd/certd/commit/2735feb3302f8308708dafc8b295adcd4b91d7f0))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/basic
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/basic
## [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 -1
View File
@@ -1 +1 @@
02:45
00:51
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/basic",
"private": false,
"version": "1.42.4",
"version": "1.45.0",
"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": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "fcbf1045fe3806fa1a0bebf5038a6f67aec283d0"
}
+6 -3
View File
@@ -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?.();
}
+2 -2
View File
@@ -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");
}
+2 -2
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
import mitt from 'mitt';
import mitt from "mitt";
export const mitter = mitt();
@@ -1,6 +1,21 @@
import dayjs from "dayjs";
export const stringUtils = {
replaceTemplate(target: string, body: Record<string, unknown>, urlEncode = false) {
let bodyStr = target;
for (const key of Object.keys(body)) {
const rawValue = body[key] == null ? "" : String(body[key]);
let value = urlEncode ? encodeURIComponent(rawValue) : rawValue;
value = value.replace(/[\u0000-\u001f]/g, char => {
const escapes: Record<string, string> = { "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t" };
return escapes[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
});
bodyStr = bodyStr.replaceAll(`\${${key}}`, value);
bodyStr = bodyStr.replaceAll(`{${key}}`, value);
}
return bodyStr;
},
maxLength(str?: string, length = 100) {
if (str) {
return str.length > length ? str.slice(0, length) + "..." : str;
+5 -1
View File
@@ -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/**"
]
}
+60
View File
@@ -3,6 +3,66 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* 缺失运行时嵌套依赖时添加清除缓存提示 ([2d0b85b](https://github.com/certd/certd/commit/2d0b85b3054fc1aa82b3ab5db34a2a56308f71f2))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/pipeline
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/pipeline
## [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
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/pipeline",
"private": false,
"version": "1.42.4",
"version": "1.45.0",
"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.4",
"@certd/plus-core": "^1.42.4",
"@certd/basic": "^1.45.0",
"@certd/plus-core": "^1.45.0",
"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": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "fcbf1045fe3806fa1a0bebf5038a6f67aec283d0"
}
+2 -9
View File
@@ -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,20 +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");
}
}
async onRequest(req: AccessRequestHandleReq) {
@@ -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");
});
});
+350 -67
View File
@@ -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] = [];
+37 -1
View File
@@ -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,9 +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");
}
}
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";
+35 -20
View File
@@ -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,14 +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");
}
// 将证书加入secret
// @ts-ignore
if (this.cert && this.cert.crt && this.cert.key) {
@@ -259,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;
}
+4 -28
View File
@@ -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,371 @@
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("logs a cache clearing hint when a nested runtime dependency is missing", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-missing-nested-"));
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", main: "index.js" }), "utf8");
fs.writeFileSync(path.join(packageDir, "index.js"), "module.exports = require('missing-nested');\n", "utf8");
const logs: string[] = [];
const logger = {
info() {},
warn() {},
error(message: string, ...args: any[]) {
logs.push([message, ...args.map(item => item?.message || String(item))].join(" "));
},
};
const service = new RuntimeDepsService({ rootDir }, null);
await assert.rejects(() => service.importRuntime("runtime-only", logger));
assert.ok(logs.some(message => message.includes("Cannot find module 'missing-nested'")));
assert.ok(logs.some(message => message.includes("插件管理页面->清除缓存依赖->重启certd")));
});
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);
});
});
+532 -20
View File
@@ -1,27 +1,539 @@
/**
* 运行时动态导入函数类型
*/
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;
let fromRuntimeDeps = false;
try {
//尝试加载本项目依赖
resolved = this.resolveProjectSpecifier(specifier).resolved;
} catch (error) {
if (!this.isModuleNotFoundError(error)) {
throw error;
}
fromRuntimeDeps = true;
resolved = await this.resolveLazyOrInstallSpecifier(specifier, logger);
}
if (!resolved) {
throw new Error(`依赖未安装成功: ${specifier}`);
}
try {
return await import(pathToFileURL(resolved).href);
} catch (error: any) {
if (fromRuntimeDeps && this.isModuleNotFoundError(error)) {
logger.error?.("动态依赖加载失败:", error);
logger.error?.("动态依赖加载失败,请前往插件管理页面->清除缓存依赖->重启certd后重试");
}
throw error;
}
}
//动态获取模块解析
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);
}
+5 -1
View File
@@ -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/**"
]
}
+38
View File
@@ -3,6 +3,44 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/lib-huawei
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/lib-huawei
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/lib-huawei
## [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
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-huawei",
"private": false,
"version": "1.42.4",
"version": "1.45.0",
"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": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "fcbf1045fe3806fa1a0bebf5038a6f67aec283d0"
}
+5 -1
View File
@@ -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/**"
]
}
+43
View File
@@ -3,6 +3,49 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/lib-iframe
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/lib-iframe
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/lib-iframe
## [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
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-iframe",
"private": false,
"version": "1.42.4",
"version": "1.45.0",
"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": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "fcbf1045fe3806fa1a0bebf5038a6f67aec283d0"
}
+1 -1
View File
@@ -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);
+5 -1
View File
@@ -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/**"
]
}
+36
View File
@@ -3,6 +3,42 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/jdcloud
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/jdcloud
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/jdcloud
## [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
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/jdcloud",
"version": "1.42.4",
"version": "1.45.0",
"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": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "fcbf1045fe3806fa1a0bebf5038a6f67aec283d0"
}
+6 -6
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More