diff --git a/README.md b/README.md index 8f0e4860..d47ef5cc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/20-builtin-skills-design.md b/docs/20-builtin-skills-design.md deleted file mode 100644 index d968df5a..00000000 --- a/docs/20-builtin-skills-design.md +++ /dev/null @@ -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 -``` - -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 -npx clawhub install skillhub-hello --registry -``` - -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. diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 77d93e5e..32cb2878 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -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? diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index 353676c3..f85f588e 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -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? diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java deleted file mode 100644 index 836f733f..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java +++ /dev/null @@ -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 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 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); - } - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java deleted file mode 100644 index 21c58f95..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ /dev/null @@ -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 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 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 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 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 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 - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java deleted file mode 100644 index 9775691f..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java +++ /dev/null @@ -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 loadPackages() throws IOException { - Set directories = discoverPackageDirectories(); - List packages = new ArrayList<>(); - for (String directory : directories) { - List 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 discoverPackageDirectories() throws IOException { - Resource[] skillMdResources = resourcePatternResolver.getResources(BUILTIN_SKILLS_PATTERN); - Set 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 loadPackageEntries(String directory) throws IOException { - Resource[] resources = resourcePatternResolver.getResources("classpath*:builtin-skills/" + directory + "/**"); - List 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 entries) { - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java deleted file mode 100644 index 298a1af5..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java +++ /dev/null @@ -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; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 481cda72..0a9fc793 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -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"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 489928bd..e2aa41c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -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"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java deleted file mode 100644 index 25224e96..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java +++ /dev/null @@ -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"; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index e9c993f6..2beaec70 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -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"; + } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7d9a1cbc..a592b035 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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} diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md deleted file mode 100644 index 4cff8bc4..00000000 --- a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md +++ /dev/null @@ -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. diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md deleted file mode 100644 index 8a313b0c..00000000 --- a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md +++ /dev/null @@ -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. diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java deleted file mode 100644 index 299a4ddb..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ /dev/null @@ -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 filesFor(Long versionId, List 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); - } - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java deleted file mode 100644 index 143da879..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java +++ /dev/null @@ -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(); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java deleted file mode 100644 index 04461428..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java +++ /dev/null @@ -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 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")); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java deleted file mode 100644 index 90166104..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java +++ /dev/null @@ -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"); - } -}