diff --git a/cli/src/index.ts b/cli/src/index.ts index 84b83e0c..39d3080e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -255,7 +255,7 @@ cli .option('--scope ', 'Install scope: user or project') .option('--agent ', 'Agent profile (repeatable)') .option('--dir ', 'Install directory') - .option('--force', 'Replace same-source member versions') + .option('--force', 'Replace same-source member versions or local changes') .option('--check', 'Show an upgrade plan without writing') .option('--registry ', 'Registry URL') .option('--token ', 'API token') diff --git a/cli/src/services/suite-service.ts b/cli/src/services/suite-service.ts index 5c9723fc..19e09e8d 100644 --- a/cli/src/services/suite-service.ts +++ b/cli/src/services/suite-service.ts @@ -460,6 +460,7 @@ async function removeSuiteTransaction(options: SuiteRemoveOptions): Promise candidate.registry !== options.registry || candidate.namespace !== options.namespace || candidate.slug !== options.slug) for (const item of current.items) { + if (item.registry !== options.registry) continue const deletedDirs = new Set(removable .filter(candidate => candidate.item.registry === item.registry && candidate.item.namespace === item.namespace && candidate.item.slug === item.slug) @@ -658,6 +659,13 @@ async function preflightExistingTargets( } const currentSuitePrefix = `suite:@${plan.namespace}/${plan.slug}@` const ownerTarget = owner?.targets.find(existing => resolve(existing.installDir) === installDir) + if (owner && ownerTarget && !force && await pathExists(installDir) + && (await snapshotSkillDirectory(installDir)).fingerprint !== owner.fingerprint) { + throw new CliError(`local changes detected at ${installDir}`, EXIT.validation, { + path: installDir, + next: 'pass --force only if replacing these local changes is intended' + }) + } if (owner && ownerTarget && owner.version !== member.version && targetInstalledBy(owner, ownerTarget).some(source => source.startsWith('suite:') && !source.startsWith(currentSuitePrefix))) { throw new CliError(`shared Suite member @${member.namespace}/${member.slug} cannot change version in place`, EXIT.validation, { diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts index fba49c88..2a0b5353 100644 --- a/cli/test/unit/services/suite-service.test.ts +++ b/cli/test/unit/services/suite-service.test.ts @@ -677,6 +677,58 @@ describe('Suite local lifecycle', () => { expect(inventory.items[0].installedBy).toEqual(['suite:@global/editor-pack@1.0.0']) }) + test('removing a Suite does not rewrite the same coordinate from another registry', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-registry-a-')) + const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-registry-b-')) + const secondRegistry = 'http://registry-b.test' + const { plan, downloads } = makePlan() + plan.members = [plan.members[0]!] + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + await installSuite({ + registry: secondRegistry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'claude', rootDir: secondRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + + const afterFirstRemoval = await new InventoryStore(home).read() + expect(afterFirstRemoval.suites).toEqual([ + expect.objectContaining({ registry: secondRegistry, slug: 'starter-pack' }) + ]) + expect(afterFirstRemoval.items).toEqual([ + expect.objectContaining({ + registry: secondRegistry, + slug: 'alpha', + installedBy: ['suite:@global/starter-pack@1.0.0'] + }) + ]) + expect(await readFile(join(secondRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + + const secondRemoval = await removeSuite({ + registry: secondRegistry, + namespace: 'global', + slug: 'starter-pack', + home + }) + expect(secondRemoval.removed).toEqual([join(secondRoot, 'alpha')]) + expect(await exists(join(secondRoot, 'alpha'))).toBe(false) + }) + test('reuses a matching legacy direct install and preserves it when Suite is removed', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) @@ -716,6 +768,35 @@ describe('Suite local lifecycle', () => { expect(inventory.items[0].installedBy).toEqual(['direct']) }) + test('requires force before replacing a locally modified same-version member', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members = [plan.members[0]!] + const member = plan.members[0]! + const skillDir = join(rootDir, member.slug) + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Alpha') + const store = new InventoryStore(home) + await store.upsertTarget(registry, member.namespace, member.slug, member.version, { + agent: 'codex', rootDir, installDir: skillDir, installedAt: new Date().toISOString() + }, member.fingerprint) + await writeFile(join(skillDir, 'SKILL.md'), '# Locally modified Alpha') + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('local changes') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf8')).toBe('# Locally modified Alpha') + expect((await store.read()).suites ?? []).toEqual([]) + }) + test('tracks direct and Suite ownership independently for each Agent target', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const directRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-direct-root-')) diff --git a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md index 39acfe0b..e153d0f3 100644 --- a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md +++ b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md @@ -278,6 +278,12 @@ CLI SHALL 在修改目标目录前完成全部成员和全部 Agent 目标的解 - **THEN** CLI 通过 Suite 级本地锁只允许一个操作进入事务 - **AND** 另一个操作明确报告繁忙,不得基于旧 inventory 提交 +#### Scenario: Existing Member has local changes +- **WHEN** Suite 安装将复用或替换一个已登记但 fingerprint 已变化的 Member 目录 +- **AND** 用户未明确传入 `--force` +- **THEN** CLI 在写入任何目标或 inventory 前拒绝安装 +- **AND** 保留本地文件和现有 inventory + ### Requirement: Suite installation SHALL preserve Agent Skills compatibility CLI SHALL 将每个 Member 作为普通 Skill 安装到 Agent 已支持的 Skill 根目录。CLI SHALL NOT 为 Suite 创建同名 `SKILL.md` 或要求 Agent 理解 Suite 协议。 @@ -307,6 +313,11 @@ CLI inventory SHALL 记录已安装 SuiteVersion、精确成员快照,以及 - **THEN** CLI 将缺失的 Suite 和来源集合按空值处理 - **AND** 已安装 Skill 记录和目标路径保持不变 +#### Scenario: Same Suite coordinate is installed from different registries +- **WHEN** 两个 Registry 各自安装了相同 Namespace 和 slug 的 Suite +- **THEN** inventory 按 Registry 分别记录 Suite 与 Member 来源 +- **AND** 移除其中一个 Registry 的 Suite 不得修改另一个 Registry 的来源或文件 + ### Requirement: Suite removal SHALL be ownership-safe `skillhub suite remove` SHALL 仅移除该 Suite 的来源记录。CLI SHALL 只自动删除不再被直接安装、未被其他 Suite 引用且未被本地修改的 Member 目录。 @@ -437,6 +448,11 @@ Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、 - **THEN** 用户可以查看 Suite 公开元数据 - **AND** 只有全部 Member 仍公开且可安装时才能获得完整安装计划 +#### Scenario: Anonymous user accesses a Suite in an archived Namespace +- **WHEN** Namespace 已归档且匿名用户访问其中的 PUBLISHED PUBLIC SuiteVersion +- **THEN** 系统拒绝查看和安装 +- **AND** Namespace 成员和平台管理员仍按现有归档 Namespace 规则访问 + #### Scenario: Namespace member accesses a namespace Suite - **WHEN** 当前 Namespace MEMBER 访问 PUBLISHED NAMESPACE_ONLY SuiteVersion - **THEN** 用户可以查看并在全部 Member 校验通过后安装 @@ -465,6 +481,12 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新 - **THEN** 系统拒绝修改 - **AND** 提示创建新的 SuiteVersion +#### Scenario: A stale draft edit races with publication +- **WHEN** 一个请求读取 DRAFT 后,另一事务先将同一 SuiteVersion 发布 +- **AND** 旧请求随后尝试保存编辑结果 +- **THEN** 系统拒绝旧请求的并发更新 +- **AND** 已发布状态、发布时间和发布内容保持不变 + ### Requirement: Suite plan and Member download metrics SHALL remain attributable and idempotent 客户端 SHALL 为一次安装计划生成独立的 idempotency key,并在安全重试时复用;服务端 SHALL 按调用者隔离该 key,并生成 operation ID 关联该计划的审计记录。服务端成功签发完整计划后 SHALL 记录一次 Suite 安装请求,但 SHALL NOT 在此时预增 Member 下载数。每个 Member 继续通过现有 Skill 下载接口按实际下载请求计数,避免计划签发与文件下载对同一 Member 重复计数。这些指标表示服务端计划签发和实际下载请求,不表示 CLI 本地安装成功。 diff --git a/server/skillhub-app/src/main/resources/db/migration/V53__skill_suite_version_optimistic_lock.sql b/server/skillhub-app/src/main/resources/db/migration/V53__skill_suite_version_optimistic_lock.sql new file mode 100644 index 00000000..7948a493 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V53__skill_suite_version_optimistic_lock.sql @@ -0,0 +1,3 @@ +-- Reject stale draft edits after another transaction publishes or otherwise changes the snapshot. +ALTER TABLE skill_suite_version + ADD COLUMN lock_version BIGINT NOT NULL DEFAULT 0; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteVersionOptimisticLockingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteVersionOptimisticLockingTest.java new file mode 100644 index 00000000..6062896d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteVersionOptimisticLockingTest.java @@ -0,0 +1,146 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.OptimisticLockException; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +class SkillSuiteVersionOptimisticLockingTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void publishingRejectsAConcurrentStaleDraftUpdate() { + PersistedSuite persisted = persistDraft(); + EntityManager publisher = entityManagerFactory.createEntityManager(); + EntityManager staleEditor = entityManagerFactory.createEntityManager(); + try { + publisher.getTransaction().begin(); + staleEditor.getTransaction().begin(); + SkillSuiteVersion published = publisher.find(SkillSuiteVersion.class, persisted.versionId()); + SkillSuiteVersion stale = staleEditor.find(SkillSuiteVersion.class, persisted.versionId()); + + published.setStatus(SkillSuiteVersionStatus.PUBLISHED); + published.setPublishedAt(Instant.parse("2026-09-09T08:00:00Z")); + publisher.getTransaction().commit(); + + stale.setDisplayName("Edited after publish"); + assertThatThrownBy(staleEditor.getTransaction()::commit) + .satisfies(error -> assertThat(hasCause(error, OptimisticLockException.class)).isTrue()); + + EntityManager verifier = entityManagerFactory.createEntityManager(); + try { + SkillSuiteVersion saved = verifier.find(SkillSuiteVersion.class, persisted.versionId()); + assertThat(saved.getStatus()).isEqualTo(SkillSuiteVersionStatus.PUBLISHED); + assertThat(saved.getDisplayName()).isEqualTo("Original draft"); + assertThat(saved.getPublishedAt()).isNotNull(); + } finally { + verifier.close(); + } + } finally { + rollbackIfActive(publisher); + rollbackIfActive(staleEditor); + publisher.close(); + staleEditor.close(); + deleteSuite(persisted); + } + } + + private PersistedSuite persistDraft() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + entityManager.getTransaction().begin(); + Namespace namespace = new Namespace("suite-locking", "Suite locking", "owner"); + entityManager.persist(namespace); + SkillSuite suite = new SkillSuite(namespace.getId(), "starter", "Starter", "owner"); + entityManager.persist(suite); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", "Original draft", "Summary", SkillVisibility.PUBLIC, "owner"); + entityManager.persist(version); + entityManager.getTransaction().commit(); + return new PersistedSuite(namespace.getId(), suite.getId(), version.getId()); + } finally { + rollbackIfActive(entityManager); + entityManager.close(); + } + } + + private void deleteSuite(PersistedSuite persisted) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + entityManager.getTransaction().begin(); + entityManager.createQuery("DELETE FROM SkillSuiteVersion version WHERE version.id = :id") + .setParameter("id", persisted.versionId()) + .executeUpdate(); + entityManager.createQuery("DELETE FROM SkillSuite suite WHERE suite.id = :id") + .setParameter("id", persisted.suiteId()) + .executeUpdate(); + entityManager.createQuery("DELETE FROM Namespace namespace WHERE namespace.id = :id") + .setParameter("id", persisted.namespaceId()) + .executeUpdate(); + entityManager.getTransaction().commit(); + } finally { + rollbackIfActive(entityManager); + entityManager.close(); + } + } + + private void rollbackIfActive(EntityManager entityManager) { + if (entityManager.getTransaction().isActive()) { + entityManager.getTransaction().rollback(); + } + } + + private boolean hasCause(Throwable error, Class expectedType) { + Throwable current = error; + while (current != null) { + if (expectedType.isInstance(current)) { + return true; + } + current = current.getCause(); + } + return false; + } + + private record PersistedSuite(Long namespaceId, Long suiteId, Long versionId) { + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java index eb4c8710..c57cb6eb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; import com.iflytek.skillhub.domain.review.ReviewSubjectType; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; @@ -165,6 +166,9 @@ public class SkillSuiteQueryService { return true; } NamespaceRole role = namespaceRoles.get(suite.getNamespaceId()); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED && role == null) { + return false; + } boolean namespaceAdmin = role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; boolean currentCreator = userId != null && userId.equals(suite.getCreatedBy()) && role != null; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java index 7b85a20d..696c9c21 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java @@ -11,6 +11,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.time.Clock; import java.time.Instant; @@ -26,6 +27,10 @@ public class SkillSuiteVersion { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + @Column(name = "lock_version", nullable = false) + private Long lockVersion; + @Column(name = "suite_id", nullable = false) private Long suiteId; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java index c94e8ad5..c539e92a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java @@ -181,6 +181,34 @@ class SkillSuiteQueryServiceTest { .isInstanceOf(DomainForbiddenException.class); } + @Test + void archivedNamespaceHidesPublishedPublicSuiteFromAnonymousUsers() { + Namespace namespace = new Namespace("team", "Team", "owner"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + SkillSuite suite = new SkillSuite(1L, "public-suite", "Public Suite", "suite-author"); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "1.0.0", SkillVisibility.PUBLIC, "suite-author"); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suite.setLatestVersionId(20L); + + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "public-suite")).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + + assertThatThrownBy(() -> service.getDetail( + "team", "public-suite", null, null, Map.of(), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + + when(memberRepository.findBySuiteVersionIdOrderByPosition(20L)).thenReturn(List.of()); + assertThat(service.getDetail( + "team", "public-suite", null, "member", + Map.of(1L, NamespaceRole.MEMBER), Set.of()).version().getVersion()) + .isEqualTo("1.0.0"); + } + @Test void pendingReviewUsesTheActualSubmitterAndReviewPermissions() { Namespace namespace = new Namespace("team", "Team", "owner"); diff --git a/web/src/pages/suite-detail.test.tsx b/web/src/pages/suite-detail.test.tsx index c3d85e08..d534e0ac 100644 --- a/web/src/pages/suite-detail.test.tsx +++ b/web/src/pages/suite-detail.test.tsx @@ -179,6 +179,9 @@ describe('SuiteDetailPage', () => { const sidebar = screen.getByRole('complementary', { name: 'suite.detailsSidebar' }) expect(within(sidebar).getByText('v1.0.0')).not.toBeNull() expect(within(sidebar).getByText('suite.installCommand')).not.toBeNull() + expect(within(sidebar).getByText( + 'skillhub suite install @global/care-workflow --version 1.0.0', + )).not.toBeNull() expect(within(sidebar).getByLabelText('suite.copyInstallCommand')).not.toBeNull() }) }) diff --git a/web/src/pages/suite-detail.tsx b/web/src/pages/suite-detail.tsx index e1c78f60..ae796ec1 100644 --- a/web/src/pages/suite-detail.tsx +++ b/web/src/pages/suite-detail.tsx @@ -24,7 +24,7 @@ export function SuiteDetailPage() { const { data: versions } = useSuiteVersions(namespace, slug) const submitMutation = useSubmitSuite() const command = useMemo( - () => suite ? `skillhub suite install @${suite.namespace}/${suite.slug}@${suite.version}` : '', + () => suite ? `skillhub suite install @${suite.namespace}/${suite.slug} --version ${suite.version}` : '', [suite], ) const suitePath = `/suite/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}`