skillhub/cli/test/integration/sync-command.test.ts
XiaoSeS a5a723d8f7 fix(cli): align publish and namespace sync semantics
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-09-04 16:42:58 +08:00

807 lines
32 KiB
TypeScript

import { createHash } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
import { describe, expect, test } from 'bun:test'
import { startFakeRegistry, type FakeSkill } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
import { SkillHubClient } from '../../src/clients/skillhub-client'
import { pullNamespace } from '../../src/services/sync-service'
import { renderPullResult } from '../../src/commands/sync'
function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } {
const content = strToU8(body)
const fileHash = createHash('sha256').update(content).digest('hex')
const fingerprint = `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`
return { zipBytes: zipSync({ 'SKILL.md': content }), fingerprint }
}
async function makeLocalSkill(rootDir: string, slug = 'demo'): Promise<string> {
const skillDir = join(rootDir, slug)
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), `---\nname: ${slug}\ndescription: Demo\nversion: 1.0.0\n---\n`)
return skillDir
}
describe('sync command', () => {
test('mutating pull service rejects an empty selection before reading the namespace', async () => {
const registry = await startFakeRegistry({
token: 'token',
skills: [{ namespace: 'team-a', slug: 'demo', ...makeSkill('# demo\n') }]
})
try {
await expect(pullNamespace({
client: new SkillHubClient(registry.url, 'token'),
registry: registry.url,
token: 'token',
namespace: 'team-a',
rootDir: '/unused',
check: false,
prune: false,
force: false,
selectedSlugs: []
})).rejects.toThrow('requires at least one selected skill')
expect(registry.received.namespaceRequests).toBe(0)
} finally {
registry.stop()
}
})
test('all sync actions reject a missing or global namespace before a registry request', async () => {
const env = await createTempHome()
const registry = await startFakeRegistry({ token: 'token' })
const invocations = [
['pull', '--check'],
['status'],
['diff'],
['push', '--all']
]
try {
for (const invocation of invocations) {
for (const namespaceArgs of [[], ['--namespace', 'global']]) {
const result = await runCli([
'sync', ...invocation, ...namespaceArgs,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(5)
expect(result.stdout).toBe('')
expect(JSON.parse(result.stderr).message).toMatch(/namespace|required|global/)
}
}
expect(registry.received.namespaceRequests).toBe(0)
expect(registry.received.publish).toBeNull()
} finally {
registry.stop()
}
})
test('non-pull sync actions reject the pull-only --skill option', async () => {
const result = await runCli([
'sync', 'status', '--namespace', 'team-a', '--skill', 'demo', '--json'
])
expect(result.exitCode).toBe(5)
expect(result.stdout).toBe('')
expect(JSON.parse(result.stderr).message).toBe('--skill is only valid with sync pull')
})
test('non-interactive JSON pull without --skill returns usage error without prompting or requesting', async () => {
const env = await createTempHome()
const registry = await startFakeRegistry({
token: 'token',
skills: [{ namespace: 'team-a', slug: 'demo', ...makeSkill('# demo\n') }]
})
try {
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(5)
expect(result.stdout).toBe('')
expect(JSON.parse(result.stderr)).toMatchObject({
ok: false,
message: expect.stringContaining('requires at least one --skill'),
exitCode: 5
})
expect(registry.received.namespaceRequests).toBe(0)
} finally {
registry.stop()
}
})
test('sync preserves server membership errors and request IDs', async () => {
const env = await createTempHome()
const registry = await startFakeRegistry({
token: 'token',
failures: { namespaceSync: 'forbidden' }
})
try {
const result = await runCli([
'sync', 'status', '--namespace', 'team-a',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(2)
expect(result.stdout).toBe('')
expect(JSON.parse(result.stderr)).toMatchObject({
ok: false,
message: 'API token is missing required scope: skill:publish',
details: { requestId: 'req-test-forbidden' }
})
} finally {
registry.stop()
}
})
test('pull --check inspects the whole namespace without writing files or requiring --skill', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const registry = await startFakeRegistry({
token: 'token',
skills: [
{ namespace: 'team-a', slug: 'first', ...makeSkill('# first\n') },
{ namespace: 'team-a', slug: 'second', ...makeSkill('# second\n') }
]
})
try {
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--check',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, check: true })
expect(JSON.parse(result.stdout).entries).toHaveLength(2)
expect(await Bun.file(join(skillsDir, 'first', 'SKILL.md')).exists()).toBe(false)
expect(await Bun.file(join(skillsDir, '.skillhub', 'namespace-sync.json')).exists()).toBe(false)
} finally {
registry.stop()
}
})
test('pull installs only explicitly selected skills', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const registry = await startFakeRegistry({
token: 'token',
skills: [
{ namespace: 'team-a', slug: 'first', ...makeSkill('# first\n') },
{ namespace: 'team-a', slug: 'second', ...makeSkill('# second\n') }
]
})
try {
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'second', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout).actions).toEqual([{ slug: 'second', action: 'installed' }])
expect(await Bun.file(join(skillsDir, 'first')).exists()).toBe(false)
expect(await Bun.file(join(skillsDir, 'second', 'SKILL.md')).exists()).toBe(true)
const workspaceState = JSON.parse(
await readFile(join(skillsDir, '.skillhub', 'namespace-sync.json'), 'utf8')
)
expect(Object.keys(workspaceState.skills)).toEqual(['second'])
} finally {
registry.stop()
}
})
test('pull reads every cursor page without duplicates and still writes only the selected skill', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const registry = await startFakeRegistry({
token: 'token',
namespacePageSize: 1,
skills: ['first', 'second', 'third'].map(slug => ({
namespace: 'team-a',
slug,
...makeSkill(`# ${slug}\n`)
}))
})
try {
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'third', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
const output = JSON.parse(result.stdout)
expect(result.exitCode).toBe(0)
expect(registry.received.namespaceRequests).toBe(3)
expect(output.entries.map((item: { slug: string }) => item.slug)).toEqual(['first', 'second', 'third'])
expect(new Set(output.entries.map((item: { slug: string }) => item.slug)).size).toBe(3)
expect(output.actions).toEqual([{ slug: 'third', action: 'installed' }])
expect(await Bun.file(join(skillsDir, 'first')).exists()).toBe(false)
expect(await Bun.file(join(skillsDir, 'second')).exists()).toBe(false)
expect(await Bun.file(join(skillsDir, 'third', 'SKILL.md')).exists()).toBe(true)
} finally {
registry.stop()
}
})
test('pull propagates committed install warnings', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({
token: 'token',
skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
})
try {
const result = await pullNamespace({
client: new SkillHubClient(registry.url, 'token'),
registry: registry.url,
token: 'token',
namespace: 'team-a',
rootDir: skillsDir,
check: false,
prune: false,
force: false,
selectedSlugs: ['demo'],
installSkillFn: async () => ({
installed: [{ agent: 'workspace', dir: join(skillsDir, 'demo') }],
warnings: ['target lock cleanup failed: simulated release failure']
})
})
expect(result.actions).toEqual([{ slug: 'demo', action: 'installed' }])
expect(result.warnings).toEqual([{
slug: 'demo',
message: 'target lock cleanup failed: simulated release failure'
}])
expect(JSON.parse(renderPullResult(result, true, false)).warnings).toEqual(result.warnings)
expect(renderPullResult(result, false, false)).toContain('warning demo: target lock cleanup failed')
} finally {
registry.stop()
}
})
test('pull installs a namespace incrementally and writes workspace metadata', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const first = makeSkill('---\nname: first\ndescription: First\nversion: 1.0.0\n---\n')
const second = makeSkill('---\nname: second\ndescription: Second\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({
token: 'token',
skills: [
{ namespace: 'team-a', slug: 'first', ...first },
{ namespace: 'team-a', slug: 'second', ...second }
]
})
try {
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'first', '--skill', 'second', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(0)
expect(JSON.parse(pulled.stdout).actions).toHaveLength(2)
const metadata = JSON.parse(await readFile(join(skillsDir, 'first', '.skillhub', 'metadata.json'), 'utf8'))
expect(metadata).toMatchObject({
source: 'skillhub', namespace: 'team-a', slug: 'first', fingerprint: first.fingerprint
})
expect(await readFile(join(skillsDir, '.skillhub', 'namespace-sync.json'), 'utf8')).toContain('team-a')
const secondPull = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'first', '--skill', 'second', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(secondPull.exitCode).toBe(0)
expect(JSON.parse(secondPull.stdout).actions).toHaveLength(0)
expect(JSON.parse(secondPull.stdout).entries.every((item: { status: string }) => item.status === 'up-to-date')).toBe(true)
} finally {
registry.stop()
}
})
test('status detects local changes and pull does not overwrite without force', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const remoteBody = '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n'
const fixture = makeSkill(remoteBody)
const registry = await startFakeRegistry({
token: 'token',
skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
})
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
await writeFile(join(skillsDir, 'demo', 'SKILL.md'), '# local change\n')
const status = await runCli([
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(JSON.parse(status.stdout).items[0].status).toBe('local-changed')
const pull = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pull.exitCode).toBe(1)
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# local change\n')
const forced = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(forced.exitCode).toBe(0)
expect(JSON.parse(forced.stdout).actions).toEqual([{ slug: 'demo', action: 'updated' }])
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe(remoteBody)
} finally {
registry.stop()
}
})
test('reports a newer remote version as update-available even when content is unchanged', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: '1.0.0',
versionId: 1,
...fixture
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.version = '1.1.0'
skill.versionId = 2
const status = await runCli([
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(JSON.parse(status.stdout).items[0]).toMatchObject({
status: 'update-available',
localVersion: '1.0.0',
remoteVersion: '1.1.0'
})
} finally {
registry.stop()
}
})
test('blocks same-version remote content drift even with force', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const original = makeSkill('# original\n')
const changed = makeSkill('# changed without a version bump\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: '1.0.0',
versionId: 1,
...original
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.fingerprint = changed.fingerprint
skill.zipBytes = changed.zipBytes
const status = await runCli([
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(JSON.parse(status.stdout).items[0]).toMatchObject({
status: 'blocked',
reason: 'remote content changed without a newer version; use explicit install after verifying the release'
})
const checked = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--check',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(checked.exitCode).toBe(6)
expect(JSON.parse(checked.stdout)).toMatchObject({ ok: false, check: true })
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(6)
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# original\n')
} finally {
registry.stop()
}
})
test('blocks automatic downgrade even when remote content is unchanged', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('# stable content\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: '2.0.0',
versionId: 2,
...fixture
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.version = '1.0.0'
skill.versionId = 1
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(6)
expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({
status: 'blocked',
localVersion: '2.0.0',
remoteVersion: '1.0.0',
reason: 'remote version is older than the installed version; local files were kept'
})
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# stable content\n')
} finally {
registry.stop()
}
})
test('blocks sync when local and remote versions cannot be ordered', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('# stable content\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: 'release-a',
versionId: 1,
...fixture
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.version = 'release-b'
skill.versionId = 2
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(6)
expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({
status: 'blocked',
localVersion: 'release-a',
remoteVersion: 'release-b',
reason: 'cannot determine version order; use explicit install after verifying the release'
})
} finally {
registry.stop()
}
})
test('hard remote guards cannot be bypassed by local changes and force', async () => {
const original = makeSkill('# original\n')
const variants = [
{
name: 'downgrade',
initialVersion: '2.0.0',
remoteVersion: '1.0.0',
remote: original,
reason: 'remote version is older than the installed version; local files were kept'
},
{
name: 'same-version drift',
initialVersion: '1.0.0',
remoteVersion: '1.0.0',
remote: makeSkill('# changed without a version bump\n'),
reason: 'remote content changed without a newer version; use explicit install after verifying the release'
},
{
name: 'unknown version order',
initialVersion: 'release-a',
remoteVersion: 'release-b',
remote: original,
reason: 'cannot determine version order; use explicit install after verifying the release'
}
]
for (const variant of variants) {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: variant.initialVersion,
versionId: 1,
...original
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
await writeFile(join(skillsDir, 'demo', 'SKILL.md'), `# local edit before ${variant.name}\n`)
skill.version = variant.remoteVersion
skill.versionId = 2
skill.fingerprint = variant.remote.fingerprint
skill.zipBytes = variant.remote.zipBytes
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
const output = JSON.parse(pulled.stdout)
expect(pulled.exitCode, variant.name).toBe(6)
expect(output.entries[0], variant.name).toMatchObject({ status: 'blocked', reason: variant.reason })
expect(output.entries[0].changedFiles, variant.name).toEqual(['SKILL.md'])
expect(output.actions, variant.name).toEqual([])
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8'))
.toBe(`# local edit before ${variant.name}\n`)
} finally {
registry.stop()
}
}
})
test('an unselected blocked skill does not change the selected skill failure classification', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const chosen = makeSkill('# chosen\n')
const blockedOriginal = makeSkill('# blocked original\n')
const blockedChanged = makeSkill('# blocked changed\n')
const skills: FakeSkill[] = [
{ namespace: 'team-a', slug: 'chosen', version: '1.0.0', ...chosen },
{ namespace: 'team-a', slug: 'blocked', version: '1.0.0', ...blockedOriginal }
]
const registry = await startFakeRegistry({ token: 'token', skills })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'chosen', '--skill', 'blocked', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
await writeFile(join(skillsDir, 'chosen', 'SKILL.md'), '# chosen local edit\n')
skills[1]!.fingerprint = blockedChanged.fingerprint
skills[1]!.zipBytes = blockedChanged.zipBytes
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'chosen', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(1)
expect(JSON.parse(result.stdout).failures).toEqual([{
slug: 'chosen',
message: 'local changes detected; pass --force to overwrite'
}])
expect(JSON.parse(result.stderr).message).toBe('namespace sync completed with failures')
expect(await readFile(join(skillsDir, 'chosen', 'SKILL.md'), 'utf8')).toBe('# chosen local edit\n')
expect(await readFile(join(skillsDir, 'blocked', 'SKILL.md'), 'utf8')).toBe('# blocked original\n')
} finally {
registry.stop()
}
})
test('prune removes only unchanged managed orphan skills', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const skills: FakeSkill[] = [
{ namespace: 'team-a', slug: 'demo', ...fixture },
{ namespace: 'team-a', slug: 'keep', ...makeSkill('# keep\n') }
]
const registry = await startFakeRegistry({ token: 'token', skills })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--skill', 'keep', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skills.splice(0, skills.length)
const pruned = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--prune',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pruned.exitCode).toBe(0)
expect(JSON.parse(pruned.stdout).actions).toContainEqual({ slug: 'demo', action: 'pruned' })
expect(await Bun.file(join(skillsDir, 'demo')).exists()).toBe(false)
expect(await Bun.file(join(skillsDir, 'keep', 'SKILL.md')).exists()).toBe(true)
} finally {
registry.stop()
}
})
test('push all validates packages and submits an uploaded version for review', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const skillDir = join(skillsDir, 'demo')
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"must":"not be uploaded"}')
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
try {
const pushed = await runCli([
'sync', 'push', '--all', '--namespace', 'team-a', '--dir', skillsDir,
'--submit-review', '--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pushed.exitCode).toBe(0)
expect(JSON.parse(pushed.stdout).items[0].action).toBe('submitted-review')
expect(JSON.parse(pushed.stdout).items[0]).toMatchObject({
status: 'UPLOADED',
reviewStatus: 'PENDING_REVIEW'
})
expect(registry.received.publish?.visibility).toBe('NAMESPACE_ONLY')
expect(registry.received.publish?.rejectExistingVersion).toBe(true)
expect(registry.received.review).toMatchObject({
namespace: 'team-a', slug: 'demo', version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY'
})
expect(registry.received.reviews).toBe(1)
} finally {
registry.stop()
await rm(skillsDir, { recursive: true, force: true })
}
})
test.each([
{ status: 'SCANNING', submitReview: false, action: 'uploaded', reviews: 0 },
{ status: 'SCANNING', submitReview: true, action: 'uploaded', reviews: 0 },
{ status: 'UPLOADED', submitReview: true, action: 'submitted-review', reviews: 1 },
{ status: 'PENDING_REVIEW', submitReview: true, action: 'submitted-review', reviews: 0 },
{ status: 'PUBLISHED', submitReview: true, action: 'uploaded', reviews: 0 }
])('push preserves $status and applies submit-review boundary', async scenario => {
const env = await createTempHome()
const skillDir = await makeLocalSkill(env.cwd)
const registry = await startFakeRegistry({ token: 'token', publishStatus: scenario.status })
try {
const result = await runCli([
'sync', 'push', skillDir, '--namespace', 'team-a',
...(scenario.submitReview ? ['--submit-review'] : []),
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
const item = JSON.parse(result.stdout).items[0]
expect(result.exitCode).toBe(0)
expect(item).toMatchObject({ status: scenario.status, action: scenario.action })
expect(registry.received.reviews).toBe(scenario.reviews)
} finally {
registry.stop()
}
})
test('PUBLIC uploaded push submits review once with PUBLIC visibility', async () => {
const env = await createTempHome()
const skillDir = await makeLocalSkill(env.cwd)
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
try {
const result = await runCli([
'sync', 'push', skillDir, '--namespace', 'team-a', '--visibility', 'public', '--submit-review',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(0)
expect(registry.received.reviews).toBe(1)
expect(registry.received.review?.targetVisibility).toBe('PUBLIC')
} finally {
registry.stop()
}
})
test('PRIVATE push rejects --submit-review before validation or upload', async () => {
const env = await createTempHome()
const skillDir = await makeLocalSkill(env.cwd)
const registry = await startFakeRegistry({ token: 'token' })
try {
const result = await runCli([
'sync', 'push', skillDir, '--namespace', 'team-a', '--visibility', 'private', '--submit-review',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(5)
expect(JSON.parse(result.stderr).message).toContain('--submit-review requires public or namespace-only visibility')
expect(registry.received.validate).toBeNull()
expect(registry.received.publish).toBeNull()
expect(registry.received.reviews).toBe(0)
} finally {
registry.stop()
}
})
test('human push output always uses submitted semantics and includes the raw status', async () => {
const env = await createTempHome()
const skillDir = await makeLocalSkill(env.cwd)
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'SCANNING' })
try {
const result = await runCli([
'sync', 'push', skillDir, '--namespace', 'team-a',
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('submitted')
expect(result.stdout).toContain('status=SCANNING')
expect(result.stdout).not.toContain('uploaded')
expect(result.stdout).toContain('Check the Web page for final publish or review status.')
} finally {
registry.stop()
}
})
test('push dry-run uses strict validation without uploading', async () => {
const env = await createTempHome()
const skillDir = join(env.cwd, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({ token: 'token' })
try {
const result = await runCli([
'sync', 'push', skillDir, '--namespace', 'team-a', '--dry-run',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout).items[0].action).toBe('validated')
expect(registry.received.validate?.rejectExistingVersion).toBe(true)
expect(registry.received.publish).toBeNull()
} finally {
registry.stop()
}
})
test('pull refuses to replace an unmanaged conflicting directory', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
await mkdir(join(skillsDir, 'demo'), { recursive: true })
await writeFile(join(skillsDir, 'demo', 'local.txt'), 'keep')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({
token: 'token', skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
})
try {
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(1)
expect(await readFile(join(skillsDir, 'demo', 'local.txt'), 'utf8')).toBe('keep')
} finally {
registry.stop()
}
})
})