fix(cli): add namespace sync manifest endpoint

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-29 14:06:34 +08:00
parent c11a51c75f
commit 2babc0935b
11 changed files with 359 additions and 1 deletions

View file

@ -0,0 +1,69 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* CLI namespace-scoped read endpoints.
*/
@RestController
@RequestMapping("/api/cli/v1/namespaces")
public class CliNamespaceController extends BaseApiController {
private static final int DEFAULT_LIMIT = 100;
private static final int MAX_LIMIT = 100;
private final CliSkillAppService cliSkillAppService;
public CliNamespaceController(CliSkillAppService cliSkillAppService, ApiResponseFactory responseFactory) {
super(responseFactory);
this.cliSkillAppService = cliSkillAppService;
}
@GetMapping("/{namespace}/skills")
@RateLimit(category = "skills", authenticated = 60, anonymous = 0)
public ApiResponse<CliNamespaceSyncResponse> listSkills(
@PathVariable String namespace,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "100") int limit,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
int page = parseCursor(cursor);
int normalizedLimit = Math.min(Math.max(limit, 1), MAX_LIMIT);
return ok("response.success.read", cliSkillAppService.listNamespaceSkills(
namespace,
page,
normalizedLimit,
userId,
userNsRoles
));
}
private int parseCursor(String cursor) {
if (cursor == null || cursor.isBlank()) {
return 0;
}
try {
int page = Integer.parseInt(cursor);
if (page < 0) {
throw new NumberFormatException("negative cursor");
}
return page;
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("cursor must be a non-negative page number", ex);
}
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto.cli;
import java.time.Instant;
/**
* Installable skill metadata used by the CLI namespace workspace synchronizer.
*/
public record CliNamespaceSyncItemResponse(
String namespace,
String slug,
String version,
Long versionId,
String fingerprint,
Instant updatedAt,
String visibility,
String downloadUrl
) {}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto.cli;
import java.util.List;
/**
* Cursor-shaped response consumed by the CLI namespace workspace synchronizer.
*/
public record CliNamespaceSyncResponse(
List<CliNamespaceSyncItemResponse> items,
String nextCursor
) {}

View file

@ -1,14 +1,16 @@
package com.iflytek.skillhub.service.cli;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
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.CliNamespaceSyncItemResponse;
import com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse;
import com.iflytek.skillhub.dto.cli.CliPublishResponse;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
@ -16,6 +18,8 @@ import com.iflytek.skillhub.service.SkillDeleteAppService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@ -82,6 +86,42 @@ public class CliSkillAppService {
);
}
public CliNamespaceSyncResponse listNamespaceSkills(
String namespace,
int page,
int limit,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
Page<Skill> skills = skillQueryService.listInstallableSkillsByNamespace(
namespace, userId, roles, PageRequest.of(page, limit));
List<CliNamespaceSyncItemResponse> items = skills.getContent().stream()
.map(skill -> toNamespaceSyncItem(skill, namespace, userId, roles))
.toList();
String nextCursor = skills.hasNext() ? String.valueOf(page + 1) : null;
return new CliNamespaceSyncResponse(items, nextCursor);
}
private CliNamespaceSyncItemResponse toNamespaceSyncItem(
Skill skill,
String namespace,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
namespace, skill.getSlug(), null, null, null, userId, userNsRoles);
return new CliNamespaceSyncItemResponse(
namespace,
skill.getSlug(),
resolved.version(),
resolved.versionId(),
resolved.fingerprint(),
skill.getUpdatedAt(),
skill.getVisibility().name(),
resolved.downloadUrl()
);
}
public ResponseEntity<InputStreamResource> downloadLatest(String namespace, String slug, HttpServletRequest request) {
String userId = (String) request.getAttribute("userId");
@SuppressWarnings("unchecked")

View file

@ -132,6 +132,38 @@ class CliSkillControllerTest {
verify(apiTokenService).touchLastUsed(token);
}
@Test
void namespaceSyncReturnsCursorResponseForValidBearer() throws Exception {
ApiToken token = new ApiToken("sync-user", "cli", "sk_test", "hash", "[]");
UserAccount user = new UserAccount("sync-user", "Sync User", "sync@example.com", "");
var response = new com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse(
List.of(new com.iflytek.skillhub.dto.cli.CliNamespaceSyncItemResponse(
"team-a", "demo", "1.0.0", 42L, "sha256:fingerprint",
java.time.Instant.parse("2026-08-20T00:00:00Z"), "NAMESPACE_ONLY",
"/api/v1/skills/team-a/demo/versions/1.0.0/download"
)),
"2"
);
given(apiTokenService.validateToken("sync-token")).willReturn(Optional.of(token));
given(userAccountRepository.findById("sync-user")).willReturn(Optional.of(user));
given(userRoleBindingRepository.findByUserId("sync-user")).willReturn(List.of());
given(cliSkillAppService.listNamespaceSkills("team-a", 1, 25, "sync-user", Map.of()))
.willReturn(response);
mockMvc.perform(get("/api/cli/v1/namespaces/team-a/skills")
.param("cursor", "1")
.param("limit", "25")
.header(HttpHeaders.AUTHORIZATION, "Bearer sync-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].namespace").value("team-a"))
.andExpect(jsonPath("$.data.items[0].slug").value("demo"))
.andExpect(jsonPath("$.data.items[0].version").value("1.0.0"))
.andExpect(jsonPath("$.data.nextCursor").value("2"));
verify(cliSkillAppService).listNamespaceSkills("team-a", 1, 25, "sync-user", Map.of());
}
@Test
void resolveReturnsCliResolveResponse() throws Exception {
given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn(

View file

@ -32,6 +32,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import java.math.BigDecimal;
import java.time.Instant;
@ -222,6 +224,30 @@ class CliSkillAppServiceTest {
assertEquals("abc123", response.fingerprint());
}
@Test
void listNamespaceSkills_mapsInstallableVersionsToCliManifest() {
Skill skill = new Skill(7L, "demo", "user-1", SkillVisibility.NAMESPACE_ONLY);
setField(skill, "id", 10L);
skill.setLatestVersionId(42L);
skill.setUpdatedBy("user-1");
given(skillQueryService.listInstallableSkillsByNamespace(
eq("team-a"), eq("user-1"), eq(Map.of()), eq(PageRequest.of(0, 100))))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 100), 1));
given(skillQueryService.resolveVersion("team-a", "demo", null, null, null, "user-1", Map.of()))
.willReturn(new SkillQueryService.ResolvedVersionDTO(
10L, "team-a", "demo", "1.0.0", 42L, "sha256:fingerprint", null,
"/api/v1/skills/team-a/demo/versions/1.0.0/download"));
var result = service.listNamespaceSkills("team-a", 0, 100, "user-1", Map.of());
assertEquals(1, result.items().size());
assertEquals("demo", result.items().getFirst().slug());
assertEquals("1.0.0", result.items().getFirst().version());
assertEquals("sha256:fingerprint", result.items().getFirst().fingerprint());
assertNull(result.nextCursor());
}
@Test
void deleteRemote_delegatesToDeleteAppService() {
var auditContext = new AuditRequestContext("127.0.0.1", "CLI/1.0");

View file

@ -83,6 +83,7 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"),
RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/cli/v1/auth/whoami"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/cli/v1/namespaces/*/skills"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/search"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/resolve"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
@ -133,6 +134,7 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/auth/whoami"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/namespaces/*/skills"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/search"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/resolve"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),

View file

@ -98,6 +98,7 @@ class RouteSecurityPolicyRegistryTest {
@Test
void apiTokenPolicySupportsNativeCliRoutes() {
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/namespaces/team-a/skills", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/search", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/global/demo/resolve", Set.of()).allowed());
assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of()).allowed());
@ -107,6 +108,16 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(registry.authorizeApiToken("DELETE", "/api/cli/v1/skills/global/demo", Set.of("skill:delete")).allowed());
}
@Test
void routeAuthorizationRequiresAuthenticationForNativeCliNamespaceSync() {
boolean matched = registry.authorizationPolicies().stream()
.anyMatch(policy -> policy.method() == HttpMethod.GET
&& "/api/cli/v1/namespaces/*/skills".equals(policy.pattern())
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
assertTrue(matched);
}
@Test
void routeAuthorizationProtectsNativeCliRemoteDeleteByAuthenticationNotSuperAdminRole() {
boolean matched = registry.authorizationPolicies().stream()

View file

@ -37,6 +37,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
@ -286,6 +287,47 @@ public class SkillQueryService {
return new PageImpl<>(pageContent, pageable, accessibleSkills.size());
}
/**
* Lists only active, visible skills whose latest version can be installed.
*
* <p>This is intentionally separate from {@link #listSkillsByNamespace}:
* the portal discovery method also exposes skills without a published
* version, while the CLI sync manifest must contain concrete downloadable
* versions. Filtering happens before pagination so cursors remain stable.
*/
public Page<Skill> listInstallableSkillsByNamespace(
String namespaceSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Namespace namespace = findNamespace(namespaceSlug);
List<Skill> accessibleSkills = skillRepository
.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE)
.stream()
.filter(skill -> visibilityChecker.canAccess(skill, currentUserId, userNsRoles))
.toList();
Map<Long, SkillVersion> latestVersions = skillVersionRepository.findByIdIn(
accessibleSkills.stream()
.map(Skill::getLatestVersionId)
.filter(Objects::nonNull)
.distinct()
.toList())
.stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Skill> installableSkills = accessibleSkills.stream()
.filter(skill -> SkillInstallability.isInstallableVersion(latestVersions.get(skill.getLatestVersionId())))
.sorted(Comparator.comparing(Skill::getSlug)
.thenComparing(Skill::getId, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
int start = Math.min((int) pageable.getOffset(), installableSkills.size());
int end = Math.min(start + pageable.getPageSize(), installableSkills.size());
return new PageImpl<>(installableSkills.subList(start, end), pageable, installableSkills.size());
}
/**
* Returns metadata for a visible version, including the stored manifest and
* parsed metadata payload.

View file

@ -239,6 +239,46 @@ class SkillQueryServiceTest {
verify(skillRepository).findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE);
}
@Test
void installableNamespaceDiscoveryFiltersBeforePaginationAndSortsBySlug() throws Exception {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
setId(namespace, 1L);
Skill ready = new Skill(1L, "ready", "owner-1", SkillVisibility.NAMESPACE_ONLY);
setId(ready, 10L);
ready.setLatestVersionId(100L);
Skill draft = new Skill(1L, "draft", "owner-1", SkillVisibility.NAMESPACE_ONLY);
setId(draft, 11L);
draft.setLatestVersionId(101L);
Skill publicReady = new Skill(1L, "public-ready", "owner-2", SkillVisibility.PUBLIC);
setId(publicReady, 12L);
publicReady.setLatestVersionId(102L);
SkillVersion readyVersion = new SkillVersion(10L, "1.0.0", "owner-1");
setId(readyVersion, 100L);
readyVersion.setStatus(SkillVersionStatus.PUBLISHED);
readyVersion.setDownloadReady(true);
SkillVersion draftVersion = new SkillVersion(11L, "1.0.0", "owner-1");
setId(draftVersion, 101L);
draftVersion.setStatus(SkillVersionStatus.DRAFT);
SkillVersion publicReadyVersion = new SkillVersion(12L, "2.0.0", "owner-2");
setId(publicReadyVersion, 102L);
publicReadyVersion.setStatus(SkillVersionStatus.PUBLISHED);
publicReadyVersion.setDownloadReady(true);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(ready, draft, publicReady));
when(skillVersionRepository.findByIdIn(List.of(100L, 101L, 102L)))
.thenReturn(List.of(readyVersion, draftVersion, publicReadyVersion));
Page<Skill> result = service.listInstallableSkillsByNamespace(
"team-a", "viewer", Map.of(1L, NamespaceRole.MEMBER), PageRequest.of(0, 1));
assertEquals(2, result.getTotalElements());
assertEquals("public-ready", result.getContent().getFirst().getSlug());
assertTrue(result.hasNext());
}
@Test
void versionContentRejectsPublicArchivedSkillForNonManager() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");

View file

@ -3348,6 +3348,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cli/v1/namespaces/{namespace}/skills": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listSkills_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cli/v1/auth/whoami": {
parameters: {
query?: never;
@ -4347,6 +4363,8 @@ export interface components {
namespace?: string;
/** Format: date-time */
updatedAt?: string;
ownerId?: string;
ownerDisplayName?: string;
canSubmitPromotion?: boolean;
headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
@ -5330,6 +5348,31 @@ export interface components {
/** Format: int32 */
limit?: number;
};
ApiResponseCliNamespaceSyncResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["CliNamespaceSyncResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
CliNamespaceSyncItemResponse: {
namespace?: string;
slug?: string;
version?: string;
/** Format: int64 */
versionId?: number;
fingerprint?: string;
/** Format: date-time */
updatedAt?: string;
visibility?: string;
downloadUrl?: string;
};
CliNamespaceSyncResponse: {
items?: components["schemas"]["CliNamespaceSyncItemResponse"][];
nextCursor?: string;
};
ApiResponseCliWhoAmIResponse: {
/** Format: int32 */
code?: number;
@ -11357,6 +11400,31 @@ export interface operations {
};
};
};
listSkills_1: {
parameters: {
query?: {
cursor?: string;
limit?: number;
};
header?: never;
path: {
namespace: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseCliNamespaceSyncResponse"];
};
};
};
};
whoami_1: {
parameters: {
query?: never;