mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
* fix(cli): validate downloaded skill fingerprint Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(cli): guard namespace sync version drift Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(cli): preserve sync safety guards under force Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(cli): fail blocked sync checks Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
451 lines
18 KiB
TypeScript
451 lines
18 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 }
|
|
}
|
|
|
|
describe('sync command', () => {
|
|
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,
|
|
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', '--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('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', '--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', '--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', '--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', '--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', '--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', '--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', '--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', '--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', '--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('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()
|
|
}
|
|
})
|
|
})
|