skillhub/cli/test/integration/sync-command.test.ts
mrLi 84feb38931 feat(cli): add namespace workspace sync
Signed-off-by: mrLi <50289372+15258725278@users.noreply.github.com>
2026-08-18 18:24:11 +08:00

187 lines
8.2 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'
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 }
}
describe('sync command', () => {
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', '--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', '--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 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 {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--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', '--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')
} 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 }]
const registry = await startFakeRegistry({ token: 'token', skills })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--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', '--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)
} 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(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'
})
} finally {
registry.stop()
await rm(skillsDir, { recursive: true, force: true })
}
})
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', '--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()
}
})
})