mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Revert "feat(bootstrap): initialize built-in skills (#481)"
This reverts commit 90fc97e740.
This commit is contained in:
parent
90fc97e740
commit
b2c4a0e34e
19 changed files with 59 additions and 1504 deletions
|
|
@ -119,10 +119,6 @@ skillhub login --token sk_xxx --registry https://skill.xfyun.cn
|
|||
skillhub search pdf
|
||||
skillhub install pdf-parser --agent codex
|
||||
|
||||
# Verify the bundled example skill after a fresh deployment
|
||||
skillhub search skillhub-hello
|
||||
skillhub install skillhub-hello --agent codex
|
||||
|
||||
# List installed skills
|
||||
skillhub list
|
||||
```
|
||||
|
|
@ -290,7 +286,6 @@ Recommended production baseline:
|
|||
- keep PostgreSQL / Redis bound to `127.0.0.1`
|
||||
- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*`
|
||||
- change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`)
|
||||
- set `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` if you do not want the bundled `skillhub-hello` skill initialized in `@global`
|
||||
- rotate or disable the bootstrap admin after initial setup
|
||||
- run `make validate-release-config` before `docker compose up -d`
|
||||
|
||||
|
|
@ -426,8 +421,6 @@ clawhub login --token YOUR_API_TOKEN
|
|||
npx clawhub search email
|
||||
npx clawhub install my-skill
|
||||
npx clawhub install my-namespace--my-skill
|
||||
npx clawhub search skillhub-hello
|
||||
npx clawhub install skillhub-hello
|
||||
|
||||
# Publish to global namespace
|
||||
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
|
||||
|
|
|
|||
|
|
@ -1,382 +0,0 @@
|
|||
# Built-in Skills Initialization Design
|
||||
|
||||
## Context
|
||||
|
||||
New SkillHub deployments currently start without a guaranteed installable skill in the registry.
|
||||
Users must first understand publishing or find an external package before they can verify search,
|
||||
detail, download, and CLI installation flows.
|
||||
|
||||
This design adds a small, built-in example skill that is bundled with the Java service and published
|
||||
automatically to `@global` during application startup.
|
||||
|
||||
The MVP example skill is `skillhub-hello`. It is intentionally generic and does not encode an
|
||||
AgentGuard-specific product decision. Future official skills can reuse the same initialization
|
||||
mechanism.
|
||||
|
||||
## Goals
|
||||
|
||||
- Bundle one or more directory-form skills in the Java service resources.
|
||||
- Enable built-in skill initialization by default.
|
||||
- Publish built-in skills to the fixed `global` namespace.
|
||||
- Publish as `PUBLIC` and `PUBLISHED` so the skill is immediately searchable and installable.
|
||||
- Use a fixed system publisher, `builtin-skill-publisher`, for owner and audit traceability.
|
||||
- Reuse the existing `SkillPublishService.publishFromEntries(...)` pipeline.
|
||||
- Keep initialization idempotent across repeated container deployments.
|
||||
- Treat published versions as immutable: same version with changed content is skipped with a warning.
|
||||
- Avoid new seed state tables and distributed locks in the MVP.
|
||||
- Keep initialization failures non-fatal to application startup.
|
||||
- Document `skillhub-hello` as an out-of-the-box verification skill.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new database table for seed state.
|
||||
- No Redis or database distributed lock.
|
||||
- No zip-based built-in skill packages.
|
||||
- No configurable target namespace; built-in skills always publish to `global`.
|
||||
- No label creation or binding for `skillhub-hello`.
|
||||
- No landing page or frontend recommendation slot.
|
||||
- No direct SQL/JPA insertion into `skill`, `skill_version`, or `skill_file`.
|
||||
- No changes to ordinary publish, review, promotion, or lifecycle behavior.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Reason |
|
||||
|----------|--------|--------|
|
||||
| Source location | Java service classpath resources | The runtime artifact always contains the built-in package |
|
||||
| Directory | `server/skillhub-app/src/main/resources/builtin-skills/` | Spring Boot resource packaging is predictable |
|
||||
| First built-in skill | `skillhub-hello` | Generic verification skill, not product-specific |
|
||||
| Startup default | Enabled | Supports out-of-the-box discovery and installation |
|
||||
| Target namespace | Fixed `global` | Built-in examples are platform-level public skills |
|
||||
| Publication state | `PUBLIC + PUBLISHED` | Immediately searchable and installable |
|
||||
| Publisher | `builtin-skill-publisher` | Stable owner and audit source |
|
||||
| Version mutability | Same version is never overwritten | Published versions remain reproducible |
|
||||
| Seed state table | None | Existing skill/version/file records are enough for MVP idempotency |
|
||||
| Distributed lock | None | Conflict-tolerant startup is sufficient for a small built-in set |
|
||||
| Labels | None | MVP validates the built-in publish mechanism only |
|
||||
| Failure behavior | Log and continue | A sample skill must not make the service unavailable |
|
||||
| Package format | Directory only | Easier review and classpath loading |
|
||||
|
||||
## Resource Layout
|
||||
|
||||
Built-in skills live under the `skillhub-app` resource tree:
|
||||
|
||||
```text
|
||||
server/skillhub-app/src/main/resources/builtin-skills/
|
||||
skillhub-hello/
|
||||
SKILL.md
|
||||
README.md
|
||||
```
|
||||
|
||||
Each direct child directory under `builtin-skills/` is treated as one skill package.
|
||||
|
||||
Rules:
|
||||
|
||||
- The directory must contain root-level `SKILL.md`.
|
||||
- File paths are relative to the skill directory.
|
||||
- Files are converted into `PackageEntry` values.
|
||||
- Files outside the skill directory are ignored.
|
||||
- Zip packages are not supported in the MVP.
|
||||
|
||||
Suggested `skillhub-hello/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: skillhub-hello
|
||||
description: A built-in example skill that verifies SkillHub discovery and installation.
|
||||
version: 1.0.0
|
||||
---
|
||||
# SkillHub Hello
|
||||
|
||||
This skill is bundled with SkillHub as a minimal example for validating discovery and installation.
|
||||
```
|
||||
|
||||
Published coordinate:
|
||||
|
||||
```text
|
||||
@global/skillhub-hello
|
||||
```
|
||||
|
||||
ClawHub canonical slug:
|
||||
|
||||
```text
|
||||
skillhub-hello
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add one configuration property:
|
||||
|
||||
```yaml
|
||||
skillhub:
|
||||
builtin-skills:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Environment override:
|
||||
|
||||
```bash
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
||||
```
|
||||
|
||||
The MVP does not expose a namespace or locations property. The implementation uses the fixed
|
||||
classpath location:
|
||||
|
||||
```text
|
||||
classpath*:builtin-skills/*/SKILL.md
|
||||
```
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Components
|
||||
|
||||
Add the following app-layer bootstrap components:
|
||||
|
||||
- `BuiltinSkillProperties`
|
||||
- `BuiltinSkillPackageLoader`
|
||||
- `BuiltinSkillInitializer`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|----------------|
|
||||
| `BuiltinSkillProperties` | Bind `skillhub.builtin-skills.enabled` |
|
||||
| `BuiltinSkillPackageLoader` | Read classpath skill directories and construct `PackageEntry` values |
|
||||
| `BuiltinSkillInitializer` | Ensure publisher, evaluate idempotency, and call the publish pipeline |
|
||||
|
||||
These classes belong in:
|
||||
|
||||
```text
|
||||
server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/
|
||||
```
|
||||
|
||||
### Publish Pipeline
|
||||
|
||||
The initializer must call the existing domain publish service:
|
||||
|
||||
```java
|
||||
skillPublishService.publishFromEntries(
|
||||
"global",
|
||||
entries,
|
||||
"builtin-skill-publisher",
|
||||
SkillVisibility.PUBLIC,
|
||||
Set.of("SUPER_ADMIN"),
|
||||
false
|
||||
);
|
||||
```
|
||||
|
||||
This preserves existing behavior for:
|
||||
|
||||
- package policy validation
|
||||
- `SKILL.md` parsing
|
||||
- slug generation
|
||||
- `Skill` creation or reuse
|
||||
- `SkillVersion` creation
|
||||
- `PUBLISHED` state assignment
|
||||
- `latestVersionId` updates
|
||||
- `SkillFile` records
|
||||
- object storage writes
|
||||
- bundle zip creation
|
||||
- `SkillPublishedEvent`
|
||||
- after-commit search index rebuild
|
||||
|
||||
The initializer must not create skill/version/file rows directly.
|
||||
Passing `false` for warning confirmation means built-in packages with validation warnings are treated
|
||||
as package quality failures and skipped instead of being silently accepted.
|
||||
|
||||
### System Publisher
|
||||
|
||||
The initializer ensures this system user exists:
|
||||
|
||||
```text
|
||||
userId: builtin-skill-publisher
|
||||
displayName: SkillHub Built-in Publisher
|
||||
email: builtin-skill-publisher@example.invalid
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- Create `UserAccount` if missing.
|
||||
- Ensure the user is an `OWNER` member of `@global`.
|
||||
- Do not create a local login credential.
|
||||
- Do not require a persisted platform role binding.
|
||||
- Pass `Set.of("SUPER_ADMIN")` only for the publish call to reuse auto-publish behavior.
|
||||
|
||||
## Idempotency And Version Policy
|
||||
|
||||
### Content Fingerprint
|
||||
|
||||
The initializer computes a package fingerprint from current classpath resources:
|
||||
|
||||
1. Sort entries by normalized path.
|
||||
2. Hash each file content with SHA-256.
|
||||
3. Build a canonical stream of `path + fileSha256`.
|
||||
4. Hash that stream to produce the package fingerprint.
|
||||
|
||||
For an existing published version, the initializer recomputes the same fingerprint from `skill_file`:
|
||||
|
||||
1. Query the target `SkillVersion`.
|
||||
2. Query its `SkillFile` rows.
|
||||
3. Sort by `filePath`.
|
||||
4. Build the canonical stream from `filePath + sha256`.
|
||||
5. Hash that stream.
|
||||
|
||||
No new persistence field is added.
|
||||
|
||||
### Startup Rules
|
||||
|
||||
For each built-in skill:
|
||||
|
||||
| Existing state | Action |
|
||||
|----------------|--------|
|
||||
| Skill does not exist | Publish |
|
||||
| Skill exists, same version does not exist | Publish new version |
|
||||
| Same version is `PUBLISHED` and fingerprint matches | Skip |
|
||||
| Same version is `PUBLISHED` and fingerprint differs | Warn and skip |
|
||||
| Same version exists but is not `PUBLISHED` | Warn and skip |
|
||||
|
||||
Same-version content changes must bump the version in `SKILL.md`. The initializer must never
|
||||
overwrite a published version.
|
||||
|
||||
### Concurrent Startup
|
||||
|
||||
The MVP does not use a distributed lock.
|
||||
|
||||
If multiple application instances start at the same time:
|
||||
|
||||
- each instance performs the idempotency check;
|
||||
- one instance may publish first;
|
||||
- later instances may hit an existing-version conflict;
|
||||
- conflict handling should re-read the existing version and skip when a valid published version is present;
|
||||
- conflicts must be logged but must not fail application startup.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Built-in skill initialization is best-effort.
|
||||
|
||||
Rules:
|
||||
|
||||
- Failure in one built-in skill does not prevent other built-in skills from being processed.
|
||||
- Any initialization failure is logged at error level.
|
||||
- Same-version fingerprint drift is logged at warning level.
|
||||
- Same-version matching content is logged at info level.
|
||||
- Successful publishing is logged at info level.
|
||||
- Exceptions are contained inside the initializer and do not abort Spring Boot startup.
|
||||
|
||||
Log context should include:
|
||||
|
||||
- skill directory
|
||||
- resolved slug
|
||||
- resolved version
|
||||
- namespace `global`
|
||||
- action
|
||||
- error message when applicable
|
||||
|
||||
## Object Storage And Search
|
||||
|
||||
Classpath resources are only the source for initialization. Published files still go through the
|
||||
configured object storage backend:
|
||||
|
||||
- LocalFile
|
||||
- MinIO
|
||||
- S3
|
||||
|
||||
The initializer does not write object storage directly.
|
||||
|
||||
Search index updates also remain event-driven. The publish service emits `SkillPublishedEvent`, and
|
||||
the existing search listener rebuilds the search document after transaction commit.
|
||||
|
||||
## User Experience
|
||||
|
||||
The MVP does not add frontend UI.
|
||||
|
||||
After startup, users can discover and install the built-in skill through existing flows:
|
||||
|
||||
- search for `skillhub-hello`;
|
||||
- open the skill detail page;
|
||||
- copy the existing install command;
|
||||
- install with ClawHub/OpenClaw.
|
||||
|
||||
Expected command:
|
||||
|
||||
```bash
|
||||
npx clawhub install skillhub-hello --registry <your-skillhub-url>
|
||||
```
|
||||
|
||||
Because labels and recommendation slots are out of scope, this design does not guarantee permanent
|
||||
homepage prominence for `skillhub-hello`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update the user-facing docs to mention the built-in verification skill:
|
||||
|
||||
- `README.md`
|
||||
- `docs/openclaw-integration.md`
|
||||
- `docs/openclaw-integration-en.md`
|
||||
|
||||
Recommended example:
|
||||
|
||||
```bash
|
||||
npx clawhub search skillhub-hello --registry <your-skillhub-url>
|
||||
npx clawhub install skillhub-hello --registry <your-skillhub-url>
|
||||
```
|
||||
|
||||
The docs should explain:
|
||||
|
||||
- `skillhub-hello` is bundled with SkillHub;
|
||||
- it validates registry search and installation;
|
||||
- operators can disable initialization with `SKILLHUB_BUILTIN_SKILLS_ENABLED=false`.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Add backend tests for:
|
||||
|
||||
- `enabled=false` skips all publishing.
|
||||
- first startup publishes `skillhub-hello`;
|
||||
- same version and same fingerprint skips;
|
||||
- same version and different fingerprint warns and skips;
|
||||
- same version in a non-`PUBLISHED` state warns and skips;
|
||||
- publish exceptions are swallowed after logging;
|
||||
- missing system publisher is created;
|
||||
- missing `@global` membership is created;
|
||||
- loader reads classpath directories into stable `PackageEntry` order;
|
||||
- loader reports a directory missing `SKILL.md`.
|
||||
|
||||
### Local Validation
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
make test-backend-app
|
||||
```
|
||||
|
||||
If implementation touches runtime packaging or staging startup behavior, also run:
|
||||
|
||||
```bash
|
||||
make staging
|
||||
```
|
||||
|
||||
### Manual Validation
|
||||
|
||||
After local startup:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/web/skills?q=skillhub-hello"
|
||||
npx clawhub search skillhub-hello --registry http://localhost:8080
|
||||
npx clawhub install skillhub-hello --registry http://localhost:8080
|
||||
```
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Potential follow-up work:
|
||||
|
||||
- external filesystem source locations;
|
||||
- zip package support;
|
||||
- seed state table;
|
||||
- distributed lock;
|
||||
- official label binding;
|
||||
- landing page official/recommended slot;
|
||||
- AgentGuard or other official skills built on the same mechanism.
|
||||
|
||||
These are outside the MVP.
|
||||
|
|
@ -72,10 +72,6 @@ npx clawhub search find-skills
|
|||
npx clawhub search find-skills --limit 5
|
||||
npx clawhub inspect find-skills
|
||||
|
||||
# Bundled verification skill initialized by default on new deployments
|
||||
npx clawhub search skillhub-hello
|
||||
npx clawhub inspect skillhub-hello
|
||||
|
||||
# Help
|
||||
npx clawhub search --help
|
||||
npx clawhub inspect --help
|
||||
|
|
@ -104,9 +100,6 @@ npx clawhub list
|
|||
npx clawhub --dir ~/.claude/skills install find-skills
|
||||
CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills
|
||||
|
||||
# Install the bundled verification skill
|
||||
npx clawhub install skillhub-hello
|
||||
|
||||
# Help
|
||||
npx clawhub install --help
|
||||
npx clawhub update --help
|
||||
|
|
@ -217,12 +210,6 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com
|
|||
clawhub login --token sk_your_api_token_here
|
||||
```
|
||||
|
||||
New SkillHub deployments initialize the bundled `skillhub-hello` skill in `@global` by default. To disable built-in skill initialization, set this before starting the backend:
|
||||
|
||||
```bash
|
||||
export SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: How do I switch back to public ClawHub?
|
||||
|
|
|
|||
|
|
@ -72,10 +72,6 @@ npx clawhub search find-skills
|
|||
npx clawhub search find-skills --limit 5
|
||||
npx clawhub inspect find-skills
|
||||
|
||||
# 新部署默认内置的验证 Skill
|
||||
npx clawhub search skillhub-hello
|
||||
npx clawhub inspect skillhub-hello
|
||||
|
||||
# 使用帮助
|
||||
npx clawhub search --help
|
||||
npx clawhub inspect --help
|
||||
|
|
@ -104,9 +100,6 @@ npx clawhub list
|
|||
npx clawhub --dir ~/.claude/skills install find-skills
|
||||
CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills
|
||||
|
||||
# 安装默认内置的验证 Skill
|
||||
npx clawhub install skillhub-hello
|
||||
|
||||
# 使用帮助
|
||||
npx clawhub install --help
|
||||
npx clawhub update --help
|
||||
|
|
@ -217,12 +210,6 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com
|
|||
clawhub login --token sk_your_api_token_here
|
||||
```
|
||||
|
||||
SkillHub 新部署默认会在 `@global` 初始化内置 `skillhub-hello`。如需关闭内置 Skill 初始化,请在启动后端前设置:
|
||||
|
||||
```bash
|
||||
export SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何切换回公共 ClawHub?
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
final class BuiltinSkillFingerprints {
|
||||
|
||||
private BuiltinSkillFingerprints() {
|
||||
}
|
||||
|
||||
static String fromEntries(List<PackageEntry> entries) {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
entries.stream()
|
||||
.sorted(Comparator.comparing(PackageEntry::path))
|
||||
.map(entry -> entry.path() + "\0" + sha256(entry.content()))
|
||||
.forEach(line -> canonical.append(line).append('\n'));
|
||||
return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static String fromFiles(List<SkillFile> files) {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
files.stream()
|
||||
.sorted(Comparator.comparing(SkillFile::getFilePath))
|
||||
.map(file -> file.getFilePath() + "\0" + file.getSha256())
|
||||
.forEach(line -> canonical.append(line).append('\n'));
|
||||
return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String sha256(byte[] content) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(content));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to calculate SHA-256 fingerprint", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
|
||||
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
|
||||
import com.iflytek.skillhub.domain.namespace.SlugValidator;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Publishes bundled example skills into the global namespace during startup.
|
||||
*/
|
||||
@Component
|
||||
public class BuiltinSkillInitializer implements ApplicationRunner {
|
||||
|
||||
static final String BUILTIN_PUBLISHER_ID = "builtin-skill-publisher";
|
||||
static final String GLOBAL_NAMESPACE = "global";
|
||||
private static final String BUILTIN_PUBLISHER_NAME = "SkillHub Built-in Publisher";
|
||||
private static final String BUILTIN_PUBLISHER_EMAIL = "builtin-skill-publisher@example.invalid";
|
||||
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class);
|
||||
|
||||
private final BuiltinSkillProperties properties;
|
||||
private final BuiltinSkillPackageLoader packageLoader;
|
||||
private final SkillMetadataParser metadataParser;
|
||||
private final SkillPackageValidator packageValidator;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillFileRepository skillFileRepository;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public BuiltinSkillInitializer(BuiltinSkillProperties properties,
|
||||
BuiltinSkillPackageLoader packageLoader,
|
||||
SkillMetadataParser metadataParser,
|
||||
SkillPackageValidator packageValidator,
|
||||
SkillPublishService skillPublishService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillFileRepository skillFileRepository,
|
||||
PlatformTransactionManager transactionManager) {
|
||||
this.properties = properties;
|
||||
this.packageLoader = packageLoader;
|
||||
this.metadataParser = metadataParser;
|
||||
this.packageValidator = packageValidator;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillFileRepository = skillFileRepository;
|
||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!properties.isEnabled()) {
|
||||
log.info("Built-in skill initialization is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Namespace globalNamespace;
|
||||
try {
|
||||
globalNamespace = ensurePublisher();
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Failed to prepare built-in skill publisher, skipping built-in skill initialization",
|
||||
exception);
|
||||
return;
|
||||
}
|
||||
if (globalNamespace == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<BuiltinSkillPackageLoader.BuiltinSkillPackage> packages;
|
||||
try {
|
||||
packages = packageLoader.loadPackages();
|
||||
} catch (Exception exception) {
|
||||
log.error("Failed to load built-in skill packages", exception);
|
||||
return;
|
||||
}
|
||||
|
||||
for (BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage : packages) {
|
||||
try {
|
||||
initializePackage(globalNamespace, skillPackage);
|
||||
} catch (Exception exception) {
|
||||
log.error("Failed to initialize built-in skill package [directory={}]",
|
||||
skillPackage.directory(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Namespace ensurePublisher() {
|
||||
return transactionTemplate.execute(status -> {
|
||||
Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE)
|
||||
.orElse(null);
|
||||
if (globalNamespace == null) {
|
||||
log.error("Missing built-in global namespace, skipping built-in skill initialization");
|
||||
return null;
|
||||
}
|
||||
|
||||
UserAccount publisher = userAccountRepository.findById(BUILTIN_PUBLISHER_ID)
|
||||
.orElseGet(() -> new UserAccount(
|
||||
BUILTIN_PUBLISHER_ID,
|
||||
BUILTIN_PUBLISHER_NAME,
|
||||
BUILTIN_PUBLISHER_EMAIL,
|
||||
null
|
||||
));
|
||||
publisher.setDisplayName(BUILTIN_PUBLISHER_NAME);
|
||||
publisher.setEmail(BUILTIN_PUBLISHER_EMAIL);
|
||||
publisher.setStatus(UserStatus.ACTIVE);
|
||||
userAccountRepository.save(publisher);
|
||||
|
||||
NamespaceMember member = namespaceMemberRepository
|
||||
.findByNamespaceIdAndUserId(globalNamespace.getId(), BUILTIN_PUBLISHER_ID)
|
||||
.orElseGet(() -> new NamespaceMember(globalNamespace.getId(), BUILTIN_PUBLISHER_ID, NamespaceRole.OWNER));
|
||||
if (member.getRole() != NamespaceRole.OWNER) {
|
||||
member.setRole(NamespaceRole.OWNER);
|
||||
}
|
||||
namespaceMemberRepository.save(member);
|
||||
return globalNamespace;
|
||||
});
|
||||
}
|
||||
|
||||
private void initializePackage(Namespace globalNamespace,
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage) {
|
||||
List<PackageEntry> entries = skillPackage.entries();
|
||||
SkillMetadata metadata = parseMetadata(skillPackage.directory(), entries);
|
||||
if (metadata.version() == null || metadata.version().isBlank()) {
|
||||
log.error("Built-in skill package is missing an explicit version [directory={}]",
|
||||
skillPackage.directory());
|
||||
return;
|
||||
}
|
||||
String skillSlug = SlugValidator.slugify(metadata.name());
|
||||
|
||||
ValidationResult validation = packageValidator.validate(entries);
|
||||
if (!validation.passed() || validation.hasWarnings()) {
|
||||
log.error("Built-in skill package failed validation [directory={}, slug={}, version={}, errors={}, warnings={}]",
|
||||
skillPackage.directory(), skillSlug, metadata.version(), validation.errors(), validation.warnings());
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPublishedOtherOwnerConflict(globalNamespace.getId(), skillSlug)) {
|
||||
log.warn("Skipping built-in skill because another owner already published the slug [directory={}, namespace={}, slug={}]",
|
||||
skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug);
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Skill> existingBuiltInSkill =
|
||||
skillRepository.findByNamespaceIdAndSlugAndOwnerId(globalNamespace.getId(), skillSlug, BUILTIN_PUBLISHER_ID);
|
||||
if (existingBuiltInSkill.isPresent()
|
||||
&& shouldSkipExistingVersion(skillPackage, existingBuiltInSkill.get(), metadata.version())) {
|
||||
return;
|
||||
}
|
||||
|
||||
publishPackage(skillPackage, skillSlug, metadata.version());
|
||||
}
|
||||
|
||||
private SkillMetadata parseMetadata(String directory, List<PackageEntry> entries) {
|
||||
PackageEntry skillMd = entries.stream()
|
||||
.filter(entry -> "SKILL.md".equals(entry.path()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Built-in package missing SKILL.md: " + directory));
|
||||
return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private boolean hasPublishedOtherOwnerConflict(Long namespaceId, String skillSlug) {
|
||||
return skillRepository.findByNamespaceIdAndSlug(namespaceId, skillSlug).stream()
|
||||
.filter(skill -> !BUILTIN_PUBLISHER_ID.equals(skill.getOwnerId()))
|
||||
.anyMatch(skill -> !skillVersionRepository
|
||||
.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED)
|
||||
.isEmpty());
|
||||
}
|
||||
|
||||
private boolean shouldSkipExistingVersion(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage,
|
||||
Skill skill,
|
||||
String version) {
|
||||
Optional<SkillVersion> existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version);
|
||||
if (existingVersion.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SkillVersion skillVersion = existingVersion.get();
|
||||
if (skillVersion.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
log.warn("Skipping built-in skill because the same version is not published [directory={}, skillId={}, version={}, status={}]",
|
||||
skillPackage.directory(), skill.getId(), version, skillVersion.getStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries());
|
||||
String existingFingerprint = BuiltinSkillFingerprints.fromFiles(skillFileRepository.findByVersionId(skillVersion.getId()));
|
||||
if (currentFingerprint.equals(existingFingerprint)) {
|
||||
log.info("Built-in skill version already exists, skipping [directory={}, skillId={}, version={}]",
|
||||
skillPackage.directory(), skill.getId(), version);
|
||||
} else {
|
||||
log.warn("Skipping built-in skill because same published version has different content [directory={}, skillId={}, version={}]",
|
||||
skillPackage.directory(), skill.getId(), version);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void publishPackage(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage,
|
||||
String skillSlug,
|
||||
String version) {
|
||||
try {
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
|
||||
GLOBAL_NAMESPACE,
|
||||
skillPackage.entries(),
|
||||
BUILTIN_PUBLISHER_ID,
|
||||
SkillVisibility.PUBLIC,
|
||||
Set.of("SUPER_ADMIN"),
|
||||
false
|
||||
);
|
||||
log.info("Published built-in skill [directory={}, namespace={}, slug={}, version={}, status={}]",
|
||||
skillPackage.directory(), GLOBAL_NAMESPACE, result.slug(),
|
||||
result.version().getVersion(), result.version().getStatus());
|
||||
} catch (RuntimeException exception) {
|
||||
ConcurrentVersionState concurrentVersionState =
|
||||
findConcurrentPublishedBuiltInVersionState(skillPackage, skillSlug, version);
|
||||
if (concurrentVersionState == ConcurrentVersionState.MATCHING) {
|
||||
log.warn("Built-in skill was already published concurrently, skipping [directory={}, namespace={}, slug={}, version={}]",
|
||||
skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version);
|
||||
return;
|
||||
}
|
||||
if (concurrentVersionState == ConcurrentVersionState.DIFFERENT) {
|
||||
log.warn("Skipping built-in skill because concurrently published same version has different content [directory={}, namespace={}, slug={}, version={}]",
|
||||
skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version);
|
||||
return;
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private ConcurrentVersionState findConcurrentPublishedBuiltInVersionState(
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage,
|
||||
String skillSlug,
|
||||
String version) {
|
||||
return namespaceRepository.findBySlug(GLOBAL_NAMESPACE)
|
||||
.flatMap(namespace -> skillRepository.findByNamespaceIdAndSlugAndOwnerId(
|
||||
namespace.getId(),
|
||||
skillSlug,
|
||||
BUILTIN_PUBLISHER_ID
|
||||
))
|
||||
.flatMap(skill -> skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version))
|
||||
.filter(skillVersion -> skillVersion.getStatus() == SkillVersionStatus.PUBLISHED)
|
||||
.map(skillVersion -> {
|
||||
String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries());
|
||||
String existingFingerprint = BuiltinSkillFingerprints.fromFiles(
|
||||
skillFileRepository.findByVersionId(skillVersion.getId()));
|
||||
if (currentFingerprint.equals(existingFingerprint)) {
|
||||
return ConcurrentVersionState.MATCHING;
|
||||
}
|
||||
return ConcurrentVersionState.DIFFERENT;
|
||||
})
|
||||
.orElse(ConcurrentVersionState.MISSING);
|
||||
}
|
||||
|
||||
private enum ConcurrentVersionState {
|
||||
MISSING,
|
||||
MATCHING,
|
||||
DIFFERENT
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.controller.support.SkillPackageContentTypeResolver;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Loads directory-form built-in skill packages from classpath resources.
|
||||
*/
|
||||
@Component
|
||||
public class BuiltinSkillPackageLoader {
|
||||
|
||||
static final String BUILTIN_SKILLS_PATTERN = "classpath*:builtin-skills/*/SKILL.md";
|
||||
private static final String ROOT_MARKER = "builtin-skills/";
|
||||
private static final String SKILL_MD = "SKILL.md";
|
||||
|
||||
private final ResourcePatternResolver resourcePatternResolver;
|
||||
|
||||
public BuiltinSkillPackageLoader() {
|
||||
this(new PathMatchingResourcePatternResolver());
|
||||
}
|
||||
|
||||
BuiltinSkillPackageLoader(ResourcePatternResolver resourcePatternResolver) {
|
||||
this.resourcePatternResolver = resourcePatternResolver;
|
||||
}
|
||||
|
||||
public List<BuiltinSkillPackage> loadPackages() throws IOException {
|
||||
Set<String> directories = discoverPackageDirectories();
|
||||
List<BuiltinSkillPackage> packages = new ArrayList<>();
|
||||
for (String directory : directories) {
|
||||
List<PackageEntry> packageEntries = loadPackageEntries(directory);
|
||||
boolean hasSkillMd = packageEntries.stream().anyMatch(packageEntry -> SKILL_MD.equals(packageEntry.path()));
|
||||
if (hasSkillMd) {
|
||||
packages.add(new BuiltinSkillPackage(directory, packageEntries));
|
||||
}
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
private Set<String> discoverPackageDirectories() throws IOException {
|
||||
Resource[] skillMdResources = resourcePatternResolver.getResources(BUILTIN_SKILLS_PATTERN);
|
||||
Set<String> directories = new TreeSet<>();
|
||||
for (Resource resource : skillMdResources) {
|
||||
String relativePath = relativeBuiltinPath(resource);
|
||||
if (relativePath == null || relativePath.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
int separatorIndex = relativePath.indexOf('/');
|
||||
if (separatorIndex > 0 && SKILL_MD.equals(relativePath.substring(separatorIndex + 1))) {
|
||||
directories.add(relativePath.substring(0, separatorIndex));
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
private List<PackageEntry> loadPackageEntries(String directory) throws IOException {
|
||||
Resource[] resources = resourcePatternResolver.getResources("classpath*:builtin-skills/" + directory + "/**");
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
for (Resource resource : resources) {
|
||||
String relativePath = relativeBuiltinPath(resource);
|
||||
if (relativePath == null || relativePath.isBlank() || relativePath.endsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
String directoryPrefix = directory + "/";
|
||||
if (!relativePath.startsWith(directoryPrefix) || directoryPrefix.length() == relativePath.length()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String packagePath = SkillPackagePolicy.normalizeEntryPath(relativePath.substring(directoryPrefix.length()));
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
byte[] content = inputStream.readAllBytes();
|
||||
entries.add(new PackageEntry(
|
||||
packagePath,
|
||||
content,
|
||||
content.length,
|
||||
SkillPackageContentTypeResolver.determineContentType(packagePath)
|
||||
));
|
||||
}
|
||||
}
|
||||
return entries.stream()
|
||||
.sorted(Comparator.comparing(PackageEntry::path))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String relativeBuiltinPath(Resource resource) throws IOException {
|
||||
String url = resource.getURL().toExternalForm();
|
||||
int markerIndex = url.lastIndexOf(ROOT_MARKER);
|
||||
if (markerIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
return url.substring(markerIndex + ROOT_MARKER.length());
|
||||
}
|
||||
|
||||
public record BuiltinSkillPackage(String directory, List<PackageEntry> entries) {
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Configuration for publishing bundled example skills at application startup.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.builtin-skills")
|
||||
public class BuiltinSkillProperties {
|
||||
private boolean enabled = true;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ public class MultipartPackageExtractor {
|
|||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
SkillPackageContentTypeResolver.determineContentType(normalizedPath)
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -113,4 +113,12 @@ public class MultipartPackageExtractor {
|
|||
return path;
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
if (filename.endsWith(".py")) return "text/x-python";
|
||||
if (filename.endsWith(".json")) return "application/json";
|
||||
if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml";
|
||||
if (filename.endsWith(".txt")) return "text/plain";
|
||||
if (filename.endsWith(".md")) return "text/markdown";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ public class SkillPackageArchiveExtractor {
|
|||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
SkillPackageContentTypeResolver.determineContentType(normalizedPath)
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
zis.closeEntry();
|
||||
}
|
||||
|
|
@ -189,4 +189,28 @@ public class SkillPackageArchiveExtractor {
|
|||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
/**
|
||||
* Resolves package entry content types from filenames for upload and built-in package ingestion.
|
||||
*/
|
||||
public final class SkillPackageContentTypeResolver {
|
||||
|
||||
private SkillPackageContentTypeResolver() {
|
||||
}
|
||||
|
||||
public static String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ public class ZipPackageExtractor {
|
|||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
SkillPackageContentTypeResolver.determineContentType(normalizedPath)
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
zis.closeEntry();
|
||||
}
|
||||
|
|
@ -119,4 +119,28 @@ public class ZipPackageExtractor {
|
|||
}
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,8 +93,6 @@ spring:
|
|||
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
|
||||
|
||||
skillhub:
|
||||
builtin-skills:
|
||||
enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true}
|
||||
auth:
|
||||
mock:
|
||||
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# SkillHub Hello
|
||||
|
||||
This built-in example skill is published to `@global` when SkillHub starts.
|
||||
|
||||
Use it to verify that skill discovery and CLI installation are working in a new deployment.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: skillhub-hello
|
||||
description: A built-in example skill that verifies SkillHub discovery and installation.
|
||||
version: 1.0.0
|
||||
---
|
||||
# SkillHub Hello
|
||||
|
||||
This skill is bundled with SkillHub as a minimal example for validating discovery and installation.
|
||||
|
|
@ -1,383 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
|
||||
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.DefaultApplicationArguments;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class BuiltinSkillInitializerTest {
|
||||
|
||||
private static final String PUBLISHER_ID = BuiltinSkillInitializer.BUILTIN_PUBLISHER_ID;
|
||||
|
||||
@Mock private BuiltinSkillPackageLoader packageLoader;
|
||||
@Mock private SkillPackageValidator packageValidator;
|
||||
@Mock private SkillPublishService skillPublishService;
|
||||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private NamespaceMemberRepository namespaceMemberRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
@Mock private SkillRepository skillRepository;
|
||||
@Mock private SkillVersionRepository skillVersionRepository;
|
||||
@Mock private SkillFileRepository skillFileRepository;
|
||||
@Mock private PlatformTransactionManager transactionManager;
|
||||
|
||||
private BuiltinSkillProperties properties;
|
||||
private BuiltinSkillInitializer initializer;
|
||||
private Namespace globalNamespace;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new BuiltinSkillProperties();
|
||||
globalNamespace = new Namespace("global", "Global", "system");
|
||||
ReflectionTestUtils.setField(globalNamespace, "id", 1L);
|
||||
lenient().when(transactionManager.getTransaction(any())).thenAnswer(ignored -> new SimpleTransactionStatus());
|
||||
lenient().when(packageValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
|
||||
initializer = new BuiltinSkillInitializer(
|
||||
properties,
|
||||
packageLoader,
|
||||
new SkillMetadataParser(),
|
||||
packageValidator,
|
||||
skillPublishService,
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository,
|
||||
userAccountRepository,
|
||||
skillRepository,
|
||||
skillVersionRepository,
|
||||
skillFileRepository,
|
||||
transactionManager
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledInitializerDoesNotLoadPackages() throws Exception {
|
||||
properties.setEnabled(false);
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(packageLoader, never()).loadPackages();
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstStartupCreatesPublisherMembershipAndPublishesPackage() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
when(skillPublishService.publishFromEntries(
|
||||
eq("global"),
|
||||
eq(skillPackage.entries()),
|
||||
eq(PUBLISHER_ID),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)
|
||||
)).thenReturn(publishResult("1.0.0"));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(userAccountRepository).save(any(UserAccount.class));
|
||||
verify(namespaceMemberRepository).save(any(NamespaceMember.class));
|
||||
verify(skillPublishService).publishFromEntries(
|
||||
"global",
|
||||
skillPackage.entries(),
|
||||
PUBLISHER_ID,
|
||||
SkillVisibility.PUBLIC,
|
||||
Set.of("SUPER_ADMIN"),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingPublisherAndMembershipAreReusedIdempotently() throws Exception {
|
||||
UserAccount existingPublisher = new UserAccount(PUBLISHER_ID, "Old name", "old@example.invalid", null);
|
||||
NamespaceMember existingMember = new NamespaceMember(1L, PUBLISHER_ID, NamespaceRole.MEMBER);
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace));
|
||||
when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.of(existingPublisher));
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID))
|
||||
.thenReturn(Optional.of(existingMember));
|
||||
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of());
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(userAccountRepository).save(existingPublisher);
|
||||
verify(namespaceMemberRepository).save(existingMember);
|
||||
org.assertj.core.api.Assertions.assertThat(existingMember.getRole()).isEqualTo(NamespaceRole.OWNER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationWarningsSkipPublishWithoutConfirmingWarnings() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(packageValidator.validate(skillPackage.entries()))
|
||||
.thenReturn(new ValidationResult(true, List.of(), List.of("warning")));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePublishedVersionWithSameFingerprintSkipsPublish() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
Skill skill = builtInSkill(11L);
|
||||
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
|
||||
when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries()));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePublishedVersionWithDifferentFingerprintSkipsPublish() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
Skill skill = builtInSkill(11L);
|
||||
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
|
||||
when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of(
|
||||
new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key")
|
||||
));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameNonPublishedVersionSkipsPublish() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
Skill skill = builtInSkill(11L);
|
||||
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.UPLOADED);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(skillFileRepository, never()).findByVersionId(any());
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void newerBuiltInVersionPublishesWhenOlderVersionExists() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.1", "Hello");
|
||||
Skill skill = builtInSkill(11L);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.1")).thenReturn(Optional.empty());
|
||||
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
|
||||
.thenReturn(publishResult("1.0.1"));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(skillPublishService).publishFromEntries(
|
||||
"global",
|
||||
skillPackage.entries(),
|
||||
PUBLISHER_ID,
|
||||
SkillVisibility.PUBLIC,
|
||||
Set.of("SUPER_ADMIN"),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOwnedPublishedSlugSkipsBuiltInPublish() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
Skill otherSkill = new Skill(1L, "skillhub-hello", "user-1", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(otherSkill, "id", 33L);
|
||||
SkillVersion otherPublishedVersion = version(33L, 44L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(33L, SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of(otherPublishedVersion));
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
verify(skillRepository, never()).findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID);
|
||||
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureIsContainedAndDoesNotAbortStartup() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
|
||||
.thenThrow(new IllegalStateException("storage unavailable"));
|
||||
|
||||
assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0])))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentPublishedSameFingerprintSkipsAfterPublishFailure() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
Skill skill = builtInSkill(11L);
|
||||
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.empty(), Optional.of(skill));
|
||||
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
|
||||
.thenThrow(new IllegalStateException("version exists"));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
|
||||
when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries()));
|
||||
|
||||
assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0])))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentPublishedDifferentFingerprintSkipsAfterPublishFailure() throws Exception {
|
||||
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
|
||||
Skill skill = builtInSkill(11L);
|
||||
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
setupPublisher();
|
||||
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
|
||||
.thenReturn(Optional.empty(), Optional.of(skill));
|
||||
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
|
||||
.thenThrow(new IllegalStateException("version exists"));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
|
||||
when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of(
|
||||
new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key")
|
||||
));
|
||||
|
||||
assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0])))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private void setupPublisher() {
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace));
|
||||
when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)).thenReturn(Optional.empty());
|
||||
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
private BuiltinSkillPackageLoader.BuiltinSkillPackage packageWithVersion(String version, String body) {
|
||||
byte[] skillMd = ("""
|
||||
---
|
||||
name: skillhub-hello
|
||||
description: SkillHub hello
|
||||
version: %s
|
||||
---
|
||||
# SkillHub Hello
|
||||
%s
|
||||
""".formatted(version, body)).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] readme = "# SkillHub Hello\n".getBytes(StandardCharsets.UTF_8);
|
||||
return new BuiltinSkillPackageLoader.BuiltinSkillPackage("skillhub-hello", List.of(
|
||||
new PackageEntry("README.md", readme, readme.length, "text/markdown"),
|
||||
new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown")
|
||||
));
|
||||
}
|
||||
|
||||
private Skill builtInSkill(Long id) {
|
||||
Skill skill = new Skill(1L, "skillhub-hello", PUBLISHER_ID, SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(skill, "id", id);
|
||||
return skill;
|
||||
}
|
||||
|
||||
private SkillVersion version(Long skillId, Long versionId, String version, SkillVersionStatus status) {
|
||||
SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER_ID);
|
||||
skillVersion.setStatus(status);
|
||||
ReflectionTestUtils.setField(skillVersion, "id", versionId);
|
||||
return skillVersion;
|
||||
}
|
||||
|
||||
private SkillPublishService.PublishResult publishResult(String version) {
|
||||
SkillVersion skillVersion = version(11L, 22L, version, SkillVersionStatus.PUBLISHED);
|
||||
return new SkillPublishService.PublishResult(11L, "skillhub-hello", skillVersion);
|
||||
}
|
||||
|
||||
private List<SkillFile> filesFor(Long versionId, List<PackageEntry> entries) {
|
||||
return entries.stream()
|
||||
.map(entry -> new SkillFile(
|
||||
versionId,
|
||||
entry.path(),
|
||||
entry.size(),
|
||||
entry.contentType(),
|
||||
sha256(entry.content()),
|
||||
"skills/11/" + versionId + "/" + entry.path()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String sha256(byte[] content) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(content));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BuiltinSkillPackageLoaderTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void loadsSkillhubHelloFromClasspath() throws Exception {
|
||||
BuiltinSkillPackageLoader loader =
|
||||
new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver());
|
||||
|
||||
var packages = loader.loadPackages();
|
||||
|
||||
var skillhubHello = packages.stream()
|
||||
.filter(skillPackage -> skillPackage.directory().equals("skillhub-hello"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(skillhubHello.entries())
|
||||
.extracting(entry -> entry.path())
|
||||
.containsExactly("README.md", "SKILL.md");
|
||||
assertThat(skillhubHello.entries())
|
||||
.allSatisfy(entry -> assertThat(entry.contentType()).isEqualTo("text/markdown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionSkillhubHelloResourcePassesPackageValidation() throws Exception {
|
||||
BuiltinSkillPackageLoader loader =
|
||||
new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver());
|
||||
SkillPackageValidator validator = new SkillPackageValidator(new SkillMetadataParser());
|
||||
|
||||
var skillhubHello = loader.loadPackages().stream()
|
||||
.filter(skillPackage -> skillPackage.directory().equals("skillhub-hello"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
var result = validator.validate(skillhubHello.entries());
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.warnings()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsBuiltInPackageFromJarClasspath() throws Exception {
|
||||
Path jarPath = tempDir.resolve("builtin-skills.jar");
|
||||
try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarPath))) {
|
||||
writeJarDirectory(jarOutputStream, "builtin-skills/");
|
||||
writeJarDirectory(jarOutputStream, "builtin-skills/skillhub-hello/");
|
||||
writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/SKILL.md", """
|
||||
---
|
||||
name: skillhub-hello
|
||||
description: SkillHub hello
|
||||
version: 1.0.0
|
||||
---
|
||||
# SkillHub Hello
|
||||
""");
|
||||
writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/README.md", "# SkillHub Hello\n");
|
||||
}
|
||||
|
||||
try (URLClassLoader classLoader = new URLClassLoader(
|
||||
new java.net.URL[]{jarPath.toUri().toURL()},
|
||||
null
|
||||
)) {
|
||||
BuiltinSkillPackageLoader loader =
|
||||
new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver(classLoader));
|
||||
|
||||
var packages = loader.loadPackages();
|
||||
|
||||
assertThat(packages).hasSize(1);
|
||||
assertThat(packages.getFirst().directory()).isEqualTo("skillhub-hello");
|
||||
assertThat(packages.getFirst().entries())
|
||||
.extracting(entry -> entry.path())
|
||||
.containsExactly("README.md", "SKILL.md");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fingerprintsAreStableAcrossEntryOrdering() {
|
||||
byte[] skillMd = """
|
||||
---
|
||||
name: demo
|
||||
description: Demo
|
||||
version: 1.0.0
|
||||
---
|
||||
# Demo
|
||||
""".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
byte[] readme = "# Demo\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var first = List.of(
|
||||
new com.iflytek.skillhub.domain.skill.validation.PackageEntry(
|
||||
"SKILL.md", skillMd, skillMd.length, "text/markdown"),
|
||||
new com.iflytek.skillhub.domain.skill.validation.PackageEntry(
|
||||
"README.md", readme, readme.length, "text/markdown")
|
||||
);
|
||||
var second = List.of(first.get(1), first.get(0));
|
||||
|
||||
assertThat(BuiltinSkillFingerprints.fromEntries(first))
|
||||
.isEqualTo(BuiltinSkillFingerprints.fromEntries(second));
|
||||
}
|
||||
|
||||
private void writeJarDirectory(JarOutputStream jarOutputStream, String name) throws Exception {
|
||||
jarOutputStream.putNextEntry(new JarEntry(name));
|
||||
jarOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
private void writeJarEntry(JarOutputStream jarOutputStream, String name, String content) throws Exception {
|
||||
jarOutputStream.putNextEntry(new JarEntry(name));
|
||||
jarOutputStream.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
jarOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BuiltinSkillPropertiesBindingTest {
|
||||
|
||||
@Test
|
||||
void defaultConfigEnablesBuiltinSkills() throws Exception {
|
||||
BuiltinSkillProperties properties = bindProperties(Map.of());
|
||||
|
||||
assertThat(properties.isEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void environmentVariableCanDisableBuiltinSkills() throws Exception {
|
||||
BuiltinSkillProperties properties = bindProperties(Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false"));
|
||||
|
||||
assertThat(properties.isEnabled()).isFalse();
|
||||
}
|
||||
|
||||
private BuiltinSkillProperties bindProperties(Map<String, Object> envVars) throws Exception {
|
||||
ConfigurableEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-env", envVars));
|
||||
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
for (org.springframework.core.env.PropertySource<?> propertySource :
|
||||
loader.load("application.yml", new ClassPathResource("application.yml"))) {
|
||||
environment.getPropertySources().addLast(propertySource);
|
||||
}
|
||||
ConfigurationPropertySources.attach(environment);
|
||||
|
||||
return Binder.get(environment)
|
||||
.bind("skillhub.builtin-skills", BuiltinSkillProperties.class)
|
||||
.orElseThrow(() -> new IllegalStateException("Failed to bind built-in skill properties"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SkillPackageContentTypeResolverTest {
|
||||
|
||||
@Test
|
||||
void determinesKnownContentTypes() {
|
||||
assertThat(SkillPackageContentTypeResolver.determineContentType("SKILL.md")).isEqualTo("text/markdown");
|
||||
assertThat(SkillPackageContentTypeResolver.determineContentType("script.py")).isEqualTo("text/x-python");
|
||||
assertThat(SkillPackageContentTypeResolver.determineContentType("config.yaml")).isEqualTo("application/x-yaml");
|
||||
assertThat(SkillPackageContentTypeResolver.determineContentType("image.png")).isEqualTo("image/png");
|
||||
assertThat(SkillPackageContentTypeResolver.determineContentType("archive.bin")).isEqualTo("application/octet-stream");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue