From 1067d0ff6ed7b9e023047b01b97f8ed31bf092e8 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 18 May 2026 10:36:58 +0800 Subject: [PATCH] feat(publish): add --dry-run validation endpoint and CLI option Add a validate-only endpoint (POST /api/cli/v1/skills/{namespace}/publish/validate) that runs the full pre-publish validation chain without persisting anything. This allows developers to check their package locally before actual publishing. The validation covers: - SKILL.md existence and frontmatter parsing (name, description required) - File extension whitelist and size limits - Credential leak scanning with line-number precision - Slug generation and name conflict detection CLI usage: `skillhub publish --dry-run` Closes #429 --- cli/src/clients/skillhub-client.ts | 24 +++ cli/src/commands/publish.ts | 38 ++++- cli/src/index.ts | 1 + cli/test/helpers/fake-registry.ts | 38 ++++- cli/test/integration/publish-dry-run.test.ts | 147 ++++++++++++++++++ .../controller/cli/CliSkillController.java | 17 ++ .../skillhub/dto/cli/CliDryRunResponse.java | 11 ++ .../service/cli/CliSkillAppService.java | 13 ++ .../controller/cli/CliDryRunValidateTest.java | 90 +++++++++++ .../skill/service/SkillPublishService.java | 112 +++++++++++++ web/src/api/generated/schema.d.ts | 61 ++++++++ 11 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 cli/test/integration/publish-dry-run.test.ts create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliDryRunResponse.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index 2d3a1793..3fe748be 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -44,6 +44,14 @@ export interface PublishResponse { visibility: string } +export interface DryRunResponse { + valid: boolean + errors: string[] + warnings: string[] + resolvedSlug: string | null + resolvedVersion: string | null +} + export class SkillHubClient { constructor( readonly registry: string, @@ -113,6 +121,22 @@ export class SkillHubClient { return this.handleJsonResponse(response) } + async validatePublish(namespace: string, file: Blob, fileName = 'skill.zip'): Promise { + const formData = new FormData() + formData.append('file', file, fileName) + let response: Response + try { + response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, { + method: 'POST', + headers: this.token ? { Authorization: `Bearer ${this.token}` } : {}, + body: formData + }) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + return this.handleJsonResponse(response) + } + private async getJson(path: string): Promise { let response: Response try { diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index 16b4453c..0b7c1184 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -14,6 +14,7 @@ export interface PublishCommandOptions { registry?: string token?: string json?: boolean + dryRun?: boolean } export async function publishCommand(path: string, options: PublishCommandOptions): Promise { @@ -40,7 +41,6 @@ export async function publishCommand(path: string, options: PublishCommandOption let archiveBlob: Blob let archiveName: string if (pathStat.isFile()) { - // If input is a file, check if it's already a zip if (await isZipFile(path)) { const buffer = await readFile(path) archiveBlob = new Blob([buffer], { type: 'application/zip' }) @@ -49,7 +49,6 @@ export async function publishCommand(path: string, options: PublishCommandOption throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path }) } } else if (pathStat.isDirectory()) { - // If input is a directory, create zip from it archiveBlob = await createZip(path) archiveName = `${basename(path)}.zip` } else { @@ -57,6 +56,41 @@ export async function publishCommand(path: string, options: PublishCommandOption } const client = new SkillHubClient(registry, token) + + if (options.dryRun) { + const result = await client.validatePublish(namespace, archiveBlob, archiveName) + + if (options.json) { + return JSON.stringify(result) + } + + const lines: string[] = [] + if (result.valid) { + lines.push('Validation passed') + } else { + lines.push('Validation failed') + } + if (result.resolvedSlug) { + lines.push(` Slug: ${result.resolvedSlug}`) + } + if (result.resolvedVersion) { + lines.push(` Version: ${result.resolvedVersion}`) + } + if (result.errors.length > 0) { + lines.push('Errors:') + for (const error of result.errors) { + lines.push(` - ${error}`) + } + } + if (result.warnings.length > 0) { + lines.push('Warnings:') + for (const warning of result.warnings) { + lines.push(` - ${warning}`) + } + } + return lines.join('\n') + } + const result = await client.publish(namespace, archiveBlob, toServerVisibility(visibility), archiveName) const detailUrl = `${registry}/space/${result.namespace}/${encodeURIComponent(result.slug)}` diff --git a/cli/src/index.ts b/cli/src/index.ts index 97f3f34f..7d7b0583 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -278,6 +278,7 @@ cli .command('publish ', 'Publish a local skill package') .option('--namespace ', 'Namespace') .option('--visibility ', 'Visibility (public|namespace-only|private)') + .option('--dry-run', 'Validate without publishing') .option('--registry ', 'Registry URL') .option('--token ', 'API token') .option('--json', 'Output JSON') diff --git a/cli/test/helpers/fake-registry.ts b/cli/test/helpers/fake-registry.ts index 7159cd2d..2ceac3a7 100644 --- a/cli/test/helpers/fake-registry.ts +++ b/cli/test/helpers/fake-registry.ts @@ -91,6 +91,11 @@ export interface CapturedPublish { visibility: string } +export interface CapturedValidate { + namespace: string + fileName: string +} + /** Last resolve GET: useful for verifying --version is forwarded as ?version=. */ export interface CapturedResolve { namespace: string @@ -116,6 +121,8 @@ interface FakeRegistryOptions { searchItems?: Array<{ namespace: string; slug: string; latestVersion: string; summary: string }> /** Skills available for resolve / download / delete / publish. */ skills?: FakeSkill[] + /** Response to return for publish/validate (dry-run) requests. */ + dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null } /** * Per-endpoint failure injection. When set for an endpoint, that endpoint * ignores all other logic and returns the specified failure (or throws for @@ -128,6 +135,7 @@ interface FakeRegistryOptions { download?: FailureMode deleteRemote?: FailureMode publish?: FailureMode + validate?: FailureMode } } @@ -167,7 +175,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { publish: CapturedPublish | null resolve: CapturedResolve | null delete: CapturedDelete | null - } = { publish: null, resolve: null, delete: null } + validate: CapturedValidate | null + } = { publish: null, resolve: null, delete: null, validate: null } // If any endpoint is configured with 'network' failure mode, we need a real // TCP-level failure. Start a connection-dropping server and return its URL @@ -339,6 +348,33 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { }) } + // Validate (dry-run): POST /api/cli/v1/skills/:namespace/publish/validate + const validateMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish\/validate$/) + if (validateMatch && req.method === 'POST') { + if (options.failures?.validate) return failureResponse(options.failures.validate) + const authErr = checkAuth(req) + if (authErr) return authErr + const namespace = validateMatch[1]! + + return req.formData().then(form => { + const fileField = form.get('file') + let fileName = 'skill.zip' + if (fileField instanceof File) { + fileName = fileField.name || fileName + } + state.validate = { namespace, fileName } + + const dryRunData = options.dryRunResponse ?? { + valid: true, + errors: [], + warnings: [], + resolvedSlug: fileName.replace(/\.zip$/, ''), + resolvedVersion: '1.0.0' + } + return Response.json({ code: 0, data: dryRunData }) + }) + } + // Publish: POST /api/cli/v1/skills/:namespace/publish const publishMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish$/) if (publishMatch && req.method === 'POST') { diff --git a/cli/test/integration/publish-dry-run.test.ts b/cli/test/integration/publish-dry-run.test.ts new file mode 100644 index 00000000..259a7ff6 --- /dev/null +++ b/cli/test/integration/publish-dry-run.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { createTempHome } from '../helpers/temp-env' +import { startFakeRegistry } from '../helpers/fake-registry' +import { runCli } from '../helpers/run-cli' + +let registry: Awaited> | undefined + +afterEach(() => { + registry?.stop() + registry = undefined +}) + +async function login(env: { home: string }, registryUrl: string) { + const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], { + HOME: env.home, + USERPROFILE: env.home + }) + if (result.exitCode !== 0) { + throw new Error(`login failed: ${result.stderr}`) + } +} + +async function makeTempDir(...files: Array<[string, string]>) { + const dir = await mkdtemp(join(tmpdir(), 'skillhub-dryrun-')) + for (const [name, content] of files) { + await writeFile(join(dir, name), content) + } + return dir +} + +describe('publish --dry-run', () => { + test('calls validate endpoint and reports success', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok' }) + await login(env, registry.url) + + const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: A test\n---\n# Hello']) + const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Validation passed') + expect(registry.received.validate).not.toBeNull() + expect(registry.received.validate!.namespace).toBe('global') + expect(registry.received.publish).toBeNull() + }) + + test('--dry-run with --json returns structured response', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ + token: 'sk_ok', + dryRunResponse: { + valid: true, + errors: [], + warnings: ['Disallowed file extension: data.bin'], + resolvedSlug: 'my-skill', + resolvedVersion: '2.0.0' + } + }) + await login(env, registry.url) + + const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: test\n---\n']) + const result = await runCli(['publish', dir, '--dry-run', '--json', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(result.exitCode).toBe(0) + const json = JSON.parse(result.stdout) + expect(json.valid).toBe(true) + expect(json.resolvedSlug).toBe('my-skill') + expect(json.resolvedVersion).toBe('2.0.0') + expect(json.warnings).toContain('Disallowed file extension: data.bin') + }) + + test('--dry-run reports validation errors', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ + token: 'sk_ok', + dryRunResponse: { + valid: false, + errors: ['Missing required file: SKILL.md at root'], + warnings: [], + resolvedSlug: null, + resolvedVersion: null + } + }) + await login(env, registry.url) + + const dir = await makeTempDir(['README.md', '# No SKILL.md here']) + const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Validation failed') + expect(result.stdout).toContain('Missing required file: SKILL.md at root') + }) + + test('--dry-run does not actually publish', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok' }) + await login(env, registry.url) + + const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n']) + await runCli(['publish', dir, '--dry-run', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(registry.received.publish).toBeNull() + }) + + test('--dry-run respects --namespace', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok' }) + await login(env, registry.url) + + const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n']) + await runCli(['publish', dir, '--dry-run', '--namespace', 'myteam', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(registry.received.validate!.namespace).toBe('myteam') + }) + + test('--dry-run requires authentication', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok' }) + + const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n']) + const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(result.exitCode).toBe(2) + expect(result.stderr).toContain('authentication') + }) +}) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java index 8ff5fbe6..df1861d5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java @@ -95,6 +95,23 @@ public class CliSkillController extends BaseApiController { namespace, slug, principal.userId(), AuditRequestContext.from(request))); } + @PostMapping(value = "/{namespace}/publish/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RateLimit(category = "publish", authenticated = 10, anonymous = 0) + public ApiResponse validatePublish( + @PathVariable String namespace, + @RequestPart("file") MultipartFile file, + @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + List entries; + try { + entries = archiveExtractor.extract(file); + } catch (IllegalArgumentException e) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage()); + } + var result = cliSkillAppService.validatePublish( + namespace, entries, principal.userId(), principal.platformRoles()); + return ok("response.success.read", result); + } + @PostMapping(value = "/{namespace}/publish", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @RateLimit(category = "publish", authenticated = 10, anonymous = 0) public ApiResponse publish( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliDryRunResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliDryRunResponse.java new file mode 100644 index 00000000..d237f72f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliDryRunResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto.cli; + +import java.util.List; + +public record CliDryRunResponse( + boolean valid, + List errors, + List warnings, + String resolvedSlug, + String resolvedVersion +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index edbadf87..8982f99f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.dto.cli.CliDeleteResponse; +import com.iflytek.skillhub.dto.cli.CliDryRunResponse; import com.iflytek.skillhub.dto.cli.CliPublishResponse; import com.iflytek.skillhub.dto.cli.CliResolveResponse; import com.iflytek.skillhub.service.AuditRequestContext; @@ -119,6 +120,18 @@ public class CliSkillAppService { ); } + public CliDryRunResponse validatePublish(String namespace, List entries, String publisherId, Set platformRoles) { + SkillPublishService.DryRunResult result = skillPublishService.validateOnly( + namespace, entries, publisherId, platformRoles); + return new CliDryRunResponse( + result.valid(), + result.errors(), + result.warnings(), + result.resolvedSlug(), + result.resolvedVersion() + ); + } + public CliPublishResponse publish(String namespace, List entries, String publisherId, SkillVisibility visibility, Set platformRoles) { SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( namespace, entries, publisherId, visibility, platformRoles, false diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java new file mode 100644 index 00000000..d671930d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java @@ -0,0 +1,90 @@ +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.cli.CliDryRunResponse; +import com.iflytek.skillhub.service.cli.CliSkillAppService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliDryRunValidateTest { + @Autowired MockMvc mockMvc; + @MockBean CliSkillAppService cliSkillAppService; + + private UsernamePasswordAuthenticationToken auth() { + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", "tester", "t@example.com", "", "api_token", Set.of("USER")); + return new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @Test + void validatePublish_returnsValidResult() throws Exception { + given(cliSkillAppService.validatePublish(eq("global"), any(), eq("user-1"), eq(Set.of("USER")))) + .willReturn(new CliDryRunResponse( + true, List.of(), List.of("Disallowed file extension: data.bin"), + "my-skill", "1.0.0")); + + MockMultipartFile file = new MockMultipartFile("file", "skill.zip", + "application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04}); + + mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") + .file(file) + .with(authentication(auth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(true)) + .andExpect(jsonPath("$.data.resolvedSlug").value("my-skill")) + .andExpect(jsonPath("$.data.resolvedVersion").value("1.0.0")) + .andExpect(jsonPath("$.data.warnings[0]").value("Disallowed file extension: data.bin")); + } + + @Test + void validatePublish_returnsInvalidResult() throws Exception { + given(cliSkillAppService.validatePublish(eq("global"), any(), eq("user-1"), eq(Set.of("USER")))) + .willReturn(new CliDryRunResponse( + false, List.of("Missing required file: SKILL.md at root"), List.of(), + null, null)); + + MockMultipartFile file = new MockMultipartFile("file", "skill.zip", + "application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04}); + + mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") + .file(file) + .with(authentication(auth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")) + .andExpect(jsonPath("$.data.resolvedSlug").doesNotExist()); + } + + @Test + void validatePublish_requiresAuthentication() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "skill.zip", + "application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04}); + + mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") + .file(file)) + .andExpect(status().isUnauthorized()); + } +} 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 98d8d809..e42b3735 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 @@ -119,6 +119,118 @@ public class SkillPublishService { this.clock = clock; } + public record DryRunResult( + boolean valid, + List errors, + List warnings, + String resolvedSlug, + String resolvedVersion + ) {} + + /** + * Validates a package without persisting anything. Used by the --dry-run CLI flow. + */ + @Transactional(readOnly = true) + public DryRunResult validateOnly( + String namespaceSlug, + List entries, + String publisherId, + Set platformRoles) { + + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + String resolvedSlug = null; + String resolvedVersion = null; + + // 1. Find namespace + var namespaceOpt = namespaceRepository.findBySlug(namespaceSlug); + if (namespaceOpt.isEmpty()) { + errors.add("Namespace not found: " + namespaceSlug); + return new DryRunResult(false, errors, warnings, null, null); + } + Namespace namespace = namespaceOpt.get(); + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + errors.add("Namespace is frozen: " + namespaceSlug); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + errors.add("Namespace is archived: " + namespaceSlug); + } + + // 2. Check membership + boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN"); + if (!isSuperAdmin) { + var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId); + if (member.isEmpty()) { + errors.add("Publisher is not a member of namespace: " + namespaceSlug); + } + } + + // 3. Package validation + ValidationResult packageValidation = skillPackageValidator.validate(entries); + errors.addAll(packageValidation.errors()); + warnings.addAll(packageValidation.warnings()); + + if (!packageValidation.passed()) { + return new DryRunResult(false, errors, warnings, null, null); + } + + // 4. Parse SKILL.md + PackageEntry skillMd = entries.stream() + .filter(e -> e.path().equals("SKILL.md")) + .findFirst() + .orElse(null); + if (skillMd == null) { + errors.add("Missing required file: SKILL.md at root"); + return new DryRunResult(false, errors, warnings, null, null); + } + + SkillMetadata metadata; + try { + metadata = skillMetadataParser.parse(new String(skillMd.content())); + } catch (Exception e) { + errors.add("Invalid SKILL.md: " + e.getMessage()); + return new DryRunResult(false, errors, warnings, null, null); + } + + if (metadata.version() == null || metadata.version().isBlank()) { + resolvedVersion = AUTO_VERSION_FORMATTER.format(currentTime()); + } else { + resolvedVersion = metadata.version(); + } + + try { + resolvedSlug = SlugValidator.slugify(metadata.name()); + } catch (Exception e) { + errors.add("Invalid skill name for slug generation: " + e.getMessage()); + return new DryRunResult(false, errors, warnings, resolvedSlug, resolvedVersion); + } + + // 5. Pre-publish validation (credential scan) + PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext( + entries, metadata, publisherId, namespace.getId()); + ValidationResult prePublishValidation = prePublishValidator.validate(context); + errors.addAll(prePublishValidation.errors()); + warnings.addAll(prePublishValidation.warnings()); + + // 6. Slug conflict check + if (resolvedSlug != null && errors.isEmpty()) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), resolvedSlug); + for (Skill existing : existingSkills) { + if (!existing.getOwnerId().equals(publisherId)) { + boolean hasPublished = !skillVersionRepository + .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + if (hasPublished) { + errors.add("Name conflict: slug \"" + resolvedSlug + "\" is already published by another user"); + break; + } + } + } + } + + return new DryRunResult(errors.isEmpty(), errors, warnings, resolvedSlug, resolvedVersion); + } + /** * Publishes an extracted package into the target namespace. * diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index d0fada13..1a11074b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1652,6 +1652,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/cli/v1/skills/{namespace}/publish/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["validatePublish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/user/profile": { parameters: { query?: never; @@ -3948,6 +3964,22 @@ export interface components { version?: string; visibility?: string; }; + ApiResponseCliDryRunResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["CliDryRunResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + CliDryRunResponse: { + valid?: boolean; + errors?: string[]; + warnings?: string[]; + resolvedSlug?: string; + resolvedVersion?: string; + }; UpdateProfileRequest: { displayName?: string; }; @@ -8294,6 +8326,35 @@ export interface operations { }; }; }; + validatePublish: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliDryRunResponse"]; + }; + }; + }; + }; getProfile: { parameters: { query?: never;