diff --git a/README.md b/README.md index d47ef5cc..8f0e4860 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ 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 ``` @@ -286,6 +290,7 @@ 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` @@ -421,6 +426,8 @@ 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 new file mode 100644 index 00000000..d968df5a --- /dev/null +++ b/docs/20-builtin-skills-design.md @@ -0,0 +1,382 @@ +# 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/21-builtin-skills-implementation-plan.md b/docs/21-builtin-skills-implementation-plan.md new file mode 100644 index 00000000..630a7fe0 --- /dev/null +++ b/docs/21-builtin-skills-implementation-plan.md @@ -0,0 +1,270 @@ +# Built-in Skills Implementation Plan + +> **Spec:** `docs/20-builtin-skills-design.md` + +> **Goal:** Implement the MVP built-in skill initialization flow for `skillhub-hello`, using Java service resources and publishing to `@global` on startup. + +## Scope + +Implement only the MVP decisions from the design: + +- Java service classpath resource source. +- `skillhub-hello` as the first built-in skill. +- Initialization enabled by default. +- Fixed target namespace `global`. +- `PUBLIC + PUBLISHED` publication. +- System publisher `builtin-skill-publisher`. +- Idempotent same-version skip. +- Same-version content drift warning and skip. +- No seed table. +- No distributed lock. +- No label binding. +- No frontend UI changes. + +## Work Items + +### 1. Add Built-In Skill Resource + +- [ ] Create `server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/`. +- [ ] Add `SKILL.md` with `name`, `description`, and `version: 1.0.0`. +- [ ] Add a concise `README.md` explaining this is a bundled verification skill. +- [ ] Confirm the package passes the existing skill package policy. + +Acceptance criteria: + +- `skillhub-hello/SKILL.md` is at the package root. +- The package can be converted to `PackageEntry` values without path rewriting. + +### 2. Add Configuration Binding + +- [ ] Add `BuiltinSkillProperties` under `com.iflytek.skillhub.bootstrap`. +- [ ] Bind prefix `skillhub.builtin-skills`. +- [ ] Add `enabled=true` default. +- [ ] Register the configuration properties if current application setup requires explicit registration. +- [ ] Add a properties binding test. + +Acceptance criteria: + +- `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` disables initialization. +- Default behavior is enabled. + +### 3. Implement Package Loader + +- [ ] Add `BuiltinSkillPackageLoader`. +- [ ] Discover built-in packages with a jar-safe classpath scan. +- [ ] Use `PathMatchingResourcePatternResolver` or an equivalent Spring resource resolver. +- [ ] Discover package roots by matching `classpath*:builtin-skills/*/SKILL.md` instead of enumerating `classpath:builtin-skills/` as a filesystem directory. +- [ ] Require root-level `SKILL.md` for each skill directory. +- [ ] Normalize relative paths with `/` separators. +- [ ] Reject unsafe paths if encountered. +- [ ] Build `PackageEntry` records with content bytes, size, and content type. +- [ ] Sort entries deterministically by path. +- [ ] Extract shared content-type resolution instead of adding a third private mapping copy. +- [ ] Reuse that shared content-type helper from upload archive extraction and the built-in loader where practical. + +Acceptance criteria: + +- Loader returns one package for `skillhub-hello`. +- Entry order is stable. +- Missing `SKILL.md` is reported without crashing the whole initializer. + +### 4. Implement Fingerprint Logic + +- [ ] Add package fingerprint calculation from `PackageEntry` list. +- [ ] Calculate per-file SHA-256. +- [ ] Calculate aggregate SHA-256 from sorted `path + sha256` pairs. +- [ ] Add existing-version fingerprint calculation from `SkillFile` rows. +- [ ] Keep this logic package-private or in a small helper, covered by unit tests. + +Acceptance criteria: + +- Same entries in different input order produce the same fingerprint. +- Changing file content changes the fingerprint. +- Changing file path changes the fingerprint. + +### 5. Implement System Publisher Setup + +- [ ] In initializer flow, ensure `UserAccount("builtin-skill-publisher")` exists. +- [ ] Ensure `@global` namespace exists or log error and skip built-in publish. +- [ ] Ensure the publisher has `NamespaceRole.OWNER` membership in `@global`. +- [ ] Do not create local credentials. +- [ ] Do not persist a real platform role binding unless existing code requires it. +- [ ] Keep system publisher setup transactionally separate from per-package publish attempts. + +Acceptance criteria: + +- Fresh database startup creates the system publisher and membership. +- Existing publisher and membership are reused idempotently. + +### 6. Implement BuiltinSkillInitializer + +- [ ] Add `BuiltinSkillInitializer` as an `ApplicationRunner`. +- [ ] Do not annotate `run(...)` with a single outer `@Transactional`. +- [ ] Keep exception handling outside the transactional publish call so rollback-only state cannot escape and fail application startup. +- [ ] If helper methods need transactions, split them into separate bean methods or services so Spring transaction proxies apply correctly. +- [ ] Exit early when `enabled=false`. +- [ ] Load built-in packages. +- [ ] Parse each package's `SKILL.md` metadata to resolve slug and version. +- [ ] For each package, check existing `@global/{slug}`, owner, and version state. +- [ ] Only manage skills owned by `builtin-skill-publisher`. +- [ ] If the same slug has a published version owned by another user, log a warning and skip the built-in package. +- [ ] Publish when no same version exists. +- [ ] Skip when same version is `PUBLISHED` and fingerprint matches. +- [ ] Warn and skip when same version is `PUBLISHED` and fingerprint differs. +- [ ] Warn and skip when same version exists but is not `PUBLISHED`. +- [ ] Catch expected publish conflicts from concurrent startup and re-check existing version. +- [ ] Catch unexpected exceptions per package and continue. + +Acceptance criteria: + +- Repeated startup of the same artifact publishes only once. +- Rebuilding the app with modified `skillhub-hello` content but unchanged version does not overwrite the existing version. +- A new `version` in `SKILL.md` publishes a new version. +- Any single package failure does not abort application startup. +- A user-owned `@global/skillhub-hello` is not overwritten or replaced by the built-in initializer. +- A caught publish failure cannot mark an outer startup transaction rollback-only. + +### 7. Reuse Publish Pipeline + +- [ ] Call `SkillPublishService.publishFromEntries(...)`. +- [ ] Use namespace `global`. +- [ ] Use publisher `builtin-skill-publisher`. +- [ ] Use `SkillVisibility.PUBLIC`. +- [ ] Pass `Set.of("SUPER_ADMIN")`. +- [ ] Pass `confirmWarnings=false`. +- [ ] Treat any package validation or pre-publish warning as a built-in package quality issue. +- [ ] If warnings are present, log an error and skip the package instead of confirming them. + +Acceptance criteria: + +- Published version is `PUBLISHED`. +- `latestVersionId` points to the built-in version. +- `SkillFile` rows and bundle object are created by the existing publish service. +- `SkillPublishedEvent` is emitted through the existing path. + +### 8. Add Tests + +- [ ] Add loader tests. +- [ ] Add properties binding test. +- [ ] Add initializer tests for disabled mode. +- [ ] Add initializer tests for first publish. +- [ ] Add initializer tests for same-version same-fingerprint skip. +- [ ] Add initializer tests for same-version changed-fingerprint skip. +- [ ] Add initializer tests for non-published same version skip. +- [ ] Add initializer tests for a newer built-in version publishing when an older built-in version already exists. +- [ ] Add initializer tests for same slug owned by another published skill owner warning and skipping. +- [ ] Add initializer tests for publish exception swallow-and-log behavior. +- [ ] Add system publisher and membership idempotency tests. +- [ ] Add tests that publish failures do not occur inside a single outer initializer transaction. +- [ ] Add a test that loads the real `skillhub-hello` resource and validates it with `SkillPackageValidator`. + +Acceptance criteria: + +- Tests cover all MVP idempotency branches. +- Tests do not require real object storage. +- Tests cover jar-safe resource discovery behavior as closely as practical without relying on filesystem-only assumptions. + +### 9. Update Documentation + +- [ ] Update `README.md` with `skillhub-hello` search/install verification commands. +- [ ] Update `docs/openclaw-integration.md`. +- [ ] Update `docs/openclaw-integration-en.md`. +- [ ] Mention `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` for operators who want to disable built-in initialization. + +Acceptance criteria: + +- Users can find a documented command to search and install `skillhub-hello`. +- Docs do not mention AgentGuard as the MVP built-in skill. + +### 10. Validation + +- [ ] Run `make test-backend-app`. +- [ ] Run `make staging` because this feature depends on packaged runtime resources and startup behavior. +- [ ] Manually start local app and verify `skillhub-hello` appears in search. +- [ ] Verify reinstall/restart does not create duplicate published versions. + +Acceptance criteria: + +- Backend tests pass. +- Packaged/staging startup can discover `skillhub-hello` from classpath resources. +- Manual search returns `skillhub-hello`. +- Repeated startup is idempotent. + +### 11. Code Review + +- [ ] Perform a focused backend code review after tests and validation pass. +- [ ] Review transaction boundaries around `BuiltinSkillInitializer`. +- [ ] Verify `run(...)` is not wrapped in a single outer transaction. +- [ ] Verify publish failures cannot mark startup rollback-only or abort application startup. +- [ ] Verify classpath scanning is jar-safe and does not rely on filesystem-only directory enumeration. +- [ ] Verify the initializer only manages skills owned by `builtin-skill-publisher`. +- [ ] Verify same-version published skills are never overwritten. +- [ ] Verify warning handling uses `confirmWarnings=false` and skips invalid built-in packages. +- [ ] Verify no label, seed table, distributed lock, frontend UI, or controller/API drift was introduced. +- [ ] Verify tests cover all MVP idempotency and failure branches. + +Acceptance criteria: + +- Review findings are resolved or explicitly accepted before PR. +- No unresolved blocker remains in startup reliability, idempotency, ownership, or resource packaging. + +## Implementation Order + +1. Add `skillhub-hello` resource. +2. Add properties binding. +3. Extract shared content-type helper. +4. Add jar-safe loader and fingerprint helper. +5. Add initializer with separated transaction boundaries, publisher setup, owner checks, and idempotency checks. +6. Add tests. +7. Update README and OpenClaw docs. +8. Run backend and staging validation. +9. Perform focused code review and resolve findings. + +## Files Expected To Change + +Backend: + +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java` +- `server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md` +- `server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md` + +Tests: + +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java` + +Docs: + +- `README.md` +- `docs/openclaw-integration.md` +- `docs/openclaw-integration-en.md` + +No generated OpenAPI files are expected to change because this implementation does not add or modify controllers. + +## Risks And Mitigations + +| Risk | Mitigation | +|------|------------| +| Classpath resource scanning behaves differently from filesystem scanning | Test loader against test resources and verify packaged startup | +| Same-version conflict during multi-instance startup | Catch conflict, re-read existing version, skip if published | +| Publish service writes object storage before a later failure | Keep failure non-fatal and rely on existing publish behavior; avoid custom storage writes | +| Built-in package accidentally changes without version bump | Fingerprint mismatch warning and skip | +| System publisher missing foreign-key requirements | Ensure `UserAccount` and `@global` membership before publish | +| Publish failure marks an outer transaction rollback-only | Do not wrap initializer `run(...)` in one transaction; catch outside publish transactions | +| User already owns the target slug | Only manage built-in-publisher-owned skills; warn and skip other owners | +| Built-in package warnings are silently accepted | Use `confirmWarnings=false`; treat warnings as package quality failures | + +## Out Of Scope Follow-Ups + +- Labels such as `official` or `example`. +- Homepage or landing recommended sections. +- Seed state table. +- Distributed lock. +- External `file:` locations. +- Zip package support. +- AgentGuard as a built-in official skill. diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 32cb2878..77d93e5e 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -72,6 +72,10 @@ 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 @@ -100,6 +104,9 @@ 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 @@ -210,6 +217,12 @@ 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 f85f588e..353676c3 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -72,6 +72,10 @@ 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 @@ -100,6 +104,9 @@ 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 @@ -210,6 +217,12 @@ 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 new file mode 100644 index 00000000..836f733f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java @@ -0,0 +1,42 @@ +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 new file mode 100644 index 00000000..21c58f95 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -0,0 +1,295 @@ +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 new file mode 100644 index 00000000..9775691f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java @@ -0,0 +1,107 @@ +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 new file mode 100644 index 00000000..298a1af5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java @@ -0,0 +1,21 @@ +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 0a9fc793..481cda72 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, - determineContentType(normalizedPath) + SkillPackageContentTypeResolver.determineContentType(normalizedPath) )); } } @@ -113,12 +113,4 @@ 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 e2aa41c3..489928bd 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, - determineContentType(normalizedPath) + SkillPackageContentTypeResolver.determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -189,28 +189,4 @@ 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 new file mode 100644 index 00000000..25224e96 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java @@ -0,0 +1,35 @@ +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 2beaec70..e9c993f6 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, - determineContentType(normalizedPath) + SkillPackageContentTypeResolver.determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -119,28 +119,4 @@ 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 a592b035..7d9a1cbc 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -93,6 +93,8 @@ 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 new file mode 100644 index 00000000..4cff8bc4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..8a313b0c --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md @@ -0,0 +1,8 @@ +--- +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 new file mode 100644 index 00000000..299a4ddb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -0,0 +1,383 @@ +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 new file mode 100644 index 00000000..143da879 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java @@ -0,0 +1,124 @@ +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 new file mode 100644 index 00000000..04461428 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java @@ -0,0 +1,47 @@ +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 new file mode 100644 index 00000000..90166104 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java @@ -0,0 +1,17 @@ +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"); + } +}