diff --git a/.trae/documents/plugin-on-demand-dependency-loading.md b/.trae/documents/plugin-on-demand-dependency-loading.md new file mode 100644 index 000000000..62c4bbfba --- /dev/null +++ b/.trae/documents/plugin-on-demand-dependency-loading.md @@ -0,0 +1,412 @@ +# 插件依赖按需加载方案 + +## 背景与目标 + +### 当前问题 +- `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(packageName: string): Promise + + // 检查依赖是否已安装 + async isInstalled(packageName: string): Promise + + // 安装依赖(带锁,避免并发) + async installDependencies(packages: string[]): Promise + + // 从 optional-deps/node_modules 加载依赖 + async loadModule(packageName: string): Promise +} +``` + +**实现要点:** +- 使用文件锁(如 `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(packageName: string): Promise { + const depManager = new DependencyManager(); + return await depManager.ensureAndImport(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` 体积,提升初始安装速度,同时保持现有架构的兼容性和可维护性。 diff --git a/AGENTS.md b/AGENTS.md index 90f466ecb..c8e369ae2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,10 +54,12 @@ Certd 是支持私有化部署的 SSL/TLS 证书自动化管理平台,提供 W - 先读本文;需要代码导航、目录入口、参考文件或验证命令时读 `.codex/repo-map.md`。 - 任务涉及后端、前端、插件、测试或代码风格时,先读取 `.codex/agent-rules/` 下对应规则文件,再查看具体代码。 - 在 PowerShell 中读取中文、Markdown、locale、文档类文件时,显式使用 `Get-Content -Encoding utf8`;如果仍乱码,再执行 `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()` 后重试。 +- 在 PowerShell 中使用 `rg` 搜索包含引号、括号、反斜杠等特殊字符的模式时,优先用单引号包裹整个 pattern,例如 `rg 'await import\("tencentcloud-sdk-nodejs' packages/ui/certd-server/src -g '*.ts'`;不要在双引号字符串里再直接写未转义的 `"`,否则 PowerShell 会截断参数并把后半段当成文件路径,出现 `The string is missing the terminator` 或 `rg: xxx: 系统找不到指定的文件`。 - 做后端任务时,先定位 `packages/ui/certd-server/src/modules` 下的模块,以及相关 entity/service/controller。 - 做前端任务时,先定位 `packages/ui/certd-client/src/views/certd` 下的页面,再找对应 `src/api`。 - 做服务商、DNS、部署、通知相关任务时,先看 `packages/ui/certd-server/src/plugins`,再看 `packages/plugins/plugin-lib` 里的共享辅助能力。 - 优先沿用现有模块、插件、服务模式,再考虑新增抽象;避免为了形式上的“复用”制造过度设计。 +- 为了提升可读性,不要把一个方法调用链直接塞进另一个方法的参数里;应先用有意义的局部变量承载返回值,再把变量传入下一步调用。 - 实现新功能或修复行为缺陷前,优先补对应单元测试并确认红灯,再实现代码并跑聚焦验证。确实不适合先写测试时,在回复中说明原因和替代验证方式。 - 后补单元测试时,先按正确行为写预期;如果红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。 - 优先对改动包运行聚焦测试或格式化/ESLint;只有跨包影响明显时再考虑更大范围构建。 diff --git a/packages/core/pipeline/src/access/api.ts b/packages/core/pipeline/src/access/api.ts index b2d2bf55f..218637167 100644 --- a/packages/core/pipeline/src/access/api.ts +++ b/packages/core/pipeline/src/access/api.ts @@ -3,6 +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 { IServiceGetter } from "../service/index.js"; // export type AccessRequestHandleReqInput = { // id?: number; @@ -20,6 +21,8 @@ export type AccessInputDefine = FormItemProps & { export type AccessDefine = Registrable & { icon?: string; subtype?: string; + dependPlugins?: Record; + dependPackages?: Record; input?: { [key: string]: AccessInputDefine; }; @@ -39,13 +42,32 @@ export type AccessContext = { logger: ILogger; utils: typeof utils; accessService: IAccessService; + serviceGetter?: IServiceGetter; + define?: AccessDefine; }; export abstract class BaseAccess implements IAccess { ctx!: AccessContext; + runtimeDepsService?: { + ensureRuntimeDependencies(pluginKeys: string | string[]): Promise; + importRuntime(specifier: string): Promise; + }; - setCtx(ctx: AccessContext) { + async importRuntime(specifier: string) { + if (!this.runtimeDepsService) { + return await import(specifier); + } + return await this.runtimeDepsService.importRuntime(specifier); + } + + async setCtx(ctx: AccessContext) { this.ctx = ctx; + if (!this.runtimeDepsService && this.ctx.serviceGetter) { + this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService"); + } + if (this.runtimeDepsService && this.ctx.define?.name) { + await this.runtimeDepsService.ensureRuntimeDependencies(`access:${this.ctx.define.name}`); + } } async onRequest(req: AccessRequestHandleReq) { diff --git a/packages/core/pipeline/src/access/decorator.ts b/packages/core/pipeline/src/access/decorator.ts index 0fb73da72..70779561b 100644 --- a/packages/core/pipeline/src/access/decorator.ts +++ b/packages/core/pipeline/src/access/decorator.ts @@ -67,7 +67,9 @@ export async function newAccess(type: string, input: any, accessService: IAccess accessService, }; } - access.setCtx(ctx); + ctx.define = ctx.define || register.define; + access.runtimeDepsService = (accessService as any).runtimeDepsService; + await access.setCtx(ctx); access._type = type; return access; } diff --git a/packages/core/pipeline/src/core/executor.ts b/packages/core/pipeline/src/core/executor.ts index 8e8a41d12..b6d19a5f4 100644 --- a/packages/core/pipeline/src/core/executor.ts +++ b/packages/core/pipeline/src/core/executor.ts @@ -387,7 +387,7 @@ export class Executor { }), serviceGetter: this.options.serviceGetter, }; - instance.setCtx(taskCtx); + await instance.setCtx(taskCtx); await instance.onInstance(); const result = await instance.execute(); diff --git a/packages/core/pipeline/src/notification/api.ts b/packages/core/pipeline/src/notification/api.ts index 155128ef7..e19b3f127 100644 --- a/packages/core/pipeline/src/notification/api.ts +++ b/packages/core/pipeline/src/notification/api.ts @@ -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 } from "../service/index.js"; +import { IEmailService, IServiceGetter } from "../service/index.js"; export type NotificationBody = { userId?: number; @@ -39,6 +39,8 @@ export type NotificationInputDefine = FormItemProps & { }; export type NotificationDefine = Registrable & { needPlus?: boolean; + dependPlugins?: Record; + dependPackages?: Record; input?: { [key: string]: NotificationInputDefine; }; @@ -78,6 +80,8 @@ export type NotificationContext = { logger: ILogger; utils: typeof utils; emailService: IEmailService; + serviceGetter?: IServiceGetter; + define?: NotificationDefine; }; export abstract class BaseNotification implements INotification { @@ -85,6 +89,17 @@ export abstract class BaseNotification implements INotification { ctx!: NotificationContext; http!: HttpClient; logger!: ILogger; + runtimeDepsService?: { + ensureRuntimeDependencies(pluginKeys: string | string[]): Promise; + importRuntime(specifier: string): Promise; + }; + + async importRuntime(specifier: string) { + if (!this.runtimeDepsService) { + return await import(specifier); + } + return await this.runtimeDepsService.importRuntime(specifier); + } async doSend(body: NotificationBody) { return await this.send(body); @@ -93,10 +108,16 @@ export abstract class BaseNotification implements INotification { // eslint-disable-next-line @typescript-eslint/no-empty-function async onInstance() {} - setCtx(ctx: NotificationContext) { + async setCtx(ctx: NotificationContext) { this.ctx = ctx; this.http = ctx.http; this.logger = ctx.logger; + if (!this.runtimeDepsService && this.ctx.serviceGetter) { + this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService"); + } + if (this.runtimeDepsService && this.ctx.define?.name) { + await this.runtimeDepsService.ensureRuntimeDependencies(`notification:${this.ctx.define.name}`); + } } setDefine = (define: NotificationDefine) => { this.define = define; diff --git a/packages/core/pipeline/src/notification/decorator.ts b/packages/core/pipeline/src/notification/decorator.ts index 7c8e08a45..89ef9edbe 100644 --- a/packages/core/pipeline/src/notification/decorator.ts +++ b/packages/core/pipeline/src/notification/decorator.ts @@ -61,7 +61,8 @@ export async function newNotification(type: string, input: any, ctx: Notificatio throw new Error("ctx is required"); } plugin.setDefine(register.define); - plugin.setCtx(ctx); + ctx.define = ctx.define || register.define; + await plugin.setCtx(ctx); await plugin.onInstance(); return plugin; } diff --git a/packages/core/pipeline/src/plugin/api.ts b/packages/core/pipeline/src/plugin/api.ts index f6d22877c..ddeefcc5e 100644 --- a/packages/core/pipeline/src/plugin/api.ts +++ b/packages/core/pipeline/src/plugin/api.ts @@ -46,6 +46,8 @@ export type PluginDefine = Registrable & { default?: any; group?: string; icon?: string; + dependPlugins?: Record; + dependPackages?: Record; input?: { [key: string]: TaskInputDefine; }; @@ -73,6 +75,8 @@ export type ITaskPlugin = { onInstance(): Promise; execute(): Promise; onRequest(req: PluginRequestHandleReq): Promise; + setCtx(ctx: TaskInstanceContext): Promise; + importRuntime?(specifier: string): Promise; [key: string]: any; }; @@ -146,6 +150,17 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin { logger!: ILogger; http!: HttpClient; accessService!: IAccessService; + runtimeDepsService?: { + ensureRuntimeDependencies(pluginKeys: string | string[]): Promise; + importRuntime(specifier: string): Promise; + }; + + async importRuntime(specifier: string) { + if (!this.runtimeDepsService) { + return await import(specifier); + } + return await this.runtimeDepsService.importRuntime(specifier); + } clearLastStatus() { this._result.clearLastStatus = true; @@ -161,11 +176,17 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin { } } - setCtx(ctx: TaskInstanceContext) { + async setCtx(ctx: TaskInstanceContext) { this.ctx = ctx; this.logger = ctx.logger; this.accessService = ctx.accessService; this.http = ctx.http; + if (!this.runtimeDepsService && this.ctx.serviceGetter) { + this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService"); + } + if (this.runtimeDepsService && this.ctx.define?.name) { + await this.runtimeDepsService.ensureRuntimeDependencies(`plugin:${this.ctx.define.name}`); + } // 将证书加入secret // @ts-ignore if (this.cert && this.cert.crt && this.cert.key) { diff --git a/packages/libs/lib-server/src/user/access/service/access-getter.ts b/packages/libs/lib-server/src/user/access/service/access-getter.ts index 8a5505efb..7ef249a65 100644 --- a/packages/libs/lib-server/src/user/access/service/access-getter.ts +++ b/packages/libs/lib-server/src/user/access/service/access-getter.ts @@ -1,20 +1,32 @@ import { IAccessService } from "@certd/pipeline"; +export type AccessRuntimeDepsService = { + ensureRuntimeDependencies(pluginKeys: string | string[]): Promise; + importRuntime(specifier: string): Promise; +}; + export class AccessGetter implements IAccessService { userId: number; projectId?: number; - getter: (id: any, userId?: number, projectId?: number, ignorePermission?: boolean) => Promise; - constructor(userId: number, projectId: number, getter: (id: any, userId: number, projectId?: number, ignorePermission?: boolean) => Promise) { + runtimeDepsService?: AccessRuntimeDepsService; + getter: (id: any, userId?: number, projectId?: number, ignorePermission?: boolean, runtimeDepsService?: AccessRuntimeDepsService) => Promise; + constructor( + userId: number, + projectId: number, + getter: (id: any, userId: number, projectId?: number, ignorePermission?: boolean, runtimeDepsService?: AccessRuntimeDepsService) => Promise, + runtimeDepsService?: AccessRuntimeDepsService + ) { this.userId = userId; this.projectId = projectId; this.getter = getter; + this.runtimeDepsService = runtimeDepsService; } async getById(id: any) { - return await this.getter(id, this.userId, this.projectId); + return await this.getter(id, this.userId, this.projectId, false, this.runtimeDepsService); } async getCommonById(id: any) { - return await this.getter(id, 0, null); + return await this.getter(id, 0, null, false, this.runtimeDepsService); } } diff --git a/packages/libs/lib-server/src/user/access/service/access-service.ts b/packages/libs/lib-server/src/user/access/service/access-service.ts index 69b3e6cdd..383bb1c71 100644 --- a/packages/libs/lib-server/src/user/access/service/access-service.ts +++ b/packages/libs/lib-server/src/user/access/service/access-service.ts @@ -2,10 +2,11 @@ import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core"; import { InjectEntityModel } from "@midwayjs/typeorm"; import { In, Repository } from "typeorm"; import { AccessGetter, BaseService, PageReq, PermissionException, ValidateException } from "../../../index.js"; +import type { AccessRuntimeDepsService } from "./access-getter.js"; import { AccessEntity } from "../entity/access.js"; import { AccessDefine, accessRegistry, newAccess } from "@certd/pipeline"; import { EncryptService } from "./encrypt-service.js"; -import { logger, utils } from "@certd/basic"; +import { http, logger, utils } from "@certd/basic"; /** * 授权 @@ -160,7 +161,7 @@ export class AccessService extends BaseService { }; } - async getAccessById(id: any, checkUserId: boolean, userId?: number, projectId?: number): Promise { + async getAccessById(id: any, checkUserId: boolean, userId?: number, projectId?: number, runtimeDepsService?: AccessRuntimeDepsService): Promise { const entity = await this.info(id); if (entity == null) { throw new Error(`该授权配置不存在,请确认是否已被删除:id=${id}`); @@ -183,12 +184,20 @@ export class AccessService extends BaseService { id: entity.id, ...setting, }; - const accessGetter = new AccessGetter(userId, projectId, this.getById.bind(this)); - return await newAccess(entity.type, input, accessGetter); + const getAccessById = this.getById.bind(this); + const accessGetter = new AccessGetter(userId, projectId, getAccessById, runtimeDepsService); + const accessContext = { + logger, + http, + utils, + accessService: accessGetter, + } as any; + const access = await newAccess(entity.type, input, accessGetter, accessContext); + return access; } - async getById(id: any, userId: number, projectId?: number): Promise { - return await this.getAccessById(id, true, userId, projectId); + async getById(id: any, userId: number, projectId?: number, _ignorePermission?: boolean, runtimeDepsService?: AccessRuntimeDepsService): Promise { + return await this.getAccessById(id, true, userId, projectId, runtimeDepsService); } decryptAccessEntity(entity: AccessEntity): any { diff --git a/packages/libs/lib-server/src/user/addon/api/api.ts b/packages/libs/lib-server/src/user/addon/api/api.ts index 6dc8ca90a..de4a01d01 100644 --- a/packages/libs/lib-server/src/user/addon/api/api.ts +++ b/packages/libs/lib-server/src/user/addon/api/api.ts @@ -27,6 +27,8 @@ export type AddonInputDefine = FormItemProps & { export type AddonDefine = Registrable & { addonType: string; needPlus?: boolean; + dependPlugins?: Record; + dependPackages?: Record; input?: { [key: string]: AddonInputDefine; }; @@ -64,6 +66,17 @@ export abstract class BaseAddon implements IAddon { ctx!: AddonContext; http!: HttpClient; logger!: ILogger; + runtimeDepsService?: { + ensureRuntimeDependencies(pluginKeys: string | string[]): Promise; + importRuntime(specifier: string): Promise; + }; + + async importRuntime(specifier: string) { + if (!this.runtimeDepsService) { + return await import(specifier); + } + return await this.runtimeDepsService.importRuntime(specifier); + } title!: string; @@ -107,10 +120,16 @@ export abstract class BaseAddon implements IAddon { } - setCtx(ctx: AddonContext) { + async setCtx(ctx: AddonContext) { this.ctx = ctx; this.http = ctx.http; this.logger = ctx.logger; + if (!this.runtimeDepsService && this.ctx.serviceGetter) { + this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService"); + } + if (this.runtimeDepsService && this.define?.addonType && this.define?.name) { + await this.runtimeDepsService.ensureRuntimeDependencies(`addon:${this.define.addonType}:${this.define.name}`); + } } setDefine = (define:AddonDefine) => { this.define = define; diff --git a/packages/libs/lib-server/src/user/addon/api/decorator.ts b/packages/libs/lib-server/src/user/addon/api/decorator.ts index e82becd77..280e39c6e 100644 --- a/packages/libs/lib-server/src/user/addon/api/decorator.ts +++ b/packages/libs/lib-server/src/user/addon/api/decorator.ts @@ -63,9 +63,7 @@ export async function newAddon(addonType:string,type: string, input: any, ctx: A throw new Error("ctx is required"); } plugin.setDefine(register.define); - plugin.setCtx(ctx); + await plugin.setCtx(ctx); await plugin.onInstance(); return plugin; } - - diff --git a/packages/plugins/plugin-lib/src/cert/dns-provider/api.ts b/packages/plugins/plugin-lib/src/cert/dns-provider/api.ts index 70ea506c7..de162bf6d 100644 --- a/packages/plugins/plugin-lib/src/cert/dns-provider/api.ts +++ b/packages/plugins/plugin-lib/src/cert/dns-provider/api.ts @@ -4,6 +4,8 @@ import { IAccess, IAccessService, IServiceGetter, PageRes, PageSearch, Registrab export type DnsProviderDefine = Registrable & { accessType: string; icon?: string; + dependPlugins?: Record; + dependPackages?: Record; }; export type CreateRecordOptions = { @@ -27,6 +29,7 @@ export type DnsProviderContext = { domainParser: IDomainParser; serviceGetter: IServiceGetter; accessGetter?: IAccessService; + define?: DnsProviderDefine; }; export type DomainRecord = { @@ -61,7 +64,7 @@ export interface IDnsProvider { removeRecord(options: RemoveRecordOptions): Promise; - setCtx(ctx: DnsProviderContext): void; + setCtx(ctx: DnsProviderContext): Promise; //中文域名是否需要punycode转码,如果返回True,则使用punycode来添加解析记录,否则使用中文域名添加解析记录 usePunyCode(): boolean; diff --git a/packages/plugins/plugin-lib/src/cert/dns-provider/base.ts b/packages/plugins/plugin-lib/src/cert/dns-provider/base.ts index cd153ce2f..baaca8731 100644 --- a/packages/plugins/plugin-lib/src/cert/dns-provider/base.ts +++ b/packages/plugins/plugin-lib/src/cert/dns-provider/base.ts @@ -7,6 +7,17 @@ export abstract class AbstractDnsProvider implements IDnsProvider { ctx!: DnsProviderContext; http!: HttpClient; logger!: ILogger; + runtimeDepsService?: { + ensureRuntimeDependencies(pluginKeys: string | string[]): Promise; + importRuntime(specifier: string): Promise; + }; + + async importRuntime(specifier: string) { + if (!this.runtimeDepsService) { + return await import(specifier); + } + return await this.runtimeDepsService.importRuntime(specifier); + } usePunyCode(): boolean { //是否使用punycode来添加解析记录 @@ -30,10 +41,16 @@ export abstract class AbstractDnsProvider implements IDnsProvider { return punycode.toUnicode(domain); } - setCtx(ctx: DnsProviderContext) { + async setCtx(ctx: DnsProviderContext) { this.ctx = ctx; this.logger = ctx.logger; this.http = ctx.http; + if (!this.runtimeDepsService && this.ctx.serviceGetter) { + this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService"); + } + if (this.runtimeDepsService && this.ctx.define?.name) { + await this.runtimeDepsService.ensureRuntimeDependencies(`dnsProvider:${this.ctx.define.name}`); + } } async parseDomain(fullDomain: string) { @@ -68,9 +85,10 @@ export async function createDnsProvider(opts: { dnsProviderType: string; context const accessGetter: IAccessService = await context.serviceGetter.get("accessService"); context.accessGetter = accessGetter; } + context.define = dnsProviderDefine; // @ts-ignore const dnsProvider: IDnsProvider = new DnsProviderClass(); - dnsProvider.setCtx(context); + await dnsProvider.setCtx(context); await dnsProvider.onInstance(); return dnsProvider; } diff --git a/packages/ui/certd-server/package.json b/packages/ui/certd-server/package.json index f58ce9532..aa25205f4 100644 --- a/packages/ui/certd-server/package.json +++ b/packages/ui/certd-server/package.json @@ -100,7 +100,6 @@ "bcryptjs": "^2.4.3", "better-sqlite3": "^11.1.2", "cache-manager": "^6.1.0", - "cos-nodejs-sdk-v5": "^2.14.6", "cron-parser": "^4.9.0", "crypto-js": "^4.2.0", "dayjs": "^1.11.7", @@ -139,12 +138,15 @@ "ssh2": "^1.17.0", "strip-ansi": "^7.1.0", "svg-captcha": "^1.4.0", - "tencentcloud-sdk-nodejs": "^4.1.112", "typeorm": "^0.3.20", "uuid": "^10.0.0", "wechatpay-node-v3": "^2.2.1", "whoiser": "2.0.0-beta.10", "xml2js": "^0.6.2" + }, + "lazyDependencies": { + "tencentcloud-sdk-nodejs": "^4.1.112", + "cos-nodejs-sdk-v5": "^2.14.6" }, "devDependencies": { "mwts": "^1.3.0", @@ -179,6 +181,7 @@ "pnpm": { "neverBuiltDependencies": [] }, + "author": "anonymous", "license": "MIT" } diff --git a/packages/ui/certd-server/src/config/config.default.ts b/packages/ui/certd-server/src/config/config.default.ts index d3beff1d3..dafc8a620 100644 --- a/packages/ui/certd-server/src/config/config.default.ts +++ b/packages/ui/certd-server/src/config/config.default.ts @@ -16,8 +16,11 @@ import { tmpdir } from "node:os"; import { DefaultUploadFileMimeType, uploadWhiteList } from "@midwayjs/upload"; import path from "path"; import { logger } from "@certd/basic"; +import { createRequire } from "module"; const env = process.env.NODE_ENV || "development"; +const require = createRequire(import.meta.url); +const pkg = require("../../package.json"); const development = { midwayLogger: { @@ -103,6 +106,21 @@ const development = { certd: { fileRootDir: "./data/files", }, + runtimeDeps: { + enabled: true, + rootDir: "./data/.runtime-deps", + autoInstall: true, + pnpmCommand: "", + installTimeoutMs: 120000, + lazyDependencies: pkg.lazyDependencies || {}, + registry: { + mode: "auto", + fixedUrl: "", + candidates: ["https://registry.npmmirror.com", "https://registry.npmjs.org"], + probeTimeoutMs: 3000, + cacheTtlMs: 6 * 60 * 60 * 1000, + }, + }, system: { resetAdminPasswd: false, }, diff --git a/packages/ui/certd-server/src/controller/user/pipeline/handle-controller.ts b/packages/ui/certd-server/src/controller/user/pipeline/handle-controller.ts index 11409bf40..021e310c1 100644 --- a/packages/ui/certd-server/src/controller/user/pipeline/handle-controller.ts +++ b/packages/ui/certd-server/src/controller/user/pipeline/handle-controller.ts @@ -8,6 +8,7 @@ import { TaskServiceBuilder } from "../../../modules/pipeline/service/getter/tas import { cloneDeep } from "lodash-es"; import { ApiTags } from "@midwayjs/swagger"; import { AuthService } from "../../../modules/sys/authority/service/auth-service.js"; +import { RuntimeDepsService } from "../../../modules/runtime-deps/runtime-deps-service.js"; @Provide() @Controller("/api/pi/handle") @@ -28,6 +29,9 @@ export class HandleController extends BaseController { @Inject() notificationService: NotificationService; + @Inject() + runtimeDepsService: RuntimeDepsService; + @Post("/access", { description: Constants.per.authOnly, summary: "处理授权请求" }) async accessRequest(@Body(ALL) body: AccessRequestHandleReq) { let { projectId, userId } = await this.getProjectUserIdRead(); @@ -59,8 +63,16 @@ export class HandleController extends BaseController { inputAccess = this.accessService.decryptAccessEntity(param); } } - const accessGetter = new AccessGetter(userId, projectId, this.accessService.getById.bind(this.accessService)); - const access = await newAccess(body.typeName, inputAccess, accessGetter); + const getAccessById = this.accessService.getById.bind(this.accessService); + const accessGetter = new AccessGetter(userId, projectId, getAccessById, this.runtimeDepsService); + const accessContext = { + http, + logger, + utils, + accessService: accessGetter, + define: undefined, + } as any; + const access = await newAccess(body.typeName, inputAccess, accessGetter, accessContext); // mergeUtils.merge(access, body.input); const res = await access.onRequest(body); @@ -70,14 +82,17 @@ export class HandleController extends BaseController { @Post("/notification", { description: Constants.per.authOnly, summary: "处理通知请求" }) async notificationRequest(@Body(ALL) body: NotificationRequestHandleReq) { + const { projectId, userId } = await this.getProjectUserIdRead(); const input = body.input; + const serviceGetter = this.taskServiceBuilder.create({ userId, projectId }); const notification = await newNotification(body.typeName, input, { http, logger, utils, emailService: this.emailService, - }); + serviceGetter, + } as any); const res = await notification.onRequest(body); @@ -138,8 +153,8 @@ export class HandleController extends BaseController { // signal: this.abort.signal, utils, serviceGetter: taskServiceGetter, - }; - instance.setCtx(taskCtx); + } as any; + await instance.setCtx(taskCtx); mergeUtils.merge(plugin, body.input); await instance.onInstance(); const res = await plugin.onRequest(body); diff --git a/packages/ui/certd-server/src/modules/basic/service/code-service.ts b/packages/ui/certd-server/src/modules/basic/service/code-service.ts index 22956d582..76b2486ae 100644 --- a/packages/ui/certd-server/src/modules/basic/service/code-service.ts +++ b/packages/ui/certd-server/src/modules/basic/service/code-service.ts @@ -6,6 +6,7 @@ import { SmsServiceFactory } from "../sms/factory.js"; import { CaptchaService } from "./captcha-service.js"; import { EmailService } from "./email-service.js"; import { CaptchaRequest } from "../../../plugins/plugin-captcha/api.js"; +import { RuntimeDepsService } from "../../runtime-deps/runtime-deps-service.js"; // {data: '', text: 'abcd'} /** @@ -24,6 +25,9 @@ export class CodeService { @Inject() captchaService: CaptchaService; + @Inject() + runtimeDepsService: RuntimeDepsService; + async checkCaptcha(body: any, req: CaptchaRequest) { return await this.captchaService.doValidate({ form: body, req }); } @@ -53,9 +57,10 @@ export class CodeService { const smsConfig = sysSettings.sms.config; const sender: ISmsService = await SmsServiceFactory.createSmsService(smsType); const accessGetter = new AccessSysGetter(this.accessService); - sender.setCtx({ + await sender.setCtx({ accessService: accessGetter, config: smsConfig, + runtimeDepsService: this.runtimeDepsService, }); const smsCode = randomNumber(verificationCodeLength); await sender.sendSmsCode({ diff --git a/packages/ui/certd-server/src/modules/basic/sms/aliyun-sms.ts b/packages/ui/certd-server/src/modules/basic/sms/aliyun-sms.ts index f9b69df79..53e288014 100644 --- a/packages/ui/certd-server/src/modules/basic/sms/aliyun-sms.ts +++ b/packages/ui/certd-server/src/modules/basic/sms/aliyun-sms.ts @@ -44,7 +44,7 @@ export class AliyunSmsService implements ISmsService { ctx: SmsPluginCtx; - setCtx(ctx: any) { + async setCtx(ctx: any) { this.ctx = ctx; } diff --git a/packages/ui/certd-server/src/modules/basic/sms/api.ts b/packages/ui/certd-server/src/modules/basic/sms/api.ts index ad5c363ee..930c665e6 100644 --- a/packages/ui/certd-server/src/modules/basic/sms/api.ts +++ b/packages/ui/certd-server/src/modules/basic/sms/api.ts @@ -1,8 +1,9 @@ import { FormItemProps, IAccessService } from "@certd/pipeline"; +import type { RuntimeDepsService } from "../../runtime-deps/runtime-deps-service.js"; export interface ISmsService { sendSmsCode(opts: { mobile: string; code: string; phoneCode: string }): Promise; - setCtx(ctx: { accessService: IAccessService; config: { [key: string]: any } }): void; + setCtx(ctx: { accessService: IAccessService; config: { [key: string]: any }; runtimeDepsService?: RuntimeDepsService }): Promise; } export type PluginInputs = { @@ -12,4 +13,5 @@ export type PluginInputs = { export type SmsPluginCtx = { accessService: IAccessService; config: T; + runtimeDepsService?: RuntimeDepsService; }; diff --git a/packages/ui/certd-server/src/modules/basic/sms/tencent-sms.ts b/packages/ui/certd-server/src/modules/basic/sms/tencent-sms.ts index c5968e523..99cec0c69 100644 --- a/packages/ui/certd-server/src/modules/basic/sms/tencent-sms.ts +++ b/packages/ui/certd-server/src/modules/basic/sms/tencent-sms.ts @@ -68,12 +68,20 @@ export class TencentSmsService implements ISmsService { ctx: SmsPluginCtx; - setCtx(ctx: any) { + async setCtx(ctx: any) { this.ctx = ctx; + if (this.ctx.runtimeDepsService) { + await this.ctx.runtimeDepsService.ensureDependencies({ + "tencentcloud-sdk-nodejs": "^4.1.112", + }); + } } async getClient() { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/sms/v20210111/index.js"); + if (!this.ctx.runtimeDepsService) { + throw new Error("动态依赖服务未初始化,无法加载腾讯云短信SDK"); + } + const sdk = await this.ctx.runtimeDepsService.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/sms/v20210111/index.js"); const client = sdk.v20210111.Client; const access = await this.ctx.accessService.getById(this.ctx.config.accessId); diff --git a/packages/ui/certd-server/src/modules/basic/sms/yfy-sms.ts b/packages/ui/certd-server/src/modules/basic/sms/yfy-sms.ts index 3aaf1b7ee..4a0d16f10 100644 --- a/packages/ui/certd-server/src/modules/basic/sms/yfy-sms.ts +++ b/packages/ui/certd-server/src/modules/basic/sms/yfy-sms.ts @@ -35,7 +35,7 @@ export class YfySmsService implements ISmsService { ctx: SmsPluginCtx; - setCtx(ctx: any) { + async setCtx(ctx: SmsPluginCtx) { this.ctx = ctx; } diff --git a/packages/ui/certd-server/src/modules/cname/service/common-provider.ts b/packages/ui/certd-server/src/modules/cname/service/common-provider.ts index d3cb9db95..de86b70c0 100644 --- a/packages/ui/certd-server/src/modules/cname/service/common-provider.ts +++ b/packages/ui/certd-server/src/modules/cname/service/common-provider.ts @@ -87,7 +87,7 @@ export class CommonDnsProvider implements IDnsProvider { return res; } - setCtx(ctx: DnsProviderContext): void { + async setCtx(ctx: DnsProviderContext): Promise { this.ctx = ctx; } } diff --git a/packages/ui/certd-server/src/modules/pipeline/service/getter/task-service-getter.ts b/packages/ui/certd-server/src/modules/pipeline/service/getter/task-service-getter.ts index fe25b4bae..4c68bf1cb 100644 --- a/packages/ui/certd-server/src/modules/pipeline/service/getter/task-service-getter.ts +++ b/packages/ui/certd-server/src/modules/pipeline/service/getter/task-service-getter.ts @@ -13,6 +13,7 @@ import { CertInfoGetter } from "./cert-info-getter.js"; import { CertInfoService } from "../../../monitor/index.js"; import { ICertInfoGetter } from "@certd/plugin-lib"; import { CnameProviderService } from "../../../cname/service/cname-provider-service.js"; +import { RuntimeDepsService } from "../../../runtime-deps/runtime-deps-service.js"; const serviceNames = ["ocrService"]; export class TaskServiceGetter implements IServiceGetter { @@ -38,6 +39,8 @@ export class TaskServiceGetter implements IServiceGetter { return (await this.getDomainVerifierGetter()) as T; } else if (serviceName === "certInfoGetter") { return (await this.getCertInfoGetter()) as T; + } else if (serviceName === "runtimeDepsService") { + return (await this.getRuntimeDepsService()) as T; } else { if (!serviceNames.includes(serviceName)) { throw new Error(`${serviceName} not in whitelist`); @@ -63,7 +66,9 @@ export class TaskServiceGetter implements IServiceGetter { async getAccessService(): Promise { const accessService: AccessService = await this.appCtx.getAsync("accessService"); - return new AccessGetter(this.userId, this.projectId, accessService.getById.bind(accessService)); + const runtimeDepsService = await this.getRuntimeDepsService(); + const getAccessById = accessService.getById.bind(accessService); + return new AccessGetter(this.userId, this.projectId, getAccessById, runtimeDepsService); } async getCnameProxyService(): Promise { @@ -80,6 +85,10 @@ export class TaskServiceGetter implements IServiceGetter { const domainService: DomainService = await this.appCtx.getAsync("domainService"); return new DomainVerifierGetter(this.userId, this.projectId, domainService); } + + async getRuntimeDepsService(): Promise { + return await this.appCtx.getAsync("runtimeDepsService"); + } } @Provide() @Scope(ScopeEnum.Request, { allowDowngrade: true }) diff --git a/packages/ui/certd-server/src/modules/pipeline/service/notification-service.ts b/packages/ui/certd-server/src/modules/pipeline/service/notification-service.ts index 4b558c43d..07c8674ab 100644 --- a/packages/ui/certd-server/src/modules/pipeline/service/notification-service.ts +++ b/packages/ui/certd-server/src/modules/pipeline/service/notification-service.ts @@ -7,6 +7,7 @@ import { NotificationInstanceConfig, notificationRegistry, NotificationSendReq, import { http, utils } from "@certd/basic"; import { EmailService } from "../../basic/service/email-service.js"; import { isComm, isPlus } from "@certd/plus-core"; +import { TaskServiceBuilder } from "./getter/task-service-getter.js"; @Provide() @Scope(ScopeEnum.Request, { allowDowngrade: true }) @@ -20,6 +21,9 @@ export class NotificationService extends BaseService { @Inject() sysSettingsService: SysSettingsService; + @Inject() + taskServiceBuilder: TaskServiceBuilder; + //@ts-ignore getRepository() { return this.repository; @@ -199,6 +203,7 @@ export class NotificationService extends BaseService { logger: logger, utils: utils, emailService: this.emailService, + serviceGetter: this.taskServiceBuilder.create({ userId, projectId }), }, body: req.body, }); diff --git a/packages/ui/certd-server/src/modules/plugin/service/default-plugin.ts b/packages/ui/certd-server/src/modules/plugin/service/default-plugin.ts index f0b25d1a9..66e83885f 100644 --- a/packages/ui/certd-server/src/modules/plugin/service/default-plugin.ts +++ b/packages/ui/certd-server/src/modules/plugin/service/default-plugin.ts @@ -136,10 +136,10 @@ return class DemoTask extends AbstractTaskPlugin { export function getDefaultDnsPlugin() { const metadata = ` accessType: aliyun # 授权类型名称 -#dependPlugins: # 依赖第三方库,安装插件时会安装依赖库,尽量使用certd已安装的库,比如http、lodash-es、utils +#dependPackages: # 依赖第三方 npm 包,运行插件时会按需安装,尽量使用 certd 已安装的库,比如 http、lodash-es、utils # @alicloud/openapi-client: ^0.4.12 -#dependLibs: # 依赖的插件,应用商店安装时会先安装依赖插件 -# aliyun: * +#dependPlugins: # 依赖的其他插件,使用 type:name 格式避免不同类型插件同名;运行插件时会同时确保被依赖插件的 dependPackages +# access:aliyun: * `; diff --git a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts index 290905683..7ddd2e07c 100644 --- a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts +++ b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts @@ -13,13 +13,21 @@ import yaml from "js-yaml"; import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin } from "./default-plugin.js"; import fs from "fs"; import path from "path"; +import { RuntimeDepsService } from "../../runtime-deps/runtime-deps-service.js"; export type PluginImportReq = { content: string; override?: boolean; }; -async function importer(modulePath: string) { +function isBareModuleSpecifier(modulePath: string) { + if (modulePath.startsWith(".") || modulePath.startsWith("/") || modulePath.startsWith("file:") || modulePath.startsWith("node:")) { + return false; + } + return !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(modulePath); +} + +async function importLocalModule(modulePath: string) { if (!modulePath) { throw new Error("modules path 不能为空"); } @@ -41,6 +49,9 @@ export class PluginService extends BaseService { @Inject() builtInPluginService: BuiltInPluginService; + @Inject() + runtimeDepsService: RuntimeDepsService; + //@ts-ignore getRepository() { return this.repository; @@ -314,6 +325,16 @@ export class PluginService extends BaseService { }).outputText; } + async importer(modulePath: string) { + if (!modulePath) { + throw new Error("modules path 不能为空"); + } + if (!isBareModuleSpecifier(modulePath)) { + return await importLocalModule(modulePath); + } + return await this.runtimeDepsService.importRuntime(modulePath); + } + private async getPluginClassFromFile(item: any) { const scriptFilePath = item.scriptFilePath; const res = await import(`../../..${scriptFilePath}`); @@ -345,6 +366,7 @@ export class PluginService extends BaseService { // const script = await this.compile(plugin.content); const script = plugin.content; const getPluginClass = new AsyncFunction("_ctx", script); + const importer = this.importer.bind(this); return await getPluginClass({ logger: logger, import: importer }); } catch (e) { logger.error("编译插件失败:", e); @@ -439,6 +461,24 @@ export class PluginService extends BaseService { }); } + async getRuntimeDependencyPluginDefines() { + const builtInList = await this.getEnabledBuiltInList(); + const customList = await this.list({ + buildQuery: bq => { + bq.andWhere("type != :type", { + type: "builtIn", + }); + }, + }); + const list = [...builtInList]; + for (const plugin of customList) { + const metadata = plugin.metadata ? yaml.load(plugin.metadata) : {}; + const extra = plugin.extra ? yaml.load(plugin.extra) : {}; + list.push({ ...plugin, ...metadata, ...extra }); + } + return list.filter(item => item.dependPackages); + } + async exportPlugin(id: number) { const info = await this.info(id); if (!info) { @@ -483,6 +523,7 @@ export class PluginService extends BaseService { }; const extra = { dependPlugins: loaded.dependPlugins, + dependPackages: loaded.dependPackages, default: loaded.default, showRunStrategy: loaded.showRunStrategy, }; diff --git a/packages/ui/certd-server/src/modules/runtime-deps/npm-registry-resolver.test.ts b/packages/ui/certd-server/src/modules/runtime-deps/npm-registry-resolver.test.ts new file mode 100644 index 000000000..47c9b88b1 --- /dev/null +++ b/packages/ui/certd-server/src/modules/runtime-deps/npm-registry-resolver.test.ts @@ -0,0 +1,41 @@ +import assert from "assert"; +import { NpmRegistryResolver } from "./npm-registry-resolver.js"; + +describe("NpmRegistryResolver", () => { + it("chooses the fastest successful registry in auto mode", async () => { + const resolver = new NpmRegistryResolver(); + resolver.config = { + mode: "auto", + fixedUrl: "", + candidates: ["https://slow.example.com", "https://fast.example.com"], + probeTimeoutMs: 100, + cacheTtlMs: 1000, + }; + resolver.probe = async registryUrl => { + return { + registryUrl, + ok: true, + elapsedMs: registryUrl.includes("fast") ? 10 : 50, + }; + }; + + const result = await resolver.resolve(); + + assert.equal(result, "https://fast.example.com"); + }); + + it("uses fixed registry without probing", async () => { + const resolver = new NpmRegistryResolver(); + resolver.config = { + mode: "fixed", + fixedUrl: "https://registry.example.com", + candidates: [], + probeTimeoutMs: 100, + cacheTtlMs: 1000, + }; + + const result = await resolver.resolve(); + + assert.equal(result, "https://registry.example.com"); + }); +}); diff --git a/packages/ui/certd-server/src/modules/runtime-deps/npm-registry-resolver.ts b/packages/ui/certd-server/src/modules/runtime-deps/npm-registry-resolver.ts new file mode 100644 index 000000000..bf57d75c0 --- /dev/null +++ b/packages/ui/certd-server/src/modules/runtime-deps/npm-registry-resolver.ts @@ -0,0 +1,82 @@ +import { Config, Provide, Scope, ScopeEnum } from "@midwayjs/core"; + +export type NpmRegistryResolverConfig = { + mode: "auto" | "fixed" | "system"; + fixedUrl: string; + candidates: string[]; + probeTimeoutMs: number; + cacheTtlMs: number; +}; + +export type RegistryProbeResult = { + registryUrl: string; + ok: boolean; + elapsedMs: number; +}; + +@Provide() +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +export class NpmRegistryResolver { + @Config("runtimeDeps.registry") + config!: NpmRegistryResolverConfig; + + private cache?: { registryUrl: string; expiresAt: number }; + + async resolve(): Promise { + 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()) { + return cached.registryUrl; + } + + const candidates = (config?.candidates || []).filter(Boolean); + const probes = await Promise.allSettled(candidates.map(registryUrl => this.probe(registryUrl))); + const okList = probes + .map(item => (item.status === "fulfilled" ? item.value : null)) + .filter((item): item is RegistryProbeResult => !!item && item.ok); + + if (okList.length > 0) { + okList.sort((a, b) => a.elapsedMs - b.elapsedMs); + const best = okList[0].registryUrl; + this.cache = { + registryUrl: best, + expiresAt: Date.now() + (config?.cacheTtlMs || 0), + }; + return best; + } + + return ""; + } + + async probe(registryUrl: string): Promise { + 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, + }; + } + } +} diff --git a/packages/ui/certd-server/src/modules/runtime-deps/runtime-deps-service.test.ts b/packages/ui/certd-server/src/modules/runtime-deps/runtime-deps-service.test.ts new file mode 100644 index 000000000..f6d450dd7 --- /dev/null +++ b/packages/ui/certd-server/src/modules/runtime-deps/runtime-deps-service.test.ts @@ -0,0 +1,490 @@ +import assert from "assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { RuntimeDepsService, type RuntimeDependencyPluginDefine } from "./runtime-deps-service.js"; +import { accessRegistry, pluginRegistry } from "@certd/pipeline"; +import { addonRegistry } from "@certd/lib-server"; + +describe("RuntimeDepsService", () => { + it("detects conflicting dependency ranges across plugins", () => { + const service = new RuntimeDepsService(); + const merged = service.collectDependencies([ + { name: "a", dependPackages: { foo: "^1.0.0" } }, + { name: "b", dependPackages: { foo: "^1.2.0" } }, + ]); + + assert.deepEqual(merged.dependencies, { foo: "^1.0.0" }); + assert.equal(merged.conflicts.length, 0); + }); + + it("reports incompatible dependency ranges", () => { + const service = new RuntimeDepsService(); + const merged = service.collectDependencies([ + { name: "a", dependPackages: { foo: "^1.0.0" } }, + { name: "b", dependPackages: { foo: "^2.0.0" } }, + ]); + + assert.equal(merged.conflicts.length, 1); + assert.equal(merged.conflicts[0].packageName, "foo"); + }); + + 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(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + return "https://registry.npmmirror.com"; + }, + } 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 }; + } + assert.equal(args[0], "install"); + assert.ok(args.includes("--ignore-workspace")); + return { stdout: "", stderr: "", code: 0 }; + }, + } as any; + + const plugins: RuntimeDependencyPluginDefine[] = [{ name: "a", dependPackages: { foo: "^1.0.0" } }]; + const result = await service.ensureInstalled(plugins); + + 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(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + 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({ 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(); + service.runtimeDepsRootDir = rootDir; + service.commandRunner = { + async run() { + throw new Error("install should not run"); + }, + } as any; + + const mod = await service.importRuntime("runtime-only"); + + assert.equal(mod.value, 42); + }); + + it("installs configured lazy dependency when import target is missing", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.lazyDependencies = { + "lazy-pkg": "^1.2.3", + }; + service.registryResolver = { + async resolve() { + 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 }; + } + 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("resolves scoped package names for lazy imports", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-scoped-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.lazyDependencies = { + "@scope/lazy": "^2.0.0", + }; + service.registryResolver = { + async resolve() { + 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 }; + } + 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(); + service.runtimeDepsRootDir = rootDir; + service.lazyDependencies = {}; + + await assert.rejects(() => service.importRuntime("missing-pkg/sub.js"), /未配置懒加载版本: missing-pkg/); + }); + + it("falls back to project node_modules when lazy dependency is not configured", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-project-fallback-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.lazyDependencies = {}; + + const mod = await service.importRuntime("dayjs"); + + assert.equal(typeof mod.default, "function"); + }); + + it("falls back to project node_modules when lazy install fails", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-project-fallback-install-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.lazyDependencies = { + dayjs: "^1.11.7", + }; + service.registryResolver = { + async resolve() { + 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 }; + } + return { stdout: "", stderr: "install failed in test", code: 1 }; + }, + } as any; + + const mod = await service.importRuntime("dayjs"); + + assert.equal(typeof mod.default, "function"); + }); + + it("keeps previously installed dependencies when installing a later plugin", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-merge-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + 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.ensureInstalled([{ name: "a", pluginType: "deploy", dependPackages: { foo: "^1.0.0" } }]); + await service.ensureInstalled([{ name: "b", pluginType: "deploy", dependPackages: { 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("includes npm dependencies from dependent plugins", () => { + const service = new RuntimeDepsService(); + accessRegistry.register("runtimeDepsAccess", { + define: { name: "runtimeDepsAccess", title: "access", dependPackages: { accessOnly: "^1.0.0" } } as any, + target: async () => ({} as any), + }); + try { + const resolved = service.resolvePluginDependencies({ + name: "deploy", + pluginType: "deploy", + dependPlugins: { "access:runtimeDepsAccess": "*" }, + dependPackages: { deployOnly: "^1.0.0" }, + }); + const merged = service.collectDependencies(resolved); + + assert.deepEqual(merged.dependencies, { + deployOnly: "^1.0.0", + accessOnly: "^1.0.0", + }); + } finally { + accessRegistry.unRegister("runtimeDepsAccess"); + } + }); + + it("installs dependencies by registered plugin key", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-key-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + 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; + pluginRegistry.register("runtimeDepsKey", { + define: { name: "runtimeDepsKey", title: "key", dependPackages: { keyed: "^1.0.0" } } as any, + target: async () => ({} as any), + }); + try { + await service.ensureRuntimeDependencies("plugin:runtimeDepsKey"); + + const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8")); + assert.deepEqual(manifest.dependencies, { keyed: "^1.0.0" }); + } finally { + pluginRegistry.unRegister("runtimeDepsKey"); + } + }); + + it("installs dependencies from multiple plugin keys including addon subtype keys", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-keys-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + 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; + accessRegistry.register("runtimeDepsArrayAccess", { + define: { name: "runtimeDepsArrayAccess", title: "access", dependPackages: { accessPkg: "^1.0.0" } } as any, + target: async () => ({} as any), + }); + addonRegistry.register("captcha:runtimeDepsArrayAddon", { + define: { addonType: "captcha", name: "runtimeDepsArrayAddon", title: "addon", dependPackages: { addonPkg: "^2.0.0" } } as any, + target: async () => ({} as any), + }); + try { + await service.ensureRuntimeDependencies(["access:runtimeDepsArrayAccess", "addon:captcha:runtimeDepsArrayAddon"]); + + const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8")); + assert.deepEqual(manifest.dependencies, { + accessPkg: "^1.0.0", + addonPkg: "^2.0.0", + }); + } finally { + accessRegistry.unRegister("runtimeDepsArrayAccess"); + addonRegistry.unRegister("captcha:runtimeDepsArrayAddon"); + } + }); + + it("reports missing dependent plugins", () => { + const service = new RuntimeDepsService(); + + assert.throws(() => service.resolvePluginDependencies({ name: "deploy", pluginType: "deploy", dependPlugins: { "access:access": "*" } }), /插件依赖缺失/); + }); + + it("reports incompatible dependent plugin versions", () => { + const service = new RuntimeDepsService(); + accessRegistry.register("runtimeDepsVersionedAccess", { + define: { name: "runtimeDepsVersionedAccess", title: "access", version: "1.4.0", dependPackages: { accessOnly: "^1.0.0" } } as any, + target: async () => ({} as any), + }); + try { + assert.throws( + () => + service.resolvePluginDependencies({ + name: "deploy", + pluginType: "deploy", + dependPlugins: { "access:runtimeDepsVersionedAccess": "^2.0.0" }, + }), + /插件依赖版本冲突/ + ); + } finally { + accessRegistry.unRegister("runtimeDepsVersionedAccess"); + } + }); + + it("reports bare dependent plugin names as invalid format", () => { + const service = new RuntimeDepsService(); + + assert.throws( + () => service.resolvePluginDependencies({ name: "deploy", pluginType: "deploy", dependPlugins: { runtimeDepsBareName: "*" } }), + /插件依赖格式错误/ + ); + }); + + it("records runtime install environment state", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-state-")); + const service = new RuntimeDepsService(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + 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 }; + } + assert.equal(args[0], "install"); + return { stdout: "", stderr: "", code: 0 }; + }, + } as any; + + await service.ensureInstalled([{ name: "a", dependPackages: { foo: "^1.0.0" } }]); + + const state = JSON.parse(fs.readFileSync(path.join(rootDir, "install-state.json"), "utf8")); + assert.equal(state.nodeVersion, process.version); + assert.equal(state.pnpmVersion, "9.1.0"); + assert.equal(state.lastError, undefined); + }); + + it("serializes installs with a file lock", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lock-")); + const serviceA = new RuntimeDepsService(); + const serviceB = new RuntimeDepsService(); + for (const service of [serviceA, serviceB]) { + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; + } + let installCount = 0; + const commandRunner = { + async run(command: string, args: string[]) { + assert.equal(command, "pnpm"); + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } + assert.equal(args[0], "install"); + installCount++; + await new Promise(resolve => setTimeout(resolve, 50)); + fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true }); + return { stdout: "", stderr: "", code: 0 }; + }, + }; + serviceA.commandRunner = commandRunner as any; + serviceB.commandRunner = commandRunner as any; + + await Promise.all([ + serviceA.ensureInstalled([{ name: "a", dependPackages: { foo: "^1.0.0" } }]), + serviceB.ensureInstalled([{ name: "a", dependPackages: { foo: "^1.0.0" } }]), + ]); + + assert.equal(installCount, 1); + }); + + 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(); + service.runtimeDepsRootDir = rootDir; + service.registryResolver = { + async resolve() { + 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"); + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + } as any; + + await service.ensureInstalled([{ name: "a", dependPackages: { 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; + } + } + }); +}); diff --git a/packages/ui/certd-server/src/modules/runtime-deps/runtime-deps-service.ts b/packages/ui/certd-server/src/modules/runtime-deps/runtime-deps-service.ts new file mode 100644 index 000000000..68d5ee5ae --- /dev/null +++ b/packages/ui/certd-server/src/modules/runtime-deps/runtime-deps-service.ts @@ -0,0 +1,618 @@ +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import crypto from "crypto"; +import { Config, Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core"; +import { createRequire } from "module"; +import { pathToFileURL } from "url"; +import { NpmRegistryResolver } from "./npm-registry-resolver.js"; +import { Registry, accessRegistry, notificationRegistry, pluginRegistry } from "@certd/pipeline"; +import { dnsProviderRegistry } from "@certd/plugin-lib"; +import { addonRegistry } from "@certd/lib-server"; + +export type RuntimeDependencyPluginDefine = { + name: string; + key?: string; + title?: string; + version?: string; + pluginType?: string; + addonType?: string; + dependPlugins?: Record; + dependPackages?: Record; +}; + +type RegisteredDefineLike = RuntimeDependencyPluginDefine & { + key?: string; + pluginType?: string; + addonType?: string; + dependPlugins?: Record; + dependPackages?: Record; +}; + +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]; +} + +type DependencyConflict = { + packageName: string; + ranges: Array<{ pluginName: string; range: string }>; +}; + +type CollectDependenciesResult = { + dependencies: Record; + conflicts: DependencyConflict[]; +}; + +type InstallResult = { + registryUrl: string; + packageJsonPath: string; +}; + +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; +}; + +const PROCESS_LOCKS = new Map>(); + +class DefaultCommandRunner implements CommandRunner { + async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise { + return await new Promise(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 }); + }); + }); + } +} + +@Provide() +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +export class RuntimeDepsService { + @Config("runtimeDeps.rootDir") + runtimeDepsRootDir = "./data/.runtime-deps"; + + @Config("runtimeDeps.autoInstall") + autoInstall = true; + + @Config("runtimeDeps.enabled") + enabled = true; + + @Config("runtimeDeps.installTimeoutMs") + installTimeoutMs = 120000; + + @Config("runtimeDeps.pnpmCommand") + pnpmCommand = ""; + + @Config("runtimeDeps.lazyDependencies") + lazyDependencies: Record = {}; + + @Inject() + registryResolver!: NpmRegistryResolver; + + commandRunner: CommandRunner = new DefaultCommandRunner(); + + private installPromises = new Map>(); + + collectDependencies(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult { + const merged: Record = {}; + const seen: Record> = {}; + + for (const plugin of plugins) { + const deps = plugin.dependPackages || {}; + for (const [packageName, range] of Object.entries(deps)) { + seen[packageName] ||= []; + seen[packageName].push({ pluginName: plugin.name, range }); + } + } + + const conflicts: DependencyConflict[] = []; + for (const [packageName, ranges] of Object.entries(seen)) { + const first = ranges[0]?.range; + if (!first) { + continue; + } + const conflict = ranges.some(item => !areRangesCompatible(first, item.range)); + if (conflict) { + conflicts.push({ packageName, ranges }); + continue; + } + merged[packageName] = first; + } + + return { dependencies: merged, conflicts }; + } + + async ensureInstalled(plugins: RuntimeDependencyPluginDefine[]): Promise { + const { dependencies, conflicts } = this.resolveDependenciesFromPlugins(plugins); + if (conflicts.length > 0) { + const conflict = conflicts[0]; + throw new Error( + `动态依赖版本冲突: ${conflict.packageName} => ${conflict.ranges.map(item => `${item.pluginName}:${item.range}`).join(", ")}` + ); + } + return await this.ensureDependencies(dependencies); + } + + async ensureDependencies(dependencies: Record): Promise { + if (!this.enabled) { + return { + registryUrl: "", + packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json"), + }; + } + if (!this.autoInstall) { + return { + registryUrl: "", + packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json"), + }; + } + const dependenciesHash = this.createDependenciesHash(dependencies); + let installPromise = this.installPromises.get(dependenciesHash); + if (!installPromise) { + installPromise = this.doEnsureInstalled(dependencies).catch(error => { + this.installPromises.delete(dependenciesHash); + throw error; + }); + this.installPromises.set(dependenciesHash, installPromise); + } + return await installPromise; + } + + resolveDependenciesFromPlugins(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult { + const expandedPlugins = plugins.flatMap(plugin => this.resolvePluginDependencies(plugin)); + return this.collectDependencies(expandedPlugins); + } + + async ensureRuntimeDependencies(pluginKeys: string | string[]): Promise { + const keys = Array.isArray(pluginKeys) ? pluginKeys : [pluginKeys]; + const pluginDefines = keys.map(pluginKey => this.getDefineByPluginKey(pluginKey)); + if (pluginDefines.every(pluginDefine => !pluginDefine.dependPackages && !pluginDefine.dependPlugins)) { + return { + registryUrl: "", + packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json"), + }; + } + const expandedPluginDefines = pluginDefines.flatMap(pluginDefine => this.resolvePluginDependencies(pluginDefine)); + return await this.ensureInstalled(expandedPluginDefines); + } + + private async doEnsureInstalled(dependencies: Record): Promise { + return await this.withInstallLock(async () => { + const rootDir = this.getRuntimeDepsRootDir(); + const packageJsonPath = path.join(rootDir, "package.json"); + const lockPath = path.join(rootDir, "pnpm-lock.yaml"); + dependencies = this.mergeInstalledDependencies(this.readManifestDependencies(packageJsonPath), dependencies); + const dependenciesHash = this.createDependenciesHash(dependencies); + const statePath = path.join(rootDir, "install-state.json"); + const currentState = this.readInstallState(statePath); + if (currentState?.dependenciesHash === dependenciesHash && fs.existsSync(path.join(rootDir, "node_modules"))) { + return { registryUrl: currentState.registryUrl || "", packageJsonPath }; + } + const manifest = { + name: "certd-runtime-deps", + private: true, + type: "module", + dependencies, + }; + fs.writeFileSync(packageJsonPath, JSON.stringify(manifest, null, 2), "utf8"); + + const registryUrl = await this.registryResolver.resolve(); + const env = this.buildChildEnv(registryUrl); + const command = this.getPnpmCommand(); + const pnpmVersion = await this.getPnpmVersion(command, env); + const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--reporter=append-only"]; + if (registryUrl) { + args.push(`--registry=${registryUrl}`); + } + const result = await this.commandRunner.run(command, args, { + cwd: rootDir, + timeoutMs: this.installTimeoutMs, + env, + }); + if (result.code !== 0) { + const message = result.stderr || result.stdout || "unknown error"; + this.writeInstallState(statePath, { + ...currentState, + installedAt: currentState?.installedAt, + failedAt: new Date().toISOString(), + registryUrl, + dependenciesHash, + nodeVersion: process.version, + pnpmVersion, + lockFileExists: fs.existsSync(lockPath), + lastError: message, + }); + throw new Error(`动态依赖安装失败: ${message}`); + } + this.writeInstallState(statePath, { + installedAt: new Date().toISOString(), + registryUrl, + dependenciesHash, + nodeVersion: process.version, + pnpmVersion, + lockFileExists: fs.existsSync(lockPath), + }); + return { registryUrl, packageJsonPath }; + }); + } + + async importRuntime(specifier: string) { + if (this.isNativeImportSpecifier(specifier)) { + return await import(specifier); + } + + const resolved = await this.resolveImportSpecifier(specifier); + return await import(pathToFileURL(resolved).href); + } + + private async resolveImportSpecifier(specifier: string) { + try { + return this.resolveRuntimeSpecifier(specifier).resolved; + } catch (runtimeError: any) { + if (!this.isModuleNotFoundError(runtimeError)) { + throw runtimeError; + } + return await this.resolveMissingRuntimeSpecifier(specifier, runtimeError); + } + } + + private async resolveMissingRuntimeSpecifier(specifier: string, runtimeError: any) { + const packageName = this.parsePackageName(specifier); + const lazyRange = this.lazyDependencies?.[packageName]; + if (!lazyRange) { + try { + return this.resolveProjectSpecifier(specifier, runtimeError).resolved; + } catch { + throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`); + } + } + try { + await this.ensureLazyDependency(packageName); + return this.resolveRuntimeSpecifier(specifier).resolved; + } catch (lazyError: any) { + return this.resolveProjectSpecifier(specifier, lazyError).resolved; + } + } + + private isNativeImportSpecifier(specifier: string) { + return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:") || specifier.startsWith("node:"); + } + + 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, cause?: any): RuntimeImportResolveResult { + try { + const packageName = this.parsePackageName(specifier); + const packageJsonPath = path.resolve("package.json"); + const require = createRequire(packageJsonPath); + const resolved = require.resolve(specifier); + return { packageName, resolved }; + } catch (projectError: any) { + if (cause) { + projectError.cause = cause; + } + throw projectError; + } + } + + 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 async ensureLazyDependency(packageName: string) { + const range = this.lazyDependencies?.[packageName]; + if (!range) { + throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`); + } + const dependencies = { + [packageName]: range, + }; + await this.ensureDependencies(dependencies); + } + + private isModuleNotFoundError(error: any) { + return error?.code === "MODULE_NOT_FOUND" || error?.code === "ERR_MODULE_NOT_FOUND"; + } + + resolvePluginDependencies(current: RuntimeDependencyPluginDefine): RuntimeDependencyPluginDefine[] { + const resolved: RuntimeDependencyPluginDefine[] = []; + const visited = new Set(); + + const visit = (item: RuntimeDependencyPluginDefine) => { + const key = this.buildPluginDependencyKey(item); + if (visited.has(key)) { + return; + } + visited.add(key); + resolved.push(item); + for (const [dependencyName, expectedRange] of Object.entries(item.dependPlugins || {})) { + const dependency = this.getDefineByPluginKey(dependencyName, item); + if (!isPluginVersionCompatible(dependency, expectedRange)) { + throw new Error(`插件依赖版本冲突: ${item.name} 依赖 ${dependencyName}@${expectedRange},当前版本为 ${dependency.version || "未声明"}`); + } + visit(dependency); + } + }; + + visit(current); + return resolved; + } + + private buildPluginDependencyKey(plugin: RuntimeDependencyPluginDefine) { + if (plugin.pluginType === "addon" && plugin.addonType) { + return `addon:${plugin.addonType}:${plugin.name}`; + } + const pluginType = plugin.pluginType === "deploy" ? "plugin" : plugin.pluginType || "unknown"; + return `${pluginType}:${plugin.name}`; + } + + private getDefineByPluginKey(pluginKey: string, owner?: RuntimeDependencyPluginDefine): RuntimeDependencyPluginDefine { + const parts = pluginKey.split(":"); + const [pluginType, subtype, name] = parts; + if (parts.length < 2 || (pluginType === "addon" && parts.length !== 3) || (pluginType !== "addon" && parts.length !== 2)) { + const ownerName = owner?.name || pluginKey; + throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey},请使用 plugin:name、access:name、notification:name、dnsProvider:name 或 addon:subtype:name 格式`); + } + const registryMap: Record; key: string; pluginType: string; addonType?: string }> = { + plugin: { registry: pluginRegistry, key: subtype, pluginType: "plugin" }, + access: { registry: accessRegistry, key: subtype, pluginType: "access" }, + notification: { registry: notificationRegistry, key: subtype, pluginType: "notification" }, + dnsProvider: { registry: dnsProviderRegistry, key: subtype, pluginType: "dnsProvider" }, + addon: { registry: addonRegistry, key: `${subtype}:${name}`, pluginType: "addon", addonType: subtype }, + }; + const target = registryMap[pluginType]; + if (!target) { + const ownerName = owner?.name || pluginKey; + throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey},未知插件类型 ${pluginType}`); + } + const define = target.registry.getDefine(target.key) as RegisteredDefineLike; + if (!define) { + throw new Error(`插件依赖缺失: ${owner?.name || pluginKey} 依赖 ${pluginKey},但该插件未注册或已禁用`); + } + return { ...define, key: pluginKey, pluginType: target.pluginType, addonType: target.addonType }; + } + + private async withInstallLock(run: () => Promise): Promise { + const rootDir = this.getRuntimeDepsRootDir(); + fs.mkdirSync(rootDir, { recursive: true }); + const lockFile = path.join(rootDir, ".install.lock"); + const previous = PROCESS_LOCKS.get(lockFile); + if (previous) { + await previous.catch(() => undefined); + } + let releaseProcessLock!: () => void; + const current = new Promise(resolve => { + releaseProcessLock = resolve; + }); + PROCESS_LOCKS.set(lockFile, current); + let fd: number | undefined; + try { + fd = await this.acquireFileLock(lockFile); + return await run(); + } finally { + if (fd != null) { + fs.closeSync(fd); + fs.rmSync(lockFile, { force: true }); + } + releaseProcessLock(); + if (PROCESS_LOCKS.get(lockFile) === current) { + PROCESS_LOCKS.delete(lockFile); + } + } + } + + private async acquireFileLock(lockFile: string) { + const deadline = Date.now() + this.installTimeoutMs; + while (true) { + try { + const fd = fs.openSync(lockFile, "wx"); + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), "utf8"); + return fd; + } catch (error: any) { + if (error?.code !== "EEXIST") { + throw error; + } + if (Date.now() > deadline) { + throw new Error(`动态依赖安装锁等待超时: ${lockFile}`); + } + await this.waitForExternalLock(lockFile, deadline); + } + } + } + + private async waitForExternalLock(lockFile: string, deadline: number) { + while (fs.existsSync(lockFile)) { + if (Date.now() > deadline) { + throw new Error(`动态依赖安装锁等待超时: ${lockFile}`); + } + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + private readInstallState(statePath: string): any { + if (!fs.existsSync(statePath)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(statePath, "utf8")); + } catch { + return null; + } + } + + private writeInstallState(statePath: string, state: any) { + fs.writeFileSync(statePath, JSON.stringify(state, null, 2), "utf8"); + } + + private readManifestDependencies(packageJsonPath: string): Record { + if (!fs.existsSync(packageJsonPath)) { + return {}; + } + try { + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return manifest.dependencies || {}; + } catch { + return {}; + } + } + + private mergeInstalledDependencies(installed: Record, requested: Record) { + 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 async getPnpmVersion(command: string, env: NodeJS.ProcessEnv) { + const rootDir = this.getRuntimeDepsRootDir(); + const result = await this.commandRunner.run(command, ["--version"], { + cwd: rootDir, + timeoutMs: Math.min(this.installTimeoutMs, 10000), + env, + }); + if (result.code !== 0) { + return ""; + } + return (result.stdout || result.stderr || "").trim(); + } + + private getPnpmCommand() { + if (this.pnpmCommand) { + return this.pnpmCommand; + } + return "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(" "); + } + + private getRuntimeDepsRootDir() { + return path.resolve(this.runtimeDepsRootDir); + } + + private createDependenciesHash(dependencies: Record) { + return crypto.createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"); + } +} + +function isPluginVersionCompatible(plugin: RuntimeDependencyPluginDefine, expectedRange: string) { + if (!expectedRange || expectedRange === "*") { + return true; + } + if (!plugin.version) { + return false; + } + return areRangesCompatible(expectedRange, plugin.version); +} diff --git a/packages/ui/certd-server/src/plugins/plugin-captcha/tencent/index.ts b/packages/ui/certd-server/src/plugins/plugin-captcha/tencent/index.ts index 41b976eb6..0e844a36a 100644 --- a/packages/ui/certd-server/src/plugins/plugin-captcha/tencent/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-captcha/tencent/index.ts @@ -8,6 +8,9 @@ import { TencentAccess } from "../../plugin-lib/tencent/access.js"; title: "腾讯云验证码", desc: "", showTest: false, + dependPackages: { + "tencentcloud-sdk-nodejs": "^4.1.112", + }, }) export class TencentCaptcha extends BaseAddon implements ICaptchaAddon { @AddonInput({ @@ -50,7 +53,7 @@ export class TencentCaptcha extends BaseAddon implements ICaptchaAddon { const access = await this.getAccess(this.accessId); - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/captcha/v20190722/index.js"); + const sdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/captcha/v20190722/index.js"); const CaptchaClient = sdk.v20190722.Client; diff --git a/packages/ui/certd-server/src/plugins/plugin-lib/tencent/access.ts b/packages/ui/certd-server/src/plugins/plugin-lib/tencent/access.ts index 515c04b01..3430e7ea3 100644 --- a/packages/ui/certd-server/src/plugins/plugin-lib/tencent/access.ts +++ b/packages/ui/certd-server/src/plugins/plugin-lib/tencent/access.ts @@ -1,11 +1,16 @@ import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline"; -@IsAccess({ +const tencentAccessDefine: any = { name: "tencent", title: "腾讯云", icon: "svg:icon-tencentcloud", order: 0, -}) + dependPackages: { + "tencentcloud-sdk-nodejs": "^4.1.112", + }, +}; + +@IsAccess(tencentAccessDefine) export class TencentAccess extends BaseAccess { @AccessInput({ title: "secretId", @@ -104,7 +109,7 @@ export class TencentAccess extends BaseAccess { } async getStsClient() { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/sts/v20180813/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/sts/v20180813/index.js"); const StsClient = sdk.v20180813.Client; const clientConfig = { diff --git a/packages/ui/certd-server/src/plugins/plugin-lib/tencent/lib/ssl-client.ts b/packages/ui/certd-server/src/plugins/plugin-lib/tencent/lib/ssl-client.ts index dada84408..3c69305c6 100644 --- a/packages/ui/certd-server/src/plugins/plugin-lib/tencent/lib/ssl-client.ts +++ b/packages/ui/certd-server/src/plugins/plugin-lib/tencent/lib/ssl-client.ts @@ -15,7 +15,7 @@ export class TencentSslClient { this.region = opts.region; } async getSslClient(): Promise { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js"); + const sdk = await this.access.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js"); const SslClient = sdk.v20191205.Client; const clientConfig = { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/tencent-dns-provider.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/tencent-dns-provider.ts index 07d50744f..090b214e7 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/tencent-dns-provider.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/tencent-dns-provider.ts @@ -2,13 +2,18 @@ import { AbstractDnsProvider, CreateRecordOptions, DnsResolveRecord, DomainRecor import { TencentAccess } from "../../plugin-lib/tencent/index.js"; import { Pager, PageRes, PageSearch } from "@certd/pipeline"; -@IsDnsProvider({ +const tencentDnsProviderDefine: any = { name: "tencent", title: "腾讯云", desc: "腾讯云域名DNS解析提供者", accessType: "tencent", icon: "svg:icon-tencentcloud", -}) + dependPlugins: { + "access:tencent": "*", + }, +}; + +@IsDnsProvider(tencentDnsProviderDefine) export class TencentDnsProvider extends AbstractDnsProvider { access!: TencentAccess; @@ -27,7 +32,7 @@ export class TencentDnsProvider extends AbstractDnsProvider { }, }, }; - const dnspodSdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/dnspod/v20210323/index.js"); + const dnspodSdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/dnspod/v20210323/index.js"); const DnspodClient = dnspodSdk.v20210323.Client; // 实例化要请求产品的client对象,clientProfile是可选的 this.client = new DnspodClient(clientConfig); diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/teo-dns-provider.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/teo-dns-provider.ts index bf7be1202..61912c3c9 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/teo-dns-provider.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/dns-provider/teo-dns-provider.ts @@ -7,6 +7,9 @@ import { TencentAccess } from "../../plugin-lib/tencent/access.js"; desc: "腾讯云EO DNS解析提供者", accessType: "tencent", icon: "svg:icon-tencentcloud", + dependPlugins: { + "access:tencent": "*", + }, }) export class TencentEoDnsProvider extends AbstractDnsProvider { access!: TencentAccess; @@ -24,7 +27,7 @@ export class TencentEoDnsProvider extends AbstractDnsProvider { }, }, }; - const teosdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/teo/v20220901/index.js"); + const teosdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/teo/v20220901/index.js"); const TeoClient = teosdk.v20220901.Client; // 实例化要请求产品的client对象,clientProfile是可选的 this.client = new TeoClient(clientConfig); diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/delete-expiring-cert/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/delete-expiring-cert/index.ts index 25570003b..456d9d568 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/delete-expiring-cert/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/delete-expiring-cert/index.ts @@ -10,6 +10,9 @@ import { TencentAccess, TencentSslClient } from "../../../plugin-lib/tencent/ind icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "仅删除未使用的证书", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.AlwaysRun, diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-all/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-all/index.ts index e3d603c30..17f823c5c 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-all/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-all/index.ts @@ -9,6 +9,9 @@ import { TencentSslClient } from "../../../plugin-lib/tencent/index.js"; icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "支持负载均衡、CDN、DDoS、直播、点播、Web应用防火墙、API网关、TEO、容器服务、对象存储、轻应用服务器、云原生微服务、云开发", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -108,7 +111,7 @@ export class DeployCertToTencentAll extends AbstractTaskPlugin { async execute(): Promise { const access = await this.getAccess(this.accessId); - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js"); const Client = sdk.v20191205.Client; const client = new Client({ credential: { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn-v2/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn-v2/index.ts index 3ae9763f1..1178c27b1 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn-v2/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn-v2/index.ts @@ -9,6 +9,9 @@ import { CertApplyPluginNames } from "@certd/plugin-cert"; icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "推荐使用,支持CDN域名以及COS加速域名", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -29,7 +32,6 @@ export class TencentDeployCertToCDNv2 extends AbstractTaskPlugin { @TaskInput(createCertDomainGetterInputDefine({ props: { required: false } })) certDomains!: string[]; - @TaskInput({ title: "Access提供者", helper: "access 授权", @@ -89,7 +91,7 @@ export class TencentDeployCertToCDNv2 extends AbstractTaskPlugin { async getCdnClient() { const accessProvider = await this.getAccess(this.accessId); - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/cdn/v20180606/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/cdn/v20180606/index.js"); const CdnClient = sdk.v20180606.Client; const clientConfig = { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn/index.ts index 67a13793f..470bbad85 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cdn/index.ts @@ -8,6 +8,9 @@ import { CertApplyPluginNames } from "@certd/plugin-cert"; icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "已废弃,请使用v2版", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -63,7 +66,7 @@ export class DeployToCdnPlugin extends AbstractTaskPlugin { Client: any; async onInstance() { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/cdn/v20180606/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/cdn/v20180606/index.js"); this.Client = sdk.v20180606.Client; } diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-clb/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-clb/index.ts index 933e0e558..5a49d739d 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-clb/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-clb/index.ts @@ -8,6 +8,9 @@ import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert"; icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "暂时只支持单向认证证书,暂时只支持通用负载均衡", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -106,7 +109,7 @@ export class DeployCertToTencentCLB extends AbstractTaskPlugin { } async getClient() { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/clb/v20180317/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/clb/v20180317/index.js"); const ClbClient = sdk.v20180317.Client; const accessProvider = (await this.getAccess(this.accessId)) as TencentAccess; diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cos/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cos/index.ts index 9321a7379..ab84df6f2 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cos/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-cos/index.ts @@ -3,19 +3,28 @@ import { CertInfo } from "@certd/plugin-cert"; import { createRemoteSelectInputDefine } from "@certd/plugin-lib"; import { TencentSslClient } from "../../../plugin-lib/tencent/index.js"; import { CertApplyPluginNames } from "@certd/plugin-cert"; -@IsTaskPlugin({ + +const deployCertToTencentCosDefine: any = { name: "DeployCertToTencentCosPlugin", title: "腾讯云-部署证书到COS", needPlus: false, icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "部署到腾讯云COS源站域名证书,注意是源站域名,加速域名请使用腾讯云CDN v2插件【注意:很不稳定,需要重试很多次偶尔才能成功一次】", + dependPlugins: { + "access:tencent": "*", + }, + dependPackages: { + "cos-nodejs-sdk-v5": "^2.14.6", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, }, }, -}) +}; + +@IsTaskPlugin(deployCertToTencentCosDefine) export class DeployCertToTencentCosPlugin extends AbstractTaskPlugin { /** * AccessProvider的id @@ -133,7 +142,7 @@ export class DeployCertToTencentCosPlugin extends AbstractTaskPlugin { async onGetDomainList(data: any) { const access = await this.getAccess(this.accessId); - const cosv5 = await import("cos-nodejs-sdk-v5"); + const cosv5 = await (this as any).importRuntime("cos-nodejs-sdk-v5"); const cos = new cosv5.default({ SecretId: access.secretId, SecretKey: access.secretKey, diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-eo/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-eo/index.ts index 6aca80027..6cea6e45f 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-eo/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-eo/index.ts @@ -11,6 +11,9 @@ import { TencentSslClient } from "../../../plugin-lib/tencent/index.js"; icon: "svg:icon-tencentcloud", desc: "腾讯云边缘安全加速平台EdgeOne(EO)", group: pluginGroups.tencent.key, + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -85,7 +88,7 @@ export class DeployCertToTencentEO extends AbstractTaskPlugin { Client: any; async onInstance() { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/teo/v20220901/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/teo/v20220901/index.js"); this.Client = sdk.v20220901.Client; } diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-live/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-live/index.ts index 1adb85a52..4d11fc8c2 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-live/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-live/index.ts @@ -11,6 +11,9 @@ import { TencentSslClient } from "../../../plugin-lib/tencent/index.js"; desc: "https://console.cloud.tencent.com/live/", group: pluginGroups.tencent.key, needPlus: false, + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -92,7 +95,7 @@ export class TencentDeployCertToLive extends AbstractTaskPlugin { async getLiveClient() { const accessProvider = await this.getAccess(this.accessId); - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/live/v20180801/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/live/v20180801/index.js"); const CssClient = sdk.v20180801.Client; const clientConfig = { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-tke-ingress/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-tke-ingress/index.ts index 13ca15940..8deadbfba 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-tke-ingress/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/deploy-to-tke-ingress/index.ts @@ -11,6 +11,9 @@ import yaml from "js-yaml"; icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "修改TKE集群密钥配置,支持Opaque和TLS证书类型。注意:\n1. serverless集群请使用K8S部署插件;\n2. Opaque类型需要【上传到腾讯云】作为前置任务;\n3. ApiServer需要开通公网访问(或者certd可访问),实际上底层仍然是通过KubeClient进行部署", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, @@ -203,7 +206,7 @@ export class DeployCertToTencentTKEIngressPlugin extends AbstractTaskPlugin { } async getTkeClient(accessProvider: any, region = "ap-guangzhou") { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/tke/v20180525/index.js"); + const sdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/tke/v20180525/index.js"); const TkeClient = sdk.v20180525.Client; const clientConfig = { credential: { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/refresh-cert/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/refresh-cert/index.ts index 237ac19d8..74800557e 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/refresh-cert/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/refresh-cert/index.ts @@ -14,6 +14,9 @@ import { omit } from "lodash-es"; group: pluginGroups.tencent.key, needPlus: false, deprecated: "腾讯更新证书(Id不变)接口已失效,本插件已下架,请使用其他接口", + dependPlugins: { + "access:tencent": "*", + }, default: { //默认值配置照抄即可 strategy: { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/start-instances/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/start-instances/index.ts index c980fc069..62e88bb5c 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/start-instances/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/start-instances/index.ts @@ -8,6 +8,9 @@ import { TencentAccess } from "../../../plugin-lib/tencent/access.js"; icon: "svg:icon-tencentcloud", group: pluginGroups.tencent.key, desc: "腾讯云实例开关机", + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.AlwaysRun, @@ -137,7 +140,7 @@ export class TencentActionInstancesPlugin extends AbstractTaskPlugin { async getCvmClient() { const accessProvider = await this.getAccess(this.accessId); - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/cvm/v20170312/index.js"); + const sdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/cvm/v20170312/index.js"); const CvmClient = sdk.v20170312.Client; if (!this.region) { diff --git a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/upload-to-tencent/index.ts b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/upload-to-tencent/index.ts index c8f5ed284..03b25bc1a 100644 --- a/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/upload-to-tencent/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-tencent/plugin/upload-to-tencent/index.ts @@ -3,18 +3,23 @@ import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert"; import { TencentAccess } from "../../../plugin-lib/tencent/access.js"; import { TencentSslClient } from "../../../plugin-lib/tencent/index.js"; -@IsTaskPlugin({ +const uploadCertToTencentDefine: any = { name: "UploadCertToTencent", title: "腾讯云-上传证书到腾讯云", icon: "svg:icon-tencentcloud", desc: "上传成功后输出:tencentCertId", group: pluginGroups.tencent.key, + dependPlugins: { + "access:tencent": "*", + }, default: { strategy: { runStrategy: RunStrategy.SkipWhenSucceed, }, }, -}) +}; + +@IsTaskPlugin(uploadCertToTencentDefine) export class UploadCertToTencent extends AbstractTaskPlugin { // @TaskInput({ title: '证书名称' }) // name!: string; @@ -48,7 +53,7 @@ export class UploadCertToTencent extends AbstractTaskPlugin { Client: any; async onInstance() { - const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js"); + const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js"); this.Client = sdk.v20191205.Client; } diff --git a/popularize/agents.md b/popularize/agents.md deleted file mode 100644 index d11012d21..000000000 --- a/popularize/agents.md +++ /dev/null @@ -1,129 +0,0 @@ -# Certd 推广 Agent 常驻上下文 - -本文档是给在 `popularize/` 目录工作的推广 Agent 看的常驻说明。进入目录后先读本文,再按任务读取 `task.md` 和对应日期的报告,避免每次重新理解推广规则。 - -## 角色定位 - -你是 Certd 的推广 Agent,名字叫"善推广"。你的身份底色是:做过开源项目管理、写过代码、推过产品的技术型推广者。 - -风格基调: -- 逻辑严谨,每一步判断都有依据 -- 善于洞察用户需求,不只听表面诉求 -- 站在用户角度思考,不讲技术黑话自嗨 -- 说话简洁直接,先给结论再给论据 -- 善用结构化方式让信息一目了然 -- 有专业深度但不端架子,该纠正就纠正,绝不编造事实 - -## 项目认知 - -Certd 是支持私有化部署的 SSL/TLS 证书自动化管理平台,核心产品模型是"证书流水线": - -- 通过 ACME 申请证书 -- 使用 DNS-01、HTTP-01、CNAME 代理或服务商集成完成域名验证 -- 将证书转换或导出为 pem、pfx、der、jks、p7b 等格式 -- 部署到主机、Nginx、Kubernetes、CDN、云厂商、面板等 110+ 目标 -- 通知用户,并监控站点证书过期时间 - -核心卖点: -- 首创流水线申请部署证书模式 -- 110+ 部署插件,覆盖主流云厂商和面板 -- 私有化部署,数据保存本地 -- 流水线数量无限制,证书申请无限制 -- 多格式转换、多目标部署、站点监控告警一体化 - -目标用户: -- 个人开发者(1-5 个域名,厌倦手动续期) -- 中小企业运维(多域名、多云厂商、到期风险高) -- 云厂商重度用户(阿里云/腾讯云全套产品,重复上传证书) -- NAS/面板用户(群晖、宝塔、1Panel,面板自带功能弱) -- SaaS/多租户企业(需要商用授权、品牌定制) - -## 必读索引 - -- `task.md`:每日推广任务的具体执行规范 -- `reports/YYYY-MM-DD-report.md`:历史推广报告,用于避免同一平台 7 天内重复推荐 -- 根目录 `AGENTS.md`:仓库全局规则、技术架构、开发边界 -- 根目录 `README.md`:产品特性、部署方式、版本对比 - -## 推广工作区边界 - -`popularize/` 目录是推广专用工作区: - -- `task.md`:推广任务规范 -- `agents.md`:本文档,推广 Agent 常驻上下文 -- `reports/`:每日推广候选报告存放目录 - -报告文件命名格式:`YYYY-MM-DD-report.md` - -## 硬性规则 - -- 每个候选链接必须通过浏览器打开验证为真实页面后再写入报告,禁止编造 URL -- 同一站点/平台 7 天内不要重复推荐或发布 -- 评论口吻像真实工程使用经验,不夸大,不硬广,不虚构未确认的使用经历 -- 如需登录、评论或发布,只准备页面和草稿,必须等用户明确确认后再提交,不要自动发布 -- 如果确实无法搜索到任何有效内容,在报告第一行写"本日无有效候选:所有目标站点不可达",不编造任何内容 -- V2EX、Google 被墙,需要通过代理访问(代理地址:http://127.0.0.1:10811) -- 中文站点优先 - -## 推广话术原则 - -1. **锚定痛点**:证书 90 天(即将 47 天)过期、多云厂商手动管理、到期忘记续签导致服务中断 -2. **差异化定位**:不是命令行工具,是"带 Web 管理台的证书 DevOps 平台" -3. **话术公式**:先共情痛点 → 给出具体解法(流水线自动化)→ 强调私有化安全/插件生态 → 自然提及 Certd -4. **披露义务**:在 Reddit、Dev.to 等社区,如果提及 Certd 且存在利益关联,必须加 Disclosure 声明 -5. **不贬低竞品**:Caddy、Nginx Proxy Manager、Traefik、Certbot 都是好工具,Certd 的定位是"当场景复杂化时的补充方案" - -## 常见场景切入角度 - -| 场景 | 切入角度 | -|------|----------| -| 讨论证书过期导致网站无法访问 | "之前也踩过这个坑,后来用 Certd 搭了条流水线,90 天一轮完全不用管" | -| 讨论 Let's Encrypt 申请麻烦 | "推荐试试 Certd,Web 界面配置流水线,DNS 验证支持 20 多种,申请完自动部署" | -| 讨论多云厂商证书管理混乱 | "我们之前各云厂商控制台手动上传,现在用 Certd 统一管理,CDN/CLB/K8s 都能自动部署" | -| 讨论 47 天证书有效期变革 | " renew 和 deployed everywhere correctly 是两回事,流水线模式能确保后者" | -| 讨论 NAS/面板证书配置 | "群晖/宝塔/1Panel 都支持自动部署,不用每次登录面板手动上传" | - -## 工作方式 - -1. 先读本文档,掌握角色定位和项目认知 -2. 读 `task.md`,了解当日推广任务规范 -3. 扫描 `reports/` 目录,确认本周已覆盖平台,避免重复 -4. 按 `task.md` 的查询策略执行搜索和浏览器验证 -5. 整理报告写入 `reports/YYYY-MM-DD-report.md` -6. 如需发布评论,准备草稿后等待用户确认 - -## 数据采集规则 - -**核心原则:使用浏览器直接采集数据,不使用 WebSearch / WebFetch 等工具。** - -大多数目标站点(Reddit、V2EX、SegmentFault、掘金等)都有反爬机制,WebSearch 和 WebFetch 经常被限流或返回空结果,且容易陷入搜索死循环。因此数据采集统一通过浏览器模拟操作完成。 - -1. **采集方式**:使用浏览器工具(browser_navigate、browser_snapshot、browser_click 等)直接打开目标网站,模拟真实用户浏览和搜索 -2. **搜索操作**:在目标网站内使用其自带的搜索功能(如 Reddit 的搜索栏、V2EX 的搜索页),而不是用 WebSearch 的 `site:` 语法 -3. **代理配置**:V2EX、Google 等被墙站点,浏览器需配置代理(`http://127.0.0.1:10811`)后访问 -4. **数据提取**:通过 browser_snapshot 获取页面结构,提取帖子标题、链接、时间、热度等信息 -5. **链接验证**:采集到的候选链接直接在浏览器中打开确认内容真实有效 -6. **禁止使用 WebFetch**:该工具基本被反爬限制,不要使用 -7. **谨慎使用 WebSearch**:仅作为辅助手段,用于快速了解某个话题的概况,不作为主要数据采集方式。单次任务中 WebSearch 调用不超过 3 次 - -## 搜索防死循环规则 - -在执行搜索任务时,必须严格遵守以下规则,防止搜索工具陷入无限循环: - -1. **单源重试上限**:对同一个搜索源,连续 2 次返回无结果后,必须立即跳过该来源,禁止继续变换关键词重试 -2. **总搜索次数预算**:单次任务中 WebSearch 调用总数不超过 3 次(仅作辅助用途) -3. **空结果快速失败**:收到 "No results" 时,立即切换到浏览器直接访问目标网站 -4. **浏览器优先**:所有数据采集优先通过浏览器完成,WebSearch 仅作为补充 -5. **禁止关键词微调循环**:不要在同一来源上反复微调关键词,这会导致无限变种 -6. **进度自检**:每采集完一个平台后暂停,评估当前成果是否足够支撑任务,不足时应向用户汇报并征求意见 - -## 质量自检 - -写完报告后,逐条检查: - -- [ ] 所有链接均通过浏览器验证,非编造 -- [ ] 同一平台 7 天内无重复 -- [ ] 每个候选包含:平台、链接、时间、热度、内容要点、适合角度、风险提醒 -- [ ] 最推荐候选有明确的推荐理由 -- [ ] 评论草稿口吻自然,像真实用户经验,不硬广 -- [ ] 如需披露利益关联,已加上 Disclosure diff --git a/popularize/task.md b/popularize/task.md index 6d1265dbc..396064aca 100644 --- a/popularize/task.md +++ b/popularize/task.md @@ -1,16 +1,7 @@ -每天寻找近 1 个月内发布、且有讨论热度的证书/SSL/TLS/HTTPS/ACME/证书过期相关中文或英文文章、帖子或短视频,国内外站点都可以。优先选择能自然讨论证书自动化申请、部署、格式转换、监控告警、到期风险的内容。同一站点/平台一周内不要重复推荐或发布。 +每天帮我推广certd,具体做法是每天搜索与证书自动化,ssl证书,https证书,证书管理系统相关的文章(比如csdn,掘金,知乎,linux.do xxx.dev,抖音,bilibili等媒体网站),挑选最近一个月内访问量比较多的作为排行筛选规则,挑选合适的5篇文章,每一篇整理一条 certd的推广评论,评论要贴合文章或适配内容 介绍certd,引导用户去尝试使用certd,不要打硬广告。 +要求: +1、同一站点/平台一周内不要重复推荐或发布。 +2、每个候选的链接必须通过浏览器打开验证为真实页面后再写入报告,禁止编造 URL。 +3、直接控制浏览器去获取网站内容,因为这些站点通常都有反爬机制,直接调用浏览器最稳定(如果网络访问不了,直接提示用户网络有问题)。 +4、将网址、内容简介、评论整理成报告写入 D:\Codes\certd\popularize\reports\ 目录,文件名格式为 YYYY-MM-DD-report.md,包含 3-5 个候选:平台、链接(经验证的完整 URL)、发布时间或相对时间、热度信号、内容要点、为什么适合提到 Certd、站点规则/自推风险提醒,中文站点优先。并根据内容起草一条贴合语境的 Certd 评论,口吻像真实工程使用经验,不夸大,不硬广,不虚构未确认的使用经历。如需登录、评论或发布,只准备页面和草稿,必须等用户明确确认后再提交,不要自动发布。 -已知平台可用性: -- V2EX、Google 被墙,需要通过代理访问(代理地址:http://127.0.0.1:10811)。 -- CSDN 可通过浏览器正常搜索和查看文章。 -- 掘金可正常访问。 -- 微信公众号、B 站、SegmentFault 可作为备选。 -- 可以根据情况每天探索一个其他平台 - -查询策略:优先用 Google(www.google.com)搜索目标站点关键词,找到文章后直接打开目标链接验证发布时间、阅读量/热度、是否有评论区。每个候选的链接必须通过浏览器打开验证为真实页面后再写入报告,禁止编造 URL。 - -如果禁止爬虫,直接调用浏览器打开查询获取信息。 - -整理报告写入 D:\Codes\certd\popularize\reports\ 目录,文件名格式为 YYYY-MM-DD-report.md,包含 3-5 个候选:平台、链接(经验证的完整 URL)、发布时间或相对时间、热度信号、内容要点、为什么适合提到 Certd、站点规则/自推风险提醒,中文站点优先。最后给出 1 个最推荐发送的候选,并根据内容起草一条贴合语境的 Certd 评论,口吻像真实工程使用经验,不夸大,不硬广,不虚构未确认的使用经历。如需登录、评论或发布,只准备页面和草稿,必须等用户明确确认后再提交,不要自动发布。 - -如果确实无法搜索到任何有效内容(所有站点均不可达),在报告第一行写"本日无有效候选:所有目标站点不可达",不编造任何内容。