diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 4f9a120c..559ccd2f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -177,7 +177,7 @@ public class SkillController extends BaseApiController { detail.manifestJson(), detail.complianceMappings().stream() .map(mapping -> new SkillComplianceMappingResponse( - mapping.standard().value(), + mapping.standard(), mapping.standardVersion(), mapping.controlId(), mapping.controlTitle(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 1f5af863..b0cb424f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; @@ -40,6 +41,10 @@ public class SkillSearchController extends BaseApiController { @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(name = "label", required = false) java.util.List labels, + @Parameter( + name = "complianceStandard", + schema = @Schema(implementation = ComplianceStandard.class) + ) @RequestParam(required = false) String complianceStandard, @Parameter(schema = @Schema(defaultValue = DEFAULT_SORT)) @RequestParam(required = false) String sort, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java index 96d4c3bc..2a71d6d2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.dto; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; + public record SkillComplianceMappingResponse( - String standard, + ComplianceStandard standard, String standardVersion, String controlId, String controlTitle, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index f62c1905..6b83ce6d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.search.SearchQuery; @@ -169,7 +170,7 @@ public class SkillSearchAppService { int page, int size, List labelSlugs, - String complianceStandard, + ComplianceStandard complianceStandard, SearchVisibilityScope scope, boolean requireInstallableLatest) { SearchResult result = searchQueryService.search(new SearchQuery( @@ -198,11 +199,11 @@ public class SkillSearchAppService { .toList(); } - private String normalizeComplianceStandard(String complianceStandard) { + private ComplianceStandard normalizeComplianceStandard(String complianceStandard) { if (complianceStandard == null || complianceStandard.isBlank()) { return null; } - return complianceStandard.trim().toLowerCase(java.util.Locale.ROOT); + return ComplianceStandard.findByValue(complianceStandard).orElse(null); } private List mapVisibleSkillSummaries(List skillIds) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 6e1244b9..fb83d13c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -28,7 +28,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.mockito.ArgumentMatchers.any; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -127,6 +129,16 @@ class SkillControllerTest { } } + @Test + void openApiShouldExposeClosedComplianceStandardEnums() throws Exception { + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"SkillComplianceMappingResponse\""))) + .andExpect(content().string(containsString("\"standard\""))) + .andExpect(content().string(containsString("\"enum\":[\"mitre_attack\",\"nist_csf\",\"gdpr\",\"hipaa\",\"soc2\"]"))) + .andExpect(content().string(containsString("\"name\":\"complianceStandard\""))); + } + @Test void resolveVersionShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.resolveVersion( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index c99e767e..1e71f982 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -12,6 +12,7 @@ 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.ComplianceStandard; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -313,7 +314,7 @@ class SkillSearchAppServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); verify(searchQueryService).search(captor.capture()); - assertEquals("gdpr", captor.getValue().complianceStandard()); + assertEquals(ComplianceStandard.GDPR, captor.getValue().complianceStandard()); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java index 0aea577c..136a45d3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java @@ -37,15 +37,21 @@ public class SkillComplianceAuditDetailFactory { return build("latest_published_removed", version, extras); } - public String build(String snapshotKind, SkillVersion version, Map extras) { + public String latestPublishedRemoved(SkillVersion version, + SkillVersion replacementLatestPublished, + Map extras) { LinkedHashMap payload = new LinkedHashMap<>(); - payload.put("snapshotKind", snapshotKind); - payload.put("versionId", version.getId()); - payload.put("version", version.getVersion()); - payload.put( - "compliance", - complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson()) - ); + if (extras != null && !extras.isEmpty()) { + payload.putAll(extras); + } + if (replacementLatestPublished != null) { + payload.put("replacementLatestPublished", snapshotPayload("latest_published_entered", replacementLatestPublished)); + } + return build("latest_published_removed", version, payload); + } + + public String build(String snapshotKind, SkillVersion version, Map extras) { + LinkedHashMap payload = snapshotPayload(snapshotKind, version); if (extras != null && !extras.isEmpty()) { payload.putAll(extras); } @@ -55,4 +61,16 @@ public class SkillComplianceAuditDetailFactory { throw new IllegalStateException("Failed to serialize compliance audit detail", ex); } } + + private LinkedHashMap snapshotPayload(String snapshotKind, SkillVersion version) { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("snapshotKind", snapshotKind); + payload.put("versionId", version.getId()); + payload.put("version", version.getVersion()); + payload.put( + "compliance", + complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson()) + ); + return payload; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index e3540f93..dc15f376 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -270,13 +270,14 @@ public class SkillGovernanceService { version.setYankReason(reason); version.setDownloadReady(false); SkillVersion saved = skillVersionRepository.save(version); - skillRepository.findById(version.getSkillId()).ifPresent(skill -> { - if (versionId.equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); - skill.setUpdatedBy(actorUserId); - skillRepository.save(skill); - } - }); + SkillVersion replacementLatestPublished = null; + Skill skill = skillRepository.findById(version.getSkillId()).orElse(null); + if (skill != null && versionId.equals(skill.getLatestVersionId())) { + replacementLatestPublished = findLatestPublishedVersion(skill.getId()); + skill.setLatestVersionId(replacementLatestPublished != null ? replacementLatestPublished.getId() : null); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } LinkedHashMap auditExtras = new LinkedHashMap<>(); if (reason != null && !reason.isBlank()) { auditExtras.put("reason", reason); @@ -289,20 +290,36 @@ public class SkillGovernanceService { null, clientIp, userAgent, - complianceAuditDetailFactory.latestPublishedRemoved(version, auditExtras) + complianceAuditDetailFactory.latestPublishedRemoved(version, replacementLatestPublished, auditExtras) ); + if (replacementLatestPublished != null) { + auditLogService.record( + actorUserId, + "YANK_SKILL_VERSION", + "SKILL_VERSION", + replacementLatestPublished.getId(), + null, + clientIp, + userAgent, + complianceAuditDetailFactory.latestPublishedEntered(replacementLatestPublished, auditExtras) + ); + } eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent( version.getSkillId(), versionId, actorUserId)); return saved; } private Long findLatestPublishedVersionId(Long skillId) { + SkillVersion latestPublishedVersion = findLatestPublishedVersion(skillId); + return latestPublishedVersion != null ? latestPublishedVersion.getId() : null; + } + + private SkillVersion findLatestPublishedVersion(Long skillId) { return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream() .max(java.util.Comparator .comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) .thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) .thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))) - .map(SkillVersion::getId) .orElse(null); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 09d972b7..af93bb93 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.doThrow; @@ -38,6 +39,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -212,11 +214,37 @@ class SkillGovernanceServiceTest { setField(yanked, "id", 22L); yanked.setStatus(SkillVersionStatus.PUBLISHED); yanked.setPublishedAt(Instant.parse("2026-03-18T10:00:00Z")); + yanked.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "Article-17" + } + ] + } + } + """); SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner"); setField(fallback, "id", 11L); fallback.setStatus(SkillVersionStatus.PUBLISHED); fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z")); + fallback.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); setField(skill, "id", 2L); @@ -232,6 +260,33 @@ class SkillGovernanceServiceTest { assertThat(skill.getLatestVersionId()).isEqualTo(11L); verify(skillRepository).save(skill); + + ArgumentCaptor entityIdCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(String.class); + verify(auditLogService, times(2)).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq("SKILL_VERSION"), + entityIdCaptor.capture(), + org.mockito.ArgumentMatchers.eq(null), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("JUnit"), + detailCaptor.capture() + ); + assertThat(entityIdCaptor.getAllValues()).containsExactlyInAnyOrder(22L, 11L); + assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> { + assertThat(detail).contains("\"snapshotKind\":\"latest_published_removed\""); + assertThat(detail).contains("\"versionId\":22"); + assertThat(detail).contains("\"replacementLatestPublished\""); + assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\""); + assertThat(detail).contains("\"versionId\":11"); + assertThat(detail).contains("\"standard\":\"soc2\""); + }); + assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> { + assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\""); + assertThat(detail).contains("\"versionId\":11"); + assertThat(detail).contains("\"standard\":\"soc2\""); + }); } @Test diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java index 3b96642f..e1c3ceb7 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.search; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import java.util.List; /** @@ -13,7 +14,7 @@ public record SearchQuery( int page, int size, List labelSlugs, - String complianceStandard, + ComplianceStandard complianceStandard, boolean requireInstallableLatest ) { public SearchQuery( diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 55081655..e093069a 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.postgres; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -275,11 +276,11 @@ public class PostgresFullTextQueryService implements SearchQueryService { return new SearchResult(skillIds, total, query.page(), query.size()); } - private String normalizeComplianceStandard(String complianceStandard) { - if (complianceStandard == null || complianceStandard.isBlank()) { + private String normalizeComplianceStandard(ComplianceStandard complianceStandard) { + if (complianceStandard == null) { return null; } - return complianceStandard.trim().toLowerCase(Locale.ROOT); + return complianceStandard.value(); } private List rerankBySemanticSimilarity(List candidateSkillIds, diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index bf11bc84..7d8ec808 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.search.postgres; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; import com.iflytek.skillhub.search.HashingSearchEmbeddingService; @@ -318,7 +319,7 @@ class PostgresFullTextQueryServiceTest { 0, 20, List.of("official"), - "gdpr", + ComplianceStandard.GDPR, false )); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index b6394e78..d69ac94d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4164,7 +4164,7 @@ export interface components { complianceMappings?: components["schemas"]["SkillComplianceMappingResponse"][]; }; SkillComplianceMappingResponse: { - standard?: string; + standard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2"; standardVersion?: string; controlId?: string; controlTitle?: string; @@ -8516,7 +8516,7 @@ export interface operations { q?: string; namespace?: string; label?: string[]; - complianceStandard?: string; + complianceStandard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2"; sort?: string; page?: number; size?: number; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index fd1ab1ee..1baf372d 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -256,7 +256,7 @@ export interface SkillVersion { } export interface SkillComplianceMapping { - standard: string + standard: ComplianceStandard standardVersion: string controlId: string controlTitle?: string @@ -332,12 +332,20 @@ export interface SkillTag { createdAt: string } +export const COMPLIANCE_STANDARD_VALUES = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const + +export type ComplianceStandard = (typeof COMPLIANCE_STANDARD_VALUES)[number] + +export function isComplianceStandard(value: string): value is ComplianceStandard { + return COMPLIANCE_STANDARD_VALUES.some((candidate) => candidate === value) +} + // Search and pagination export interface SearchParams { q?: string namespace?: string label?: string - complianceStandard?: string + complianceStandard?: ComplianceStandard sort?: string page?: number size?: number diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 9104a2d6..fde277b0 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -1,10 +1,12 @@ import { lazy, Suspense, type ComponentType } from 'react' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' +import type { ComplianceStandard } from '@/api/types' import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { createRequireAuth } from '@/shared/lib/auth-route' import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { isComplianceStandard } from '@/api/types' /** * Central route registry for the SkillHub web app. @@ -199,12 +201,13 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; namespace?: string; label?: string; complianceStandard?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; complianceStandard?: ComplianceStandard; sort: string; page: number; starredOnly: boolean } => { + const rawComplianceStandard = typeof search.complianceStandard === 'string' ? search.complianceStandard : '' return { q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined, label: typeof search.label === 'string' && search.label ? search.label : undefined, - complianceStandard: typeof search.complianceStandard === 'string' && search.complianceStandard ? search.complianceStandard : undefined, + complianceStandard: isComplianceStandard(rawComplianceStandard) ? rawComplianceStandard : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', diff --git a/web/src/features/skill/install-command.test.ts b/web/src/features/skill/install-command.test.ts index 60b8ee3a..3b2e4eea 100644 --- a/web/src/features/skill/install-command.test.ts +++ b/web/src/features/skill/install-command.test.ts @@ -68,6 +68,12 @@ describe('install-command', () => { ) }) + it('adds an explicit version to the ClawHub install command when a version is selected', () => { + expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe( + 'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version 0.9.0', + ) + }) + it('builds a one-line SkillHub npx command for the global namespace', () => { expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe( 'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn', @@ -80,6 +86,12 @@ describe('install-command', () => { ) }) + it('adds an explicit version to the SkillHub install command when a version is selected', () => { + expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe( + 'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version 0.9.0', + ) + }) + it('uses the runtime app base url when available', () => { setMockWindow('https://app.example.com') diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 3f409b9d..d1581848 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -29,14 +29,21 @@ export function getBaseUrl(): string { return `${window.location.protocol}//${window.location.host}` } -export function buildInstallCommand(namespace: string, slug: string, baseUrl: string): string { - const installTarget = buildInstallTarget(namespace, slug) - return `npx clawhub install ${installTarget} --registry ${baseUrl}` +function buildVersionedCommand(command: string, version?: string): string { + if (!version) { + return command + } + return `${command} --version ${version}` } -export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string { +export function buildInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string { + const installTarget = buildInstallTarget(namespace, slug) + return buildVersionedCommand(`npx clawhub install ${installTarget} --registry ${baseUrl}`, version) +} + +export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string { const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}` - return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}` + return buildVersionedCommand(`npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`, version) } interface CommandBlockProps { @@ -80,11 +87,11 @@ function CommandBlock({ command }: CommandBlockProps) { ) } -export function InstallCommand({ namespace, slug }: InstallCommandProps) { +export function InstallCommand({ namespace, slug, version }: InstallCommandProps) { const { t } = useTranslation() const baseUrl = useMemo(() => getBaseUrl(), []) - const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug]) - const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug]) + const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version]) + const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version]) return ( diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index ea739fe8..d4730c23 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' -import type { SkillSummary } from '@/api/types' +import { COMPLIANCE_STANDARD_VALUES, type ComplianceStandard, type SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' import { SearchBar } from '@/features/search/search-bar' import { SkillCard } from '@/features/skill/skill-card' @@ -17,7 +17,6 @@ import { Button } from '@/shared/ui/button' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 -const COMPLIANCE_STANDARD_OPTIONS = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const function blurActiveElement() { if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { @@ -94,7 +93,7 @@ export function SearchPage() { const q = normalizeSearchQuery(searchParams.q || '') const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' - const complianceStandard = searchParams.complianceStandard || '' + const complianceStandard = searchParams.complianceStandard const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false @@ -105,7 +104,7 @@ export function SearchPage() { q: string namespace: string label: string - complianceStandard: string + complianceStandard?: ComplianceStandard sort: string page: number starredOnly: boolean @@ -142,7 +141,7 @@ export function SearchPage() { q, namespace: namespace || undefined, label: selectedLabel || undefined, - complianceStandard: complianceStandard || undefined, + complianceStandard, sort, page, size: PAGE_SIZE, @@ -228,8 +227,8 @@ export function SearchPage() { navigate({ to: '/search', search: buildSearchState({ label: nextLabel, page: 0 }) }) } - const handleComplianceToggle = (standard: string) => { - const nextStandard = complianceStandard === standard ? '' : standard + const handleComplianceToggle = (standard: ComplianceStandard) => { + const nextStandard = complianceStandard === standard ? undefined : standard navigate({ to: '/search', search: buildSearchState({ complianceStandard: nextStandard, page: 0 }) }) } @@ -359,7 +358,7 @@ export function SearchPage() { {!starredOnly && (
{t('search.compliance.label')} - {COMPLIANCE_STANDARD_OPTIONS.map((standard) => ( + {COMPLIANCE_STANDARD_VALUES.map((standard) => (