Compare commits

...

38 Commits

Author SHA1 Message Date
ecc-tools[bot] e0e0bb51ce feat: add certd ECC bundle (.claude/homunculus/instincts/inherited/certd-instincts.yaml) 2026-07-30 02:08:23 +00:00
ecc-tools[bot] 1dc5a19c45 feat: add certd ECC bundle (.codex/agents/docs-researcher.toml) 2026-07-30 02:08:22 +00:00
ecc-tools[bot] 04aa9041e8 feat: add certd ECC bundle (.codex/agents/reviewer.toml) 2026-07-30 02:08:21 +00:00
ecc-tools[bot] 0d86c84b28 feat: add certd ECC bundle (.codex/agents/explorer.toml) 2026-07-30 02:08:20 +00:00
ecc-tools[bot] 6605669113 feat: add certd ECC bundle (.codex/AGENTS.md) 2026-07-30 02:08:19 +00:00
ecc-tools[bot] 4b8747b5da feat: add certd ECC bundle (.codex/config.toml) 2026-07-30 02:08:18 +00:00
ecc-tools[bot] 2ba4132c50 feat: add certd ECC bundle (.claude/identity.json) 2026-07-30 02:08:17 +00:00
ecc-tools[bot] 49dc2796cd feat: add certd ECC bundle (.agents/skills/certd/agents/openai.yaml) 2026-07-30 02:08:15 +00:00
ecc-tools[bot] 1e07d69932 feat: add certd ECC bundle (.agents/skills/certd/SKILL.md) 2026-07-30 02:08:14 +00:00
ecc-tools[bot] aabf73a736 feat: add certd ECC bundle (.claude/skills/certd/SKILL.md) 2026-07-30 02:08:13 +00:00
ecc-tools[bot] c9be2293ab feat: add certd ECC bundle (.claude/ecc-tools.json) 2026-07-30 02:08:12 +00:00
xiaojunnuo 4662e45e58 build: release 2026-07-19 01:28:05 +08:00
xiaojunnuo 1fefbdc9ab build: publish 2026-07-19 01:14:35 +08:00
xiaojunnuo 1cb2a57c55 build: trigger build image 2026-07-19 01:14:24 +08:00
xiaojunnuo 246ee83015 v1.42.6 2026-07-19 01:13:42 +08:00
xiaojunnuo 335ddfc7a5 build: prepare to build 2026-07-19 01:12:01 +08:00
xiaojunnuo 5b500830a1 fix: 修复正常批量删除流水线报权限不足的bug 2026-07-19 01:05:03 +08:00
xiaojunnuo 5f53b81c75 perf: 优化动态加载依赖镜像地址,多次重试 2026-07-18 21:07:56 +08:00
xiaojunnuo c8c269f612 chore: 1 2026-07-17 15:44:23 +08:00
xiaojunnuo 743617dbda build: release 2026-07-15 23:54:59 +08:00
xiaojunnuo 89806b828a build: publish 2026-07-15 23:35:15 +08:00
xiaojunnuo 2e530bfdb0 build: trigger build image 2026-07-15 23:35:05 +08:00
xiaojunnuo 83495b3213 v1.42.5 2026-07-15 23:34:16 +08:00
xiaojunnuo 5108416904 build: prepare to build 2026-07-15 23:31:35 +08:00
xiaojunnuo 5589da1822 chore: 修复单元测试 2026-07-15 23:30:49 +08:00
xiaojunnuo 21e5aed3f3 chore: 修复单元测试的问题 2026-07-15 23:25:28 +08:00
xiaojunnuo 4a88f795e1 chore: vke kubeconfig 有效期校验 2026-07-15 23:10:58 +08:00
xiaojunnuo 604fa5be63 perf: 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config 2026-07-15 23:01:32 +08:00
xiaojunnuo 7ed1be994f fix: 修复dingtalk通知格式没有换行的bug 2026-07-15 23:00:42 +08:00
xiaojunnuo 6cc74a1c0a chore: lint 2026-07-15 01:25:55 +08:00
xiaojunnuo 167b303fae fix: 修复上传到cos报runtimeDepsService未初始化的问题 2026-07-15 01:09:57 +08:00
xiaojunnuo b91c9e4ea6 perf: 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim 2026-07-14 21:32:32 +08:00
xiaojunnuo 584b7b6f45 build: release 2026-07-12 03:41:59 +08:00
xiaojunnuo 2312c444ef build: release 2026-07-12 03:36:09 +08:00
xiaojunnuo 0373d019f8 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-07-12 03:11:04 +08:00
xiaojunnuo fe09e75b80 refactor(nginx-proxy-manager): 替换直接动态导入为运行时依赖导入
将插件中直接使用的动态导入改为通过runtimeDepsService统一处理,同时重构导入方式为runtimeImport变量,移除冗余的form-data和otplib直接导入,统一导入逻辑
2026-07-12 03:10:52 +08:00
xiaojunnuo 432c9c6cc1 build: publish 2026-07-12 02:48:34 +08:00
xiaojunnuo 5fc1d55879 build: trigger build image 2026-07-12 02:48:22 +08:00
104 changed files with 2624 additions and 2179 deletions
+133
View File
@@ -0,0 +1,133 @@
```markdown
# certd Development Patterns
> Auto-generated skill from repository analysis
## Overview
This skill teaches the core development patterns and workflows for the `certd` TypeScript codebase. It covers coding conventions, file organization, commit patterns, and detailed step-by-step instructions for extending user preferences—a common feature workflow. The repository is structured for modularity, with clear separation between UI and backend logic, and emphasizes maintainable, convention-driven development.
## Coding Conventions
### File Naming
- **CamelCase** is used for file names.
- Example: `userPreferences.ts`, `preferencesDrawer.vue`
### Import Style
- **Absolute imports** are preferred.
- Example:
```typescript
import { getUserPreferences } from 'packages/ui/certd-client/src/vben/layouts/widgets/preferences/api';
```
### Export Style
- **Named exports** are used throughout the codebase.
- Example:
```typescript
export function getUserPreferences() { ... }
export const PREFERENCE_KEYS = [ ... ];
```
### Commit Patterns
- **Conventional commits** are used, with the `feat` prefix for new features.
- Example:
```
feat: add account sync to preferences
```
## Workflows
### Extend Preferences Feature
**Trigger:** When someone wants to add or enhance a user preference feature (e.g., import/export, sync to account).
**Command:** `/extend-preferences`
Follow these steps to extend user preferences functionality:
1. **Update localization files**
Add or modify strings in:
- `packages/ui/certd-client/src/locales/langs/en-US/preferences.ts`
- `packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts`
```typescript
// en-US/preferences.ts
export default {
sync: "Sync Preferences",
import: "Import Preferences",
// ...
};
```
2. **Modify or add Vue components for preferences UI**
Update or create components such as:
- `preferences-drawer.vue`
- `account-sync.ts`
```vue
<!-- preferences-drawer.vue -->
<template>
<div>
<button @click="syncPreferences">{{ $t('preferences.sync') }}</button>
</div>
</template>
```
3. **Update or add supporting icon definitions**
Edit:
- `packages/ui/certd-client/src/vben/icons/lucide.ts`
```typescript
export const SyncIcon = { /* icon definition */ };
```
4. **Implement or update store logic for settings**
Update:
- `packages/ui/certd-client/src/store/settings/index.tsx`
```typescript
export function syncPreferencesToAccount() { ... }
```
5. **Add or update backend API/controller for user preferences**
Edit or add:
- `packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts`
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.ts`
```typescript
// user-preferences.ts
export async function updateUserPreferences(req, res) { ... }
```
6. **Write or update backend tests for new preference logic**
Add or update:
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts`
```typescript
test('should sync preferences', async () => { ... });
```
7. **Update backend models if necessary**
Edit:
- `packages/ui/certd-server/src/modules/mine/service/models.ts`
```typescript
export interface UserPreferences { ... }
```
## Testing Patterns
- **Test files** follow the `*.test.*` naming convention.
- Example: `user-preferences.test.ts`
- **Testing framework** is not explicitly detected, but tests are written in TypeScript and likely use a standard Node.js testing library (e.g., Jest or Mocha).
- **Test Example:**
```typescript
test('should update preferences', async () => {
// Arrange
// Act
// Assert
});
```
## Commands
| Command | Purpose |
|----------------------|--------------------------------------------------------------|
| /extend-preferences | Guide to extend or enhance user preference functionality |
```
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Certd"
short_description: "Repo-specific patterns and workflows for certd"
default_prompt: "Use the certd repo skill to follow existing architecture, testing, and workflow conventions."
policy:
allow_implicit_invocation: true
+227
View File
@@ -0,0 +1,227 @@
{
"version": "1.3",
"schemaVersion": "1.0",
"generatedBy": "ecc-tools",
"generatedAt": "2026-07-30T02:07:44.046Z",
"repo": "https://github.com/certd/certd",
"referenceSetReadiness": {
"score": 0,
"present": 0,
"total": 7,
"items": [
{
"id": "deep-analyzer-corpus",
"label": "Deep analyzer corpus",
"status": "missing",
"evidence": [],
"recommendation": "Add analyzer fixture, golden, benchmark, or reference-set files that can catch analyzer regressions."
},
{
"id": "rag-evaluator",
"label": "RAG/evaluator comparison",
"status": "missing",
"evidence": [],
"recommendation": "Add retrieval or evaluator reference-set comparison fixtures with expected ranking behavior."
},
{
"id": "pr-salvage",
"label": "PR salvage/review corpus",
"status": "missing",
"evidence": [],
"recommendation": "Add stale-PR, review-thread, reopen-flow, or salvage reference cases for queue cleanup automation."
},
{
"id": "discussion-triage",
"label": "Discussion triage corpus",
"status": "missing",
"evidence": [],
"recommendation": "Add public discussion triage fixtures, golden cases, or reference sets for informational, answered, and no-response classifications."
},
{
"id": "harness-compatibility",
"label": "Harness compatibility",
"status": "missing",
"evidence": [],
"recommendation": "Add cross-harness, adapter-compliance, or harness-audit evidence for Claude, Codex, OpenCode, Zed, dmux, and agent surfaces."
},
{
"id": "security-evidence",
"label": "Security evidence",
"status": "missing",
"evidence": [],
"recommendation": "Attach security evidence such as SBOMs, SARIF, audit reports, or AgentShield evidence packs."
},
{
"id": "ci-failure-mode",
"label": "CI failure-mode evidence",
"status": "missing",
"evidence": [],
"recommendation": "Add captured CI failure logs, dry-run fixtures, or troubleshooting docs for common workflow failure modes."
}
]
},
"profiles": {
"requested": "core",
"recommended": "core",
"effective": "core",
"requestedAlias": "core",
"recommendedAlias": "core",
"effectiveAlias": "core"
},
"requestedProfile": "core",
"profile": "core",
"recommendedProfile": "core",
"effectiveProfile": "core",
"tier": "free",
"requestedComponents": [
"repo-baseline"
],
"selectedComponents": [
"repo-baseline"
],
"requestedAddComponents": [],
"requestedRemoveComponents": [],
"blockedRemovalComponents": [],
"tierFilteredComponents": [],
"requestedRootPackages": [
"runtime-core"
],
"selectedRootPackages": [
"runtime-core"
],
"requestedPackages": [
"runtime-core"
],
"requestedAddPackages": [],
"requestedRemovePackages": [],
"selectedPackages": [
"runtime-core"
],
"packages": [
"runtime-core"
],
"blockedRemovalPackages": [],
"tierFilteredRootPackages": [],
"tierFilteredPackages": [],
"conflictingPackages": [],
"dependencyGraph": {
"runtime-core": []
},
"resolutionOrder": [
"runtime-core"
],
"requestedModules": [
"runtime-core"
],
"selectedModules": [
"runtime-core"
],
"modules": [
"runtime-core"
],
"managedFiles": [
".claude/skills/certd/SKILL.md",
".agents/skills/certd/SKILL.md",
".agents/skills/certd/agents/openai.yaml",
".claude/identity.json",
".codex/config.toml",
".codex/AGENTS.md",
".codex/agents/explorer.toml",
".codex/agents/reviewer.toml",
".codex/agents/docs-researcher.toml",
".claude/homunculus/instincts/inherited/certd-instincts.yaml"
],
"packageFiles": {
"runtime-core": [
".claude/skills/certd/SKILL.md",
".agents/skills/certd/SKILL.md",
".agents/skills/certd/agents/openai.yaml",
".claude/identity.json",
".codex/config.toml",
".codex/AGENTS.md",
".codex/agents/explorer.toml",
".codex/agents/reviewer.toml",
".codex/agents/docs-researcher.toml",
".claude/homunculus/instincts/inherited/certd-instincts.yaml"
]
},
"moduleFiles": {
"runtime-core": [
".claude/skills/certd/SKILL.md",
".agents/skills/certd/SKILL.md",
".agents/skills/certd/agents/openai.yaml",
".claude/identity.json",
".codex/config.toml",
".codex/AGENTS.md",
".codex/agents/explorer.toml",
".codex/agents/reviewer.toml",
".codex/agents/docs-researcher.toml",
".claude/homunculus/instincts/inherited/certd-instincts.yaml"
]
},
"files": [
{
"moduleId": "runtime-core",
"path": ".claude/skills/certd/SKILL.md",
"description": "Repository-specific Claude Code skill generated from git history."
},
{
"moduleId": "runtime-core",
"path": ".agents/skills/certd/SKILL.md",
"description": "Codex-facing copy of the generated repository skill."
},
{
"moduleId": "runtime-core",
"path": ".agents/skills/certd/agents/openai.yaml",
"description": "Codex skill metadata so the repo skill appears cleanly in the skill interface."
},
{
"moduleId": "runtime-core",
"path": ".claude/identity.json",
"description": "Suggested identity.json baseline derived from repository conventions."
},
{
"moduleId": "runtime-core",
"path": ".codex/config.toml",
"description": "Repo-local Codex MCP and multi-agent baseline aligned with ECC defaults."
},
{
"moduleId": "runtime-core",
"path": ".codex/AGENTS.md",
"description": "Codex usage guide that points at the generated repo skill and workflow bundle."
},
{
"moduleId": "runtime-core",
"path": ".codex/agents/explorer.toml",
"description": "Read-only explorer role config for Codex multi-agent work."
},
{
"moduleId": "runtime-core",
"path": ".codex/agents/reviewer.toml",
"description": "Read-only reviewer role config focused on correctness and security."
},
{
"moduleId": "runtime-core",
"path": ".codex/agents/docs-researcher.toml",
"description": "Read-only docs researcher role config for API verification."
},
{
"moduleId": "runtime-core",
"path": ".claude/homunculus/instincts/inherited/certd-instincts.yaml",
"description": "Continuous-learning instincts derived from repository patterns."
}
],
"workflows": [],
"adapters": {
"claudeCode": {
"skillPath": ".claude/skills/certd/SKILL.md",
"identityPath": ".claude/identity.json",
"commandPaths": []
},
"codex": {
"configPath": ".codex/config.toml",
"agentsGuidePath": ".codex/AGENTS.md",
"skillPath": ".agents/skills/certd/SKILL.md"
}
}
}
@@ -0,0 +1,486 @@
# Instincts generated from https://github.com/certd/certd
# Generated: 2026-07-30T02:08:05.402Z
# Version: 2.0
# NOTE: This file supplements (does not replace) any existing curated instincts.
# High-confidence manually curated instincts should be preserved alongside these.
---
id: certd-commit-conventional
trigger: "when writing a commit message"
confidence: 0.85
domain: git
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Commit Conventional
## Action
Use conventional commit format with prefixes: feat
## Evidence
- 2 commits analyzed
- Detected conventional commit pattern
- Examples: feat: 偏好设置支持从剪切板导入, feat: 偏好设置支持保存到账号并在登录后自动同步
---
id: certd-commit-length
trigger: "when writing a commit message"
confidence: 0.6
domain: git
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Commit Length
## Action
Keep commit messages concise (~22 characters)
## Evidence
- Average commit message length: 22 chars
- Based on 2 commits
---
id: certd-naming-files
trigger: "when creating a new file"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Naming Files
## Action
Use camelCase naming convention
## Evidence
- Analyzed file naming patterns in repository
- Dominant pattern: camelCase
---
id: certd-export-style
trigger: "when exporting from a module"
confidence: 0.7
domain: code-style
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Export Style
## Action
Prefer named exports
## Evidence
- Export pattern analysis
- Dominant style: named
---
id: certd-arch-feature-based
trigger: "when adding a new feature"
confidence: 0.85
domain: architecture
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Arch Feature Based
## Action
Create a new folder in src/features/ with all related code colocated
## Evidence
- Feature-based module organization detected
- Structure: src/features/[feature-name]/
---
id: certd-workflow-extend-preferences-feature
trigger: "when doing extend preferences feature"
confidence: 0.6
domain: workflow
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Workflow Extend Preferences Feature
## Action
Follow the extend-preferences-feature workflow:
1. Update localization files for new preference-related strings
2. Modify or add Vue components for preferences UI
3. Update or add supporting icon definitions
4. Implement or update store logic for settings
5. Add or update backend API/controller for user preferences
6. Write or update backend tests for new preference logic
7. Update backend models if necessary
## Evidence
- Workflow detected from commit patterns
- Frequency: ~2x per month
- Files: packages/ui/certd-client/src/locales/langs/en-US/preferences.ts, packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts, packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue
---
id: certd-instinct-file-naming
trigger: "When creating a new file in the codebase"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct File Naming
## Action
Name the file using camelCase
## Evidence
- Pattern in codeStyle.namingConventions.files
---
id: certd-instinct-function-naming
trigger: "When defining a new function"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Function Naming
## Action
Use camelCase for function names
## Evidence
- Pattern in codeStyle.namingConventions.functions
---
id: certd-instinct-class-naming
trigger: "When defining a new class"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Class Naming
## Action
Use PascalCase for class names
## Evidence
- Pattern in codeStyle.namingConventions.classes
---
id: certd-instinct-constant-naming
trigger: "When declaring a constant"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Constant Naming
## Action
Use SCREAMING_SNAKE_CASE for constant names
## Evidence
- Pattern in codeStyle.namingConventions.constants
---
id: certd-instinct-import-style
trigger: "When importing modules"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Import Style
## Action
Use absolute import paths
## Evidence
- Pattern in codeStyle.importStyle
---
id: certd-instinct-export-style
trigger: "When exporting from a module"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Export Style
## Action
Use named exports
## Evidence
- Pattern in codeStyle.exportStyle
---
id: certd-instinct-try-catch
trigger: "When handling errors in code"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Try Catch
## Action
Use try-catch blocks for error handling
## Evidence
- Pattern in errorHandling.style
---
id: certd-instinct-test-location
trigger: "When adding or updating tests"
confidence: 0.7
domain: testing
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Test Location
## Action
Place tests in both source and test-specific folders as appropriate (mixed location)
## Evidence
- Pattern in architecture.folderStructure.testLocation
- Seen in files like packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts
---
id: certd-instinct-backend-test-pattern
trigger: "When adding backend logic for user preferences"
confidence: 0.8
domain: testing
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Backend Test Pattern
## Action
Create or update a corresponding .test.ts file in the same directory
## Evidence
- Seen in packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts
---
id: certd-instinct-conventional-commits
trigger: "When writing a commit message"
confidence: 0.9
domain: git
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Conventional Commits
## Action
Use the conventional commit format with a type prefix (e.g., feat: ...)
## Evidence
- Pattern in commits.type
- Examples: feat: 偏好设置支持从剪切板导入
---
id: certd-instinct-commit-length
trigger: "When writing a commit message"
confidence: 0.7
domain: git
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Commit Length
## Action
Keep the commit message concise, around 22 characters on average
## Evidence
- Pattern in commits.averageLength
---
id: certd-instinct-extend-preferences-localization
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Localization
## Action
Update localization files for new preference-related strings
## Evidence
- Workflow: extend-preferences-feature
- Files: packages/ui/certd-client/src/locales/langs/en-US/preferences.ts, packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts
---
id: certd-instinct-extend-preferences-ui
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Ui
## Action
Modify or add Vue components for the preferences UI
## Evidence
- Workflow: extend-preferences-feature
- Files: packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue
---
id: certd-instinct-extend-preferences-icons
trigger: "When extending or adding a user preference feature"
confidence: 0.8
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Icons
## Action
Update or add supporting icon definitions
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-client/src/vben/icons/lucide.ts
---
id: certd-instinct-extend-preferences-store
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Store
## Action
Implement or update store logic for settings
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-client/src/store/settings/index.tsx
---
id: certd-instinct-extend-preferences-backend-api
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Backend Api
## Action
Add or update backend API/controller for user preferences
## Evidence
- Workflow: extend-preferences-feature
- Files: packages/ui/certd-server/src/controller/user/mine/user-preferences.ts, packages/ui/certd-server/src/controller/user/mine/user-settings-controller.ts
---
id: certd-instinct-extend-preferences-backend-test
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Backend Test
## Action
Write or update backend tests for new preference logic
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts
---
id: certd-instinct-extend-preferences-models
trigger: "When extending or adding a user preference feature and new data is needed"
confidence: 0.8
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Models
## Action
Update backend models if necessary
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-server/src/modules/mine/service/models.ts
+14
View File
@@ -0,0 +1,14 @@
{
"version": "2.0",
"technicalLevel": "technical",
"preferredStyle": {
"verbosity": "detailed",
"codeComments": true,
"explanations": true
},
"domains": [
"typescript"
],
"suggestedBy": "ecc-tools-repo-analysis",
"createdAt": "2026-07-30T02:08:05.402Z"
}
+133
View File
@@ -0,0 +1,133 @@
```markdown
# certd Development Patterns
> Auto-generated skill from repository analysis
## Overview
This skill teaches the core development patterns and workflows for the `certd` TypeScript codebase. It covers coding conventions, file organization, commit patterns, and detailed step-by-step instructions for extending user preferences—a common feature workflow. The repository is structured for modularity, with clear separation between UI and backend logic, and emphasizes maintainable, convention-driven development.
## Coding Conventions
### File Naming
- **CamelCase** is used for file names.
- Example: `userPreferences.ts`, `preferencesDrawer.vue`
### Import Style
- **Absolute imports** are preferred.
- Example:
```typescript
import { getUserPreferences } from 'packages/ui/certd-client/src/vben/layouts/widgets/preferences/api';
```
### Export Style
- **Named exports** are used throughout the codebase.
- Example:
```typescript
export function getUserPreferences() { ... }
export const PREFERENCE_KEYS = [ ... ];
```
### Commit Patterns
- **Conventional commits** are used, with the `feat` prefix for new features.
- Example:
```
feat: add account sync to preferences
```
## Workflows
### Extend Preferences Feature
**Trigger:** When someone wants to add or enhance a user preference feature (e.g., import/export, sync to account).
**Command:** `/extend-preferences`
Follow these steps to extend user preferences functionality:
1. **Update localization files**
Add or modify strings in:
- `packages/ui/certd-client/src/locales/langs/en-US/preferences.ts`
- `packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts`
```typescript
// en-US/preferences.ts
export default {
sync: "Sync Preferences",
import: "Import Preferences",
// ...
};
```
2. **Modify or add Vue components for preferences UI**
Update or create components such as:
- `preferences-drawer.vue`
- `account-sync.ts`
```vue
<!-- preferences-drawer.vue -->
<template>
<div>
<button @click="syncPreferences">{{ $t('preferences.sync') }}</button>
</div>
</template>
```
3. **Update or add supporting icon definitions**
Edit:
- `packages/ui/certd-client/src/vben/icons/lucide.ts`
```typescript
export const SyncIcon = { /* icon definition */ };
```
4. **Implement or update store logic for settings**
Update:
- `packages/ui/certd-client/src/store/settings/index.tsx`
```typescript
export function syncPreferencesToAccount() { ... }
```
5. **Add or update backend API/controller for user preferences**
Edit or add:
- `packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts`
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.ts`
```typescript
// user-preferences.ts
export async function updateUserPreferences(req, res) { ... }
```
6. **Write or update backend tests for new preference logic**
Add or update:
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts`
```typescript
test('should sync preferences', async () => { ... });
```
7. **Update backend models if necessary**
Edit:
- `packages/ui/certd-server/src/modules/mine/service/models.ts`
```typescript
export interface UserPreferences { ... }
```
## Testing Patterns
- **Test files** follow the `*.test.*` naming convention.
- Example: `user-preferences.test.ts`
- **Testing framework** is not explicitly detected, but tests are written in TypeScript and likely use a standard Node.js testing library (e.g., Jest or Mocha).
- **Test Example:**
```typescript
test('should update preferences', async () => {
// Arrange
// Act
// Assert
});
```
## Commands
| Command | Purpose |
|----------------------|--------------------------------------------------------------|
| /extend-preferences | Guide to extend or enhance user preference functionality |
```
+26
View File
@@ -0,0 +1,26 @@
# ECC for Codex CLI
This supplements the root `AGENTS.md` with a repo-local ECC baseline.
## Repo Skill
- Repo-generated Codex skill: `.agents/skills/certd/SKILL.md`
- Claude-facing companion skill: `.claude/skills/certd/SKILL.md`
- Keep user-specific credentials and private MCPs in `~/.codex/config.toml`, not in this repo.
## MCP Baseline
Treat `.codex/config.toml` as the default ECC-safe baseline for work in this repository.
The generated baseline enables GitHub, Context7, Exa, Memory, Playwright, and Sequential Thinking.
## Multi-Agent Support
- Explorer: read-only evidence gathering
- Reviewer: correctness, security, and regression review
- Docs researcher: API and release-note verification
## Workflow Files
- No dedicated workflow command files were generated for this repo.
Use these workflow files as reusable task scaffolds when the detected repository workflows recur.
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.4"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Verify APIs, framework behavior, and release-note claims against primary documentation before changes land.
Cite the exact docs or file paths that support each claim.
Do not invent undocumented behavior.
"""
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.4"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Stay in exploration mode.
Trace the real execution path, cite files and symbols, and avoid proposing fixes unless the parent agent asks for them.
Prefer targeted search and file reads over broad scans.
"""
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.4"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Review like an owner.
Prioritize correctness, security, behavioral regressions, and missing tests.
Lead with concrete findings and avoid style-only feedback unless it hides a real bug.
"""
+48
View File
@@ -0,0 +1,48 @@
#:schema https://developers.openai.com/codex/config-schema.json
# ECC Tools generated Codex baseline
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
[mcp_servers.context7]
command = "npx"
args = ["-y", "@upstash/context7-mcp@latest"]
[mcp_servers.exa]
url = "https://mcp.exa.ai/mcp"
[mcp_servers.memory]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-memory"]
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--extension"]
[mcp_servers.sequential-thinking]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
[features]
multi_agent = true
[agents]
max_threads = 6
max_depth = 1
[agents.explorer]
description = "Read-only codebase explorer for gathering evidence before changes are proposed."
config_file = "agents/explorer.toml"
[agents.reviewer]
description = "PR reviewer focused on correctness, security, and missing tests."
config_file = "agents/reviewer.toml"
[agents.docs_researcher]
description = "Documentation specialist that verifies APIs, framework behavior, and release notes."
config_file = "agents/docs-researcher.toml"
+4 -2
View File
@@ -70,6 +70,8 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
- 先读本文,再按任务读取具体代码或技能文件。
- PowerShell 读取中文、Markdown、locale、文档类文件时使用 `Get-Content -Raw -Encoding UTF8`;仍乱码时先执行 `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()`
- PowerShell 中用 `rg` 搜索含引号、括号、反斜杠的 pattern 时,优先用单引号包裹整个 pattern,例如 `rg 'await import\("tencentcloud-sdk-nodejs' packages/ui/certd-server/src -g '*.ts'`
- 手工编辑或创建文件时优先使用 `apply_patch`。单个文件内有多处不连续改动时,拆成多个独立的 `*** Update File` 块,每块只改一处附近上下文;不要在同一个 update hunk 里强塞多个 `@@`
- 只有真正机械化的大批量替换、格式化或生成任务才考虑脚本/工具。若必须使用临时脚本,应放在临时目录并在同一个受控步骤内完成创建、执行、删除;不要把临时脚本落在仓库里跨多步工具调用执行。
- 不要主动运行 `pnpm install`;缺依赖、TTY、网络导致安装或测试失败时,停止尝试并说明环境问题。
- 优先沿用现有模块、插件、service、页面模式;不要为形式上的复用制造过度抽象。
- 代码可读性优先于短写法。复杂条件、三元表达式、链式调用、内联对象和多层 helper 调用要拆成命名清晰的中间变量或小方法。
@@ -210,8 +212,8 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
- 后端业务数据、接口、实体、权限、迁移:改 `packages/ui/certd-server/src/modules``src/controller`
- 表单、列表、插件配置 UI:改 `packages/ui/certd-client/src/views/certd` 及对应 `src/api`
## 注意事项
## 其他注意事项
### 旧版数据兼容
- 新增插件参数时,必须要考虑旧版数据兼容,比如新增一个deployType参数,有两种值:`default``custom`,需要在使用时判空,走旧版逻辑。
- 新增插件参数时,必须要考虑旧版数据兼容,比如新增一个deployType参数,有两种值:`default``custom`,需要在使用时判空,走旧版逻辑。
+22
View File
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
### Bug Fixes
* 修复正常批量删除流水线报权限不足的bug ([5b50083](https://github.com/certd/certd/commit/5b500830a122c6c42dab054e57fed509050f94da))
### Performance Improvements
* 优化动态加载依赖镜像地址,多次重试 ([5f53b81](https://github.com/certd/certd/commit/5f53b81c75dd242b4260ac08cae14c6d1a08a883))
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
* 修复dingtalk通知格式没有换行的bug ([7ed1be9](https://github.com/certd/certd/commit/7ed1be994f8b4b74cdeb38743060c912c027248b))
### Performance Improvements
* 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim ([b91c9e4](https://github.com/certd/certd/commit/b91c9e4ea671cb359ef164e27864de1d66cba9d3))
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
### Bug Fixes
+7 -5
View File
@@ -107,11 +107,13 @@ https://certd.handfree.work/
**镜像版本:**
| 版本标签 | 基础系统 | 说明 |
| --- | --- | --- | --- |
| `latest` / `[version]` | Alpine Linux | 默认版本,镜像体积小 |
| `slim` / `[version]-slim` | Debian slim | 基于glibcdns解析兼容性好(可能需要配置security_opt -seccomp=unconfined |
| `armv7` / `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
| 标签 | 指定版本 | 基础系统 | 说明 |
| --- | --- | --- | --- |
| `latest` | `[version]` | Alpine Linux | 默认版本,镜像体积小 |
| `slim` | `[version]-slim` | Debian slim | 基于glibcdns解析兼容性好(可能需要配置security_opt -seccomp=unconfined |
| `armv7` | `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
**镜像地址:**
+30
View File
@@ -3,6 +3,36 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
### Bug Fixes
* 修复正常批量删除流水线报权限不足的bug ([5b50083](https://github.com/certd/certd/commit/5b500830a122c6c42dab054e57fed509050f94da))
### Performance Improvements
* 优化动态加载依赖镜像地址,多次重试 ([5f53b81](https://github.com/certd/certd/commit/5f53b81c75dd242b4260ac08cae14c6d1a08a883))
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
* 修复dingtalk通知格式没有换行的bug ([7ed1be9](https://github.com/certd/certd/commit/7ed1be994f8b4b74cdeb38743060c912c027248b))
### Performance Improvements
* 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim ([b91c9e4](https://github.com/certd/certd/commit/b91c9e4ea671cb359ef164e27864de1d66cba9d3))
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
### Bug Fixes
* 修复火山引擎查不到自定义源站域名的问题 ([02dabe1](https://github.com/certd/certd/commit/02dabe11db3e9b13ca4621ce9ddd2b808bfca390))
* 修复火山引擎自定义源站域名查询不到的问题 ([e44bf9d](https://github.com/certd/certd/commit/e44bf9d77375d48ac7fd1582e69fae02dfd248fa))
* **pipeline:** 重构运行时依赖加载逻辑,修复火山引擎DNS解析报runtimeDepsService未初始化的bug ([ec69b8f](https://github.com/certd/certd/commit/ec69b8f11bfd4b20991aef74a72a47182ca79a9d))
## [1.42.3](https://github.com/certd/certd/compare/v1.42.2...v1.42.3) (2026-07-08)
### Bug Fixes
+1 -1
View File
@@ -9,5 +9,5 @@
}
},
"npmClient": "pnpm",
"version": "1.42.4"
"version": "1.42.6"
}
+1
View File
@@ -39,6 +39,7 @@
"test:unit": "cross-env NODE_ENV=unittest pnpm -r --workspace-concurrency=1 run test:unit",
"pub": "echo 1",
"dev": "pnpm run -r --parallel compile ",
"lint_all": "pnpm run -r --parallel lint ",
"pub_all": "node ./scripts/pub-all.js",
"release": "time /t >trigger/release.trigger && git add trigger/release.trigger && git commit -m \"build: release\" && git push",
"publish_to_atomgit": "node --experimental-json-modules ./scripts/publish-atomgit.js",
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/publishlab/node-acme-client/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/acme-client
## [1.42.5](https://github.com/publishlab/node-acme-client/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/acme-client
## [1.42.4](https://github.com/publishlab/node-acme-client/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/acme-client
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client",
"private": false,
"author": "nmorsman",
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"module": "./dist/index.js",
"main": "./dist/index.js",
@@ -18,7 +18,7 @@
"types"
],
"dependencies": {
"@certd/basic": "^1.42.4",
"@certd/basic": "^1.42.6",
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.9.0",
@@ -75,5 +75,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/basic
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/basic
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/basic
+1 -1
View File
@@ -1 +1 @@
02:45
01:12
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/basic",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -54,5 +54,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
### Performance Improvements
* 优化动态加载依赖镜像地址,多次重试 ([5f53b81](https://github.com/certd/certd/commit/5f53b81c75dd242b4260ac08cae14c6d1a08a883))
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
### Performance Improvements
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
### Bug Fixes
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/pipeline",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -21,8 +21,8 @@
"lint": "eslint --fix"
},
"dependencies": {
"@certd/basic": "^1.42.4",
"@certd/plus-core": "^1.42.4",
"@certd/basic": "^1.42.6",
"@certd/plus-core": "^1.42.6",
"dayjs": "^1.11.7",
"lodash-es": "^4.17.21",
"reflect-metadata": "^0.2.2"
@@ -51,5 +51,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+2 -9
View File
@@ -3,7 +3,7 @@ import { FormItemProps } from "../dt/index.js";
import { HttpClient, ILogger, utils } from "@certd/basic";
import * as _ from "lodash-es";
import { PluginRequestHandleReq } from "../plugin/index.js";
import { IRuntimeDepsService, IServiceGetter } from "../service/index.js";
import { IServiceGetter, getRuntimeDepsService } from "../service/index.js";
// export type AccessRequestHandleReqInput<T = any> = {
// id?: number;
@@ -48,20 +48,13 @@ export type AccessContext = {
export abstract class BaseAccess implements IAccess {
ctx!: AccessContext;
runtimeDepsService?: IRuntimeDepsService;
async importRuntime(specifier: string) {
if (!this.runtimeDepsService) {
throw new Error("runtimeDepsService 未初始化");
}
return await this.runtimeDepsService.importRuntime(specifier, this.ctx.logger);
return await getRuntimeDepsService().importRuntime(specifier, this.ctx.logger);
}
async setCtx(ctx: AccessContext) {
this.ctx = ctx;
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
}
}
async onRequest(req: AccessRequestHandleReq) {
@@ -3,7 +3,7 @@ import { Registrable } from "../registry/index.js";
import { FormItemProps, HistoryResult, Pipeline } from "../dt/index.js";
import { HttpClient, ILogger, utils } from "@certd/basic";
import * as _ from "lodash-es";
import { IEmailService, IRuntimeDepsService, IServiceGetter } from "../service/index.js";
import { IEmailService, IServiceGetter, getRuntimeDepsService } from "../service/index.js";
export type NotificationBody = {
userId?: number;
@@ -89,16 +89,16 @@ export abstract class BaseNotification implements INotification {
ctx!: NotificationContext;
http!: HttpClient;
logger!: ILogger;
runtimeDepsService?: IRuntimeDepsService;
async importRuntime(specifier: string) {
if (!this.runtimeDepsService) {
return await import(specifier);
}
return await this.runtimeDepsService.importRuntime(specifier, this.logger);
return await getRuntimeDepsService().importRuntime(specifier, this.logger);
}
async doSend(body: NotificationBody) {
if (body.content) {
const content = body.content?.replace(/\n/g, " \n");
body.content = content;
}
return await this.send(body);
}
abstract send(body: NotificationBody): Promise<void>;
@@ -109,9 +109,6 @@ export abstract class BaseNotification implements INotification {
this.ctx = ctx;
this.http = ctx.http;
this.logger = ctx.logger;
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
}
}
setDefine = (define: NotificationDefine) => {
this.define = define;
+3 -10
View File
@@ -10,7 +10,7 @@ import { INotificationService } from "../notification/index.js";
import { Registrable } from "../registry/index.js";
import { IPluginConfigService } from "../service/config.js";
import { TaskEmitter } from "../service/emit.js";
import { ICnameProxyService, IEmailService, IRuntimeDepsService, IServiceGetter, IUrlService } from "../service/index.js";
import { ICnameProxyService, IEmailService, IServiceGetter, IUrlService, getRuntimeDepsService } from "../service/index.js";
export type PluginRequestHandleReq<T = any> = {
typeName: string;
@@ -76,7 +76,7 @@ export type ITaskPlugin = {
execute(): Promise<void | string>;
onRequest(req: PluginRequestHandleReq<any>): Promise<any>;
setCtx(ctx: TaskInstanceContext): Promise<void>;
importRuntime?(specifier: string): Promise<any>;
importRuntime(specifier: string): Promise<any>;
[key: string]: any;
};
@@ -150,13 +150,9 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin {
logger!: ILogger;
http!: HttpClient;
accessService!: IAccessService;
runtimeDepsService!: IRuntimeDepsService;
async importRuntime(specifier: string) {
if (!this.runtimeDepsService) {
throw new Error("runtimeDepsService 未初始化");
}
return await this.runtimeDepsService.importRuntime(specifier, this.logger);
return await getRuntimeDepsService().importRuntime(specifier, this.logger);
}
clearLastStatus() {
@@ -178,9 +174,6 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin {
this.logger = ctx.logger;
this.accessService = ctx.accessService;
this.http = ctx.http;
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
}
// 将证书加入secret
// @ts-ignore
if (this.cert && this.cert.crt && this.cert.key) {
@@ -2,41 +2,41 @@ 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";
import { RuntimeDepsService, NpmRegistryResolver, type RuntimeDependencyPluginDefine } from "./runtime.js";
import { accessRegistry } from "../access/registry.js";
import { pluginRegistry } from "../plugin/registry.js";
describe("RuntimeDepsService", () => {
it("detects conflicting dependency ranges across plugins", () => {
const service = new RuntimeDepsService();
const service = new RuntimeDepsService({}, null);
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 service = new RuntimeDepsService({}, null);
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;
const service = new RuntimeDepsService({ rootDir }, null);
service.registryResolver = {
async resolve() {
return "https://registry.npmmirror.com";
},
async resolveOrdered() {
return ["https://registry.npmmirror.com"];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[]) {
@@ -50,22 +50,22 @@ describe("RuntimeDepsService", () => {
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;
const service = new RuntimeDepsService({ rootDir }, null);
service.registryResolver = {
async resolve() {
return "";
},
async resolveOrdered() {
return [""];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[]) {
@@ -77,9 +77,7 @@ describe("RuntimeDepsService", () => {
return { stdout: "", stderr: "", code: 0 };
},
} as any;
await service.ensureDependencies({ dependencies: { directPkg: "^1.0.0" } });
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
assert.deepEqual(manifest.dependencies, { directPkg: "^1.0.0" });
});
@@ -91,31 +89,26 @@ describe("RuntimeDepsService", () => {
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;
const service = new RuntimeDepsService({ rootDir }, null);
service.commandRunner = {
async run() {
throw new Error("install should not run");
},
} as any;
const mod = await service.importRuntime("runtime-only");
assert.equal(mod.value, 42);
});
it("installs configured lazy dependency when import target is missing", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-"));
const service = new RuntimeDepsService();
service.runtimeDepsRootDir = rootDir;
service.lazyDependencies = {
"lazy-pkg": "^1.2.3",
};
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "lazy-pkg": "^1.2.3" } }, null);
service.registryResolver = {
async resolve() {
return "";
},
async resolveOrdered() {
return [""];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[]) {
@@ -130,9 +123,7 @@ describe("RuntimeDepsService", () => {
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);
@@ -140,15 +131,14 @@ describe("RuntimeDepsService", () => {
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",
};
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "@scope/lazy": "^2.0.0" } }, null);
service.registryResolver = {
async resolve() {
return "";
},
async resolveOrdered() {
return [""];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[]) {
@@ -163,9 +153,7 @@ describe("RuntimeDepsService", () => {
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);
@@ -173,59 +161,27 @@ describe("RuntimeDepsService", () => {
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 = {};
const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }, null);
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 service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }, null);
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;
const service = new RuntimeDepsService({ rootDir }, null);
service.registryResolver = {
async resolve() {
return "";
},
async resolveOrdered() {
return [""];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[]) {
@@ -237,22 +193,17 @@ describe("RuntimeDepsService", () => {
return { stdout: "", stderr: "", code: 0 };
},
} as any;
await service.ensureInstalled({ plugins: [{ name: "a", pluginType: "deploy", dependPackages: { foo: "^1.0.0" } }] });
await service.ensureInstalled({ plugins: [{ 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",
});
assert.deepEqual(manifest.dependencies, { foo: "^1.0.0", bar: "^2.0.0" });
});
it("includes npm dependencies from dependent plugins", () => {
const service = new RuntimeDepsService();
it("includes npm dependencies from dependent plugins", async () => {
const service = new RuntimeDepsService({}, { accessRegistry, pluginRegistry });
accessRegistry.register("runtimeDepsAccess", {
define: { name: "runtimeDepsAccess", title: "access", dependPackages: { accessOnly: "^1.0.0" } } as any,
target: async () => ({} as any),
target: async () => ({}) as any,
});
try {
const resolved = service.resolvePluginDependencies({
@@ -262,11 +213,7 @@ describe("RuntimeDepsService", () => {
dependPackages: { deployOnly: "^1.0.0" },
});
const merged = service.collectDependencies(resolved);
assert.deepEqual(merged.dependencies, {
deployOnly: "^1.0.0",
accessOnly: "^1.0.0",
});
assert.deepEqual(merged.dependencies, { deployOnly: "^1.0.0", accessOnly: "^1.0.0" });
} finally {
accessRegistry.unRegister("runtimeDepsAccess");
}
@@ -274,12 +221,14 @@ describe("RuntimeDepsService", () => {
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;
const service = new RuntimeDepsService({ rootDir }, { pluginRegistry, accessRegistry });
service.registryResolver = {
async resolve() {
return "";
},
async resolveOrdered() {
return [""];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[]) {
@@ -293,11 +242,11 @@ describe("RuntimeDepsService", () => {
} as any;
pluginRegistry.register("runtimeDepsKey", {
define: { name: "runtimeDepsKey", title: "key", dependPackages: { keyed: "^1.0.0" } } as any,
target: async () => ({} as any),
target: async () => ({}) as any,
});
try {
service.setRegistries({ pluginRegistry, accessRegistry });
await service.ensureRuntimeDependencies({ pluginKeys: "plugin:runtimeDepsKey" });
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
assert.deepEqual(manifest.dependencies, { keyed: "^1.0.0" });
} finally {
@@ -305,58 +254,16 @@ describe("RuntimeDepsService", () => {
}
});
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({ pluginKeys: ["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();
const service = new RuntimeDepsService({}, { accessRegistry, pluginRegistry });
assert.throws(() => service.resolvePluginDependencies({ name: "deploy", pluginType: "deploy", dependPlugins: { "access:access": "*" } }), /插件依赖缺失/);
});
it("reports incompatible dependent plugin versions", () => {
const service = new RuntimeDepsService();
const service = new RuntimeDepsService({}, { accessRegistry, pluginRegistry });
accessRegistry.register("runtimeDepsVersionedAccess", {
define: { name: "runtimeDepsVersionedAccess", title: "access", version: "1.4.0", dependPackages: { accessOnly: "^1.0.0" } } as any,
target: async () => ({} as any),
target: async () => ({}) as any,
});
try {
assert.throws(
@@ -374,73 +281,10 @@ describe("RuntimeDepsService", () => {
});
it("reports bare dependent plugin names as invalid format", () => {
const service = new RuntimeDepsService();
const service = new RuntimeDepsService({}, null);
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({ plugins: [{ 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({ plugins: [{ name: "a", dependPackages: { foo: "^1.0.0" } }] }), serviceB.ensureInstalled({ plugins: [{ 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;
@@ -448,12 +292,14 @@ describe("RuntimeDepsService", () => {
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;
const service = new RuntimeDepsService({ rootDir }, null);
service.registryResolver = {
async resolve() {
return "";
},
async resolveOrdered() {
return [""];
},
} as any;
service.commandRunner = {
async run(command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) {
@@ -467,7 +313,6 @@ describe("RuntimeDepsService", () => {
return { stdout: "", stderr: "", code: 0 };
},
} as any;
await service.ensureInstalled({ plugins: [{ name: "a", dependPackages: { foo: "^1.0.0" } }] });
} finally {
if (oldNodeOptions == null) {
@@ -483,27 +328,94 @@ describe("RuntimeDepsService", () => {
}
});
it.skip("clears runtime dependency directory", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-clear-"));
const runtimeRootDir = path.join(rootDir, ".runtime-deps");
fs.mkdirSync(path.join(runtimeRootDir, "node_modules", "foo"), { recursive: true });
fs.writeFileSync(path.join(runtimeRootDir, "package.json"), "{}", "utf8");
const service = new RuntimeDepsService();
service.runtimeDepsRootDir = runtimeRootDir;
service.installTimeoutMs = 1000;
await service.clearRuntimeDeps();
assert.equal(fs.existsSync(runtimeRootDir), true);
const remainingEntries = fs.readdirSync(runtimeRootDir).filter(e => e !== ".install.lock");
assert.equal(remainingEntries.length, 0);
});
it("rejects clearing unexpected runtime dependency path", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-clear-invalid-"));
const service = new RuntimeDepsService();
service.runtimeDepsRootDir = rootDir;
const service = new RuntimeDepsService({ rootDir }, null);
await assert.rejects(() => service.clearRuntimeDeps(), /动态依赖目录配置异常/);
});
});
describe("NpmRegistryResolver", () => {
it("returns the fastest successful registry via resolve()", async () => {
const resolver = new NpmRegistryResolver({
mode: "auto",
candidates: ["https://slow.example.com", "https://fast.example.com"],
probeTimeoutMs: 100,
cacheTtlMs: 1000,
});
resolver.probe = async (registryUrl: string) => ({
registryUrl,
ok: true,
elapsedMs: registryUrl.includes("fast") ? 10 : 50,
});
const result = await resolver.resolve();
assert.equal(result, "https://fast.example.com");
});
it("uses fixed registry without probing", async () => {
const resolver = new NpmRegistryResolver({
mode: "fixed",
fixedUrl: "https://registry.example.com",
probeTimeoutMs: 100,
cacheTtlMs: 1000,
});
const result = await resolver.resolve();
assert.equal(result, "https://registry.example.com");
});
it("returns ordered list via resolveOrdered (fastest first)", async () => {
const resolver = new NpmRegistryResolver({
mode: "auto",
candidates: ["https://slow.example.com", "https://fast.example.com"],
probeTimeoutMs: 100,
cacheTtlMs: 1000,
});
resolver.probe = async (registryUrl: string) => ({
registryUrl,
ok: true,
elapsedMs: registryUrl.includes("fast") ? 10 : 50,
});
const result = await resolver.resolveOrdered();
assert.deepEqual(result, ["https://fast.example.com", "https://slow.example.com"]);
});
it("includes failed registries at the end of resolveOrdered", async () => {
const resolver = new NpmRegistryResolver({
mode: "auto",
candidates: ["https://good.example.com", "https://bad.example.com"],
probeTimeoutMs: 100,
cacheTtlMs: 1000,
});
resolver.probe = async (registryUrl: string) => {
if (registryUrl.includes("bad")) {
return { registryUrl, ok: false, elapsedMs: 200 };
}
return { registryUrl, ok: true, elapsedMs: 30 };
};
const result = await resolver.resolveOrdered();
assert.deepEqual(result, ["https://good.example.com", "https://bad.example.com"]);
});
it("returns empty ordered list when no candidates", async () => {
const resolver = new NpmRegistryResolver({ mode: "auto", candidates: [] });
const result = await resolver.resolveOrdered();
assert.deepEqual(result, []);
const single = await resolver.resolve();
assert.equal(single, "");
});
it("re-validates cached URL on resolveOrdered call", async () => {
let probeCount = 0;
const resolver = new NpmRegistryResolver({
mode: "auto",
candidates: ["https://mirror.example.com"],
cacheTtlMs: 60000,
});
resolver.probe = async (registryUrl: string) => {
probeCount++;
return { registryUrl, ok: true, elapsedMs: 10 };
};
const first = await resolver.resolveOrdered();
assert.deepEqual(first, ["https://mirror.example.com"]);
assert.equal(probeCount, 1);
const second = await resolver.resolveOrdered();
assert.deepEqual(second, ["https://mirror.example.com"]);
assert.equal(probeCount, 2);
});
});
+814 -14
View File
@@ -1,27 +1,827 @@
/**
*
*/
export type ImportRuntime = (specifier: string, logger?: ILogger) => Promise<any>;
/**
*
*/
import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import crypto from "crypto";
import { createRequire } from "module";
import { pathToFileURL } from "url";
import { logger as defaultLogger } from "@certd/basic";
import type { Registry } from "../registry/registry.js";
export type ILogger = {
info: (message: string) => void;
warn?: (message: string) => void;
error?: (message: string, ...args: any[]) => void;
};
/**
*
*/
export type ImportRuntime = (specifier: string, logger?: ILogger) => Promise<any>;
export type EnsureRuntimeDepsOptions = {
pluginKeys: string | string[];
logger?: ILogger;
};
/**
*
*/
export interface IRuntimeDepsService {
ensureRuntimeDependencies(options: EnsureRuntimeDepsOptions): Promise<any>;
importRuntime: ImportRuntime;
}
export type RuntimeDependencyPluginDefine = {
name: string;
key?: string;
title?: string;
version?: string;
pluginType?: string;
addonType?: string;
dependPlugins?: Record<string, string>;
dependPackages?: Record<string, string>;
};
type RegisteredDefineLike = RuntimeDependencyPluginDefine & {
key?: string;
pluginType?: string;
addonType?: string;
dependPlugins?: Record<string, string>;
dependPackages?: Record<string, string>;
};
type DependencyConflict = {
packageName: string;
ranges: Array<{ pluginName: string; range: string }>;
};
type CollectDependenciesResult = {
dependencies: Record<string, string>;
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<CommandRunnerResult>;
};
export type NpmRegistryResolverConfig = {
mode?: "auto" | "fixed" | "system";
fixedUrl?: string;
candidates?: string[];
probeTimeoutMs?: number;
cacheTtlMs?: number;
};
export type RegistryProbeResult = {
registryUrl: string;
ok: boolean;
elapsedMs: number;
};
export class NpmRegistryResolver {
config: NpmRegistryResolverConfig;
private cache?: { orderedUrls: string[]; expiresAt: number };
constructor(config?: NpmRegistryResolverConfig) {
this.config = config || {};
}
async resolve(): Promise<string> {
const config = this.config;
if (config?.mode === "fixed" && config.fixedUrl) {
return config.fixedUrl;
}
if (config?.mode === "system") {
return "";
}
const cached = this.cache;
if (cached && cached.expiresAt > Date.now()) {
const fastUrl = cached.orderedUrls[0];
if (fastUrl) {
const probeResult = await this.probe(fastUrl);
if (probeResult.ok) {
return cached.orderedUrls[0] || "";
}
}
this.cache = undefined;
}
const candidates = (config?.candidates || []).filter(Boolean);
if (candidates.length === 0) {
return "";
}
const orderedUrls = await this.internalProbeAll(candidates);
this.cache = { orderedUrls, expiresAt: Date.now() + (config?.cacheTtlMs ?? 300_000) };
return orderedUrls[0] || "";
}
async resolveOrdered(): Promise<string[]> {
const config = this.config;
if (config?.mode === "fixed" && config.fixedUrl) {
return [config.fixedUrl];
}
if (config?.mode === "system") {
return [];
}
const cached = this.cache;
if (cached && cached.expiresAt > Date.now()) {
const fastUrl = cached.orderedUrls[0];
if (fastUrl) {
const probeResult = await this.probe(fastUrl);
if (probeResult.ok) {
return cached.orderedUrls;
}
}
this.cache = undefined;
}
const candidates = (config?.candidates || []).filter(Boolean);
if (candidates.length === 0) {
return [];
}
const orderedUrls = await this.internalProbeAll(candidates);
this.cache = { orderedUrls, expiresAt: Date.now() + (config?.cacheTtlMs ?? 300_000) };
return orderedUrls;
}
private async internalProbeAll(candidates: string[]): Promise<string[]> {
const probes = await Promise.allSettled(candidates.map(registryUrl => this.probe(registryUrl)));
const okList: RegistryProbeResult[] = [];
const failList: RegistryProbeResult[] = [];
for (const item of probes) {
const result = item.status === "fulfilled" ? item.value : null;
if (result && result.ok) {
okList.push(result);
} else if (result) {
failList.push(result);
}
}
okList.sort((a, b) => a.elapsedMs - b.elapsedMs);
failList.sort((a, b) => a.elapsedMs - b.elapsedMs);
return [...okList.map(r => r.registryUrl), ...failList.map(r => r.registryUrl)];
}
async probe(registryUrl: string): Promise<RegistryProbeResult> {
const timeoutMs = this.config?.probeTimeoutMs || 3000;
const started = Date.now();
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${registryUrl.replace(/\/$/, "")}/-/ping`, { signal: controller.signal });
return { registryUrl, ok: res.ok, elapsedMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
} catch {
return { registryUrl, ok: false, elapsedMs: Date.now() - started };
}
}
}
export type RuntimeDepsConfig = {
rootDir?: string;
autoInstall?: boolean;
enabled?: boolean;
installTimeoutMs?: number;
pnpmCommand?: string;
lazyDependencies?: Record<string, string>;
registry?: NpmRegistryResolverConfig;
};
function normalizeRange(range: string) {
return range.trim().replace(/^\^/, "").replace(/^~?/, "");
}
function areRangesCompatible(a: string, b: string) {
if (!a || !b) {
return true;
}
if (a === "*" || b === "*") {
return true;
}
const left = normalizeRange(a).split(".");
const right = normalizeRange(b).split(".");
return left[0] === right[0];
}
const PROCESS_LOCKS = new Map<string, Promise<unknown>>();
class DefaultCommandRunner implements CommandRunner {
async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult> {
return await new Promise<CommandRunnerResult>(resolve => {
let stdout = "";
let stderr = "";
let settled = false;
const child = spawn(command, args, { cwd: options.cwd, env: options.env, windowsHide: true, shell: process.platform === "win32" });
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
child.kill("SIGTERM");
resolve({ stdout, stderr: stderr || `command timeout after ${options.timeoutMs}ms`, code: 1 });
}, options.timeoutMs);
child.stdout?.on("data", chunk => {
stdout += chunk.toString();
});
child.stderr?.on("data", chunk => {
stderr += chunk.toString();
});
child.on("error", error => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve({ stdout, stderr: error.message, code: 1 });
});
child.on("close", code => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve({ stdout, stderr, code: code || 0 });
});
});
}
}
export class RuntimeDepsService {
runtimeDepsRootDir: string;
autoInstall: boolean;
enabled: boolean;
installTimeoutMs: number;
pnpmCommand: string;
lazyDependencies: Record<string, string>;
registryResolver!: NpmRegistryResolver;
commandRunner: CommandRunner = new DefaultCommandRunner();
pluginLazyDependencies: Record<string, string> = {};
private installPromises = new Map<string, Promise<InstallResult>>();
private registriesMap: Record<string, { registry: Registry<any>; pluginType: string; addonType?: string }> | null = null;
constructor(config: RuntimeDepsConfig, registries: any) {
this.runtimeDepsRootDir = config?.rootDir ?? "./data/.runtime-deps";
this.autoInstall = config?.autoInstall ?? true;
this.enabled = config?.enabled ?? true;
this.installTimeoutMs = config?.installTimeoutMs ?? 120000;
this.pnpmCommand = config?.pnpmCommand ?? "";
this.lazyDependencies = config?.lazyDependencies ?? {};
this.registryResolver = new NpmRegistryResolver(config?.registry);
if (registries) {
this.setRegistries(registries);
}
}
setRegistries(registries: { pluginRegistry?: Registry<any>; accessRegistry?: Registry<any>; notificationRegistry?: Registry<any>; dnsProviderRegistry?: Registry<any>; addonRegistry?: Registry<any> }) {
const map: Record<string, { registry: Registry<any>; pluginType: string; addonType?: string }> = {};
if (registries.pluginRegistry) {
map["plugin"] = { registry: registries.pluginRegistry, pluginType: "plugin" };
}
if (registries.accessRegistry) {
map["access"] = { registry: registries.accessRegistry, pluginType: "access" };
}
if (registries.notificationRegistry) {
map["notification"] = { registry: registries.notificationRegistry, pluginType: "notification" };
}
if (registries.dnsProviderRegistry) {
map["dnsProvider"] = { registry: registries.dnsProviderRegistry, pluginType: "dnsProvider" };
}
if (registries.addonRegistry) {
map["addon"] = { registry: registries.addonRegistry, pluginType: "addon", addonType: "" };
}
this.registriesMap = map;
}
collectDependencies(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult {
const merged: Record<string, string> = {};
const seen: Record<string, Array<{ pluginName: string; range: string }>> = {};
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(options: { plugins: RuntimeDependencyPluginDefine[]; logger?: ILogger }): Promise<InstallResult> {
const { plugins, logger: log } = options;
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, logger: log });
}
async ensureDependencies(options: { dependencies: Record<string, string>; logger?: ILogger }): Promise<InstallResult> {
const { dependencies, logger: log } = options;
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) {
const nodeModulesPath = path.join(this.getRuntimeDepsRootDir(), "node_modules");
if (!fs.existsSync(nodeModulesPath)) {
this.installPromises.delete(dependenciesHash);
installPromise = undefined;
}
}
if (!installPromise) {
installPromise = this.doEnsureInstalled({ dependencies, logger: log }).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(options: { pluginKeys: string | string[]; logger?: ILogger }): Promise<InstallResult> {
const { pluginKeys, logger: log } = options;
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({ plugins: expandedPluginDefines, logger: log });
}
async importRuntime(specifier: string, logger: ILogger = defaultLogger) {
if (this.isNativeImportSpecifier(specifier)) {
return await import(specifier);
}
const resolved = await this.resolveImportSpecifier(specifier, logger);
return await import(pathToFileURL(resolved).href);
}
private async resolveImportSpecifier(specifier: string, logger: ILogger = defaultLogger) {
try {
return this.resolveRuntimeSpecifier(specifier).resolved;
} catch (runtimeError: any) {
if (!this.isModuleNotFoundError(runtimeError)) {
throw runtimeError;
}
return await this.resolveMissingRuntimeSpecifier(specifier, runtimeError, logger);
}
}
private async resolveMissingRuntimeSpecifier(specifier: string, runtimeError: any, logger?: ILogger) {
const packageName = this.parsePackageName(specifier);
const mergedDeps = this.getMergedLazyDependencies();
const lazyRange = mergedDeps[packageName];
if (!lazyRange) {
try {
return this.resolveProjectSpecifier(specifier, runtimeError).resolved;
} catch {
throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`);
}
}
try {
await this.ensureLazyDependency(packageName, logger);
return this.resolveRuntimeSpecifier(specifier).resolved;
} catch (lazyError: any) {
logger?.error?.(`动态依赖安装失败: ${lazyError.message}`);
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, logger?: ILogger) {
const range = this.lazyDependencies?.[packageName];
if (!range) {
throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`);
}
await this.ensureDependencies({ dependencies: { [packageName]: range }, logger });
}
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<string>();
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(":");
let pluginType: string, name: string, subtype: string | undefined;
if (parts.length === 2) {
[pluginType, name] = parts;
} else if (parts.length === 3) {
[pluginType, subtype, name] = parts;
} else {
const ownerName = owner?.name || pluginKey;
throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey}`);
}
if (!this.registriesMap) {
throw new Error("注册表未设置,请先调用 setRegistries");
}
const target = this.registriesMap[pluginType];
if (!target) {
const ownerName = owner?.name || pluginKey;
throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey},未知插件类型 ${pluginType}`);
}
// addon 类型的 key 需要包含 subtype
const registryKey = pluginType === "addon" && subtype ? `${subtype}:${name}` : name;
const define = target.registry.getDefine(registryKey) as RegisteredDefineLike;
if (!define) {
throw new Error(`插件依赖缺失: ${owner?.name || pluginKey} 依赖 ${pluginKey},但该插件未注册或已禁用`);
}
return { ...define, key: pluginKey, pluginType: target.pluginType, addonType: target.addonType };
}
private async doEnsureInstalled(options: { dependencies: Record<string, string>; logger?: ILogger }): Promise<InstallResult> {
let { dependencies } = options;
const log = options.logger || defaultLogger;
return await this.withInstallLock(async () => {
const rootDir = this.getRuntimeDepsRootDir();
const packageJsonPath = path.join(rootDir, "package.json");
const lockPath = path.join(rootDir, "pnpm-lock.yaml");
log.info(`第三方依赖安装: ${JSON.stringify(dependencies)}`);
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"))) {
log.info("第三方依赖已安装");
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 allRegistryUrls = await this.registryResolver.resolveOrdered();
const urlsToTry = allRegistryUrls.length > 0 ? allRegistryUrls : [""];
let lastError: string | undefined;
for (const tryUrl of urlsToTry) {
const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--no-frozen-lockfile", "--reporter=append-only"];
if (tryUrl) {
args.push(`--registry=${tryUrl}`);
}
const tryEnv = tryUrl ? this.buildChildEnv(tryUrl) : env;
log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}${tryUrl ? `,镜像: ${tryUrl}` : ""}`);
const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv });
if (result.code === 0) {
this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl: tryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) });
log.info("第三方依赖安装完成");
return { registryUrl: tryUrl, packageJsonPath };
}
lastError = result.stderr || result.stdout || "unknown error";
log.warn?.(`镜像 ${tryUrl || "默认"} 安装失败${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
}
this.writeInstallState(statePath, {
...currentState,
installedAt: currentState?.installedAt,
failedAt: new Date().toISOString(),
registryUrl: urlsToTry[0],
dependenciesHash,
nodeVersion: process.version,
pnpmVersion,
lockFileExists: fs.existsSync(lockPath),
lastError,
});
throw new Error(`动态依赖安装失败: ${lastError}`);
});
}
private async withInstallLock<T>(run: () => Promise<T>): Promise<T> {
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<void>(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);
try {
fs.rmSync(lockFile, { force: true });
} catch {
try {
fs.rmSync(lockFile, { force: true });
} catch {}
}
}
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));
}
}
async clearRuntimeDeps() {
const rootDir = this.getRuntimeDepsRootDir();
const normalizedRootDir = path.normalize(rootDir);
if (!normalizedRootDir.endsWith(path.normalize(".runtime-deps"))) {
throw new Error(`动态依赖目录配置异常,拒绝清理: ${rootDir}`);
}
await this.withInstallLock(async () => {
if (fs.existsSync(rootDir)) {
const entries = fs.readdirSync(rootDir);
for (const entry of entries) {
if (entry === ".install.lock") {
continue;
}
fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
}
}
this.installPromises.clear();
return undefined;
});
}
getMergedLazyDependencies(): Record<string, string> {
return { ...this.lazyDependencies, ...this.pluginLazyDependencies };
}
collectPluginDeps(logger?: ILogger) {
if (!this.registriesMap) {
return;
}
const deps: Record<string, string> = {};
for (const { registry } of Object.values(this.registriesMap)) {
const defineList = registry.getDefineList();
for (const define of defineList) {
const dependPackages = (define as any).dependPackages as Record<string, string> | undefined;
if (!dependPackages) {
continue;
}
for (const [pkgName, range] of Object.entries(dependPackages)) {
const existing = deps[pkgName];
if (existing && !areRangesCompatible(existing, range)) {
(logger || defaultLogger).warn?.(`懒加载依赖版本冲突: ${pkgName} => ${existing} vs ${range},保留已有版本`);
continue;
}
deps[pkgName] = range;
}
}
}
this.pluginLazyDependencies = deps;
(logger || defaultLogger).info(`从插件注册表收集到 ${Object.keys(deps).length} 个懒加载依赖`);
}
refreshPluginDeps(logger?: ILogger) {
this.collectPluginDeps(logger);
}
private 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<string, string> {
if (!fs.existsSync(packageJsonPath)) {
return {};
}
try {
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
return manifest.dependencies || {};
} catch {
return {};
}
}
private mergeInstalledDependencies(installed: Record<string, string>, requested: Record<string, string>) {
const dependencies = { ...installed };
for (const [packageName, range] of Object.entries(requested)) {
const installedRange = dependencies[packageName];
if (installedRange && !areRangesCompatible(installedRange, range)) {
throw new Error(`动态依赖版本冲突: ${packageName} => installed:${installedRange}, requested:${range}`);
}
dependencies[packageName] = installedRange || range;
}
return dependencies;
}
private async getPnpmVersion(command: string, env: NodeJS.ProcessEnv) {
const result = await this.commandRunner.run(command, ["--version"], { cwd: this.getRuntimeDepsRootDir(), timeoutMs: Math.min(this.installTimeoutMs, 10000), env });
if (result.code !== 0) {
return "";
}
return (result.stdout || result.stderr || "").trim();
}
private getPnpmCommand() {
return this.pnpmCommand || "pnpm";
}
private buildChildEnv(registryUrl: string) {
const env = { ...process.env };
for (const key of ["NODE_OPTIONS", "VSCODE_INSPECTOR_OPTIONS", "NODE_INSPECTOR_PORT", "NODE_DEBUG"]) {
if (!env[key]) {
continue;
}
if (key === "NODE_OPTIONS") {
env[key] = this.stripDebugNodeOptions(env[key] as string);
} else {
delete env[key];
}
}
if (registryUrl) {
env.npm_config_registry = registryUrl;
env.pnpm_config_registry = registryUrl;
}
env.CI = env.CI || "true";
env.npm_config_confirm_modules_purge = "false";
env.pnpm_config_confirm_modules_purge = "false";
return env;
}
private stripDebugNodeOptions(value: string) {
return value
.split(/\s+/)
.filter(Boolean)
.filter(item => !/^--inspect(-brk|-port)?(=|$)/.test(item))
.filter(item => !/^--debug(=|$)/.test(item))
.join(" ");
}
getRuntimeDepsRootDir() {
return path.resolve(this.runtimeDepsRootDir);
}
private createDependenciesHash(dependencies: Record<string, string>) {
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);
}
let runtimeDepsServiceInstance: RuntimeDepsService | null = null;
export function initRuntimeDepsService(config: RuntimeDepsConfig, registries: any): RuntimeDepsService {
runtimeDepsServiceInstance = new RuntimeDepsService(config, registries);
return runtimeDepsServiceInstance;
}
export function getRuntimeDepsService(): RuntimeDepsService {
if (!runtimeDepsServiceInstance) {
throw new Error("RuntimeDepsService 未初始化");
}
return runtimeDepsServiceInstance!;
}
export async function importRuntime(specifier: string, logger: ILogger = defaultLogger): Promise<any> {
return getRuntimeDepsService().importRuntime(specifier, logger);
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/lib-huawei
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/lib-huawei
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/lib-huawei
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-huawei",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
"types": "./dist/d/index.d.ts",
@@ -26,9 +26,10 @@
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"esmock": "^2.7.5",
"prettier": "3.3.3",
"tslib": "^2.8.1"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/lib-iframe
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/lib-iframe
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/lib-iframe
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-iframe",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -37,5 +37,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/jdcloud
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/jdcloud
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/jdcloud
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/jdcloud",
"version": "1.42.4",
"version": "1.42.6",
"description": "jdcloud openApi sdk",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
@@ -35,6 +35,7 @@
"chai": "^5.1.0",
"config": "^1.30.0",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"esmock": "^2.7.5",
"js-yaml": "^3.11.0",
"mocha": "^10.6.0",
@@ -62,5 +63,5 @@
"fetch"
]
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/lib-k8s
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/lib-k8s
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/lib-k8s
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-k8s",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -21,7 +21,7 @@
"lint": "eslint --fix"
},
"dependencies": {
"@certd/basic": "^1.42.4",
"@certd/basic": "^1.42.6",
"@kubernetes/client-node": "0.21.0"
},
"devDependencies": {
@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
### Bug Fixes
* 修复正常批量删除流水线报权限不足的bug ([5b50083](https://github.com/certd/certd/commit/5b500830a122c6c42dab054e57fed509050f94da))
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
### Bug Fixes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/lib-server",
"version": "1.42.4",
"version": "1.42.6",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -29,11 +29,11 @@
],
"license": "AGPL",
"dependencies": {
"@certd/acme-client": "^1.42.4",
"@certd/basic": "^1.42.4",
"@certd/pipeline": "^1.42.4",
"@certd/plugin-lib": "^1.42.4",
"@certd/plus-core": "^1.42.4",
"@certd/acme-client": "^1.42.6",
"@certd/basic": "^1.42.6",
"@certd/pipeline": "^1.42.6",
"@certd/plugin-lib": "^1.42.6",
"@certd/plus-core": "^1.42.6",
"@midwayjs/cache": "3.14.0",
"@midwayjs/core": "3.20.11",
"@midwayjs/i18n": "3.20.13",
@@ -69,5 +69,5 @@
"typeorm": "^0.3.20",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
@@ -253,7 +253,6 @@ export abstract class BaseService<T> {
if (!Array.isArray(ids)) {
ids = [ids];
}
ids = this.filterIds(ids);
const res = await this.getRepository().find({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -266,7 +265,7 @@ export abstract class BaseService<T> {
},
});
if (!res || res.length === ids.length) {
return;
return ids;
}
throw new PermissionException("权限不足");
}
@@ -280,6 +279,12 @@ export abstract class BaseService<T> {
});
}
async batchDelete(ids: number[], userId: number, projectId?: number): Promise<number> {
if (!ids || ids.length === 0) {
throw new ValidateException("ids不能为空");
}
if (!Array.isArray(ids)) {
ids = [ids];
}
ids = this.filterIds(ids);
if (userId != null) {
const userProjectQuery = this.buildUserProjectQuery(userId, projectId);
@@ -1,15 +1,6 @@
import { HttpClient, ILogger, utils } from "@certd/basic";
import {upperFirst} from "lodash-es";
import {
accessRegistry,
FormItemProps,
IAccessService,
IRuntimeDepsService,
IServiceGetter,
PluginRequestHandleReq,
Registrable
} from "@certd/pipeline";
import { upperFirst } from "lodash-es";
import { accessRegistry, FormItemProps, IAccessService, IServiceGetter, PluginRequestHandleReq, Registrable, getRuntimeDepsService } from "@certd/pipeline";
export type AddonRequestHandleReqInput<T = any> = {
id?: number;
@@ -19,7 +10,7 @@ export type AddonRequestHandleReqInput<T = any> = {
export type AddonRequestHandleReq<T = any> = {
addonType: string;
} &PluginRequestHandleReq<AddonRequestHandleReqInput<T>>;
} & PluginRequestHandleReq<AddonRequestHandleReqInput<T>>;
export type AddonInputDefine = FormItemProps & {
title: string;
@@ -48,8 +39,6 @@ export type AddonInstanceConfig = {
};
};
export interface IAddon {
ctx: AddonContext;
[key: string]: any;
@@ -67,13 +56,9 @@ export abstract class BaseAddon implements IAddon {
ctx!: AddonContext;
http!: HttpClient;
logger!: ILogger;
runtimeDepsService?: IRuntimeDepsService;
async importRuntime(specifier: string) {
if (!this.runtimeDepsService) {
return await import(specifier);
}
return await this.runtimeDepsService.importRuntime(specifier, this.logger);
return await getRuntimeDepsService().importRuntime(specifier, this.logger);
}
title!: string;
@@ -85,7 +70,7 @@ export abstract class BaseAddon implements IAddon {
if (accessId == null) {
throw new Error("您还没有配置授权");
}
const accessService = await this.ctx.serviceGetter.get<IAccessService>("accessService")
const accessService = await this.ctx.serviceGetter.get<IAccessService>("accessService");
let res: any = null;
if (isCommon) {
res = await accessService.getCommonById(accessId);
@@ -118,15 +103,12 @@ export abstract class BaseAddon implements IAddon {
this.ctx = ctx;
this.http = ctx.http;
this.logger = ctx.logger;
if (!this.runtimeDepsService && this.ctx.serviceGetter) {
this.runtimeDepsService = await this.ctx.serviceGetter.get("runtimeDepsService");
}
}
setDefine = (define:AddonDefine) => {
setDefine = (define: AddonDefine) => {
this.define = define;
};
async onRequest(req:AddonRequestHandleReq) {
async onRequest(req: AddonRequestHandleReq) {
if (!req.action) {
throw new Error("action is required");
}
@@ -144,10 +126,8 @@ export abstract class BaseAddon implements IAddon {
}
throw new Error(`action ${req.action} not found`);
}
}
export interface IAddonGetter {
getById<T = any>(id: any): Promise<T>;
getCommonById<T = any>(id: any): Promise<T>;
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/midway-flyway-js
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Performance Improvements
* 给SQLITE_IOERR_WRITE增加友好报错提示,将certd:latest镜像改为certd:slim ([b91c9e4](https://github.com/certd/certd/commit/b91c9e4ea671cb359ef164e27864de1d66cba9d3))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/midway-flyway-js
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/midway-flyway-js",
"version": "1.42.4",
"version": "1.42.6",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -52,5 +52,5 @@
"typeorm": "^0.3.20",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+37 -32
View File
@@ -1,8 +1,8 @@
import * as path from 'path';
import * as fs from 'fs';
import { QueryRunner, Table } from 'typeorm';
import { FlywayHistory } from './entity.js';
import * as crypto from 'crypto';
import * as path from "path";
import * as fs from "fs";
import { QueryRunner, Table } from "typeorm";
import { FlywayHistory } from "./entity.js";
import * as crypto from "crypto";
/**
*
@@ -32,10 +32,10 @@ const DefaultLogger = {
},
};
let customLogger:any = null;
export function setFlywayLogger (logger: any) {
let customLogger: any = null;
export function setFlywayLogger(logger: any) {
customLogger = logger;
};
}
export class Flyway {
scriptDir;
@@ -45,8 +45,8 @@ export class Flyway {
connection;
logger;
constructor(opts: any) {
this.scriptDir = opts.scriptDir ?? 'db/migration';
this.flywayTableName = opts.flywayTableName ?? 'flyway_history';
this.scriptDir = opts.scriptDir ?? "db/migration";
this.flywayTableName = opts.flywayTableName ?? "flyway_history";
this.baseline = opts.baseline ?? false;
this.allowHashNotMatch = opts.allowHashNotMatch ?? false;
this.logger = customLogger || opts.logger || DefaultLogger;
@@ -54,9 +54,9 @@ export class Flyway {
}
async run(ignores?: (RegExp | string)[]) {
this.logger.info('[ midfly ] start-------------');
this.logger.info("[ midfly ] start-------------");
if (!fs.existsSync(this.scriptDir)) {
this.logger.info('[ midfly ] scriptDir<' + this.scriptDir + '> not found');
this.logger.info("[ midfly ] scriptDir<" + this.scriptDir + "> not found");
return;
}
@@ -77,7 +77,7 @@ export class Flyway {
continue;
}
if (!file.isBaseline) {
this.logger.info('need exec script file: ', file.script);
this.logger.info("need exec script file: ", file.script);
//执行sql文件
if (/\.sql$/.test(file.script)) {
await this.execSql(filepath, queryRunner);
@@ -87,7 +87,7 @@ export class Flyway {
// await this.execJsOrTs(filepath, t);
// }
} else {
this.logger.info('baseline script file: ', file.script);
this.logger.info("baseline script file: ", file.script);
}
await this.storeSqlExecLog(file.script, filepath, true, queryRunner);
await queryRunner.commitTransaction();
@@ -95,10 +95,15 @@ export class Flyway {
this.logger.error(err);
await this.storeSqlExecLog(file.script, filepath, false, queryRunner);
await queryRunner.rollbackTransaction();
if (err.code === "SQLITE_IOERR_WRITE") {
this.logger.warn("SQLite数据库写入失败,可能您的操作系统版本太低,请将「certd:latest」镜像改为「certd:slim」即可。(如需指定版本可以修改成「certd:[version]-slim」)", file.script);
}
throw err;
}
}
this.logger.info('[ midfly ] end-------------');
this.logger.info("[ midfly ] end-------------");
}
private async storeSqlExecLog(filename: string, filepath: string, success: boolean, queryRunner: QueryRunner) {
@@ -160,17 +165,17 @@ export class Flyway {
name: this.flywayTableName,
columns: [
{
name: 'id',
name: "id",
type: this.connection.driver.normalizeType({
type: this.connection.driver.mappedDataTypes.migrationId,
}),
isGenerated: true,
generationStrategy: 'increment',
generationStrategy: "increment",
isPrimary: true,
isNullable: false,
},
{
name: 'timestamp',
name: "timestamp",
type: this.connection.driver.normalizeType({
type: this.connection.driver.mappedDataTypes.migrationTimestamp,
}),
@@ -178,23 +183,23 @@ export class Flyway {
isNullable: false,
},
{
name: 'name',
name: "name",
type: this.connection.driver.normalizeType({
type: this.connection.driver.mappedDataTypes.migrationName,
}),
isNullable: false,
},
{
name: 'hash',
name: "hash",
type: this.connection.driver.normalizeType({
type: this.connection.driver.mappedDataTypes.migrationName,
}),
isNullable: true,
},
{
name: 'success',
name: "success",
type: this.connection.driver.normalizeType({
type: 'boolean',
type: "boolean",
}),
isNullable: true,
},
@@ -210,7 +215,7 @@ export class Flyway {
}
let ret = false;
for (const ignore of ignores) {
if (typeof ignore === 'string' && file === ignore) {
if (typeof ignore === "string" && file === ignore) {
ret = true;
break;
}
@@ -233,20 +238,20 @@ export class Flyway {
if (history.hash !== hash && this.allowHashNotMatch === false) {
throw new Error(file + `hash conflict ,old: ${history.hash} != new: ${hash}`);
}
this.logger.info('[ midfly ] script<' + file + '> already executed');
this.logger.info("[ midfly ] script<" + file + "> already executed");
return true;
}
this.logger.info('[ midfly ] script<' + file + '> not yet execute');
this.logger.info("[ midfly ] script<" + file + "> not yet execute");
return false;
}
private async getFileHash(filepath: string) {
const content = fs.readFileSync(filepath).toString();
return crypto.createHash('md5').update(content.toString()).digest('hex');
return crypto.createHash("md5").update(content.toString()).digest("hex");
}
private async execSql(filepath: string, queryRunner: QueryRunner) {
this.logger.info('[ midfly ] exec ', filepath);
this.logger.info("[ midfly ] exec ", filepath);
const content = fs.readFileSync(filepath).toString().trim();
const arr = this.splitSql2Array(content);
for (const s of arr) {
@@ -255,11 +260,11 @@ export class Flyway {
}
private async execOnePart(sql: string, queryRunner: QueryRunner) {
this.logger.debug('exec sql index: ', sql);
this.logger.debug("exec sql index: ", sql);
try {
await queryRunner.query(sql);
} catch (err: any) {
this.logger.error('exec sql error ', err.message, err);
this.logger.error("exec sql error ", err.message, err);
throw err;
}
}
@@ -275,11 +280,11 @@ export class Flyway {
const temp = String(str).trim();
if (temp === 'null') {
if (temp === "null") {
return [];
}
const semicolon = ';';
const semicolon = ";";
const deepChars = ['"', "'"];
const splits = [];
@@ -289,7 +294,7 @@ export class Flyway {
if (deepChars.indexOf(charAt) >= 0) {
//如果是深度char
if (i !== 0 && temp.charAt(i - 1) === '\\') {
if (i !== 0 && temp.charAt(i - 1) === "\\") {
//如果前一个是转义字符,忽略它
} else {
//说明需要进出深度了
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/plugin-cert
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
**Note:** Version bump only for package @certd/plugin-cert
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/plugin-cert
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-cert",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -20,7 +20,7 @@
"lint": "eslint --fix"
},
"dependencies": {
"@certd/plugin-lib": "^1.42.4"
"@certd/plugin-lib": "^1.42.6"
},
"devDependencies": {
"@types/chai": "^4.3.12",
@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/plugin-lib
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
### Bug Fixes
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-lib",
"private": false,
"version": "1.42.4",
"version": "1.42.6",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -17,9 +17,9 @@
"lint": "eslint --fix"
},
"dependencies": {
"@certd/acme-client": "^1.42.4",
"@certd/basic": "^1.42.4",
"@certd/pipeline": "^1.42.4",
"@certd/acme-client": "^1.42.6",
"@certd/basic": "^1.42.6",
"@certd/pipeline": "^1.42.6",
"dayjs": "^1.11.7",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
@@ -45,5 +45,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "a6ef6996c3a68ce0c2a4577a0934ed74a53f0515"
"gitHead": "246ee83015bf5589adc2a5fa3d1388c8d9a2a252"
}
@@ -1,5 +1,5 @@
import { HttpClient, ILogger } from "@certd/basic";
import { IAccessService, IRuntimeDepsService, PageRes, PageSearch } from "@certd/pipeline";
import { IAccessService, PageRes, PageSearch, getRuntimeDepsService } from "@certd/pipeline";
import punycode from "punycode.js";
import { CreateRecordOptions, DnsProviderContext, DnsProviderDefine, DnsResolveRecord, DomainRecord, IDnsProvider, RemoveRecordOptions } from "./api.js";
import { dnsProviderRegistry } from "./registry.js";
@@ -7,13 +7,9 @@ export abstract class AbstractDnsProvider<T = any> implements IDnsProvider<T> {
ctx!: DnsProviderContext;
http!: HttpClient;
logger!: ILogger;
runtimeDepsService?: IRuntimeDepsService;
async importRuntime(specifier: string) {
if (!this.runtimeDepsService) {
throw new Error("runtimeDepsService 未初始化");
}
return await this.runtimeDepsService.importRuntime(specifier, this.logger);
return await getRuntimeDepsService().importRuntime(specifier, this.logger);
}
usePunyCode(): boolean {
@@ -42,9 +38,6 @@ export abstract class AbstractDnsProvider<T = any> implements IDnsProvider<T> {
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");
}
}
async parseDomain(fullDomain: string) {
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
**Note:** Version bump only for package @certd/ui-client
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
**Note:** Version bump only for package @certd/ui-client
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-client",
"version": "1.42.4",
"version": "1.42.6",
"private": true,
"scripts": {
"dev": "vite --open",
@@ -59,7 +59,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.36.0",
"cos-js-sdk-v5": "^1.7.0",
"cron-parser": "^4.9.0",
"cropperjs": "^1.6.1",
"cssnano": "^7.0.6",
@@ -105,8 +104,8 @@
"zod-defaults": "^0.1.3"
},
"devDependencies": {
"@certd/lib-iframe": "^1.42.4",
"@certd/pipeline": "^1.42.4",
"@certd/lib-iframe": "^1.42.6",
"@certd/pipeline": "^1.42.6",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^4.3.12",
-26
View File
@@ -1,26 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"ignorePatterns": ["dist"],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"prettier"
],
"env": {
"mocha": true
},
"rules": {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-this-alias": "off",
// 允许any
"@typescript-eslint/no-unsafe-anyassignment": "off"
}
}
+17
View File
@@ -3,6 +3,23 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.42.6](https://github.com/certd/certd/compare/v1.42.5...v1.42.6) (2026-07-18)
### Bug Fixes
* 修复正常批量删除流水线报权限不足的bug ([5b50083](https://github.com/certd/certd/commit/5b500830a122c6c42dab054e57fed509050f94da))
## [1.42.5](https://github.com/certd/certd/compare/v1.42.4...v1.42.5) (2026-07-15)
### Bug Fixes
* 修复上传到cos报runtimeDepsService未初始化的问题 ([167b303](https://github.com/certd/certd/commit/167b303faeca02cc11cf97e4be2a3df914852167))
* 修复dingtalk通知格式没有换行的bug ([7ed1be9](https://github.com/certd/certd/commit/7ed1be994f8b4b74cdeb38743060c912c027248b))
### Performance Improvements
* 优化vke keubconfig获取方式,改成先查询,如果没有再创建临时config ([604fa5b](https://github.com/certd/certd/commit/604fa5be634d099d797bfee5c2b0f26ce0ac8461))
## [1.42.4](https://github.com/certd/certd/compare/v1.42.3...v1.42.4) (2026-07-11)
### Bug Fixes
+16 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-server",
"version": "1.42.4",
"version": "1.42.6",
"description": "fast-server base midway",
"private": true,
"type": "module",
@@ -25,6 +25,7 @@
"format": "prettier --write src",
"lint": "mwts fix",
"ci": "pnpm run cov",
"lint2": "cross-env NODE_ENV=production mwtsc -p tsconfig.build.json",
"build-only": "cross-env NODE_ENV=production mwtsc -p tsconfig.build.json --cleanOutDir --skipLibCheck",
"build": "pnpm run build-only && pnpm run export-metadata",
"export-metadata": "node export-plugin-yaml.js",
@@ -41,20 +42,20 @@
"lint1": "eslint --fix"
},
"dependencies": {
"@certd/acme-client": "^1.42.4",
"@certd/basic": "^1.42.4",
"@certd/commercial-core": "^1.42.4",
"@certd/acme-client": "^1.42.6",
"@certd/basic": "^1.42.6",
"@certd/commercial-core": "^1.42.6",
"@certd/cv4pve-api-javascript": "^8.4.2",
"@certd/jdcloud": "^1.42.4",
"@certd/lib-huawei": "^1.42.4",
"@certd/lib-k8s": "^1.42.4",
"@certd/lib-server": "^1.42.4",
"@certd/midway-flyway-js": "^1.42.4",
"@certd/pipeline": "^1.42.4",
"@certd/plugin-cert": "^1.42.4",
"@certd/plugin-lib": "^1.42.4",
"@certd/plugin-plus": "^1.42.4",
"@certd/plus-core": "^1.42.4",
"@certd/jdcloud": "^1.42.6",
"@certd/lib-huawei": "^1.42.6",
"@certd/lib-k8s": "^1.42.6",
"@certd/lib-server": "^1.42.6",
"@certd/midway-flyway-js": "^1.42.6",
"@certd/pipeline": "^1.42.6",
"@certd/plugin-cert": "^1.42.6",
"@certd/plugin-lib": "^1.42.6",
"@certd/plugin-plus": "^1.42.6",
"@certd/plus-core": "^1.42.6",
"@koa/cors": "^5.0.0",
"@midwayjs/bootstrap": "3.20.11",
"@midwayjs/cache": "3.14.0",
@@ -125,6 +126,7 @@
"@types/nodemailer": "^6.4.8",
"c8": "^10.1.2",
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"esmock": "^2.7.5",
"mocha": "^10.6.0",
"mwts": "^1.3.0",
@@ -1,3 +1,4 @@
import { getRuntimeDepsService } from "@certd/pipeline";
import { ALL, Body, Controller, Inject, Post, Provide, Query, RequestIP } from "@midwayjs/core";
import { addonRegistry, AddonService, CrudController, SysPrivateSettings, SysPublicSettings, SysSafeSetting, SysSettingsEntity, SysSettingsService } from "@certd/lib-server";
import { cloneDeep, merge } from "lodash-es";
@@ -7,7 +8,6 @@ import { getEmailSettings } from "../../../modules/sys/settings/fix.js";
import { http, logger, utils } from "@certd/basic";
import { CodeService } from "../../../modules/basic/service/code-service.js";
import { SmsServiceFactory } from "../../../modules/basic/sms/factory.js";
import { RuntimeDepsService } from "../../../modules/runtime-deps/runtime-deps-service.js";
/**
*/
@@ -24,9 +24,6 @@ export class SysSettingsController extends CrudController<SysSettingsService> {
codeService: CodeService;
@Inject()
addonService: AddonService;
@Inject()
runtimeDepsService: RuntimeDepsService;
getService() {
return this.service;
}
@@ -222,7 +219,7 @@ export class SysSettingsController extends CrudController<SysSettingsService> {
@Post("/clearRuntimeDeps", { description: "sys:settings:edit" })
async clearRuntimeDeps() {
await this.runtimeDepsService.clearRuntimeDeps();
await getRuntimeDepsService().clearRuntimeDeps();
return this.ok(true);
}
}
@@ -1,11 +1,11 @@
import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
import { AutoCron } from "./auto-cron.js";
import { AutoInitSite } from "./auto-init-site.js";
import { AutoLoadPlugins } from "./auto-load-plugins.js";
import { AutoCron } from "./auto-cron.js";
import { AutoMitterRegister } from "./auto-mitter-register.js";
import { AutoPipelineEmitterRegister } from "./auto-pipeline-emitter-register.js";
import { AutoFix } from "./fix/auto-fix.js";
import { AutoPrint } from "./auto-print.js";
import { AutoFix } from "./fix/auto-fix.js";
@Autoload()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -1,8 +1,11 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { Config, Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { logger } from "@certd/basic";
import { PluginService } from "../plugin/service/plugin-service.js";
import { registerPaymentProviders } from "../suite/payments/index.js";
import { RuntimeDepsService } from "../runtime-deps/runtime-deps-service.js";
import { getRuntimeDepsService, initRuntimeDepsService } from "@certd/pipeline";
import { pluginRegistry, accessRegistry, notificationRegistry } from "@certd/pipeline";
import { dnsProviderRegistry } from "@certd/plugin-lib";
import { addonRegistry } from "@certd/lib-server";
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -10,8 +13,8 @@ export class AutoLoadPlugins {
@Inject()
pluginService: PluginService;
@Inject()
runtimeDepsService: RuntimeDepsService;
@Config("runtimeDeps")
runtimeDepsConfig: any;
async init() {
logger.info(`加载插件开始,加载模式:${process.env.certd_plugin_loadmode}`);
@@ -35,7 +38,16 @@ export class AutoLoadPlugins {
await registerPaymentProviders();
logger.info(`加载插件完成,加载模式:${process.env.certd_plugin_loadmode}`);
//初始化第三方依赖服务
initRuntimeDepsService(this.runtimeDepsConfig, {
pluginRegistry,
accessRegistry,
notificationRegistry,
dnsProviderRegistry,
addonRegistry,
});
// 收集插件 dependPackages 并安装
await this.runtimeDepsService.refreshPluginDeps();
const service = getRuntimeDepsService();
service.refreshPluginDeps();
}
}
@@ -1,4 +1,4 @@
import { cache, isDev, randomNumber, simpleNanoId } from "@certd/basic";
import { cache, isDev, randomNumber, simpleNanoId } from "@certd/basic";
import { AccessService, AccessSysGetter, CodeErrorException, SysSettingsService } from "@certd/lib-server";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { ISmsService } from "../sms/api.js";
@@ -6,7 +6,6 @@ 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: '<svg.../svg>', text: 'abcd'}
/**
@@ -25,9 +24,6 @@ export class CodeService {
@Inject()
captchaService: CaptchaService;
@Inject()
runtimeDepsService: RuntimeDepsService;
async checkCaptcha(body: any, req: CaptchaRequest) {
return await this.captchaService.doValidate({ form: body, req });
}
@@ -60,7 +56,6 @@ export class CodeService {
await sender.setCtx({
accessService: accessGetter,
config: smsConfig,
runtimeDepsService: this.runtimeDepsService,
});
const smsCode = randomNumber(verificationCodeLength);
await sender.sendSmsCode({
@@ -1,9 +1,8 @@
import { FormItemProps, IAccessService } from "@certd/pipeline";
import type { RuntimeDepsService } from "../../runtime-deps/runtime-deps-service.js";
import { FormItemProps, IAccessService } from "@certd/pipeline";
export interface ISmsService {
sendSmsCode(opts: { mobile: string; code: string; phoneCode: string }): Promise<void>;
setCtx(ctx: { accessService: IAccessService; config: { [key: string]: any }; runtimeDepsService?: RuntimeDepsService }): Promise<void>;
setCtx(ctx: { accessService: IAccessService; config: { [key: string]: any } }): Promise<void>;
}
export type PluginInputs<T = any> = {
@@ -13,5 +12,4 @@ export type PluginInputs<T = any> = {
export type SmsPluginCtx<T = any> = {
accessService: IAccessService;
config: T;
runtimeDepsService?: RuntimeDepsService;
};
@@ -1,5 +1,6 @@
import { TencentAccess } from "../../../plugins/plugin-lib/tencent/access.js";
import { ISmsService, PluginInputs, SmsPluginCtx } from "./api.js";
import { TencentAccess } from "../../../plugins/plugin-lib/tencent/access.js";
import { importRuntime } from "@certd/pipeline";
import { ISmsService, PluginInputs } from "./api.js";
export type TencentSmsConfig = {
accessId: string;
@@ -66,26 +67,16 @@ export class TencentSmsService implements ISmsService {
};
}
ctx: SmsPluginCtx<TencentSmsConfig>;
ctx: { accessService: any; config: TencentSmsConfig };
async setCtx(ctx: any) {
this.ctx = ctx;
if (this.ctx.runtimeDepsService) {
await this.ctx.runtimeDepsService.ensureDependencies({
dependencies: {
"tencentcloud-sdk-nodejs": "^4.1.112",
},
});
}
}
async getClient() {
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 sdk = await importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/sms/v20210111/index.js");
const client = sdk.v20210111.Client;
const access = await this.ctx.accessService.getById<TencentAccess>(this.ctx.config.accessId);
const access: TencentAccess = await this.ctx.accessService.getById(this.ctx.config.accessId);
// const region = this.region;
const clientConfig = {
@@ -1,3 +1,4 @@
import { importRuntime } from "@certd/pipeline";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { UserSettingsService } from "./user-settings-service.js";
import { UserTwoFactorSetting } from "./models.js";
@@ -13,13 +14,12 @@ export class TwoFactorService {
userSettingsService: UserSettingsService;
@Inject()
userService: UserService;
async getAuthenticatorQrCode(userId: any) {
const setting = await this.getSetting(userId);
const authenticatorSetting = setting.authenticator;
if (!authenticatorSetting.secret) {
const { authenticator } = await import("otplib");
const { authenticator } = await importRuntime("otplib");
authenticatorSetting.secret = authenticator.generateSecret();
await this.userSettingsService.saveSetting(userId, null, setting);
@@ -38,7 +38,7 @@ export class TwoFactorService {
async saveAuthenticator(req: { userId: any; verifyCode: any }) {
const userId = req.userId;
const { authenticator } = await import("otplib");
const { authenticator } = await importRuntime("otplib");
const setting = await this.getSetting(userId);
const authenticatorSetting = setting.authenticator;
@@ -77,7 +77,7 @@ export class TwoFactorService {
}
async verifyAuthenticatorCode(userId: any, verifyCode: string) {
const { authenticator } = await import("otplib");
const { authenticator } = await importRuntime("otplib");
const setting = await this.getSetting(userId);
if (!setting.authenticator.enabled) {
throw new Error("authenticator 未开启");
@@ -806,6 +806,8 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
}
async batchDelete(ids: number[], userId: number, projectId?: number): Promise<number> {
ids = this.filterIds(ids);
const userProjectQuery = this.buildUserProjectQuery(userId, projectId);
await this.repository.delete({
id: In(ids),
@@ -1,4 +1,4 @@
import { IServiceGetter } from "@certd/pipeline";
import { IServiceGetter } from "@certd/pipeline";
import { ApplicationContext, IMidwayContainer, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { AccessGetter, AccessService } from "@certd/lib-server";
import { CnameProxyService } from "./cname-proxy-service.js";
@@ -13,7 +13,6 @@ 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 {
@@ -39,8 +38,6 @@ 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`);
@@ -84,10 +81,6 @@ export class TaskServiceGetter implements IServiceGetter {
const domainService: DomainService = await this.appCtx.getAsync("domainService");
return new DomainVerifierGetter(this.userId, this.projectId, domainService);
}
async getRuntimeDepsService(): Promise<RuntimeDepsService> {
return await this.appCtx.getAsync("runtimeDepsService");
}
}
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -1,7 +1,7 @@
import { Config, Inject, Provide, Scope, ScopeEnum, sleep } from "@midwayjs/core";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { In, MoreThan, Repository } from "typeorm";
import { AccessService, BaseService, isEnterprise, NeedSuiteException, NeedVIPException, PageReq, SysPublicSettings, SysSettingsService, SysSiteInfo } from "@certd/lib-server";
import { AccessService, BaseService, isEnterprise, NeedSuiteException, NeedVIPException, PageReq, SysPublicSettings, SysSettingsService, SysSiteInfo, ValidateException } from "@certd/lib-server";
import { PipelineEntity } from "../entity/pipeline.js";
import { PipelineDetail } from "../entity/vo/pipeline-detail.js";
import { Executor, IAccessService, ICnameProxyService, INotificationService, Notification, Pipeline, pluginRegistry, ResultType, RunHistory, RunnableCollection, SysInfo, UserInfo } from "@certd/pipeline";
@@ -962,21 +962,25 @@ export class PipelineService extends BaseService<PipelineEntity> {
return result;
}
async batchDelete(ids: number[], userId?: number, projectId?: number):Promise<number> {
async batchDelete(ids: number[], userId?: number, projectId?: number): Promise<number> {
if (!isPlus()) {
throw new NeedVIPException("此功能需要升级Certd专业版");
}
for (const id of ids) {
if (userId && userId > 0) {
await this.checkUserId(id, userId);
}
if (projectId) {
await this.checkUserId(id, projectId, "projectId");
}
await this.delete(id);
ids.push(id);
if (!ids || ids.length === 0) {
throw new ValidateException("ids不能为空");
}
return ids.length
ids = this.filterIds(ids);
if (userId && userId > 0) {
await this.checkUserId(ids, userId);
}
if (projectId) {
await this.checkUserId(ids, projectId, "projectId");
}
for (const id of ids) {
await this.delete(id);
}
return ids.length;
}
async batchUpdateGroup(ids: number[], groupId: number, userId: any, projectId?: number) {
@@ -88,6 +88,7 @@ export class TemplateService extends BaseService<TemplateEntity> {
}
async batchDelete(ids: number[], userId: number, projectId?: number): Promise<number> {
ids = this.filterIds(ids);
const where: any = {
id: In(ids),
};
@@ -1,4 +1,4 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { addonRegistry, BaseService, PageReq } from "@certd/lib-server";
import { PluginEntity } from "../entity/plugin.js";
import { InjectEntityModel } from "@midwayjs/typeorm";
@@ -6,14 +6,13 @@ import { IsNull, Not, Repository } from "typeorm";
import { isComm } from "@certd/plus-core";
import { BuiltInPluginService } from "../../pipeline/service/builtin-plugin-service.js";
import { merge } from "lodash-es";
import { accessRegistry, notificationRegistry, pluginRegistry } from "@certd/pipeline";
import { dnsProviderRegistry } from "@certd/plugin-cert";
import { logger } from "@certd/basic";
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";
import { importRuntime as importRuntimeDirect, getRuntimeDepsService, pluginRegistry, accessRegistry, notificationRegistry } from "@certd/pipeline";
export type PluginImportReq = {
content: string;
@@ -49,9 +48,6 @@ export class PluginService extends BaseService<PluginEntity> {
@Inject()
builtInPluginService: BuiltInPluginService;
@Inject()
runtimeDepsService: RuntimeDepsService;
//@ts-ignore
getRepository() {
return this.repository;
@@ -270,7 +266,7 @@ export class PluginService extends BaseService<PluginEntity> {
return;
}
await this.registerPlugin(item);
await this.runtimeDepsService.refreshPluginDeps();
await this.refreshPluginDeps();
}
async unRegisterById(id: any) {
@@ -298,7 +294,12 @@ export class PluginService extends BaseService<PluginEntity> {
} else {
logger.warn(`不支持的插件类型:${item.pluginType}`);
}
await this.runtimeDepsService.refreshPluginDeps();
await this.refreshPluginDeps();
}
async refreshPluginDeps() {
const service = getRuntimeDepsService();
service.refreshPluginDeps();
}
async update(param: any) {
@@ -334,7 +335,7 @@ export class PluginService extends BaseService<PluginEntity> {
if (!isBareModuleSpecifier(modulePath)) {
return await importLocalModule(modulePath);
}
return await this.runtimeDepsService.importRuntime(modulePath, logger);
return await importRuntimeDirect(modulePath, logger);
}
private async getPluginClassFromFile(item: any) {
@@ -1,41 +0,0 @@
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");
});
});
@@ -1,80 +0,0 @@
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.Singleton)
export class NpmRegistryResolver {
@Config("runtimeDeps.registry")
config!: NpmRegistryResolverConfig;
private cache?: { registryUrl: string; expiresAt: number };
async resolve(): Promise<string> {
const config = this.config;
if (config?.mode === "fixed" && config.fixedUrl) {
return config.fixedUrl;
}
if (config?.mode === "system") {
return "";
}
const cached = this.cache;
if (cached && cached.expiresAt > Date.now()) {
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<RegistryProbeResult> {
const timeoutMs = this.config?.probeTimeoutMs || 3000;
const started = Date.now();
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${registryUrl.replace(/\/$/, "")}/-/ping`, { signal: controller.signal });
return {
registryUrl,
ok: res.ok,
elapsedMs: Date.now() - started,
};
} finally {
clearTimeout(timer);
}
} catch {
return {
registryUrl,
ok: false,
elapsedMs: Date.now() - started,
};
}
}
}
@@ -1,733 +0,0 @@
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";
import { logger, ILogger } from "@certd/basic";
export type RuntimeDependencyPluginDefine = {
name: string;
key?: string;
title?: string;
version?: string;
pluginType?: string;
addonType?: string;
dependPlugins?: Record<string, string>;
dependPackages?: Record<string, string>;
};
type RegisteredDefineLike = RuntimeDependencyPluginDefine & {
key?: string;
pluginType?: string;
addonType?: string;
dependPlugins?: Record<string, string>;
dependPackages?: Record<string, string>;
};
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<string, string>;
conflicts: DependencyConflict[];
};
type InstallResult = {
registryUrl: string;
packageJsonPath: string;
};
type RuntimeImportResolveResult = {
resolved: string;
packageName: string;
};
type CommandRunnerResult = {
stdout: string;
stderr: string;
code: number;
};
type CommandRunner = {
// @ts-ignore
run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult>;
};
const PROCESS_LOCKS = new Map<string, Promise<unknown>>();
class DefaultCommandRunner implements CommandRunner {
// @ts-ignore
async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult> {
return await new Promise<CommandRunnerResult>(resolve => {
let stdout = "";
let stderr = "";
let settled = false;
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
windowsHide: true,
// @ts-ignore
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.Singleton)
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<string, string> = {};
/**
* registry
*/
pluginLazyDependencies: Record<string, string> = {};
@Inject()
registryResolver!: NpmRegistryResolver;
commandRunner: CommandRunner = new DefaultCommandRunner();
private installPromises = new Map<string, Promise<InstallResult>>();
collectDependencies(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult {
const merged: Record<string, string> = {};
const seen: Record<string, Array<{ pluginName: string; range: string }>> = {};
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(options: { plugins: RuntimeDependencyPluginDefine[]; logger?: ILogger }): Promise<InstallResult> {
const { plugins, logger: log } = options;
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, logger: log });
}
async ensureDependencies(options: { dependencies: Record<string, string>; logger?: ILogger }): Promise<InstallResult> {
const { dependencies, logger: log } = options;
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) {
const nodeModulesPath = path.join(this.getRuntimeDepsRootDir(), "node_modules");
if (!fs.existsSync(nodeModulesPath)) {
this.installPromises.delete(dependenciesHash);
installPromise = undefined;
}
}
if (!installPromise) {
installPromise = this.doEnsureInstalled({ dependencies, logger: log }).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(options: { pluginKeys: string | string[]; logger?: ILogger }): Promise<InstallResult> {
const { pluginKeys, logger: log } = options;
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({ plugins: expandedPluginDefines, logger: log });
}
private async doEnsureInstalled(options: { dependencies: Record<string, string>; logger?: ILogger }): Promise<InstallResult> {
let { dependencies } = options;
const log = options.logger || logger;
return await this.withInstallLock(async () => {
const rootDir = this.getRuntimeDepsRootDir();
const packageJsonPath = path.join(rootDir, "package.json");
const lockPath = path.join(rootDir, "pnpm-lock.yaml");
log.info(`第三方依赖安装: ${JSON.stringify(dependencies)}`);
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"))) {
log.info("第三方依赖已安装");
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", "--no-frozen-lockfile", "--reporter=append-only"];
if (registryUrl) {
args.push(`--registry=${registryUrl}`);
}
log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}`);
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,
// @ts-ignore
nodeVersion: process.version,
pnpmVersion,
lockFileExists: fs.existsSync(lockPath),
lastError: message,
});
throw new Error(`动态依赖安装失败: ${message}`);
}
this.writeInstallState(statePath, {
installedAt: new Date().toISOString(),
registryUrl,
dependenciesHash,
// @ts-ignore
nodeVersion: process.version,
pnpmVersion,
lockFileExists: fs.existsSync(lockPath),
});
log.info("第三方依赖安装完成");
return { registryUrl, packageJsonPath };
});
}
async importRuntime(specifier: string, logger?: ILogger) {
if (this.isNativeImportSpecifier(specifier)) {
return await import(specifier);
}
const resolved = await this.resolveImportSpecifier(specifier, logger);
return await import(pathToFileURL(resolved).href);
}
private async resolveImportSpecifier(specifier: string, logger?: ILogger) {
try {
return this.resolveRuntimeSpecifier(specifier).resolved;
} catch (runtimeError: any) {
if (!this.isModuleNotFoundError(runtimeError)) {
throw runtimeError;
}
return await this.resolveMissingRuntimeSpecifier(specifier, runtimeError, logger);
}
}
private async resolveMissingRuntimeSpecifier(specifier: string, runtimeError: any, logger?: ILogger) {
const packageName = this.parsePackageName(specifier);
const mergedDeps = this.getMergedLazyDependencies();
const lazyRange = mergedDeps[packageName];
if (!lazyRange) {
try {
return this.resolveProjectSpecifier(specifier, runtimeError).resolved;
} catch {
throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`);
}
}
try {
await this.ensureLazyDependency(packageName, logger);
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, logger?: ILogger) {
const range = this.lazyDependencies?.[packageName];
if (!range) {
throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`);
}
const dependencies = {
[packageName]: range,
};
await this.ensureDependencies({ dependencies, logger });
}
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<string>();
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, rawName] = parts;
let name = rawName;
if (parts.length === 2) {
name = subtype;
} else if (parts.length === 3) {
//无修改
} else {
const ownerName = owner?.name || pluginKey;
throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey},请使用 plugin:name、access:name、notification:name、dnsProvider:name 或 addon:subtype:name 格式`);
}
const registryMap: Record<string, { registry: Registry<any>; key: string; pluginType: string; addonType?: string }> = {
plugin: { registry: pluginRegistry, key: name, pluginType: "plugin" },
access: { registry: accessRegistry, key: name, pluginType: "access" },
notification: { registry: notificationRegistry, key: name, pluginType: "notification" },
dnsProvider: { registry: dnsProviderRegistry, key: name, 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<T>(run: () => Promise<T>): Promise<T> {
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<void>(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);
try {
fs.rmSync(lockFile, { force: true });
} catch {
try {
fs.rmSync(lockFile, { force: true });
} catch {
// Windows 下 closeSync 后文件句柄可能未立即释放,忽略清理失败
}
}
}
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");
// @ts-ignore
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));
}
}
async clearRuntimeDeps() {
const rootDir = this.getRuntimeDepsRootDir();
const normalizedRootDir = path.normalize(rootDir);
if (!normalizedRootDir.endsWith(path.normalize(".runtime-deps"))) {
throw new Error(`动态依赖目录配置异常,拒绝清理: ${rootDir}`);
}
await this.withInstallLock(async () => {
if (fs.existsSync(rootDir)) {
const entries = fs.readdirSync(rootDir);
for (const entry of entries) {
if (entry === ".install.lock") {
continue;
}
const entryPath = path.join(rootDir, entry);
fs.rmSync(entryPath, { recursive: true, force: true });
}
}
this.installPromises.clear();
return undefined;
});
}
/**
* package.json lazyDependencies dependPackages
* 使
*/
getMergedLazyDependencies(): Record<string, string> {
return { ...this.lazyDependencies, ...this.pluginLazyDependencies };
}
/**
* dependPackages pluginLazyDependencies
*/
collectPluginDeps() {
const registries: Array<{ registry: Registry<any>; keyFormatter?: (name: string) => string }> = [
{ registry: pluginRegistry },
{ registry: accessRegistry },
{ registry: notificationRegistry },
{ registry: dnsProviderRegistry },
{ registry: addonRegistry },
];
const deps: Record<string, string> = {};
for (const { registry } of registries) {
const defineList = registry.getDefineList();
for (const define of defineList) {
const dependPackages = (define as any).dependPackages as Record<string, string> | undefined;
if (!dependPackages) {
continue;
}
for (const [pkgName, range] of Object.entries(dependPackages)) {
const existing = deps[pkgName];
if (existing && !areRangesCompatible(existing, range)) {
logger.warn(`懒加载依赖版本冲突: ${pkgName} => ${existing} vs ${range},保留已有版本`);
continue;
}
deps[pkgName] = range;
}
}
}
this.pluginLazyDependencies = deps;
logger.info(`从插件注册表收集到 ${Object.keys(deps).length} 个懒加载依赖`);
}
/**
* dependPackages importRuntime
*/
async refreshPluginDeps() {
this.collectPluginDeps();
}
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<string, string> {
if (!fs.existsSync(packageJsonPath)) {
return {};
}
try {
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
return manifest.dependencies || {};
} catch {
return {};
}
}
private mergeInstalledDependencies(installed: Record<string, string>, requested: Record<string, string>) {
const dependencies = { ...installed };
for (const [packageName, range] of Object.entries(requested)) {
const installedRange = dependencies[packageName];
if (installedRange && !areRangesCompatible(installedRange, range)) {
throw new Error(`动态依赖版本冲突: ${packageName} => installed:${installedRange}, requested:${range}`);
}
dependencies[packageName] = installedRange || range;
}
return dependencies;
}
// @ts-ignore
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) {
// @ts-ignore
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<string, string>) {
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);
}
@@ -1,4 +1,4 @@
import { AbstractDnsProvider, CreateRecordOptions, DomainRecord, DnsResolveRecord, IsDnsProvider, RemoveRecordOptions } from "@certd/plugin-cert";
import { AbstractDnsProvider, CreateRecordOptions, DomainRecord, DnsResolveRecord, IsDnsProvider, RemoveRecordOptions } from "@certd/plugin-cert";
import { AliyunAccess } from "../../plugin-lib/aliyun/access/aliyun-access.js";
import { AliyunClient } from "../../plugin-lib/aliyun/index.js";
import { Pager, PageRes, PageSearch } from "@certd/pipeline";
@@ -16,7 +16,7 @@ export class AliyunDnsProvider extends AbstractDnsProvider {
async onInstance() {
const access: AliyunAccess = this.ctx.access as AliyunAccess;
this.client = new AliyunClient({ logger: this.logger, importRuntime: this.importRuntime.bind(this) });
this.client = new AliyunClient({ logger: this.logger });
await this.client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
@@ -241,7 +241,7 @@ export class DeployCertToAliyunAckPlugin extends AbstractTaskPlugin {
}
async getClient(aliyunProvider: any, regionId: string) {
const client = new AliyunClient({ logger: this.logger, useROAClient: true, importRuntime: aliyunProvider.importRuntime.bind(aliyunProvider) });
const client = new AliyunClient({ logger: this.logger, useROAClient: true });
await client.init({
accessKeyId: aliyunProvider.accessKeyId,
accessKeySecret: aliyunProvider.accessKeySecret,
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@@ -128,7 +128,7 @@ export class AliyunDeployCertToALB extends AbstractTaskPlugin {
async onInstance() {}
async getLBClient(access: AliyunAccess, region: string) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
const version = "2020-06-16";
await client.init({
@@ -1,4 +1,4 @@
import { optionsUtils } from "@certd/basic";
import { optionsUtils } from "@certd/basic";
import { AbstractTaskPlugin, CertTargetItem, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert";
import { CertInfo, createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
@@ -209,7 +209,7 @@ export class DeployCertToAliyunCDN extends AbstractTaskPlugin {
}
async getClient(access: AliyunAccess) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
await client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, CertTargetItem, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { AbstractTaskPlugin, CertTargetItem, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import dayjs from "dayjs";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@@ -143,7 +143,7 @@ export class DeployCertToAliyunDCDN extends AbstractTaskPlugin {
}
async getClient(access: AliyunAccess) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
await client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -137,8 +137,8 @@ export class AliyunDeployCertToFC extends AbstractTaskPlugin {
const client = await this.getClient(access);
const $Util = await access.importRuntime("@alicloud/tea-util");
const $OpenApi = await access.importRuntime("@alicloud/openapi-client");
const $Util = await this.importRuntime("@alicloud/tea-util");
const $OpenApi = await this.importRuntime("@alicloud/openapi-client");
let privateKey = this.cert.key;
try {
@@ -204,7 +204,7 @@ export class AliyunDeployCertToFC extends AbstractTaskPlugin {
}
async getClient(access: AliyunAccess) {
const $OpenApi = await access.importRuntime("@alicloud/openapi-client");
const $OpenApi = await this.importRuntime("@alicloud/openapi-client");
const config = new $OpenApi.Config({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -221,8 +221,8 @@ export class AliyunDeployCertToFC extends AbstractTaskPlugin {
const access = await this.getAccess<AliyunAccess>(this.accessId);
const client = await this.getClient(access);
const $OpenApi = await access.importRuntime("@alicloud/openapi-client");
const $Util = await access.importRuntime("@alicloud/tea-util");
const $OpenApi = await this.importRuntime("@alicloud/openapi-client");
const $Util = await this.importRuntime("@alicloud/tea-util");
const params = new $OpenApi.Params({
// 接口名称
action: "ListCustomDomains",
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo, CertReader } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@@ -117,7 +117,7 @@ export class AliyunDeployCertToNLB extends AbstractTaskPlugin {
async onInstance() {}
async getLBClient(access: AliyunAccess, region: string) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
const version = "2022-04-30";
await client.init({
@@ -196,7 +196,7 @@ export class DeployCertToAliyunOSS extends AbstractTaskPlugin {
async getClient(access: AliyunAccess) {
// @ts-ignore
const OSS = await access.importRuntime("ali-oss");
const OSS = await this.importRuntime("ali-oss");
return new OSS.default({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@@ -130,7 +130,7 @@ export class AliyunDeployCertToSLB extends AbstractTaskPlugin {
async onInstance() {}
async getLBClient(access: AliyunAccess, region: string) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
const version = "2014-05-15";
await client.init({
accessKeyId: access.accessKeyId,
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AbstractTaskPlugin, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@@ -103,7 +103,7 @@ export class AliyunDeployCertToWafCloud extends AbstractTaskPlugin {
async onInstance() {}
async getWafClient(access: AliyunAccess) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
await client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -1,4 +1,4 @@
import { AbstractTaskPlugin, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AbstractTaskPlugin, IsTaskPlugin, Pager, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@@ -115,7 +115,7 @@ export class AliyunDeployCertToWaf extends AbstractTaskPlugin {
async onInstance() {}
async getWafClient(access: AliyunAccess) {
const client = new AliyunClient({ logger: this.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.logger });
await client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -101,7 +101,7 @@ export class AliossAccess extends BaseAccess {
async getClient(access: AliyunAccess) {
// @ts-ignore
const OSS = await access.importRuntime("ali-oss");
const OSS = await this.importRuntime("ali-oss");
return new OSS.default({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
@@ -1,4 +1,4 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { AliyunClientV2 } from "../lib/aliyun-client-v2.js";
import { AliyunSslClient } from "../lib/ssl-client.js";
@IsAccess({
@@ -1,5 +1,6 @@
import { ILogger } from "@certd/basic";
import { ILogger } from "@certd/basic";
import { AliyunAccess } from "../access/aliyun-access.js";
import { importRuntime as importRuntimeDirect } from "@certd/pipeline";
export type AliyunClientV2Req = {
action: string;
@@ -26,11 +27,15 @@ export class AliyunClientV2 {
this.endpoint = opts.endpoint;
}
async importRuntime(name: string) {
return await importRuntimeDirect(name, this.logger);
}
async getClient() {
if (this.client) {
return this.client;
}
const $OpenApi = await this.access.importRuntime("@alicloud/openapi-client");
const $OpenApi = await this.importRuntime("@alicloud/openapi-client");
// const Credential = await import("@alicloud/credentials");
// //@ts-ignore
// const credential = new Credential.default.default({
@@ -52,9 +57,9 @@ export class AliyunClientV2 {
async doRequest(req: AliyunClientV2Req) {
const client = await this.getClient();
const $OpenApi = await this.access.importRuntime("@alicloud/openapi-client");
const $Util = await this.access.importRuntime("@alicloud/tea-util");
const OpenApiUtil = await this.access.importRuntime("@alicloud/openapi-util");
const $OpenApi = await this.importRuntime("@alicloud/openapi-client");
const $Util = await this.importRuntime("@alicloud/tea-util");
const OpenApiUtil = await this.importRuntime("@alicloud/openapi-util");
const params = new $OpenApi.Params({
// 接口名称
action: req.action,
@@ -1,21 +1,24 @@
import { getGlobalAgents, ILogger } from "@certd/basic";
import { ImportRuntime } from "@certd/pipeline";
import { getGlobalAgents, ILogger } from "@certd/basic";
import { importRuntime as importRuntimeDirect } from "@certd/pipeline";
export class AliyunClient {
client: any;
logger: ILogger;
agent: any;
useROAClient: boolean;
importRuntime: ImportRuntime;
constructor(opts: { logger: ILogger; useROAClient?: boolean; importRuntime?: ImportRuntime }) {
constructor(opts: { logger: ILogger; useROAClient?: boolean }) {
this.logger = opts.logger;
this.useROAClient = opts.useROAClient || false;
this.importRuntime = opts.importRuntime || (async (specifier: string) => await import(specifier));
const agents = getGlobalAgents();
this.agent = agents.httpsAgent;
}
async importRuntime(specifier: string) {
return await importRuntimeDirect(specifier, this.logger);
}
async getSdk() {
if (this.useROAClient) {
return await this.getROAClient();
@@ -1,4 +1,4 @@
import { ILogger, utils } from "@certd/basic";
import { ILogger, utils } from "@certd/basic";
import { AliyunAccess } from "../access/index.js";
import { AliyunClient } from "./index.js";
import { CertInfo, CertReader, SimpleCertDetail } from "@certd/plugin-lib";
@@ -42,6 +42,7 @@ export type CasCertId = {
export class AliyunSslClient {
opts: AliyunSslClientOpts;
logger: ILogger;
constructor(opts: AliyunSslClientOpts) {
this.opts = opts;
this.logger = opts.logger;
@@ -55,7 +56,7 @@ export class AliyunSslClient {
async getClient() {
const access = this.opts.access;
const client = new AliyunClient({ logger: this.opts.logger, importRuntime: access.importRuntime.bind(access) });
const client = new AliyunClient({ logger: this.opts.logger });
let endpoint = this.opts.endpoint || "cas.aliyuncs.com";
if (this.opts.endpoint == null && this.opts.region) {
@@ -1,4 +1,4 @@
import dayjs from "dayjs";
import dayjs from "dayjs";
import { TencentAccess, TencentCosAccess, TencentCosClient } from "../../tencent/index.js";
import { BaseOssClient, OssFileItem } from "../api.js";
@@ -109,7 +109,7 @@ export class TencentAccess extends BaseAccess {
}
async getStsClient() {
const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/sts/v20180813/index.js");
const sdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/sts/v20180813/index.js");
const StsClient = sdk.v20180813.Client;
const clientConfig = {
@@ -1,21 +1,23 @@
import { TencentAccess } from "../access.js";
import { TencentAccess } from "../access.js";
import { ILogger, safePromise } from "@certd/basic";
import { ImportRuntime } from "@certd/pipeline";
import fs from "fs";
import { importRuntime as importRuntimeDirect } from "@certd/pipeline";
export class TencentCosClient {
access: TencentAccess;
logger: ILogger;
region: string;
bucket: string;
importRuntime: ImportRuntime;
constructor(opts: { access: TencentAccess; logger: ILogger; region: string; bucket: string; importRuntime?: ImportRuntime }) {
constructor(opts: { access: TencentAccess; logger: ILogger; region: string; bucket: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.bucket = opts.bucket;
this.region = opts.region;
this.importRuntime = opts.importRuntime || (async (specifier: string) => await import(specifier));
}
async importRuntime(specifier: string) {
return await importRuntimeDirect(specifier, this.logger);
}
async getCosClient() {
@@ -1,348 +0,0 @@
name: NginxProxyManagerDeploy
icon: logos:nginx
title: Nginx Proxy Manager-部署到主机
group: panel
desc: 上传自定义证书到 Nginx Proxy Manager,并绑定到所选主机。
setting: null
sysSetting: null
type: custom
disabled: false
version: 1.0.0
pluginType: deploy
author: samler
input:
cert:
title: 域名证书
helper: 请选择前置任务产出的证书。
component:
name: output-selector
from:
- ":cert:"
required: true
order: 0
certDomains:
title: 证书域名
component:
name: cert-domains-getter
mergeScript: |
return {
component: {
inputKey: ctx.compute(({ form }) => {
return form.cert;
}),
},
}
required: false
order: 0
accessId:
title: NPM授权
component:
name: access-selector
type: samler/nginxProxyManager
helper: 选择用于部署的 Nginx Proxy Manager 授权。
required: true
order: 0
proxyHostIds:
title: 代理主机
component:
name: remote-select
vModel: value
mode: tags
type: plugin
action: onGetProxyHostOptions
search: true
pager: false
multi: true
watches:
- certDomains
- accessId
required: true
helper: 选择要绑定此证书的一个或多个代理主机。
mergeScript: |
return {
component: {
form: ctx.compute(({ form }) => {
return form;
}),
},
}
order: 0
certificateLabel:
title: 证书标识
component:
name: a-input
allowClear: true
placeholder: certd_npm_example_com
helper: 可选。留空时默认使用 certd_npm_<主域名规范化>.
required: false
order: 0
cleanupMatchingCertificates:
title: 自动清理未使用证书
component:
name: a-switch
vModel: checked
helper: 部署成功后,自动删除除当前证书外所有未被任何主机引用的证书。
required: false
order: 0
output: {}
default:
strategy:
runStrategy: 1
showRunStrategy: false
content: |
const { AbstractTaskPlugin } = await import("@certd/pipeline");
const { CertReader } = await import("@certd/plugin-cert");
function normalizeDomain(domain) {
return String(domain ?? "").trim().toLowerCase();
}
function wildcardMatches(pattern, candidate) {
if (!pattern.startsWith("*.")) {
return false;
}
const suffix = pattern.slice(1).toLowerCase();
return candidate.endsWith(suffix);
}
function isDomainMatch(left, right) {
const normalizedLeft = normalizeDomain(left);
const normalizedRight = normalizeDomain(right);
return (
normalizedLeft === normalizedRight ||
wildcardMatches(normalizedLeft, normalizedRight) ||
wildcardMatches(normalizedRight, normalizedLeft)
);
}
function normalizeDomainIdentity(domain) {
return normalizeDomain(domain).replace(/^\*\./, "");
}
function certificateHasBindings(certificate) {
return (
(certificate.proxy_hosts?.length ?? 0) > 0 ||
(certificate.redirection_hosts?.length ?? 0) > 0 ||
(certificate.dead_hosts?.length ?? 0) > 0 ||
(certificate.streams?.length ?? 0) > 0
);
}
function sanitizeDomainSegment(value) {
const sanitized = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return sanitized || "unknown";
}
function buildDefaultCertificateLabel(cert) {
const mainDomain = CertReader.getMainDomain(cert.crt);
return `certd_npm_${sanitizeDomainSegment(mainDomain)}`;
}
function normalizeStringList(input) {
if (Array.isArray(input)) {
return input;
}
if (input == null || input === "") {
return [];
}
return [input];
}
function resolveCertificateDomains(cert, configuredDomains) {
const configured = normalizeStringList(configuredDomains)
.map((value) => String(value).trim())
.filter(Boolean);
if (configured.length > 0) {
return Array.from(new Set(configured));
}
return new CertReader(cert).getAllDomains();
}
function buildProxyHostLabel(host) {
const domains = host.domain_names?.length ? host.domain_names.join(", ") : "(no domains)";
return `${domains} <#${host.id}>`;
}
function hasAnyCertDomainMatch(host, certDomains) {
if (!certDomains.length) {
return false;
}
const hostDomains = host.domain_names ?? [];
return hostDomains.some((hostDomain) => certDomains.some((certDomain) => isDomainMatch(hostDomain, certDomain)));
}
function buildProxyHostOptions(hosts, certDomains) {
const sortedHosts = [...hosts].sort((left, right) => {
return buildProxyHostLabel(left).localeCompare(buildProxyHostLabel(right));
});
const matched = [];
const unmatched = [];
for (const host of sortedHosts) {
const option = {
label: buildProxyHostLabel(host),
value: String(host.id),
domain: host.domain_names?.[0] ?? "",
};
if (hasAnyCertDomainMatch(host, certDomains)) {
matched.push(option);
} else {
unmatched.push(option);
}
}
if (matched.length && unmatched.length) {
return [
{
label: "匹配证书域名的主机",
options: matched,
},
{
label: "其他代理主机",
options: unmatched,
},
];
}
return matched.length ? matched : unmatched;
}
function normalizeProxyHostIds(proxyHostIds) {
return Array.from(
new Set(
normalizeStringList(proxyHostIds)
.map((value) => Number.parseInt(String(value), 10))
.filter((value) => Number.isInteger(value) && value > 0),
),
);
}
return class NpmDeployToProxyHosts extends AbstractTaskPlugin {
cert;
certDomains;
accessId;
proxyHostIds;
certificateLabel;
cleanupMatchingCertificates = false;
async execute() {
const access = await this.getAccess(this.accessId);
const client = access.createClient();
const proxyHostIds = normalizeProxyHostIds(this.proxyHostIds);
if (proxyHostIds.length === 0) {
throw new Error("请至少选择一个 Nginx Proxy Manager 代理主机");
}
const certificateLabel = this.certificateLabel?.trim() || buildDefaultCertificateLabel(this.cert);
const certificateDomains = resolveCertificateDomains(this.cert, this.certDomains);
let certificate = await client.findCustomCertificateByNiceName(certificateLabel);
if (!certificate) {
this.logger.info(`在 Nginx Proxy Manager 中创建自定义证书 "${certificateLabel}"`);
certificate = await client.createCustomCertificate(certificateLabel, certificateDomains);
} else {
this.logger.info(`复用已有自定义证书 "${certificateLabel}" (#${certificate.id})`);
}
await client.uploadCertificate(certificate.id, {
certificate: this.cert.crt,
certificateKey: this.cert.key,
intermediateCertificate: this.cert.ic,
});
this.logger.info(`证书内容已上传到 Nginx Proxy Manager 证书 #${certificate.id}`);
for (const proxyHostId of proxyHostIds) {
this.logger.info(`将证书 #${certificate.id} 绑定到代理主机 #${proxyHostId}`);
try {
await client.assignCertificateToProxyHost(proxyHostId, certificate.id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`为代理主机 #${proxyHostId} 绑定证书失败:${message}`);
}
}
if (this.cleanupMatchingCertificates === true) {
await this.cleanupOldCertificates(client, certificate.id);
}
this.logger.info(`部署完成,共更新 ${proxyHostIds.length} 个代理主机`);
}
async onGetProxyHostOptions(req = {}) {
if (!this.accessId) {
throw new Error("请先选择 Nginx Proxy Manager 授权");
}
const access = await this.getAccess(this.accessId);
const proxyHosts = await access.getProxyHostList(req);
return buildProxyHostOptions(proxyHosts, normalizeStringList(this.certDomains));
}
async cleanupOldCertificates(client, currentCertificateId) {
const certificates = await client.getCertificatesWithExpand(undefined, [
"proxy_hosts",
"redirection_hosts",
"dead_hosts",
"streams",
]);
const candidates = certificates.filter((certificate) => {
return certificate.id !== currentCertificateId;
});
if (candidates.length === 0) {
this.logger.info("未发现可自动清理的旧证书");
return;
}
const deletedIds = [];
const skippedInUse = [];
const failedDeletes = [];
for (const candidate of candidates) {
if (certificateHasBindings(candidate)) {
skippedInUse.push(`#${candidate.id} ${candidate.nice_name}`);
continue;
}
this.logger.info(`自动清理旧证书 #${candidate.id} ${candidate.nice_name}`);
try {
await client.deleteCertificate(candidate.id);
deletedIds.push(candidate.id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failedDeletes.push(`#${candidate.id} ${candidate.nice_name}: ${message}`);
}
}
if (deletedIds.length > 0) {
this.logger.info(`自动清理完成,共删除 ${deletedIds.length} 张旧证书:${deletedIds.map((id) => `#${id}`).join(", ")}`);
} else {
this.logger.info("未删除任何旧证书");
}
if (skippedInUse.length > 0) {
this.logger.info(`以下旧证书仍被其他资源引用,已跳过清理:${skippedInUse.join(", ")}`);
}
if (failedDeletes.length > 0) {
this.logger.warn(`以下旧证书清理失败,已跳过:${failedDeletes.join(", ")}`);
}
}
};
@@ -1,344 +0,0 @@
name: nginxProxyManager
icon: logos:nginx
title: Nginx Proxy Manager 授权
group: null
desc: 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。
setting: null
sysSetting: null
type: custom
disabled: false
version: 1.0.0
pluginType: access
author: samler
input:
endpoint:
title: NPM 地址
component:
name: a-input
allowClear: true
placeholder: https://npm.example.com
helper: 请输入 Nginx Proxy Manager 根地址,不要带 /api 后缀。
required: true
email:
title: 邮箱
component:
name: a-input
allowClear: true
placeholder: admin@example.com
required: true
password:
title: 密码
component:
name: a-input-password
allowClear: true
placeholder: 请输入密码
required: true
encrypt: true
totpSecret:
title: TOTP 密钥
component:
name: a-input-password
allowClear: true
placeholder: Optional base32 TOTP secret
helper: 当 Nginx Proxy Manager 账号开启 2FA 时必填。
required: false
encrypt: true
ignoreTls:
title: 忽略无效 TLS
component:
name: a-switch
vModel: checked
helper: 仅在 Nginx Proxy Manager 使用自签 HTTPS 证书时开启。
required: false
testRequest:
title: 测试
component:
name: api-test
action: TestRequest
helper: 测试登录并拉取代理主机列表。
content: |
const { BaseAccess } = await import("@certd/pipeline");
const httpsModule = await import("node:https");
const { URL } = await import("node:url");
const axiosModule = await import("axios");
const formDataModule = await import("form-data");
const { authenticator } = await import("otplib");
const https = httpsModule.default ?? httpsModule;
const axios = axiosModule.default ?? axiosModule;
const FormData = formDataModule.default ?? formDataModule;
function normalizeEndpoint(endpoint) {
const trimmed = String(endpoint ?? "").trim();
if (!trimmed) {
throw new Error("Nginx Proxy Manager 地址不能为空");
}
const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
return withoutTrailingSlash.endsWith("/api")
? withoutTrailingSlash.slice(0, -4)
: withoutTrailingSlash;
}
function buildHttpsAgent(endpoint, ignoreTls) {
const url = new URL(endpoint);
if (url.protocol !== "https:") {
return undefined;
}
return new https.Agent({
rejectUnauthorized: !ignoreTls,
});
}
function describeError(error, action) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
const message =
data?.error?.message ||
data?.error ||
data?.message ||
(typeof data === "string" ? data : null) ||
error.message;
return new Error(`${action} failed${status ? ` (${status})` : ""}: ${message}`);
}
if (error instanceof Error) {
return new Error(`${action} failed: ${error.message}`);
}
return new Error(`${action} failed`);
}
class NginxProxyManagerClient {
constructor(options) {
this.options = options;
this.endpoint = normalizeEndpoint(options.endpoint);
this.apiBaseUrl = `${this.endpoint}/api`;
this.token = undefined;
this.tokenPromise = undefined;
this.httpClient = axios.create({
baseURL: this.apiBaseUrl,
timeout: 30000,
maxBodyLength: Infinity,
maxContentLength: Infinity,
httpsAgent: buildHttpsAgent(this.endpoint, options.ignoreTls === true),
headers: {
Accept: "application/json",
},
});
}
async verifyAccess() {
const proxyHosts = await this.getProxyHosts();
return {
proxyHostCount: proxyHosts.length,
};
}
async getProxyHosts(searchQuery) {
return await this.requestWithAuth({
method: "GET",
url: "/nginx/proxy-hosts",
params: {
expand: "certificate",
...(searchQuery ? { query: searchQuery } : {}),
},
});
}
async getCertificates(searchQuery) {
return await this.requestWithAuth({
method: "GET",
url: "/nginx/certificates",
params: searchQuery ? { query: searchQuery } : undefined,
});
}
async getCertificatesWithExpand(searchQuery, expand = []) {
return await this.requestWithAuth({
method: "GET",
url: "/nginx/certificates",
params: {
...(searchQuery ? { query: searchQuery } : {}),
...(expand.length > 0 ? { expand: expand.join(",") } : {}),
},
});
}
async findCustomCertificateByNiceName(niceName) {
const certificates = await this.getCertificates(niceName);
return certificates.find((certificate) => {
return certificate.provider === "other" && certificate.nice_name === niceName;
});
}
async createCustomCertificate(niceName, domainNames = []) {
return await this.requestWithAuth({
method: "POST",
url: "/nginx/certificates",
data: {
provider: "other",
nice_name: niceName,
domain_names: domainNames,
},
});
}
async deleteCertificate(certificateId) {
await this.requestWithAuth({
method: "DELETE",
url: `/nginx/certificates/${certificateId}`,
});
}
async uploadCertificate(certificateId, payload) {
const form = new FormData();
form.append("certificate", Buffer.from(payload.certificate, "utf8"), {
filename: "fullchain.pem",
contentType: "application/x-pem-file",
});
form.append("certificate_key", Buffer.from(payload.certificateKey, "utf8"), {
filename: "privkey.pem",
contentType: "application/x-pem-file",
});
if (payload.intermediateCertificate) {
form.append("intermediate_certificate", Buffer.from(payload.intermediateCertificate, "utf8"), {
filename: "chain.pem",
contentType: "application/x-pem-file",
});
}
await this.requestWithAuth({
method: "POST",
url: `/nginx/certificates/${certificateId}/upload`,
data: form,
headers: form.getHeaders(),
});
}
async assignCertificateToProxyHost(hostId, certificateId) {
await this.requestWithAuth({
method: "PUT",
url: `/nginx/proxy-hosts/${hostId}`,
data: {
certificate_id: certificateId,
},
});
}
async login() {
if (this.token) {
return this.token;
}
if (!this.tokenPromise) {
this.tokenPromise = this.performLogin().finally(() => {
this.tokenPromise = undefined;
});
}
this.token = await this.tokenPromise;
return this.token;
}
async performLogin() {
const initialLogin = await this.request({
method: "POST",
url: "/tokens",
data: {
identity: this.options.email,
secret: this.options.password,
},
});
if (initialLogin.token) {
return initialLogin.token;
}
if (!initialLogin.requires_2fa || !initialLogin.challenge_token) {
throw new Error("登录失败:Nginx Proxy Manager 未返回访问令牌");
}
if (!this.options.totpSecret) {
throw new Error("登录失败:该 Nginx Proxy Manager 账号启用了 2FA,但未配置 totpSecret");
}
let code;
try {
code = authenticator.generate(this.options.totpSecret);
} catch (error) {
throw describeError(error, "Generating TOTP code");
}
const completedLogin = await this.request({
method: "POST",
url: "/tokens/2fa",
data: {
challenge_token: initialLogin.challenge_token,
code,
},
});
if (!completedLogin.token) {
throw new Error("2FA 登录失败:Nginx Proxy Manager 未返回访问令牌");
}
return completedLogin.token;
}
async requestWithAuth(config) {
const token = await this.login();
const headers = {
...(config.headers ?? {}),
Authorization: `Bearer ${token}`,
};
return await this.request({
...config,
headers,
});
}
async request(config) {
const action = `${config.method ?? "GET"} ${config.url ?? "/"}`;
try {
const response = await this.httpClient.request(config);
return response.data;
} catch (error) {
throw describeError(error, action);
}
}
}
return class NginxProxyManagerAccess extends BaseAccess {
endpoint = "";
email = "";
password = "";
totpSecret = "";
ignoreTls = false;
testRequest = true;
createClient() {
return new NginxProxyManagerClient({
endpoint: this.endpoint,
email: this.email,
password: this.password,
totpSecret: this.totpSecret || undefined,
ignoreTls: this.ignoreTls === true,
});
}
async onTestRequest() {
const client = this.createClient();
const result = await client.verifyAccess();
this.ctx.logger.info(`Nginx Proxy Manager 授权验证成功,找到 ${result.proxyHostCount} 个代理主机`);
return `成功(${result.proxyHostCount} 个代理主机)`;
}
async getProxyHostList(req = {}) {
const client = this.createClient();
return await client.getProxyHosts(req.searchKey);
}
};
@@ -121,7 +121,7 @@ export class DingTalkNotification extends BaseNotification {
at.isAtAll = true;
}
const color = body.errorMessage ? "red" : "green";
const color = body.errorMessage ? "#CC0000": "#00CC66";
const res = await this.http.request({
url: webhook,
method: "POST",
@@ -142,7 +142,7 @@ export class DeployCertToTencentCosPlugin extends AbstractTaskPlugin {
async onGetDomainList(data: any) {
const access = await this.getAccess(this.accessId);
const cosv5 = await (this as any).importRuntime("cos-nodejs-sdk-v5");
const cosv5 = await this.importRuntime("cos-nodejs-sdk-v5");
const cos = new cosv5.default({
SecretId: access.secretId,
SecretKey: access.secretKey,
@@ -95,7 +95,7 @@ export class TencentDeployCertToLive extends AbstractTaskPlugin {
async getLiveClient() {
const accessProvider = await this.getAccess<TencentAccess>(this.accessId);
const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/live/v20180801/index.js");
const sdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/live/v20180801/index.js");
const CssClient = sdk.v20180801.Client;
const clientConfig = {
@@ -53,7 +53,7 @@ export class UploadCertToTencent extends AbstractTaskPlugin {
Client: any;
async onInstance() {
const sdk = await (this as any).importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js");
const sdk = await this.importRuntime("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js");
this.Client = sdk.v20191205.Client;
}
@@ -43,7 +43,11 @@ export class VolcengineAccess extends BaseAccess {
testRequest = true;
async onTestRequest() {
await this.getCallerIdentity();
const result = await this.getCallerIdentity();
this.ctx.logger.info("✅ 密钥有效!");
this.ctx.logger.info(` 账户ID: ${result.accountId}`);
this.ctx.logger.info(` ARN: ${result.arn}`);
this.ctx.logger.info(` 用户ID: ${result.userId}`);
return "ok";
}
@@ -60,18 +64,18 @@ export class VolcengineAccess extends BaseAccess {
});
const result = res.Result || {};
this.ctx.logger.info("✅ 密钥有效!");
this.ctx.logger.info(` 账户ID: ${result.AccountId}`);
this.ctx.logger.info(` ARN: ${result.Trn}`);
this.ctx.logger.info(` 用户ID: ${result.IdentityId}`);
return {
valid: true,
accountId: result.AccountId,
arn: result.Trn,
userId: result.IdentityId,
userId: parseInt(result.IdentityId),
};
}
async getUserId() {
const { userId } = await this.getCallerIdentity();
return userId;
}
}
new VolcengineAccess();
@@ -10,12 +10,15 @@ export class VolcengineCdnClient {
this.opts = opts;
}
async importRuntime(packageName: string) {
return this.opts.access.importRuntime(packageName);
}
async getCdnClient() {
if (this.service) {
return this.service;
}
const importRuntime = this.opts.importRuntime || this.opts.access.importRuntime.bind(this.opts.access);
const { cdn } = await importRuntime("@volcengine/openapi");
const { cdn } = await this.importRuntime("@volcengine/openapi");
const service = new cdn.CdnService();
// 设置ak、sk
service.setAccessKeyId(this.opts.access.accessKeyId);
@@ -19,10 +19,12 @@ export class VolcengineDnsClient {
constructor(opts: VolcengineOpts) {
this.opts = opts;
}
async importRuntime(packageName: string) {
return this.opts.access.importRuntime(packageName);
}
async doRequest(req: VolcengineReq) {
const importRuntime = this.opts.importRuntime || this.opts.access.importRuntime.bind(this.opts.access);
const { Signer } = await importRuntime("@volcengine/openapi");
const { Signer } = await this.importRuntime("@volcengine/openapi");
// http request data
const openApiRequestData: any = {
@@ -19,16 +19,23 @@ describe("VolcengineDeployToVKE", () => {
plugin.kubeconfigType = "Public";
plugin.logger = { info: () => undefined } as any;
let requestBody: any;
const kubeconfigId = await (plugin as any).createKubeconfig({
let createRequestBody: any;
const kubeconfig = ["apiVersion: v1", "clusters:", "- cluster:", " server: https://example.com", " name: vke", "contexts: []", "current-context: vke"].join("\n");
const vkeService = {
request: async (req: any) => {
requestBody = req.body;
return { Result: { Id: "kc-123" } };
if (req.action === "CreateKubeconfig") {
createRequestBody = req.body;
return { Result: { Id: "kc-123" } };
} else if (req.action === "ListKubeconfigs") {
return { Result: { Items: [{ Id: "kc-123", Kubeconfig: Buffer.from(kubeconfig).toString("base64") }] } };
}
return {};
},
});
};
const kubeconfigStr = await (plugin as any).createKubeconfig({ vkeService });
assert.equal(kubeconfigId, "kc-123");
assert.equal(requestBody.ClusterId, "cc1234567890123456789");
assert.equal(createRequestBody.ClusterId, "cc1234567890123456789");
assert.equal(kubeconfigStr, kubeconfig);
});
it("decodes the base64 kubeconfig returned by VKE", async () => {
@@ -38,16 +45,15 @@ describe("VolcengineDeployToVKE", () => {
const kubeconfig = ["apiVersion: v1", "clusters:", "- cluster:", " server: https://example.com", " name: vke", "contexts: []", "current-context: vke"].join("\n");
const result = await (plugin as any).getKubeconfig(
{
request: async () => ({
Result: {
Items: [{ Id: "kc-123", Kubeconfig: Buffer.from(kubeconfig).toString("base64") }],
},
}),
},
"kc-123"
);
const vkeService = {
request: async () => ({
Result: {
Items: [{ Id: "kc-123", Kubeconfig: Buffer.from(kubeconfig).toString("base64") }],
},
}),
};
const result = await (plugin as any).getKubeconfig({ vkeService, kubeconfigId: "kc-123" });
assert.equal(result, kubeconfig);
});
@@ -208,10 +208,10 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
this.logger.info("使用已有证书中心ID:" + certId);
}
const kubeconfigId = await this.createKubeconfig(vkeService);
try {
const kubeconfig = await this.getKubeconfig(vkeService, kubeconfigId);
const userId = await access.getUserId();
this.logger.info(`当前用户ID:${userId}`);
const kubeconfig = await this.getOrCreateKubeconfig({ vkeService, userId });
try{
const k8sClient = new this.K8sClient({
kubeConfigStr: kubeconfig,
logger: this.logger,
@@ -224,9 +224,7 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
throw new Error(this.formatK8sError(e.response.body));
}
throw e;
} finally {
await this.deleteKubeconfig(vkeService, kubeconfigId);
}
}
await utils.sleep(5000);
this.logger.info("VKE证书替换完成");
@@ -250,7 +248,19 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
return await client.getVkeService({ region: this.regionId });
}
private async createKubeconfig(vkeService: any) {
async getOrCreateKubeconfig(opts: {vkeService: any, userId: number }) : Promise<string> {
// 先查询
let kubeconfig = await this.getKubeconfig(opts);
// 如果不存在,再创建
if (!kubeconfig) {
return await this.createKubeconfig(opts);
}
return kubeconfig;
}
private async createKubeconfig(opts: {vkeService: any}) {
const {vkeService} = opts;
const clusterId = this.getClusterId();
const res = await vkeService.request({
action: "CreateKubeconfig",
@@ -258,7 +268,7 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
body: {
ClusterId: clusterId,
Type: this.kubeconfigType,
ValidDuration: 3600,
ValidDuration: 26280, //3年
},
});
const kubeconfigId = res.Result?.Id || res.Id;
@@ -266,25 +276,49 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
throw new Error(`生成VKE Kubeconfig失败:${JSON.stringify(res)}`);
}
this.logger.info(`已生成临时Kubeconfig:${kubeconfigId}`);
return kubeconfigId;
const config = await this.getKubeconfig({ vkeService, kubeconfigId });
return config;
}
private async getKubeconfig(vkeService: any, kubeconfigId: string) {
private async getKubeconfig(opts: {vkeService: any, kubeconfigId?: string, userId?:number }) {
const {vkeService, kubeconfigId, userId} = opts;
const clusterId = this.getClusterId();
const query: any = {
ClusterIds: [clusterId],
Types: [this.kubeconfigType],
}
if (kubeconfigId) {
query.Ids = [kubeconfigId];
}
if (userId) {
query.UserIds = [userId];
}
const res = await vkeService.request({
action: "ListKubeconfigs",
method: "POST",
body: {
Filter: {
ClusterIds: [clusterId],
Ids: [kubeconfigId],
Types: [this.kubeconfigType],
},
Filter: query,
PageNumber: 1,
PageSize: 10,
},
});
const items = res.Result?.Items || res.Items || [];
let items = res.Result?.Items || res.Items || [];
if (items.length === 0) {
return null;
}
const now = new Date();
items = items.filter((it: any) => {
if (!it.ExpireTime) {
return true;
}
const expireTime = new Date(it.ExpireTime);
return expireTime > now;
});
if (items.length === 0) {
return null;
}
const item = items.find((it: any) => it.Id === kubeconfigId) || items[0];
const kubeconfig = item?.Kubeconfig;
if (!kubeconfig) {
@@ -293,25 +327,25 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
return this.decodeKubeconfig(kubeconfig);
}
private async deleteKubeconfig(vkeService: any, kubeconfigId?: string) {
if (!kubeconfigId) {
return;
}
const clusterId = this.getClusterId();
try {
await vkeService.request({
action: "DeleteKubeconfigs",
method: "POST",
body: {
ClusterId: clusterId,
Ids: [kubeconfigId],
},
});
this.logger.info(`已删除临时Kubeconfig:${kubeconfigId}`);
} catch (e) {
this.logger.warn(`删除临时Kubeconfig失败:${e.message || e}`);
}
}
// private async deleteKubeconfig(vkeService: any, kubeconfigId?: string) {
// if (!kubeconfigId) {
// return;
// }
// const clusterId = this.getClusterId();
// try {
// await vkeService.request({
// action: "DeleteKubeconfigs",
// method: "POST",
// body: {
// ClusterId: clusterId,
// Ids: [kubeconfigId],
// },
// });
// this.logger.info(`已删除临时Kubeconfig:${kubeconfigId}`);
// } catch (e) {
// this.logger.warn(`删除临时Kubeconfig失败:${e.message || e}`);
// }
// }
private getClusterId() {
if (!this.clusterId) {
@@ -476,24 +510,19 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
}
const access = await this.getAccess<VolcengineAccess>(this.accessId);
const vkeService = await this.getVkeService(access);
const kubeconfigId = await this.createKubeconfig(vkeService);
try {
const kubeconfig = await this.getKubeconfig(vkeService, kubeconfigId);
const k8sClient = new this.K8sClient({
kubeConfigStr: kubeconfig,
logger: this.logger,
skipTLSVerify: this.skipTLSVerify,
});
const res = await k8sClient.getSecrets({ namespace: this.namespace || "default" });
const list = res.body?.items || res.items || [];
return list.map((item: any) => ({
label: item.metadata.name,
value: item.metadata.name,
}));
} finally {
await this.deleteKubeconfig(vkeService, kubeconfigId);
}
const userId = await access.getUserId();
const kubeconfig = await this.getOrCreateKubeconfig({ vkeService, userId });
const k8sClient = new this.K8sClient({
kubeConfigStr: kubeconfig,
logger: this.logger,
skipTLSVerify: this.skipTLSVerify,
});
const res = await k8sClient.getSecrets({ namespace: this.namespace || "default" });
const list = res.body?.items || res.items || [];
return list.map((item: any) => ({
label: item.metadata.name,
value: item.metadata.name,
}));
}
}
@@ -243,7 +243,7 @@ export class VolcengineDeployToVOD extends AbstractTaskPlugin {
return {
value: item.Domain,
label: item.Domain,
domain : item.Domain,
domain: item.Domain,
};
});
return this.ctx.utils.options.buildGroupOptions(list, this.certDomains);

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