mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
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 <path> --dry-run`
Closes #429
This commit is contained in:
parent
098616dcb6
commit
1067d0ff6e
11 changed files with 549 additions and 3 deletions
|
|
@ -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<PublishResponse>(response)
|
||||
}
|
||||
|
||||
async validatePublish(namespace: string, file: Blob, fileName = 'skill.zip'): Promise<DryRunResponse> {
|
||||
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<DryRunResponse>(response)
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface PublishCommandOptions {
|
|||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
|
||||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ cli
|
|||
.command('publish <path>', 'Publish a local skill package')
|
||||
.option('--namespace <slug>', 'Namespace')
|
||||
.option('--visibility <v>', 'Visibility (public|namespace-only|private)')
|
||||
.option('--dry-run', 'Validate without publishing')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
147
cli/test/integration/publish-dry-run.test.ts
Normal file
147
cli/test/integration/publish-dry-run.test.ts
Normal file
|
|
@ -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<ReturnType<typeof startFakeRegistry>> | 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<CliDryRunResponse> validatePublish(
|
||||
@PathVariable String namespace,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
|
||||
List<PackageEntry> 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<CliPublishResponse> publish(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.dto.cli;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CliDryRunResponse(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
String resolvedSlug,
|
||||
String resolvedVersion
|
||||
) {}
|
||||
|
|
@ -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<PackageEntry> entries, String publisherId, Set<String> 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<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
|
||||
namespace, entries, publisherId, visibility, platformRoles, false
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -119,6 +119,118 @@ public class SkillPublishService {
|
|||
this.clock = clock;
|
||||
}
|
||||
|
||||
public record DryRunResult(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
List<String> 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<PackageEntry> entries,
|
||||
String publisherId,
|
||||
Set<String> platformRoles) {
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
List<String> 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<Skill> 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.
|
||||
*
|
||||
|
|
|
|||
61
web/src/api/generated/schema.d.ts
vendored
61
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue