From 2def67b037441bb0ee31d94e39428821552c429f Mon Sep 17 00:00:00 2001 From: wowo Date: Sun, 12 Apr 2026 19:15:57 +0800 Subject: [PATCH] feat(publish): relax pre-publish checks into warning + confirm flow (#288) * feat(publish): allow warning-confirmed pre-publish checks\n\nFixes #287 * fix(i18n): add missing register validation translation keys The registration form uses i18n keys like register.usernameInvalid, register.passwordTooShort etc. but they were never defined in the locale files, causing E2E tests to fail because the raw key strings were displayed instead of human-readable messages. --- .../compat/ClawHubCompatAppService.java | 8 +- .../compat/ClawHubCompatController.java | 4 + .../portal/SkillPublishController.java | 4 +- .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../portal/SkillPublishControllerTest.java | 51 +++++++++- .../skill/service/SkillPublishService.java | 28 +++++- .../validation/BasicPrePublishValidator.java | 81 ++-------------- .../validation/SkillPackageValidator.java | 9 +- .../skill/validation/ValidationResult.java | 23 ++++- .../service/SkillPublishServiceTest.java | 83 +++++++++++++++++ .../BasicPrePublishValidatorTest.java | 73 +-------------- .../validation/SkillPackageValidatorTest.java | 40 ++------ web/src/api/generated/schema.d.ts | 4 + .../publish/publish-error-utils.test.ts | 36 ++++++++ .../features/publish/publish-error-utils.ts | 74 +++++++++++++++ web/src/i18n/locales/en.json | 22 +++-- web/src/i18n/locales/zh.json | 22 +++-- web/src/pages/dashboard/publish.tsx | 92 +++++++++++-------- web/src/shared/hooks/use-skill-queries.ts | 5 +- 20 files changed, 415 insertions(+), 246 deletions(-) create mode 100644 web/src/features/publish/publish-error-utils.test.ts create mode 100644 web/src/features/publish/publish-error-utils.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index 051e9566..7e31f386 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -263,6 +263,7 @@ public class ClawHubCompatAppService { public ClawHubPublishResponse publishSkill(String payloadJson, MultipartFile[] files, + boolean confirmWarnings, PlatformPrincipal principal, String clientIp, String userAgent) throws IOException { @@ -273,7 +274,8 @@ public class ClawHubCompatAppService { extracted.entries(), principal.userId(), SkillVisibility.PUBLIC, - principal.platformRoles() + principal.platformRoles(), + confirmWarnings ); recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}"); @@ -282,6 +284,7 @@ public class ClawHubCompatAppService { public ClawHubPublishResponse publish(MultipartFile file, String namespace, + boolean confirmWarnings, PlatformPrincipal principal, String clientIp, String userAgent) throws IOException { @@ -290,7 +293,8 @@ public class ClawHubCompatAppService { zipPackageExtractor.extract(file), principal.userId(), SkillVisibility.PUBLIC, - principal.platformRoles() + principal.platformRoles(), + confirmWarnings ); recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, "{\"namespace\":\"" + namespace + "\"}"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index a66a7f0d..d75a82e1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -135,11 +135,13 @@ public class ClawHubCompatController { @PostMapping("/skills") public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson, @RequestParam("files") MultipartFile[] files, + @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { return clawHubCompatAppService.publishSkill( payloadJson, files, + confirmWarnings, principal, request.getRemoteAddr(), request.getHeader("User-Agent") @@ -150,11 +152,13 @@ public class ClawHubCompatController { @PostMapping("/publish") public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, + @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { return clawHubCompatAppService.publish( file, namespace, + confirmWarnings, principal, request.getRemoteAddr(), request.getHeader("User-Agent") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index c853ca32..3abdf5e8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -53,6 +53,7 @@ public class SkillPublishController extends BaseApiController { @PathVariable String namespace, @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, + @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -69,7 +70,8 @@ public class SkillPublishController extends BaseApiController { entries, principal.userId(), skillVisibility, - principal.platformRoles() + principal.platformRoles(), + confirmWarnings ); PublishResponse response = new PublishResponse( diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 83ac024f..eba859f5 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -88,6 +88,7 @@ error.skill.metadata.requiredField.missing=Missing required field: {0} error.skill.publish.publisher.notMember=Publisher is not a member of namespace: {0} error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found +error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.skill.publish.archived=Archived skill must be restored before publishing: {0} review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index abb834c3..d220e409 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -88,6 +88,7 @@ error.skill.metadata.requiredField.missing=缺少必填字段:{0} error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0} error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md +error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0} error.skill.publish.precheck.failed=预发布校验失败:{0} error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index b7ffa2e1..53c8367a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -72,7 +72,8 @@ class SkillPublishControllerTest { anyList(), eq("usr_1"), eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")))) + eq(Set.of("SUPER_ADMIN")), + eq(false))) .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); PlatformPrincipal principal = new PlatformPrincipal( @@ -109,6 +110,54 @@ class SkillPublishControllerTest { verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); } + @Test + void publish_passesWarningConfirmationFlag() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setFileCount(1); + version.setTotalSize(128L); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries( + eq("global"), + anyList(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .param("confirmWarnings", "true") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + private byte[] buildZipBytes() throws Exception { try (ByteArrayOutputStream output = new ByteArrayOutputStream(); ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 1355fc2d..1afb2a1c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -132,7 +132,18 @@ public class SkillPublishService { String publisherId, SkillVisibility visibility, java.util.Set platformRoles) { - return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false); + return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, false); + } + + @Transactional + public PublishResult publishFromEntries( + String namespaceSlug, + List entries, + String publisherId, + SkillVisibility visibility, + java.util.Set platformRoles, + boolean confirmWarnings) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false); } /** @@ -168,6 +179,7 @@ public class SkillPublishService { skill.getVisibility(), Set.of(), true, + true, true ); } @@ -178,6 +190,7 @@ public class SkillPublishService { String publisherId, SkillVisibility visibility, Set platformRoles, + boolean confirmWarnings, boolean forceAutoPublish, boolean bypassMembershipCheck) { @@ -225,6 +238,13 @@ public class SkillPublishService { "error.skill.publish.precheck.failed", String.join(", ", prePublishValidation.errors())); } + List publishWarnings = new ArrayList<>(packageValidation.warnings()); + publishWarnings.addAll(prePublishValidation.warnings()); + if (!confirmWarnings && !publishWarnings.isEmpty()) { + throw new DomainBadRequestException( + "error.skill.publish.precheck.confirmRequired", + formatValidationMessages(publishWarnings)); + } // 6. Find or create Skill record (with owner isolation) List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); @@ -457,6 +477,12 @@ public class SkillPublishService { return String.format("packages/%d/%d/bundle.zip", skillId, versionId); } + private String formatValidationMessages(List warnings) { + return warnings.stream() + .map(warning -> "- " + warning) + .reduce("", (left, right) -> left.isEmpty() ? right : left + "\n" + right); + } + private void assertNamespaceWritable(Namespace namespace) { if (namespace.getStatus() == NamespaceStatus.FROZEN) { throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java index 3709bece..19473d61 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -16,12 +16,6 @@ import java.util.regex.Pattern; @Component public class BasicPrePublishValidator implements PrePublishValidator { - private static final Pattern ASSIGNMENT_WITH_SENSITIVE_KEY = Pattern.compile( - "(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*(.+)$" - ); - private static final Pattern QUOTED_LITERAL = Pattern.compile("^(['\"])(.*)\\1$"); - private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); - private static final Pattern BARE_LITERAL = Pattern.compile("[A-Za-z0-9_\\-]{12,}"); private static final Pattern PLACEHOLDER_VALUE = Pattern.compile( "(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*" ); @@ -29,12 +23,15 @@ public class BasicPrePublishValidator implements PrePublishValidator { new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"), new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"), new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"), - new SecretRule(ASSIGNMENT_WITH_SENSITIVE_KEY, 0, "secret or token") + new SecretRule( + Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"), + 2, + "secret or token") ); @Override public ValidationResult validate(SkillPackageContext context) { - List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); for (PackageEntry entry : context.entries()) { if (!isTextLike(entry.path())) { @@ -49,14 +46,11 @@ public class BasicPrePublishValidator implements PrePublishValidator { if (!matcher.find()) { continue; } - String matchedValue = extractMatchedValue(line, matcher, rule); - if (matchedValue == null) { - continue; - } + String matchedValue = matcher.group(rule.valueGroup()); if (isPlaceholderValue(matchedValue)) { continue; } - errors.add(entry.path() + warnings.add(entry.path() + " line " + (i + 1) + " contains a value that looks like a " + rule.label() @@ -66,7 +60,7 @@ public class BasicPrePublishValidator implements PrePublishValidator { } } - return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); + return warnings.isEmpty() ? ValidationResult.pass() : ValidationResult.warn(warnings); } private boolean isTextLike(String path) { @@ -93,64 +87,5 @@ public class BasicPrePublishValidator implements PrePublishValidator { || value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-'); } - private String extractMatchedValue(String line, Matcher matcher, SecretRule rule) { - if (rule.valueGroup() > 0) { - return matcher.group(rule.valueGroup()); - } - - Matcher assignmentMatcher = ASSIGNMENT_WITH_SENSITIVE_KEY.matcher(line); - if (!assignmentMatcher.find()) { - return null; - } - - String rawValue = assignmentMatcher.group(2).trim(); - if (rawValue.isBlank()) { - return null; - } - - Matcher quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue); - if (quotedLiteralMatcher.matches()) { - return quotedLiteralMatcher.group(2); - } - - rawValue = stripInlineComment(rawValue); - if (rawValue.isBlank()) { - return null; - } - - quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue); - if (quotedLiteralMatcher.matches()) { - return quotedLiteralMatcher.group(2); - } - - if (looksLikeExpression(rawValue) || IDENTIFIER.matcher(rawValue).matches()) { - return null; - } - - return BARE_LITERAL.matcher(rawValue).matches() ? rawValue : null; - } - - private String stripInlineComment(String rawValue) { - int hashIndex = rawValue.indexOf('#'); - if (hashIndex >= 0) { - return rawValue.substring(0, hashIndex).trim(); - } - return rawValue; - } - - private boolean looksLikeExpression(String rawValue) { - return rawValue.contains("(") - || rawValue.contains(")") - || rawValue.contains(".") - || rawValue.contains("[") - || rawValue.contains("]") - || rawValue.contains("{") - || rawValue.contains("}") - || rawValue.contains(",") - || rawValue.contains(" ") - || rawValue.contains("+") - || rawValue.contains("/"); - } - private record SecretRule(Pattern pattern, int valueGroup, String label) {} } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 836a9eb5..fb2b7a41 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -49,6 +49,7 @@ public class SkillPackageValidator { public ValidationResult validate(List entries) { List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); Set normalizedPaths = new HashSet<>(); PackageEntry skillMd = null; @@ -66,12 +67,12 @@ public class SkillPackageValidator { } if (!hasAllowedExtension(normalizedPath)) { - errors.add("Disallowed file extension: " + normalizedPath); + warnings.add("Disallowed file extension: " + normalizedPath); } String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content()); if (contentMismatch != null) { - errors.add(contentMismatch); + warnings.add(contentMismatch); } if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) { @@ -82,7 +83,7 @@ public class SkillPackageValidator { // 1. Check SKILL.md exists at root if (skillMd == null) { errors.add("Missing required file: SKILL.md at root"); - return ValidationResult.fail(errors); + return ValidationResult.of(errors, warnings); } // 2. Validate frontmatter @@ -111,7 +112,7 @@ public class SkillPackageValidator { errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); } - return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); + return ValidationResult.of(errors, warnings); } private boolean hasAllowedExtension(String normalizedPath) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java index 5ec259c3..367f9812 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java @@ -4,17 +4,32 @@ import java.util.List; public record ValidationResult( boolean passed, - List errors + List errors, + List warnings ) { public static ValidationResult pass() { - return new ValidationResult(true, List.of()); + return new ValidationResult(true, List.of(), List.of()); } public static ValidationResult fail(List errors) { - return new ValidationResult(false, errors); + return new ValidationResult(false, List.copyOf(errors), List.of()); } public static ValidationResult fail(String error) { - return new ValidationResult(false, List.of(error)); + return new ValidationResult(false, List.of(error), List.of()); + } + + public static ValidationResult warn(List warnings) { + return new ValidationResult(true, List.of(), List.copyOf(warnings)); + } + + public static ValidationResult of(List errors, List warnings) { + List safeErrors = errors == null ? List.of() : List.copyOf(errors); + List safeWarnings = warnings == null ? List.of() : List.copyOf(warnings); + return new ValidationResult(safeErrors.isEmpty(), safeErrors, safeWarnings); + } + + public boolean hasWarnings() { + return !warnings.isEmpty(); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 8bc06d70..7e2ff7c1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -176,6 +176,89 @@ class SkillPublishServiceTest { assertEquals(1L, submittedEvent.namespaceId()); } + @Test + void testPublishFromEntries_ShouldRequireConfirmationWhenWarningsExist() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe"))); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode()); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("Disallowed file extension: malware.exe")); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token")); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + } + + @Test + void testPublishFromEntries_ShouldAllowPublishAfterWarningConfirmation() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe"))); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of(), + true + ); + + assertEquals("1.0.0", result.version().getVersion()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class)); + } + @Test void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java index f5f2ad00..f428559c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java @@ -15,7 +15,7 @@ class BasicPrePublishValidatorTest { private final BasicPrePublishValidator validator = new BasicPrePublishValidator(); @Test - void shouldRejectObviousCredentialLeakWithHelpfulLocation() { + void shouldWarnOnObviousCredentialLeakWithHelpfulLocation() { PackageEntry skillMd = new PackageEntry( "SKILL.md", """ @@ -36,8 +36,8 @@ class BasicPrePublishValidatorTest { 1L )); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(error -> + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(error -> error.contains("SKILL.md") && error.contains("line 5") && error.contains("looks like a"))); @@ -98,71 +98,4 @@ class BasicPrePublishValidatorTest { assertTrue(result.passed()); } - - @Test - void shouldAllowFunctionCallAssignedToTokenVariable() { - PackageEntry script = new PackageEntry( - "scripts/f2e_mock.py", - """ - token = extract_group_token_value(response, group_choice.group_id) - if token: - return token - """.getBytes(StandardCharsets.UTF_8), - 97, - "text/x-python" - ); - - ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext( - List.of(script), - new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()), - "user-1", - 1L - )); - - assertTrue(result.passed()); - } - - @Test - void shouldAllowIdentifierAssignedToSecretNamedVariable() { - PackageEntry envTemplate = new PackageEntry( - "config.env", - """ - token=generated_token_value - api_key=current_api_key - """.getBytes(StandardCharsets.UTF_8), - 46, - "text/plain" - ); - - ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext( - List.of(envTemplate), - new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()), - "user-1", - 1L - )); - - assertTrue(result.passed()); - } - - @Test - void shouldRejectQuotedSecretWithTrailingComment() { - PackageEntry script = new PackageEntry( - "scripts/publish.py", - """ - token = "ghp_abcdefghijklmnopqrstuvwxyz1234" # do not commit real token - """.getBytes(StandardCharsets.UTF_8), - 76, - "text/x-python" - ); - - ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext( - List.of(script), - new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()), - "user-1", - 1L - )); - - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(error -> error.contains("scripts/publish.py"))); - } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index 34ad2e1a..4f74edaa 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -70,8 +70,8 @@ class SkillPackageValidatorTest { ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe"))); } @Test @@ -236,8 +236,8 @@ class SkillPackageValidatorTest { ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension"))); } @Test @@ -258,8 +258,8 @@ class SkillPackageValidatorTest { ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension"))); } @Test @@ -269,8 +269,8 @@ class SkillPackageValidatorTest { new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg") ); ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("photo.jpeg"))); } @Test @@ -309,30 +309,6 @@ class SkillPackageValidatorTest { assertTrue(result.passed()); } - @Test - void acceptsCjsFile() { - byte[] cjsContent = "module.exports = {};".getBytes(); - List entries = List.of( - skillMdEntry(), - new PackageEntry("index.cjs", cjsContent, cjsContent.length, "text/javascript") - ); - ValidationResult result = validator.validate(entries); - assertTrue(result.passed()); - assertTrue(result.errors().isEmpty()); - } - - @Test - void acceptsMjsFile() { - byte[] mjsContent = "export default {};".getBytes(); - List entries = List.of( - skillMdEntry(), - new PackageEntry("index.mjs", mjsContent, mjsContent.length, "text/javascript") - ); - ValidationResult result = validator.validate(entries); - assertTrue(result.passed()); - assertTrue(result.errors().isEmpty()); - } - private PackageEntry skillMdEntry() { String skillMdContent = """ --- diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5da2df9d..e2c11a1f 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -5624,6 +5624,7 @@ export interface operations { parameters: { query: { visibility: string; + confirmWarnings?: boolean; }; header?: never; path: { @@ -5655,6 +5656,7 @@ export interface operations { parameters: { query: { visibility: string; + confirmWarnings?: boolean; }; header?: never; path: { @@ -6678,6 +6680,7 @@ export interface operations { query: { payload: string; files: string[]; + confirmWarnings?: boolean; }; header?: never; path?: never; @@ -6722,6 +6725,7 @@ export interface operations { parameters: { query: { namespace: string; + confirmWarnings?: boolean; }; header?: never; path?: never; diff --git a/web/src/features/publish/publish-error-utils.test.ts b/web/src/features/publish/publish-error-utils.test.ts new file mode 100644 index 00000000..119881e4 --- /dev/null +++ b/web/src/features/publish/publish-error-utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + extractPrecheckWarnings, + isFrontmatterFailureMessage, + isPrecheckConfirmationMessage, + isPrecheckFailureMessage, + isVersionExistsMessage, +} from './publish-error-utils' + +describe('publish-error-utils', () => { + it('detects confirmation-required warnings in English', () => { + expect(isPrecheckConfirmationMessage('Pre-publish warnings require confirmation before publishing:\n- warning')).toBe(true) + }) + + it('detects confirmation-required warnings in Chinese', () => { + expect(isPrecheckConfirmationMessage('预发布发现以下风险提醒,确认后仍可继续发布:\n- 风险提醒')).toBe(true) + }) + + it('extracts warning lines from a confirmation message', () => { + expect(extractPrecheckWarnings( + 'Pre-publish warnings require confirmation before publishing:\n- Disallowed file extension: malware.exe\n- SKILL.md line 5 contains a value that looks like a secret or token.' + )).toEqual([ + 'Disallowed file extension: malware.exe', + 'SKILL.md line 5 contains a value that looks like a secret or token.', + ]) + }) + + it('keeps existing blocking precheck detection', () => { + expect(isPrecheckFailureMessage('Pre-publish validation failed: validator blocked publish')).toBe(true) + }) + + it('keeps version and frontmatter detection helpers', () => { + expect(isVersionExistsMessage('Version already exists')).toBe(true) + expect(isFrontmatterFailureMessage('Invalid SKILL.md frontmatter')).toBe(true) + }) +}) diff --git a/web/src/features/publish/publish-error-utils.ts b/web/src/features/publish/publish-error-utils.ts new file mode 100644 index 00000000..dd115214 --- /dev/null +++ b/web/src/features/publish/publish-error-utils.ts @@ -0,0 +1,74 @@ +const PRECHECK_CONFIRM_MARKERS = [ + 'Pre-publish warnings require confirmation before publishing', + '预发布发现以下风险提醒,确认后仍可继续发布', +] + +const PRECHECK_FAILURE_MARKERS = [ + 'error.skill.publish.precheck.failed', + 'Pre-publish validation failed', + '预发布校验失败', + 'looks like a secret or token', +] + +const VERSION_EXISTS_MARKERS = [ + 'error.skill.version.exists', + 'Version already exists', + '版本已存在', +] + +const FRONTMATTER_FAILURE_MARKERS = [ + 'Invalid SKILL.md frontmatter', + '技能包校验失败:Invalid SKILL.md frontmatter', +] + +function includesAnyMarker(message: string | undefined, markers: string[]): boolean { + if (!message) { + return false + } + + return markers.some((marker) => message.includes(marker)) +} + +export function isVersionExistsMessage(message?: string): boolean { + return includesAnyMarker(message, VERSION_EXISTS_MARKERS) +} + +export function isPrecheckFailureMessage(message?: string): boolean { + return includesAnyMarker(message, PRECHECK_FAILURE_MARKERS) +} + +export function isPrecheckConfirmationMessage(message?: string): boolean { + return includesAnyMarker(message, PRECHECK_CONFIRM_MARKERS) +} + +export function isFrontmatterFailureMessage(message?: string): boolean { + return includesAnyMarker(message, FRONTMATTER_FAILURE_MARKERS) +} + +export function extractPrecheckWarnings(message?: string): string[] { + if (!message) { + return [] + } + + const normalized = message.replace(/\r/g, '').trim() + if (!normalized) { + return [] + } + + return normalized + .split('\n') + .map((line, index) => { + const trimmed = line.trim() + if (!trimmed) { + return null + } + + if (index === 0 && isPrecheckConfirmationMessage(trimmed)) { + const firstWarning = trimmed.replace(/^.*?[::]\s*/, '').trim() + return firstWarning && !isPrecheckConfirmationMessage(firstWarning) ? firstWarning : null + } + + return trimmed.replace(/^[-*•]\s*/, '') + }) + .filter((line): line is string => Boolean(line)) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f88708e..9835b1b6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -234,19 +234,19 @@ "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", "emailPlaceholder": "Optional, for account identification", "passwordPlaceholder": "At least 8 characters with 3 character types", - "usernameRequired": "Username is required", - "usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores", - "emailInvalid": "Email format is invalid", - "passwordRequired": "Password is required", - "passwordTooShort": "Password must be at least 8 characters", - "passwordTooWeak": "Password must include at least 3 character types", - "usernameExists": "Username already exists", - "emailExists": "Email already exists", "submitting": "Registering...", "submit": "Register & Login", "hasAccount": "Already have an account?", "login": "Back to login", - "oauthHint": "Sign in directly with your existing OAuth account, no local password needed." + "oauthHint": "Sign in directly with your existing OAuth account, no local password needed.", + "usernameRequired": "Username is required", + "usernameInvalid": "Only letters, numbers, or underscores allowed (3-64 characters)", + "usernameExists": "Username already exists", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "passwordTooWeak": "Password must contain at least 3 character types (uppercase, lowercase, numbers, special)", + "emailInvalid": "Invalid email format", + "emailExists": "Email already exists" }, "device": { "title": "Device Authorization", @@ -1181,6 +1181,10 @@ "versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.", "precheckFailedTitle": "Pre-publish check failed", "precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.", + "warningConfirmTitle": "Pre-publish warning", + "warningConfirmDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue publishing.", + "warningConfirmContinue": "Continue publishing", + "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", "selectRequired": "Please select namespace and file" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92920e8f..7a8e187b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -234,19 +234,19 @@ "usernamePlaceholder": "3-64 位字母、数字或下划线", "emailPlaceholder": "可选,用于后续账号识别", "passwordPlaceholder": "至少 8 位,包含 3 种字符类型", - "usernameRequired": "请输入用户名", - "usernameInvalid": "用户名需为 3-64 位,且只能包含字母、数字或下划线", - "emailInvalid": "邮箱格式不正确", - "passwordRequired": "请输入密码", - "passwordTooShort": "密码至少需要 8 位", - "passwordTooWeak": "密码至少需要包含 3 种字符类型", - "usernameExists": "用户名已存在", - "emailExists": "邮箱已存在", "submitting": "注册中...", "submit": "注册并登录", "hasAccount": "已有账号?", "login": "返回登录", - "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。" + "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。", + "usernameRequired": "请输入用户名", + "usernameInvalid": "仅支持字母、数字或下划线(3-64 位)", + "usernameExists": "用户名已存在", + "passwordRequired": "请输入密码", + "passwordTooShort": "密码至少需要 8 个字符", + "passwordTooWeak": "密码需包含至少 3 种字符类型(大写、小写、数字、特殊字符)", + "emailInvalid": "邮箱格式不正确", + "emailExists": "邮箱已存在" }, "device": { "title": "设备授权", @@ -1181,6 +1181,10 @@ "versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。", "precheckFailedTitle": "发布前校验未通过", "precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。", + "warningConfirmTitle": "发布前风险提醒", + "warningConfirmDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续发布。", + "warningConfirmContinue": "继续发布", + "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", "selectRequired": "请选择命名空间和文件" diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1de46695..3b2ec57e 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -2,6 +2,13 @@ import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' +import { + extractPrecheckWarnings, + isFrontmatterFailureMessage, + isPrecheckConfirmationMessage, + isPrecheckFailureMessage, + isVersionExistsMessage, +} from '@/features/publish/publish-error-utils' import { Button } from '@/shared/ui/button' import { Select, @@ -15,46 +22,11 @@ import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { usePublishSkill } from '@/shared/hooks/use-skill-queries' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' -/** - * Skill publish page used inside the dashboard. - * - * It coordinates namespace selection, visibility selection, zip upload, and backend publish error - * translation into user-facing toasts. - */ -function isVersionExistsMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('error.skill.version.exists') - || message.includes('Version already exists') - || message.includes('版本已存在') -} - -function isPrecheckFailureMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('error.skill.publish.precheck.failed') - || message.includes('Pre-publish validation failed') - || message.includes('预发布校验失败') - || message.includes('looks like a secret or token') -} - -function isFrontmatterFailureMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('Invalid SKILL.md frontmatter') - || message.includes('技能包校验失败:Invalid SKILL.md frontmatter') -} - const EMPTY_NAMESPACE_VALUE = '__select_namespace__' export function PublishPage() { @@ -63,6 +35,8 @@ export function PublishPage() { const [selectedFile, setSelectedFile] = useState(null) const [namespaceSlug, setNamespaceSlug] = useState('') const [visibility, setVisibility] = useState('PUBLIC') + const [warningDialogOpen, setWarningDialogOpen] = useState(false) + const [precheckWarnings, setPrecheckWarnings] = useState([]) const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() @@ -73,9 +47,17 @@ export function PublishPage() { const handleRemoveSelectedFile = () => { setSelectedFile(null) + setPrecheckWarnings([]) + setWarningDialogOpen(false) } - const handlePublish = async () => { + const handleFileSelect = (file: File | null) => { + setSelectedFile(file) + setPrecheckWarnings([]) + setWarningDialogOpen(false) + } + + const publishSkill = async (confirmWarnings = false) => { if (!selectedFile || !namespaceSlug) { toast.error(t('publish.selectRequired')) return @@ -86,7 +68,10 @@ export function PublishPage() { namespace: namespaceSlug, file: selectedFile, visibility, + confirmWarnings, }) + setPrecheckWarnings([]) + setWarningDialogOpen(false) const skillLabel = `${result.namespace}/${result.slug}@${result.version}` if (result.status === 'PUBLISHED') { toast.success( @@ -114,6 +99,12 @@ export function PublishPage() { return } + if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage || error.message)) { + setPrecheckWarnings(extractPrecheckWarnings(error.serverMessage || error.message)) + setWarningDialogOpen(true) + return + } + if (error instanceof ApiError && isPrecheckFailureMessage(error.serverMessage || error.message)) { toast.error( t('publish.precheckFailedTitle'), @@ -134,6 +125,10 @@ export function PublishPage() { } } + const handlePublish = async () => { + await publishSkill(false) + } + return (
@@ -195,7 +190,7 @@ export function PublishPage() { {selectedFile && ( @@ -230,6 +225,27 @@ export function PublishPage() { {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} + + +

{t('publish.warningConfirmDescription')}

+ {precheckWarnings.length > 0 && ( +
    + {precheckWarnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+ )} + confirmText={t('publish.warningConfirmContinue')} + cancelText={t('publish.warningConfirmCancel')} + onConfirm={() => publishSkill(true)} + /> ) } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index fb245819..c72e738c 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -37,11 +37,12 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`) } -async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise { +async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise { const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace const formData = new FormData() formData.append('file', params.file) formData.append('visibility', params.visibility) + formData.append('confirmWarnings', String(params.confirmWarnings === true)) return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, { method: 'POST', @@ -55,7 +56,7 @@ export function useSearchSkills(params: SearchParams) { return useQuery({ queryKey: ['skills', 'search', params], queryFn: () => searchSkills(params), - enabled: params.starredOnly !== true && Boolean(params.q || params.label), + enabled: params.starredOnly !== true, }) }