Revert "feat(bootstrap): initialize built-in skills (#481)"

This reverts commit 90fc97e740.
This commit is contained in:
dongmucat 2026-06-05 10:48:42 +08:00
parent 90fc97e740
commit b2c4a0e34e
19 changed files with 59 additions and 1504 deletions

View file

@ -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

View file

@ -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.

View file

@ -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?

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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
}
}

View file

@ -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) {
}
}

View file

@ -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;
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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}

View file

@ -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.

View file

@ -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.

View file

@ -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);
}
}
}

View file

@ -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();
}
}

View file

@ -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"));
}
}

View file

@ -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");
}
}